From 7e77c3c6fe0d1c0d578863368bd2ec8602a24c91 Mon Sep 17 00:00:00 2001 From: "nhat.nguyenhong" Date: Tue, 22 Sep 2026 08:39:15 +0000 Subject: [PATCH] Fix sidebar quota stuck on loading; forward upstream cost Sidebar (src/tui.tsx): - Read quota()/now() signals inside the returned JSX instead of hoisting quota() into a const above the return. The hoisted read subscribed once under a non-tracking Solid owner, freezing the panel after first paint; the slot handler re-runs on session change, which masked the bug as '/session fixes it'. - Harden refreshQuota: whole refresh (key resolve + fetch) inside try/catch so any throw surfaces inline instead of wedging on loading. Defensive api.state access (non-array provider tolerated) and process.env access via safeEnv; key source tracked by name, never logged. - Refresh triggers: keep session.idle, add session.status/session.updated/ message.updated (debounced) plus refresh on sidebar session_id change. - Debug-gated tui-quota trace lines to the shared debug file (silent unless debug is on); /cc-usage hardened the same way. Provider cost (src/model.ts, src/usage.ts): - Forward per-request USD from finish-step (usage.raw cost/market_cost/ gateway_cost) and provider-metadata (gateway.cost/marketCost) into providerMetadata.commandcode.{cost,marketCost} on the terminal finish part and doGenerate result; also stash into usage.raw. A reported 0 is preserved; only undefined means absent. Docs: README sidebar interval corrected (3 min, not 60 s) and trigger list updated; AGENTS.md invariants 10 (cost) and 13 (Solid gotcha). --- AGENTS.md | 29 ++++++--- README.md | 12 ++-- src/model.ts | 38 ++++++++++-- src/tui.tsx | 168 +++++++++++++++++++++++++++++++++++++++------------ src/usage.ts | 78 +++++++++++++++++++++++- 5 files changed, 271 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4f32041..c39e533 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ These are load-bearing. Breaking one causes silent failures in opencode. be omitted from the envelope; upstream rejects the whole request otherwise. 6. **Usage is nested in V3.** Fill `inputTokens.{total,noCache,cacheRead,cacheWrite}` and `outputTokens.{total,text,reasoning}` — not flat `promptTokens`/`completionTokens`. + `inputTokens.total` is input-only (parity with `server.py`); `totalTokens` is input+output. 7. **`supportedUrls` is `{}` and stays empty.** CommandsCode URLs are not fetched by the SDK. 8. **Redact before surfacing errors.** Any upstream error text passed to the client must go through `redact()`. @@ -115,13 +116,21 @@ These are load-bearing. Breaking one causes silent failures in opencode. `transform.ts`, forward each assistant `reasoning` part as `{type:"reasoning", text, signature?}`; do not drop it and do not fabricate empty reasoning. `server.py` predates this requirement and is not the guide here. -10. **`file://` npm specs bypass install.** opencode imports `dist/index.js` directly, so the repo - must be rebuilt for opencode to see source changes. -11. **`/models` is config-driven, not provider-driven.** opencode builds the model list from +10. **Upstream cost never appears on `finish`.** Per-request USD arrives on `finish-step` + (`usage.raw.cost/market_cost/gateway_cost`, numbers) and `provider-metadata` + (`providerMetadata.gateway.cost/marketCost`, strings). `finish.totalUsage` carries tokens + only. Capture both into `providerMetadata: { commandcode: { cost, marketCost } }` on the + terminal V3 `finish` part (and `doGenerate` result), and stash into `usage.raw`. A reported + `0` is meaningful; only `undefined` means absent. The built-in sidebar still shows `$0.00` + until the fork consumes this field (anomalyco/opencode#43818); `/cc-usage` is the accurate + dollar source meanwhile. +11. **`file://` npm specs bypass install.** opencode imports `dist/index.js` directly, so the repo + must be rebuilt for opencode to see source changes. +12. **`/models` is config-driven, not provider-driven.** opencode builds the model list from `provider.commandcode.models` in `opencode.json` and never asks a custom `file://` provider to discover models (only internal providers can register `discoverModels`). Refresh the map with `npm run sync-models`; do not expect a discovery hook in `src/` to populate it. -12. **The TUI plugin lives outside the provider entrypoint.** `src/tui.tsx` is loaded from +13. **The TUI plugin lives outside the provider entrypoint.** `src/tui.tsx` is loaded from **source** (not `dist/`) via `opencode plugin `, which writes a `tui.json` `plugin` entry; it is exposed as `./tui` in `package.json`. opencode compiles the `.tsx` at load with its Bun/Solid transform and maps `solid-js` / `@opentui/solid` to its internal modules, so the @@ -135,14 +144,20 @@ These are load-bearing. Breaking one causes silent failures in opencode. `api.slots.register({ order, slots: { sidebar_content } })`; `order: 90` keeps it above the built-in panels (100 context, 200 mcp, 300 lsp, 400 todo, 500 files). The slot renderer returns Solid JSX and is reactive; it relies on `api.theme` and `api.lifecycle.onDispose`. -13. **Slash commands and `Ctrl+P` share one registry.** In opencode 1.x the slash menu queries + Solid reactivity gotcha: **read `createSignal` getters inside the returned JSX, never hoist + `signal()` into a `const` above the `return` in the slot component.** A hoisted read subscribes + once under a non-tracking owner and the panel freezes after first paint (fetch succeeds, Solid + never re-renders). The slot handler itself re-runs on session change, which masks the bug — + it looks "fixed" after `/session` but stays stale on fresh launch. The built-in + `internal:sidebar-context` plugin reads its memos inside JSX for the same reason. +14. **Slash commands and `Ctrl+P` share one registry.** In opencode 1.x the slash menu queries the same `namespace: "palette"` commands the palette lists (both filter out `hidden: true`). A slash-only entry is not expressible; registering `/cc-*` also adds them to `Ctrl+P`. -14. **Toggles are resolved per request, never at load.** `zdr` and `debug` are read in +15. **Toggles are resolved per request, never at load.** `zdr` and `debug` are read in `model.ts`/`log.ts` on every call from the toggle file / env, so `/cc-*` changes take effect without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` > `x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts. -15. **Quota is live from the alpha billing API, not `server.py`.** `src/quota.ts` reads +16. **Quota is live from the alpha billing API, not `server.py`.** `src/quota.ts` reads `/alpha/whoami`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`, and `/alpha/usage/summary` (the same endpoints the `cmd` CLI `/usage` uses). `server.py` predates these and is not the guide here. `resetAt` has shipped as both seconds and epoch ms — normalize diff --git a/README.md b/README.md index 10c1e70..946b820 100644 --- a/README.md +++ b/README.md @@ -223,10 +223,13 @@ plan (including org and pay-as-you-go) without a maintained price table. Free an accounts can return no `windowLimits`; those rows stay hidden and the missing section is reported instead of being shown as zero. -The sidebar refreshes after each completed turn (`session.idle`, debounced) plus every -60 seconds as a fallback (`COMMANDCODE_QUOTA_INTERVAL_MS` overrides it) and the -countdown ticks every 30 seconds. Fetch failures are redacted and shown inline; the panel never -blocks the provider. +The sidebar refreshes after each completed turn (`session.idle`, plus `session.status`, +`session.updated`, and `message.updated` as fallbacks since `session.idle` is a server-plugin +event that may never reach the TUI bus — all debounced) and when the active session changes, +plus every 3 minutes as a fallback (`COMMANDCODE_QUOTA_INTERVAL_MS` overrides it) and the +countdown ticks every 30 seconds. Key resolution and fetch failures are redacted and shown +inline; the panel never stays on `loading…` and never blocks the provider. Extra trace lines +(`tui-quota`) are appended to the debug log only when debug tracing is on. Headless check (no TUI), reading the key from `COMMANDCODE_API_KEY` or the opencode config: @@ -252,6 +255,7 @@ The API key is never logged; quota errors pass through `redact()` like every oth | Tool results | Yes — paired results are replayed; unpaired ids are dropped | | Multiple images (vision) | Yes — `data:` URIs, raw base64, `Uint8Array`, and remote URLs | | Token usage | Yes — input/output totals, cache read, reasoning tokens | +| Provider-reported cost | Yes — per-request USD from `finish-step`/`provider-metadata` is forwarded as `providerMetadata.commandcode.cost` (plus `marketCost`); also stashed in `usage.raw`. The built-in sidebar still shows `$0.00` until opencode itself consumes this field (upstream `anomalyco/opencode#43818`); `/cc-usage` remains the accurate dollar source | | Finish reasons | Yes — unified (`stop`, `length`, `tool-calls`, `content-filter`, `error`, `other`) plus raw | | Sampling parameters | Yes — `temperature`, `topP`, `topK`, `stopSequences`, `seed`, presence/frequency penalties | | `reasoning_effort` | Yes — via `providerOptions.commandcode` | diff --git a/src/model.ts b/src/model.ts index 989bc5d..564d0c4 100644 --- a/src/model.ts +++ b/src/model.ts @@ -7,6 +7,7 @@ import type { LanguageModelV3StreamPart, LanguageModelV3StreamResult, LanguageModelV3Usage, + SharedV3ProviderMetadata, SharedV3Warning, } from "@ai-sdk/provider"; import { @@ -22,7 +23,7 @@ import { debugWhen, isDebugEnabled } from "./log.js"; import { redact } from "./redact.js"; import { zdrFromEnvOrFile } from "./toggles.js"; import { transform } from "./transform.js"; -import { finishReasonFrom, usageFromFinish, type FinishEvent } from "./usage.js"; +import { finishReasonFrom, usageFromFinish, costFromFinishStep, costFromProviderMetadata, type FinishEvent, type FinishStepEvent, type ProviderMetadataEvent } from "./usage.js"; // x-cmd-zdr breaks some models; off by default (server.py never sends it). // Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR. @@ -299,6 +300,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 { const toolCalls: Array> = []; let usage: LanguageModelV3Usage = zeroUsage(); let finishReason: LanguageModelV3FinishReason = { unified: "other", raw: undefined }; + let providerMetadata: SharedV3ProviderMetadata | undefined; let warnings: SharedV3Warning[] = []; const reader = stream.getReader(); @@ -322,6 +324,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 { case "finish": usage = value.usage; finishReason = value.finishReason; + providerMetadata = value.providerMetadata; break; case "error": throw value.error instanceof Error ? value.error : new Error(String(value.error)); @@ -348,9 +351,10 @@ class CommandCodeLanguageModel implements LanguageModelV3 { `toolCalls=${toolCalls.length}`, `finish=${finishReason.unified ?? "?"}`, `usage=${JSON.stringify(usage)}`, + `providerMetadata=${JSON.stringify(providerMetadata ?? {})}`, ); - return { content, finishReason, usage, warnings, request, response }; + return { content, finishReason, usage, warnings, request, response, ...(providerMetadata ? { providerMetadata } : {}) }; } private async *streamParts(body: ReadableStream, dbg: boolean): AsyncGenerator { @@ -364,6 +368,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 { let hadToolCalls = false; let usage: LanguageModelV3Usage | undefined; let finishReason: LanguageModelV3FinishReason | undefined; + let cost: number | undefined; + let marketCost: number | undefined; let errored = false; for await (const evt of iterateEvents(body, dbg)) { @@ -438,6 +444,19 @@ class CommandCodeLanguageModel implements LanguageModelV3 { usage = usageFromFinish(evt as FinishEvent); finishReason = finishReasonFrom(evt as FinishEvent, hadToolCalls); break; + case "finish-step": { + // Upstream dollar cost rides here (usage.raw.cost), never on `finish`. + const found = costFromFinishStep(evt as unknown as FinishStepEvent); + if (found.cost !== undefined) cost = found.cost; + if (found.marketCost !== undefined) marketCost = found.marketCost; + break; + } + case "provider-metadata": { + const found = costFromProviderMetadata(evt as unknown as ProviderMetadataEvent); + if (cost === undefined && found.cost !== undefined) cost = found.cost; + if (marketCost === undefined && found.marketCost !== undefined) marketCost = found.marketCost; + break; + } case "error": { errored = true; const err = evt.error; @@ -464,11 +483,22 @@ class CommandCodeLanguageModel implements LanguageModelV3 { } if (textOpen) yield { type: "text-end", id: textId }; if (reasoningOpen) yield { type: "reasoning-end", id: reasoningId }; - debugWhen(dbg, "stream", "terminal", `finish=${finishReason ? (finishReason.unified ?? "?") : "synthesized"}`, `usage=${JSON.stringify(usage ?? zeroUsage())}`); + const finalUsage = usage ?? zeroUsage(); + // A reported cost of 0 is meaningful (flat-fee routed request); only + // `undefined` means "upstream sent no cost". + const reported = { + ...(cost !== undefined ? { cost } : {}), + ...(marketCost !== undefined ? { marketCost } : {}), + }; + const hasCost = cost !== undefined || marketCost !== undefined; + if (hasCost) finalUsage.raw = { ...(finalUsage.raw ?? {}), ...reported }; + const finishMetadata: SharedV3ProviderMetadata | undefined = hasCost ? { commandcode: reported } : undefined; + debugWhen(dbg, "stream", "terminal", `finish=${finishReason ? (finishReason.unified ?? "?") : "synthesized"}`, `usage=${JSON.stringify(finalUsage)}`, `providerMetadata=${JSON.stringify(finishMetadata ?? {})}`); yield { type: "finish", - usage: usage ?? zeroUsage(), + usage: finalUsage, finishReason: finishReason ?? { unified: hadToolCalls ? "tool-calls" : "stop", raw: undefined }, + ...(finishMetadata ? { providerMetadata: finishMetadata } : {}), }; } } diff --git a/src/tui.tsx b/src/tui.tsx index fb0c607..c161160 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -14,10 +14,14 @@ // `namespace: "palette"` command list, so these entries appear in both; `hidden: true` // would remove them from both. +import { appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createSignal } from "solid-js"; import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js"; -import { readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js"; +import { redact } from "./redact.js"; +import { debugFromEnvOrFile, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js"; type ToastVariant = "info" | "success" | "warning" | "error"; @@ -56,7 +60,7 @@ type TuiApi = { keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void }; ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void }; slots: { register(plugin: TuiSlotPlugin): string }; - event: { on(type: string, handler: () => void): () => void }; + event: { on(type: string, handler: (event?: { type?: string }) => void): () => void }; theme: TuiTheme; state: TuiState; lifecycle: { onDispose(fn: () => void): () => void }; @@ -66,6 +70,38 @@ const ID = "commandcode-toggles"; const CATEGORY = "CommandCode"; const SIDEBAR_ORDER = 90; const DEFAULT_QUOTA_INTERVAL_MS = 180_000; +// Extra TUI-bus signals that mark the end of a turn. `session.idle` is a server +// plugin event and may never fire here; these keep the panel fresh regardless. +const QUOTA_TRIGGER_EVENTS = ["session.idle", "session.status", "session.updated", "message.updated"] as const; + +function safeEnv(name: string): string | undefined { + try { + return typeof process === "undefined" ? undefined : process.env?.[name]; + } catch { + return undefined; + } +} + +/** Debug-only trace to the shared debug file. Silent unless debug is on; never logs the key. */ +function trace(...args: unknown[]): void { + if (!debugFromEnvOrFile()) return; + const file = safeEnv("COMMANDCODE_DEBUG_FILE") ?? join(tmpdir(), "commandcode-debug.log"); + const parts = args.map((value) => (typeof value === "string" ? value : safeJson(value))); + const line = `[commandcode] ${new Date().toISOString()} [tui-quota] ${parts.join(" ")}`; + try { + appendFileSync(file, redact(line) + "\n", "utf8"); + } catch { + // Never let tracing break the panel. + } +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} function state(value: boolean | undefined): string { return value === true ? "on" : "off"; @@ -80,32 +116,47 @@ function resolveRef(value: unknown): string | undefined { if (typeof value !== "string" || value.length === 0) return undefined; const trimmed = value.trim(); const template = /^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(trimmed); - if (template) return template[1] ? process.env[template[1]] : undefined; + if (template) return template[1] ? safeEnv(template[1]) : undefined; if (/^\{.*\}$/.test(trimmed)) return undefined; return trimmed; } function commandCodeOptions(api: TuiApi): Record | undefined { - return api.state.config?.provider?.["commandcode"]?.options; + try { + return api.state?.config?.provider?.["commandcode"]?.options; + } catch { + return undefined; + } } -function resolveApiKey(api: TuiApi): string | undefined { - const provider = api.state.provider?.find((entry) => entry.id === "commandcode"); +function providersOf(api: TuiApi): TuiProvider[] { + try { + const list = api.state?.provider; + if (Array.isArray(list)) return list as TuiProvider[]; + // Tolerate a non-array provider state shape instead of throwing. + if (list && typeof list === "object") return Object.values(list) as TuiProvider[]; + } catch { + /* fall through */ + } + return []; +} + +function resolveApiKeySource(api: TuiApi): { key?: string; source: string } { + const provider = providersOf(api).find((entry) => entry?.id === "commandcode"); const options = commandCodeOptions(api); const headers = options?.["headers"] as Record | undefined; - const candidates: unknown[] = [ - provider?.key, - provider?.options?.["apiKey"], - headers?.["Authorization"], - headers?.["authorization"], - options?.["apiKey"], - process.env["COMMANDCODE_API_KEY"], + const named: Array<[string, unknown]> = [ + ["provider.key", provider?.key], + ["provider.options.apiKey", provider?.options?.["apiKey"]], + ["headers.Authorization", headers?.["Authorization"] ?? headers?.["authorization"]], + ["options.apiKey", options?.["apiKey"]], + ["COMMANDCODE_API_KEY", safeEnv("COMMANDCODE_API_KEY")], ]; - for (const candidate of candidates) { + for (const [source, candidate] of named) { const value = resolveRef(candidate); - if (value) return value.replace(/^Bearer\s+/i, ""); + if (value) return { key: value.replace(/^Bearer\s+/i, ""), source }; } - return undefined; + return { source: "none" }; } function resolveBaseURL(api: TuiApi): string | undefined { @@ -113,7 +164,7 @@ function resolveBaseURL(api: TuiApi): string | undefined { } function quotaIntervalMs(): number { - const raw = Number(process.env["COMMANDCODE_QUOTA_INTERVAL_MS"]); + const raw = Number(safeEnv("COMMANDCODE_QUOTA_INTERVAL_MS")); return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_QUOTA_INTERVAL_MS; } @@ -133,38 +184,55 @@ export const tui = async (api: TuiApi): Promise => { let inflight = false; let pending = false; let debounceTimer: ReturnType | undefined; + let lastSessionId: string | undefined; const refresh = (): void => setToggles(readToggles()); const refreshQuota = async (): Promise => { if (inflight) { pending = true; - return; - } - const apiKey = resolveApiKey(api); - if (!apiKey) { - setQuota({ ok: false, error: { kind: "config", message: "No API key found" } }); + trace("coalesced", "inflight"); return; } inflight = true; try { - setQuota(await fetchQuota({ apiKey, baseURL: resolveBaseURL(api) })); + const { key: apiKey, source } = resolveApiKeySource(api); + if (!apiKey) { + setQuota({ ok: false, error: { kind: "config", message: "No API key found" } }); + setNow(Date.now()); + trace("no-key", `source=${source}`); + return; + } + trace("fetch-start", `source=${source}`); + const result = await fetchQuota({ apiKey, baseURL: resolveBaseURL(api) }); + setQuota(result); + if (result.ok) { + trace( + "fetch-ok", + `windows=${result.quota.windows.map((w) => `${w.id}:${w.used}/${w.cap}`).join(",")}`, + ); + } else { + trace("fetch-error", `kind=${result.error.kind}`); + } } catch (error) { - setQuota({ - ok: false, - error: { kind: "network", message: error instanceof Error ? error.message : String(error) }, - }); + // Key resolution, state access, and fetch can all throw (e.g. unsynced + // api.state); surface it instead of wedging the panel on `loading…`. + const message = redact(error instanceof Error ? error.message : String(error)); + setQuota({ ok: false, error: { kind: "network", message } }); + trace("fetch-throw", message); } finally { inflight = false; setNow(Date.now()); if (pending) { pending = false; + trace("flush-pending"); void refreshQuota(); } } }; - const scheduleQuotaRefresh = (delayMs = 800): void => { + const scheduleQuotaRefresh = (reason: string, delayMs = 800): void => { + trace("scheduled", `reason=${reason}`, `delayMs=${delayMs}`); if (debounceTimer !== undefined) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = undefined; @@ -188,10 +256,18 @@ export const tui = async (api: TuiApi): Promise => { return theme.current.success; } + // Reads quota()/now() inside the JSX so Solid tracks the signals. Hoisting + // `quota()` into a `const` above the return would subscribe once under a + // non-tracking owner (the slot re-invokes the handler on session change, + // which is why /session "fixed" it) and freeze the panel after first paint. + const windowsOf = (result: QuotaResult | null): QuotaWindow[] => + result?.ok === true ? result.quota.windows : []; + + const errorOf = (result: QuotaResult | null): string | undefined => + result?.ok === false ? result.error.message : undefined; + const Status = () => { - const result = quota(); const theme = api.theme; - const windows: QuotaWindow[] = result?.ok === true ? result.quota.windows : []; return ( @@ -200,12 +276,12 @@ export const tui = async (api: TuiApi): Promise => { zdr:{state(toggles().zdr)} debug:{state(toggles().debug)} - {windows.map((w) => ( + {windowsOf(quota()).map((w) => ( {shortWindow(w, now())} ))} - {result?.ok === false ? ( - quota: {result.error.message} - ) : result === null ? ( + {errorOf(quota()) !== undefined ? ( + quota: {errorOf(quota())} + ) : quota() === null ? ( quota: loading… ) : null} @@ -215,7 +291,12 @@ export const tui = async (api: TuiApi): Promise => { api.slots.register({ order: SIDEBAR_ORDER, slots: { - sidebar_content() { + sidebar_content(_ctx, props?: { session_id?: string }) { + const id = props?.session_id; + if (id !== undefined && id !== lastSessionId) { + lastSessionId = id; + scheduleQuotaRefresh("session-change"); + } return ; }, }, @@ -258,7 +339,7 @@ export const tui = async (api: TuiApi): Promise => { namespace: "palette", slashName: "cc-usage", run: async () => { - const apiKey = resolveApiKey(api); + const { key: apiKey } = resolveApiKeySource(api); if (!apiKey) { api.ui.toast({ title: CATEGORY, @@ -267,7 +348,16 @@ export const tui = async (api: TuiApi): Promise => { }); return; } - const result = await fetchQuota({ apiKey, baseURL: resolveBaseURL(api) }); + let result: QuotaResult; + try { + result = await fetchQuota({ apiKey, baseURL: resolveBaseURL(api) }); + } catch (error) { + const message = redact(error instanceof Error ? error.message : String(error)); + setQuota({ ok: false, error: { kind: "network", message } }); + trace("usage-throw", message); + api.ui.toast({ title: CATEGORY, message, variant: "error" }); + return; + } if (!result.ok) { setQuota(result); api.ui.toast({ title: CATEGORY, message: result.error.message, variant: "error" }); @@ -285,7 +375,9 @@ export const tui = async (api: TuiApi): Promise => { const toggleTimer = setInterval(refresh, 1500); const clockTimer = setInterval(() => setNow(Date.now()), 30_000); const quotaTimer = setInterval(() => void refreshQuota(), quotaIntervalMs()); - const unsubs = [api.event.on("session.idle", () => scheduleQuotaRefresh())]; + const unsubs = QUOTA_TRIGGER_EVENTS.map((type) => + api.event.on(type, (event) => scheduleQuotaRefresh(event?.type ?? type)), + ); api.lifecycle.onDispose(() => { for (const unsub of unsubs) unsub(); if (debounceTimer !== undefined) clearTimeout(debounceTimer); diff --git a/src/usage.ts b/src/usage.ts index 5f00059..130e1a4 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -6,8 +6,15 @@ export type CommandCodeUsage = { outputTokens?: number; totalTokens?: number; cachedInputTokens?: number; + reasoningTokens?: number; + cost?: number | string; + market_cost?: number | string; + marketCost?: number | string; + gateway_cost?: number | string; + gatewayCost?: number | string; inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number }; outputTokenDetails?: { textTokens?: number; reasoningTokens?: number }; + raw?: Record; }; export type FinishEvent = { @@ -16,6 +23,73 @@ export type FinishEvent = { totalUsage?: CommandCodeUsage; }; +export type FinishStepEvent = { + usage?: CommandCodeUsage; + providerMetadata?: Record; +}; + +export type ProviderMetadataEvent = { + providerMetadata?: Record; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Finite numbers pass through; numeric strings (gateway `cost: "0.0003"`) are coerced. */ +function numberFrom(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim().length > 0) { + const n = Number(value.trim()); + if (Number.isFinite(n)) return n; + } + return undefined; +} + +/** + * Upstream dollar cost arrives on `finish-step`, not `finish`: + * `usage.raw.{cost,market_cost,gateway_cost}` (numbers). A reported `0` is + * meaningful (flat-fee routed request) and must be preserved. + */ +export function costFromFinishStep(evt: FinishStepEvent): { cost?: number; marketCost?: number } { + const fallback = costFromProviderMetadata(evt); + const usage = evt.usage; + if (!usage || typeof usage !== "object") return fallback; + const raw = isRecord(usage.raw) ? usage.raw : {}; + const cost = numberFrom(usage.cost ?? raw["cost"]) ?? fallback.cost; + const marketCost = + numberFrom( + usage.market_cost ?? + usage.marketCost ?? + raw["market_cost"] ?? + raw["marketCost"] ?? + usage.gateway_cost ?? + usage.gatewayCost ?? + raw["gateway_cost"] ?? + raw["gatewayCost"], + ) ?? fallback.marketCost; + return { + ...(cost !== undefined ? { cost } : {}), + ...(marketCost !== undefined ? { marketCost } : {}), + }; +} + +/** Fallback/supplement: `provider-metadata` carries gateway cost as strings. */ +export function costFromProviderMetadata(evt: ProviderMetadataEvent): { + cost?: number; + marketCost?: number; +} { + const pm = evt.providerMetadata; + if (!isRecord(pm)) return {}; + const gateway = isRecord(pm["gateway"]) ? (pm["gateway"] as Record) : {}; + const cost = numberFrom(gateway["cost"]); + const marketCost = numberFrom(gateway["marketCost"] ?? gateway["market_cost"]); + return { + ...(cost !== undefined ? { cost } : {}), + ...(marketCost !== undefined ? { marketCost } : {}), + }; +} + /** CommandCode `finish` -> LanguageModelV3Usage. */ export function usageFromFinish(evt: FinishEvent): LanguageModelV3Usage { const tu = evt.totalUsage; @@ -26,10 +100,12 @@ export function usageFromFinish(evt: FinishEvent): LanguageModelV3Usage { const details = tu.inputTokenDetails ?? {}; const outDetails = tu.outputTokenDetails ?? {}; const cacheRead = tu.cachedInputTokens ?? details.cacheReadTokens; + // inputTokens.total is input-only (parity with server.py); totalTokens is input+output. + const total = tu.totalTokens ?? input + output; return { inputTokens: { - total: tu.totalTokens ?? input + output, + total: tu.inputTokens ?? total - output, noCache: details.noCacheTokens, cacheRead, cacheWrite: undefined,