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.
|
be omitted from the envelope; upstream rejects the whole request otherwise.
|
||||||
6. **Usage is nested in V3.** Fill `inputTokens.{total,noCache,cacheRead,cacheWrite}` and
|
6. **Usage is nested in V3.** Fill `inputTokens.{total,noCache,cacheRead,cacheWrite}` and
|
||||||
`outputTokens.{total,text,reasoning}` — not flat `promptTokens`/`completionTokens`.
|
`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.
|
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
|
8. **Redact before surfacing errors.** Any upstream error text passed to the client must go through
|
||||||
`redact()`.
|
`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?}`;
|
`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
|
do not drop it and do not fabricate empty reasoning. `server.py` predates this requirement and is
|
||||||
not the guide here.
|
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.
|
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
|
`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
|
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.
|
`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`
|
**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
|
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
|
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
|
`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
|
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`.
|
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`).
|
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`.
|
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
|
`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` >
|
without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` >
|
||||||
`x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts.
|
`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/whoami`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`, and
|
||||||
`/alpha/usage/summary` (the same endpoints the `cmd` CLI `/usage` uses). `server.py` predates
|
`/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
|
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
|
accounts can return no `windowLimits`; those rows stay hidden and the missing section is reported
|
||||||
instead of being shown as zero.
|
instead of being shown as zero.
|
||||||
|
|
||||||
The sidebar refreshes after each completed turn (`session.idle`, debounced) plus every
|
The sidebar refreshes after each completed turn (`session.idle`, plus `session.status`,
|
||||||
60 seconds as a fallback (`COMMANDCODE_QUOTA_INTERVAL_MS` overrides it) and the
|
`session.updated`, and `message.updated` as fallbacks since `session.idle` is a server-plugin
|
||||||
countdown ticks every 30 seconds. Fetch failures are redacted and shown inline; the panel never
|
event that may never reach the TUI bus — all debounced) and when the active session changes,
|
||||||
blocks the provider.
|
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:
|
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 |
|
| Tool results | Yes — paired results are replayed; unpaired ids are dropped |
|
||||||
| Multiple images (vision) | Yes — `data:` URIs, raw base64, `Uint8Array`, and remote URLs |
|
| Multiple images (vision) | Yes — `data:` URIs, raw base64, `Uint8Array`, and remote URLs |
|
||||||
| Token usage | Yes — input/output totals, cache read, reasoning tokens |
|
| 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 |
|
| 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 |
|
| Sampling parameters | Yes — `temperature`, `topP`, `topK`, `stopSequences`, `seed`, presence/frequency penalties |
|
||||||
| `reasoning_effort` | Yes — via `providerOptions.commandcode` |
|
| `reasoning_effort` | Yes — via `providerOptions.commandcode` |
|
||||||
|
|||||||
38
src/model.ts
38
src/model.ts
@ -7,6 +7,7 @@ import type {
|
|||||||
LanguageModelV3StreamPart,
|
LanguageModelV3StreamPart,
|
||||||
LanguageModelV3StreamResult,
|
LanguageModelV3StreamResult,
|
||||||
LanguageModelV3Usage,
|
LanguageModelV3Usage,
|
||||||
|
SharedV3ProviderMetadata,
|
||||||
SharedV3Warning,
|
SharedV3Warning,
|
||||||
} from "@ai-sdk/provider";
|
} from "@ai-sdk/provider";
|
||||||
import {
|
import {
|
||||||
@ -22,7 +23,7 @@ import { debugWhen, isDebugEnabled } from "./log.js";
|
|||||||
import { redact } from "./redact.js";
|
import { redact } from "./redact.js";
|
||||||
import { zdrFromEnvOrFile } from "./toggles.js";
|
import { zdrFromEnvOrFile } from "./toggles.js";
|
||||||
import { transform } from "./transform.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).
|
// 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.
|
// 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" }>> = [];
|
const toolCalls: Array<Extract<LanguageModelV3Content, { type: "tool-call" }>> = [];
|
||||||
let usage: LanguageModelV3Usage = zeroUsage();
|
let usage: LanguageModelV3Usage = zeroUsage();
|
||||||
let finishReason: LanguageModelV3FinishReason = { unified: "other", raw: undefined };
|
let finishReason: LanguageModelV3FinishReason = { unified: "other", raw: undefined };
|
||||||
|
let providerMetadata: SharedV3ProviderMetadata | undefined;
|
||||||
let warnings: SharedV3Warning[] = [];
|
let warnings: SharedV3Warning[] = [];
|
||||||
|
|
||||||
const reader = stream.getReader();
|
const reader = stream.getReader();
|
||||||
@ -322,6 +324,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
case "finish":
|
case "finish":
|
||||||
usage = value.usage;
|
usage = value.usage;
|
||||||
finishReason = value.finishReason;
|
finishReason = value.finishReason;
|
||||||
|
providerMetadata = value.providerMetadata;
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
throw value.error instanceof Error ? value.error : new Error(String(value.error));
|
throw value.error instanceof Error ? value.error : new Error(String(value.error));
|
||||||
@ -348,9 +351,10 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
`toolCalls=${toolCalls.length}`,
|
`toolCalls=${toolCalls.length}`,
|
||||||
`finish=${finishReason.unified ?? "?"}`,
|
`finish=${finishReason.unified ?? "?"}`,
|
||||||
`usage=${JSON.stringify(usage)}`,
|
`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> {
|
private async *streamParts(body: ReadableStream<Uint8Array>, dbg: boolean): AsyncGenerator<LanguageModelV3StreamPart> {
|
||||||
@ -364,6 +368,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
let hadToolCalls = false;
|
let hadToolCalls = false;
|
||||||
let usage: LanguageModelV3Usage | undefined;
|
let usage: LanguageModelV3Usage | undefined;
|
||||||
let finishReason: LanguageModelV3FinishReason | undefined;
|
let finishReason: LanguageModelV3FinishReason | undefined;
|
||||||
|
let cost: number | undefined;
|
||||||
|
let marketCost: number | undefined;
|
||||||
let errored = false;
|
let errored = false;
|
||||||
|
|
||||||
for await (const evt of iterateEvents(body, dbg)) {
|
for await (const evt of iterateEvents(body, dbg)) {
|
||||||
@ -438,6 +444,19 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
usage = usageFromFinish(evt as FinishEvent);
|
usage = usageFromFinish(evt as FinishEvent);
|
||||||
finishReason = finishReasonFrom(evt as FinishEvent, hadToolCalls);
|
finishReason = finishReasonFrom(evt as FinishEvent, hadToolCalls);
|
||||||
break;
|
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": {
|
case "error": {
|
||||||
errored = true;
|
errored = true;
|
||||||
const err = evt.error;
|
const err = evt.error;
|
||||||
@ -464,11 +483,22 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
}
|
}
|
||||||
if (textOpen) yield { type: "text-end", id: textId };
|
if (textOpen) yield { type: "text-end", id: textId };
|
||||||
if (reasoningOpen) yield { type: "reasoning-end", id: reasoningId };
|
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 {
|
yield {
|
||||||
type: "finish",
|
type: "finish",
|
||||||
usage: usage ?? zeroUsage(),
|
usage: finalUsage,
|
||||||
finishReason: finishReason ?? { unified: hadToolCalls ? "tool-calls" : "stop", raw: undefined },
|
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`
|
// `namespace: "palette"` command list, so these entries appear in both; `hidden: true`
|
||||||
// would remove them from both.
|
// 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 { createSignal } from "solid-js";
|
||||||
|
|
||||||
import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.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";
|
type ToastVariant = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
@ -56,7 +60,7 @@ type TuiApi = {
|
|||||||
keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void };
|
keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void };
|
||||||
ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void };
|
ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void };
|
||||||
slots: { register(plugin: TuiSlotPlugin): string };
|
slots: { register(plugin: TuiSlotPlugin): string };
|
||||||
event: { on(type: string, handler: () => void): () => void };
|
event: { on(type: string, handler: (event?: { type?: string }) => void): () => void };
|
||||||
theme: TuiTheme;
|
theme: TuiTheme;
|
||||||
state: TuiState;
|
state: TuiState;
|
||||||
lifecycle: { onDispose(fn: () => void): () => void };
|
lifecycle: { onDispose(fn: () => void): () => void };
|
||||||
@ -66,6 +70,38 @@ const ID = "commandcode-toggles";
|
|||||||
const CATEGORY = "CommandCode";
|
const CATEGORY = "CommandCode";
|
||||||
const SIDEBAR_ORDER = 90;
|
const SIDEBAR_ORDER = 90;
|
||||||
const DEFAULT_QUOTA_INTERVAL_MS = 180_000;
|
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 {
|
function state(value: boolean | undefined): string {
|
||||||
return value === true ? "on" : "off";
|
return value === true ? "on" : "off";
|
||||||
@ -80,32 +116,47 @@ function resolveRef(value: unknown): string | undefined {
|
|||||||
if (typeof value !== "string" || value.length === 0) return undefined;
|
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
const template = /^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(trimmed);
|
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;
|
if (/^\{.*\}$/.test(trimmed)) return undefined;
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandCodeOptions(api: TuiApi): Record<string, unknown> | undefined {
|
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 {
|
function providersOf(api: TuiApi): TuiProvider[] {
|
||||||
const provider = api.state.provider?.find((entry) => entry.id === "commandcode");
|
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 options = commandCodeOptions(api);
|
||||||
const headers = options?.["headers"] as Record<string, unknown> | undefined;
|
const headers = options?.["headers"] as Record<string, unknown> | undefined;
|
||||||
const candidates: unknown[] = [
|
const named: Array<[string, unknown]> = [
|
||||||
provider?.key,
|
["provider.key", provider?.key],
|
||||||
provider?.options?.["apiKey"],
|
["provider.options.apiKey", provider?.options?.["apiKey"]],
|
||||||
headers?.["Authorization"],
|
["headers.Authorization", headers?.["Authorization"] ?? headers?.["authorization"]],
|
||||||
headers?.["authorization"],
|
["options.apiKey", options?.["apiKey"]],
|
||||||
options?.["apiKey"],
|
["COMMANDCODE_API_KEY", safeEnv("COMMANDCODE_API_KEY")],
|
||||||
process.env["COMMANDCODE_API_KEY"],
|
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const [source, candidate] of named) {
|
||||||
const value = resolveRef(candidate);
|
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 {
|
function resolveBaseURL(api: TuiApi): string | undefined {
|
||||||
@ -113,7 +164,7 @@ function resolveBaseURL(api: TuiApi): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function quotaIntervalMs(): number {
|
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;
|
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 inflight = false;
|
||||||
let pending = false;
|
let pending = false;
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let lastSessionId: string | undefined;
|
||||||
|
|
||||||
const refresh = (): void => setToggles(readToggles());
|
const refresh = (): void => setToggles(readToggles());
|
||||||
|
|
||||||
const refreshQuota = async (): Promise<void> => {
|
const refreshQuota = async (): Promise<void> => {
|
||||||
if (inflight) {
|
if (inflight) {
|
||||||
pending = true;
|
pending = true;
|
||||||
return;
|
trace("coalesced", "inflight");
|
||||||
}
|
|
||||||
const apiKey = resolveApiKey(api);
|
|
||||||
if (!apiKey) {
|
|
||||||
setQuota({ ok: false, error: { kind: "config", message: "No API key found" } });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inflight = true;
|
inflight = true;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
setQuota({
|
// Key resolution, state access, and fetch can all throw (e.g. unsynced
|
||||||
ok: false,
|
// api.state); surface it instead of wedging the panel on `loading…`.
|
||||||
error: { kind: "network", message: error instanceof Error ? error.message : String(error) },
|
const message = redact(error instanceof Error ? error.message : String(error));
|
||||||
});
|
setQuota({ ok: false, error: { kind: "network", message } });
|
||||||
|
trace("fetch-throw", message);
|
||||||
} finally {
|
} finally {
|
||||||
inflight = false;
|
inflight = false;
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pending = false;
|
pending = false;
|
||||||
|
trace("flush-pending");
|
||||||
void refreshQuota();
|
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);
|
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
debounceTimer = undefined;
|
debounceTimer = undefined;
|
||||||
@ -188,10 +256,18 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
return theme.current.success;
|
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 Status = () => {
|
||||||
const result = quota();
|
|
||||||
const theme = api.theme;
|
const theme = api.theme;
|
||||||
const windows: QuotaWindow[] = result?.ok === true ? result.quota.windows : [];
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg={theme.current.text}>
|
<text fg={theme.current.text}>
|
||||||
@ -200,12 +276,12 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
<text fg={theme.current.textMuted}>
|
<text fg={theme.current.textMuted}>
|
||||||
zdr:{state(toggles().zdr)} debug:{state(toggles().debug)}
|
zdr:{state(toggles().zdr)} debug:{state(toggles().debug)}
|
||||||
</text>
|
</text>
|
||||||
{windows.map((w) => (
|
{windowsOf(quota()).map((w) => (
|
||||||
<text fg={tone(w, theme)}>{shortWindow(w, now())}</text>
|
<text fg={tone(w, theme)}>{shortWindow(w, now())}</text>
|
||||||
))}
|
))}
|
||||||
{result?.ok === false ? (
|
{errorOf(quota()) !== undefined ? (
|
||||||
<text fg={theme.current.error}>quota: {result.error.message}</text>
|
<text fg={theme.current.error}>quota: {errorOf(quota())}</text>
|
||||||
) : result === null ? (
|
) : quota() === null ? (
|
||||||
<text fg={theme.current.textMuted}>quota: loading…</text>
|
<text fg={theme.current.textMuted}>quota: loading…</text>
|
||||||
) : null}
|
) : null}
|
||||||
</box>
|
</box>
|
||||||
@ -215,7 +291,12 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
api.slots.register({
|
api.slots.register({
|
||||||
order: SIDEBAR_ORDER,
|
order: SIDEBAR_ORDER,
|
||||||
slots: {
|
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 />;
|
return <Status />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -258,7 +339,7 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
namespace: "palette",
|
namespace: "palette",
|
||||||
slashName: "cc-usage",
|
slashName: "cc-usage",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const apiKey = resolveApiKey(api);
|
const { key: apiKey } = resolveApiKeySource(api);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
api.ui.toast({
|
api.ui.toast({
|
||||||
title: CATEGORY,
|
title: CATEGORY,
|
||||||
@ -267,7 +348,16 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
|||||||
});
|
});
|
||||||
return;
|
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) {
|
if (!result.ok) {
|
||||||
setQuota(result);
|
setQuota(result);
|
||||||
api.ui.toast({ title: CATEGORY, message: result.error.message, variant: "error" });
|
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 toggleTimer = setInterval(refresh, 1500);
|
||||||
const clockTimer = setInterval(() => setNow(Date.now()), 30_000);
|
const clockTimer = setInterval(() => setNow(Date.now()), 30_000);
|
||||||
const quotaTimer = setInterval(() => void refreshQuota(), quotaIntervalMs());
|
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(() => {
|
api.lifecycle.onDispose(() => {
|
||||||
for (const unsub of unsubs) unsub();
|
for (const unsub of unsubs) unsub();
|
||||||
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
|
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
|
||||||
|
|||||||
78
src/usage.ts
78
src/usage.ts
@ -6,8 +6,15 @@ export type CommandCodeUsage = {
|
|||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
cachedInputTokens?: 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 };
|
inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number };
|
||||||
outputTokenDetails?: { textTokens?: number; reasoningTokens?: number };
|
outputTokenDetails?: { textTokens?: number; reasoningTokens?: number };
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FinishEvent = {
|
export type FinishEvent = {
|
||||||
@ -16,6 +23,73 @@ export type FinishEvent = {
|
|||||||
totalUsage?: CommandCodeUsage;
|
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. */
|
/** CommandCode `finish` -> LanguageModelV3Usage. */
|
||||||
export function usageFromFinish(evt: FinishEvent): LanguageModelV3Usage {
|
export function usageFromFinish(evt: FinishEvent): LanguageModelV3Usage {
|
||||||
const tu = evt.totalUsage;
|
const tu = evt.totalUsage;
|
||||||
@ -26,10 +100,12 @@ export function usageFromFinish(evt: FinishEvent): LanguageModelV3Usage {
|
|||||||
const details = tu.inputTokenDetails ?? {};
|
const details = tu.inputTokenDetails ?? {};
|
||||||
const outDetails = tu.outputTokenDetails ?? {};
|
const outDetails = tu.outputTokenDetails ?? {};
|
||||||
const cacheRead = tu.cachedInputTokens ?? details.cacheReadTokens;
|
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 {
|
return {
|
||||||
inputTokens: {
|
inputTokens: {
|
||||||
total: tu.totalTokens ?? input + output,
|
total: tu.inputTokens ?? total - output,
|
||||||
noCache: details.noCacheTokens,
|
noCache: details.noCacheTokens,
|
||||||
cacheRead,
|
cacheRead,
|
||||||
cacheWrite: undefined,
|
cacheWrite: undefined,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user