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).
This commit is contained in:
nhat.nguyenhong 2026-09-22 08:39:15 +00:00
parent ef9a944924
commit 7e77c3c6fe
5 changed files with 271 additions and 54 deletions

View File

@ -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
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.
11. **`/models` is config-driven, not provider-driven.** opencode builds the model list from
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 <path>`, 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

View File

@ -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` |

View File

@ -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<Extract<LanguageModelV3Content, { type: "tool-call" }>> = [];
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<Uint8Array>, dbg: boolean): AsyncGenerator<LanguageModelV3StreamPart> {
@ -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 } : {}),
};
}
}

View File

@ -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<string, unknown> | 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<string, unknown> | 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<void> => {
let inflight = false;
let pending = false;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let lastSessionId: string | undefined;
const refresh = (): void => setToggles(readToggles());
const refreshQuota = async (): Promise<void> => {
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<void> => {
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 (
<box flexDirection="column" gap={0}>
<text fg={theme.current.text}>
@ -200,12 +276,12 @@ export const tui = async (api: TuiApi): Promise<void> => {
<text fg={theme.current.textMuted}>
zdr:{state(toggles().zdr)} debug:{state(toggles().debug)}
</text>
{windows.map((w) => (
{windowsOf(quota()).map((w) => (
<text fg={tone(w, theme)}>{shortWindow(w, now())}</text>
))}
{result?.ok === false ? (
<text fg={theme.current.error}>quota: {result.error.message}</text>
) : result === null ? (
{errorOf(quota()) !== undefined ? (
<text fg={theme.current.error}>quota: {errorOf(quota())}</text>
) : quota() === null ? (
<text fg={theme.current.textMuted}>quota: loading…</text>
) : null}
</box>
@ -215,7 +291,12 @@ export const tui = async (api: TuiApi): Promise<void> => {
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 <Status />;
},
},
@ -258,7 +339,7 @@ export const tui = async (api: TuiApi): Promise<void> => {
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<void> => {
});
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<void> => {
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);

View File

@ -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<string, unknown>;
};
export type FinishEvent = {
@ -16,6 +23,73 @@ export type FinishEvent = {
totalUsage?: CommandCodeUsage;
};
export type FinishStepEvent = {
usage?: CommandCodeUsage;
providerMetadata?: Record<string, unknown>;
};
export type ProviderMetadataEvent = {
providerMetadata?: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>) : {};
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,