Skip to content

feat: models.dev price sync + CodeWhale usage ledger - #25

Open
purrfecto114-lgtm wants to merge 3 commits into
myunwang:mainfrom
purrfecto114-lgtm:pr/3-models-dev-pricing
Open

feat: models.dev price sync + CodeWhale usage ledger#25
purrfecto114-lgtm wants to merge 3 commits into
myunwang:mainfrom
purrfecto114-lgtm:pr/3-models-dev-pricing

Conversation

@purrfecto114-lgtm

Copy link
Copy Markdown
Contributor

这是什么(写给普通用户)

桌宠现在能算出 CodeWhale 花了多少钱:按 models.dev 的公开价格表给每个回合计价,日/周/终身花费一目了然,面板上的拆分行也变成三家的了。

你会在意的变化:

  • 💰 CodeWhale 的 token 用量自动入账(按模型单价计价,含缓存读写差异计价)
  • 🌐 价格表来自 models.dev 公开数据,24 小时刷新一次,失败不影响启动(离线也能用上次缓存)
  • 📊 面板拆分行:Claude $x · Codex $y · CodeWhale $z(没有 CodeWhale 花费的机器保持原样,不多噪音)
  • 🔢 合计与拆分之和永远一致(修掉了「合计 > 拆分之和」的矛盾显示)
  • ⏱️ 重试投递不会重复计费(按 turn_id 去重)

测试: npm test exit 0(新增 pr3-smoke:特殊 key、数值边界、去重、缓存计价数学、TOML 幂等+旧格式升级)。变更规模:9 个文件,+848/−27。

堆叠说明: 本 PR 是 4 个堆叠 PR 的第 3 个(pr/1-security-hardeningpr/2-codewhale-providerpr/3-models-dev-pricingpr/4-build-docs-gui)。依赖第 2 个(台账消费 provider PR 转发的 turn_end usage)。

系列背景:按 #10 review 的建议从最新 main 重建并四分;review 中 6 个阻断项的逐一回应见 #10 的收尾评论。


📐 技术附录(写给审阅者 / agent,点击展开)

1. models.dev 目录的形状(实测验证)

models.dev/catalog.json = {models, providers} 顶层两对象——只有 providers 带价格;362 行的顶层 models 是与 provider 无关的元数据。两个易错点:

  • 目录合法地包含 __proto__/constructor 这类 key 作为 provider/model id → 缓存全部用 null-prototype 字典构建,危险 key 直接拒收(原型污染防护,变异测试验证过 Object.create(null) 被删掉时测试会红)
  • 未知路径返回 200 + SPA HTML(不是 404)→ 非 JSON 解析失败按刷新错误处理,不会把 HTML 存进缓存

2. 数值与网络边界

  • 所有数字过界检查:NaN/Infinity/负数/离谱值 → null(宁缺毋错)
  • 64 MiB 响应上限、15s 超时、原子 0600 写入、24h TTL
  • 按需惰性刷新,无定时任务(读取时检查 TTL,后台静默更新;网络失败永不阻塞启动)
  • 缓存路径跟随 LLMPET_CODEWHALE_HOME(与台账一致;测试里 env 指向不同目录才是真覆盖——同目录是假覆盖,曾因此修过测试)

3. 计量语义(DeepSeek 特有的坑)

  • input_tokens 已包含 cache 命中部分 → 缓存读按 cache-read 价计、绝不双重计费
  • reasoning_replay_tokens 上游定价语义不明 → 计入诊断、故意不计费
  • 未知模型按诚实的 $0 计价并进诊断,不猜测价格
  • turn_id 去重:钩子重试投递不会双计(有界去重窗口)
  • 性能:seenTurns 修剪 O(1) 常态路径 + 半量批量丢弃;20k 回合灌入 < 5s(曾是 O(n²) 全排序,20k 要 20s)

4. 面板三向拆分

combineUsage 增加 CodeWhale 泳道(machine 总额 + 主宠计费视图,byModel/todayByProvider 标记 codewhalemachineGrowth 总额含它)。拆分行规则:三家有量三向拼、两量两拼、单量不显示(单 agent 机器不该看到 Codex $0.000 噪音——上游原规则保留)。Claude/Codex/CodeWhale 是专有名词,三语言不翻译。

