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:
parent
ef9a944924
commit
7e77c3c6fe
27
AGENTS.md
27
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
|
||||
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
|
||||
|
||||
12
README.md
12
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` |
|
||||
|
||||
38
src/model.ts
38
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<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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
168
src/tui.tsx
168
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<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);
|
||||
|
||||
78
src/usage.ts
78
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<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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user