5. pr3-smoke 覆盖清单

特殊 key 拒收、数值边界、turn_id 去重、缓存感知计价数学、TOML 幂等 + 旧格式升级(块重写无重复、用户内容保留)、env 真覆盖、事件名对真实 CodeWhale 词表校验、20k 灌入 < 5s。

- renderer windows: sandbox: true (preload only uses contextBridge/ipcRenderer,
  renderer JS has zero node API usage) + deny-by-default CSP meta on
  pet/panel/archive (connect-src none — all comms go over IPC)
- command-safety: fail-closed single read-only command recognizer replacing the
  old prefix-only check; closes env-exec, git branch/remote mutation,
  git diff --output/--ext-diff, rg --pre, fd -x/-X, date -s and shell-grammar
  chaining/substitution/redirect bypasses
- pretool-hook: Claude Code PreToolUse gate emitting the current
  hookSpecificOutput.permissionDecision structure (flat top-level fields are
  silently ignored by CC >= v1.0.59); no-op output leaves the normal permission
  flow in charge. stdin is buffer-concatenated once so CJK payloads split
  across chunk boundaries no longer corrupt the decoded JSON
- hookinstall: registers the Bash-matcher PreToolUse gate alongside the state
  hooks; uninstall/hooksCurrent account for it
- server: runtime.json ownership is now first-live-wins with stale-record
  healing — a probe-gated guard replaces the unconditional 15s re-assert that
  made two live instances flip-flop the file (each overwrite invalidating the
  other's token mid-flight); same-port stale tokens are rewritten immediately;
  guard interval injectable via runtimeGuardMs for tests
- tests: command-safety bypass matrix (31 reject / 39 allow cases) +
  runtime-ownership four-scenario suite (live peer untouched, stale record
  taken over, same-port stale token healed, stop() never deletes a live
  peer's record)

npm test exit 0.
…aller

Verified against CodeWhale's actual hook contract (docs/HOOKS.md + executor
source, commit 7d942bd) — the half-finished refresh had invented event names
that do not exist upstream:

- hook/codewhale-hook.js: registers the 10 real lifecycle events
  (session_start/session_end/message_submit/tool_call_before/tool_call_after/
  turn_end/subagent_spawn/subagent_complete/on_error/mode_change); upstream's
  turn_start/error DO NOT exist. tool_call_before reads its payload from the
  DEEPSEEK_* env vars (there is no CODEWHALE_TOOL_NAME upstream); observer
  events merge their stdin JSON. Fails closed to {decision:"ask"} whenever
  LLMPET is unreachable/untrusted, because an unanswered CodeWhale hook
  defaults to ALLOW on timeout. Observer hooks install with background=true
  (state sync never rides the turn's critical path); the readStdin guard
  timer is cleared+unref'd so hook processes exit ~30ms after their work
  instead of idling 300ms; turn_end usage metadata (turn_id/status/provider)
  is forwarded for the ledger. The permission gate auto-allows CodeWhale's
  real shell tool name exec_shell (the Claude-era 'Bash' spelling never
  arrives on this route and made the auto-allow dead code -> permission
  fatigue). mode_change maps to a synthetic ModeChange event + 'attention'
  oneshot — the adapter turns Notification into a needs-input card, wrong
  for a user-initiated Plan/Work/Operate switch.
- backend/codewhale-provider.js: managed TOML block installer. The permission
  gate is a strict gate (continue_on_error = false, timeout 600s) so an
  unresponsive pet DENIES instead of CodeWhale's default allow-on-timeout;
  observer hooks stay best-effort. User TOML outside BEGIN/END markers is
  preserved; atomic 0600 writes.
- backend/codewhale-permission.js: bounded permission holder — 64 pending cap
  (overflow -> ask), 8-minute auto-DENY (never ask: the un-answered upstream
  timeout ALLOWS; honors the documented autoDenyMs option, invalid values
  fall back to the 8-minute window), connection-close cleanup, session-end
  sweep.
- server: /codewhale-permission endpoint behind the same loopback/Host/Origin/
  per-boot token boundary as /state; agent_id routing tags sessions; turn_end
  usage (incl. turn_id/provider/status metadata) is handed to a ledger
  callback (wired in the pricing PR).
- main: cw-* permission ids route to the CodeWhale holder from the pet bubble;
  pending lists merge on the main pet (no dedicated CodeWhale pet yet);
  installer runs only when ~/.codewhale exists or LLMPET_ENABLE_CODEWHALE=1.
  Permission cards identify the asking agent (CodeWhale · exec_shell) and
  state the auto-deny window; the hint derives from the pending entry's own
  createdAt/expiresAt so the first push and every stats-snapshot rebuild of
  the same card agree; askPermission reports 'timed out' distinctly from
  'not running'.
- adapter: agentOf/AGENT_NAME know 'codewhale' (first-class like dsh);
  humanizeTool understands exec_shell/write_file/edit_file/apply_patch/
  fim_edit/read_file (field names verified against CodeWhale's permission-rule
  docs: exec_shell->command, file tools->path); onPermissionAdded falls back
  to the entry's own agent identity when the session is not in the store yet
  (a tool_call_before can beat session_start).
- transport: postState reuses the already-read runtime port (one readFileSync
  per hook event instead of two).

npm test exit 0 — incl. the e2e suite driving the real HTTP server and both
hook subprocesses (env->/state routing, permission trust boundary, timeout
branch against a never-responding server, stdin->stdout protocol shapes).

Windows CI fix (follow-up to the initial red run): the test-suite HOME
isolation only overrode env.HOME, but Node's os.homedir() on Windows reads
USERPROFILE — so on windows-latest the hook subprocess resolved the REAL
user home, found the runtime.json the test server itself had just written
there, and the "LLMPET unreachable -> ask" case deterministically hung on
the permission bridge until the 10s SIGKILL (exit != 0). macOS/Linux stayed
green because HOME is honored there. Both test files now override
USERPROFILE alongside HOME — in the parent before the first backend require
(RUNTIME_PATH is frozen at module load) and in the unreachable-child env.
The observer-hook timing check also moved to a dual-clock scheme:
spawn->exit (<280ms) is only asserted off-Windows (cold-start noise has no
discriminative power there), while the new ack->exit measure (<150ms from
usage delivery to process exit) strips spawn cost and catches the 300ms
stdin-guard leak on every platform. Verified by simulating Windows homedir
semantics via an os.homedir shim reading USERPROFILE: old tree fails at the
unreachable case exactly as CI did; fixed tree passes both clocks.

Second Windows-only follow-up, found on the re-run: every assertion group
passed on windows-latest but the suite still exited 1 at the final sandbox
cleanup — fs.rmSync hit ENOTEMPTY on `.octopus` because the runtime.json it
had just unlinked was not yet gone from the directory listing (POSIX shows
deletions immediately, Windows does not; the file was written microseconds
earlier by the last runtime-ownership assertion). Node ships the exact
remedy for this race: rmSync's maxRetries/retryDelay options retry
ENOTEMPTY/EBUSY/EPERM with linear backoff. All sandbox cleanups in both
test files now pass maxRetries=10, retryDelay=50.

Third Windows-only follow-up: the re-run still died at sandbox cleanup with
ENOTEMPTY even with maxRetries=10 (all 11 attempts failed across the full
2.78s backoff window — timestamps prove the retries ran). Root cause: the
global logger (backend/log.js) keeps ONE ever-open append stream on
~/.octopus/octopus.log, and server.stop() does not close it. On POSIX an
unlink is just an inode refcount drop, so Linux/macOS never notice; on
Windows the open write handle pins the file's directory entry in a
delete-pending state, so rmdir reports ENOTEMPTY for as long as the handle
lives — no amount of rmSync retries can win. Fix: log.js now exports
shutdown() (ends the stream, resolves on close with a 500ms fallback, never
rejects; a later log() transparently re-opens). Both test suites await
shutdown() before deleting their fake HOME, and still keep maxRetries as a
belt for the residual post-unlink visibility race.

Fourth Windows-only fix, this one a genuine flake that pre-exists upstream
and can hit any CI run: the re-run crashed smoke.js itself with an
uncaught ECONNRESET. Timestamps in the job log show a 4-second stall
between two pure-function assertions (only possible on a contended
runner), putting the last HTTP request 6.4s in the past — past the Node
http server's default keepAliveTimeout of 5s. The server closed the idle
keep-alive socket; Node 19+'s globalAgent (keepAlive now defaults to
true) then reused the dead socket for the next post() and read a TCP RST.
Linux runners are fast enough to never open the window, which is why this
never fired on ubuntu. Fix: both HTTP-speaking tests (smoke.js and
e2e-codewhale.js) now route every request through an explicit
new http.Agent({ keepAlive: false }) — one fresh connection per request,
nothing pooled, nothing to go stale. (Runtime-ownership and pr3-smoke do
no direct HTTP.)
- models-dev-sync: independent price cache from models.dev/catalog.json
  (verified shape: {models, providers} — only providers carries prices; the
  362-row top-level models object is provider-independent metadata). Catalog
  legitimately contains keys like __proto__/constructor as provider/model ids:
  the cache is built with null-prototype dictionaries and unsafe keys are
  rejected outright, all numbers bounds-checked (NaN/Inf/negative/absurd ->
  null), 64 MiB body cap, 15s timeout, atomic 0600 write, 24h TTL. Network
  failure never blocks startup. Unknown paths on models.dev return 200 + SPA
  HTML, so non-JSON parse failures are handled as refresh errors. The cache
  path follows LLMPET_CODEWHALE_HOME like the ledger. On-demand refresh only
  (no timers beyond a TTL check at read time).
- codewhale-metering: consumes the verified turn_end usage payload
  (input_tokens/output_tokens/prompt_cache_hit/miss/write, reasoning_tokens)
  keyed by turn_id (retried hook deliveries cannot double-bill; bounded dedup
  window). DeepSeek semantics honored: input_tokens already includes the
  cache-hit portion, so cached tokens are billed at the cache-read rate and
  never twice. reasoning_replay_tokens have ambiguous upstream pricing —
  counted in diagnostics, deliberately not billed. Unknown models count tokens
  at honest $0 and land in diagnostics instead of being guessed. seenTurns
  pruning is O(1) on the common path (size counter + batch drop to half the
  cap); a 20k-turn bulk load records in <5s.
- adapter/growth/main: the CodeWhale ledger joins the machine total and the
  main pet's billing view (tagged 'codewhale' in byModel/todayByProvider);
  machineGrowth totals include it. Panel split line becomes a three-way join
  when CodeWhale has spend (Claude $x · Codex $y · CodeWhale $z); machines
  without CodeWhale spend keep the upstream two-way string; totals never
  contradict the split sum again.
- test/pr3-smoke: protocol-shaped assertions (special keys, bounds, turn_id
  dedup, cache-aware pricing math, TOML idempotence + old-format upgrade with
  user content preserved, env override actually overriding, event-name
  validity against the real CodeWhale vocabulary).

npm test exit 0.
purrfecto114-lgtm added a commit to purrfecto114-lgtm/LLMPET that referenced this pull request Aug 30, 2026
…rity hardening + CodeWhale provider + models.dev pricing + build/docs

Merges the four stacked PR branches (pr/1..pr/4) into fork main:
- myunwang#21 pr/1-security-hardening (11ff1ba): renderer sandbox+CSP, command-safety recognizer, PreToolUse gate, runtime first-live-wins
- myunwang#24 pr/2-codewhale-provider (990b964): CodeWhale provider, permission bridge, TOML installer (+ Windows CI hardening)
- myunwang#25 pr/3-models-dev-pricing (16987ce): models.dev price sync + CodeWhale usage ledger
- myunwang#26 pr/4-build-docs-gui (da8fb38): Linux release job, lockfile guard, CodeWhale docs, GUI fixes

All 4 PRs: CI green, mergeable_state=clean, stacked on upstream main 81d18d4.
Conflict policy: theirs (PR side) — the series is the verified final state.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant