Compare commits

..

14 Commits

Author SHA1 Message Date
49d518871b Drop COMMANDCODE_DEBUG, COMMANDCODE_DEBUG_FILE, and COMMANDCODE_ZDR env vars 2026-09-24 14:26:45 +00:00
053e34d350 docs: centralize environment variables into a dedicated section 2026-09-24 14:16:51 +00:00
78a4f42bc0 fix: sync-models will now prompt for API key when there is none, and it won't terminate when there is no opencode.json 2026-09-24 14:07:32 +00:00
ac29d26e12 rate limit the quota update: once every 2 min 2026-09-24 13:52:50 +00:00
a1fe7c7a87 Load tui plugin via direct src/tui.tsx file spec instead of repo root 2026-09-24 07:53:03 +00:00
cc7b9fc600 auto retry when receive 5xx error from server with isRetryable = true 2026-09-23 03:27:39 +00:00
ec7f32feea Make sync-models an interactive model picker 2026-09-22 12:15:39 +00:00
7e77c3c6fe 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).
2026-09-22 08:39:15 +00:00
ef9a944924 add usage tracking to sidebar 2026-09-16 15:30:12 +00:00
a52efe1f70 add zdr and debug status to the sidebar 2026-09-16 14:34:49 +00:00
41fe0788a7 add /cc-zdr, /cc-debug, /cc-status slash commands 2026-09-16 14:11:07 +00:00
0872a2099b only send x-cmd-zdr when COMMANDCODE_ZDR=1 2026-09-15 10:16:14 +00:00
c89943df1c Send x-cmd-zdr: 1 header on upstream generate requests 2026-09-15 09:47:00 +00:00
70b35d5164 add sample opencode config 2026-09-14 12:16:10 +07:00
16 changed files with 3125 additions and 198 deletions

View File

@ -23,14 +23,15 @@ Run from the repository root.
```powershell ```powershell
npm install # dev deps: typescript, @types/node, @ai-sdk/provider npm install # dev deps: typescript, @types/node, @ai-sdk/provider
npm run typecheck # tsc --noEmit npm run typecheck # tsc --noEmit (provider) + tsc -p tsconfig.tui.json (TUI plugin)
npm run build # tsc -> dist/ npm run build # tsc -> dist/ (provider only; the TUI plugin is loaded from source)
npm run smoke # live request against CommandCode (needs a key) npm run smoke # live request against CommandCode (needs a key)
npm run quota # live 5-hour/weekly/monthly quota (needs a key)
npm run sync-models # regenerate provider.commandcode.models from the live catalog npm run sync-models # regenerate provider.commandcode.models from the live catalog
``` ```
There is no unit-test suite. `scripts/smoke.mjs` is the end-to-end check. `scripts/sync-models.mjs` There is no unit-test suite. `scripts/smoke.mjs` is the end-to-end check. `scripts/sync-models.mjs`
is the model-catalog generator (needs `dist/` first; writes the opencode config unless `--out`/`--dry-run`). is the interactive model picker (needs `dist/` first; needs a TTY; writes the global opencode config).
After any change under `src/`, run `npm run typecheck`, then `npm run build`, then `npm run smoke`. After any change under `src/`, run `npm run typecheck`, then `npm run build`, then `npm run smoke`.
A change is not done until it typechecks and the smoke test passes. A change is not done until it typechecks and the smoke test passes.
@ -44,8 +45,15 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (return
src/events.ts Async iterator over the NDJSON/SSE response body. src/events.ts Async iterator over the NDJSON/SSE response body.
src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification. src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification.
src/redact.ts Credential scrubbing for error surfaces. src/redact.ts Credential scrubbing for error surfaces.
src/log.ts Opt-in tracing (toggle file) to a log file.
src/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json).
src/quota.ts Live 5-hour/weekly/monthly quota from the CommandCode alpha billing API.
src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar.
src/tui-shims.d.ts Ambient types for the opencode-provided solid-js/@opentui/solid runtime.
tsconfig.tui.json Typechecks src/tui.tsx (+ quota/toggles) against the shim (no dependencies).
src/constants.ts Defaults, paths, headers, passthrough params, static config block. src/constants.ts Defaults, paths, headers, passthrough params, static config block.
scripts/smoke.mjs Live end-to-end check. scripts/smoke.mjs Live end-to-end check.
scripts/quota.mjs Print the live quota headlessly (reads the same config as smoke).
scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing). scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing).
server.py Reference Python proxy (do not modify unless explicitly asked). server.py Reference Python proxy (do not modify unless explicitly asked).
``` ```
@ -99,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()`.
@ -107,12 +116,68 @@ 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.
13. **The TUI plugin lives outside the provider entrypoint.** `src/tui.tsx` is loaded from
**source** (not `dist/`) via a `tui.json` `plugin` entry that must be a **direct `file://` URL
to `src/tui.tsx`** (global `~/.config/opencode/tui.json` or project `.opencode/tui.json`),
wired by hand: `opencode plugin <path>` cannot install a bare `.tsx` (it needs a `package.json`
manifest next to the target), and the spec must never point at the **repo root** — a directory
spec is also picked up as a server plugin via `package.json` `main` → `dist/index.js` (the
provider, not a plugin). Do not re-add a `./tui` export for this; it is deliberately removed.
opencode compiles the `.tsx` at load with
its Bun/Solid transform and maps `solid-js` / `@opentui/solid` to its internal modules, so the
package stays dependency-free — do not add a build step or real imports of those packages.
`src/tui-shims.d.ts` is the only local stand-in for types; keep it in sync with what the file
actually imports. Verified on opencode 1.18.31: the module's **default export must be
`{ id, tui }`** (a bare `tui` named export is imported but never invoked), `id` is required,
and `tui` is `async (api) => {}`. Commands register via
`api.keymap.registerLayer({ commands })` with `{ name, run, title, desc, category,
namespace: "palette", slashName }`. The sidebar panel registers via
`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`.
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`.
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, so `/cc-*` changes take effect
without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` >
`x-cmd-zdr` header > toggle file. Do not reintroduce load-time consts.
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
(>= 1e12 = ms). The monthly meter is **derived** (spend + remaining credits), so it works on any
plan; never hardcode a plan→cap table. The TUI resolves the key from `api.state` (provider
entry, then `config.provider.commandcode.options`, then `COMMANDCODE_API_KEY`), expanding
`{env:VAR}` itself, and must never log the key — quota errors go through `redact()`.
17. **Retryable upstream failures can arrive inside an HTTP 200 stream.** CommandCode's gateway
answers `200` and then emits an SSE `error` event carrying `statusCode`/`isRetryable` (e.g.
`{"type":"server_error","message":"Invalid error response format: Gateway request failed",
"statusCode":520,"isRetryable":true}`). `fetchWithRetry` buffers only the `start`/`start-step`
preamble and, if the first real event is such a retryable error and attempts remain, cancels the
body and retries with backoff. Once any content event is seen the error is surfaced, not
retried (retrying mid-stream would duplicate output). Non-retryable in-stream errors (400s)
surface immediately. Keep the preamble set in sync if upstream adds new pre-content events.
## Change workflow ## Change workflow
@ -135,6 +200,12 @@ Automated:
npm run typecheck; npm run build; npm run smoke npm run typecheck; npm run build; npm run smoke
``` ```
Quota changes additionally need a live check (needs a key):
```powershell
npm run quota
```
Inside opencode (restart it first if config changed): Inside opencode (restart it first if config changed):
```powershell ```powershell

186
README.md
View File

@ -48,7 +48,7 @@ npm run build
"provider": { "provider": {
"commandcode": { "commandcode": {
"name": "Command Code", "name": "Command Code",
"npm": "file:///C:/DevTools/pienv/ccprovider/dist/index.js", "npm": "file:///C:/dev/opencode-commandcode-provider/dist/index.js",
"options": { "options": {
"apiKey": "{env:COMMANDCODE_API_KEY}" "apiKey": "{env:COMMANDCODE_API_KEY}"
}, },
@ -84,13 +84,26 @@ to it, but opencode builds `/models` and the TUI picker from this map and never
opencode reads the model list from `provider.commandcode.models`; a custom provider cannot opencode reads the model list from `provider.commandcode.models`; a custom provider cannot
register dynamic discovery. `scripts/sync-models.mjs` fetches the live catalog from register dynamic discovery. `scripts/sync-models.mjs` fetches the live catalog from
`GET /provider/v1/models` and writes the full map into your opencode config. `GET /provider/v1/models` and opens an interactive checkbox picker (nothing pre-checked).
Only the selected models are vision-probed and written into your global opencode config —
deselected ids are removed from it.
```powershell ```powershell
npm run build # the script imports the compiled provider npm run build # the script imports the compiled provider
npm run sync-models # fetch catalog, probe vision, merge into ~/.config/opencode/opencode.json npm run sync-models # fetch catalog, pick models, probe vision, write ~/.config/opencode/opencode.json
``` ```
Keys: `↑/↓` move (one model per step), `space` toggles, `Ctrl-A` selects all, `Ctrl-U` clears,
typing filters, `Esc` clears the filter (empty filter aborts), `enter` confirms, `Ctrl-C` aborts
without writing. Each row shows the model id and name plus a second line with its context window
(`ctx 1M (1000000)`); the catalog carries no pricing fields, so no cost is shown. Needs an
interactive terminal.
Before anything else the script requires a CommandCode API key (from
`provider.commandcode.options.apiKey` or `COMMANDCODE_API_KEY`). If neither is present — including
when `opencode.json` does not exist yet — it exits and points you at the CommandCode Console
(<https://commandcode.ai/studio/provider>) to generate one. It never writes without a key.
Then restart opencode and confirm: Then restart opencode and confirm:
```powershell ```powershell
@ -108,21 +121,11 @@ Mapping and behaviour:
`qwen…vl`, …). Probing is best-effort: upstream is nondeterministic and a re-run may flip a `qwen…vl`, …). Probing is best-effort: upstream is nondeterministic and a re-run may flip a
borderline model. borderline model.
- Generated fields (`name`, `limit`, `reasoning`, `attachment`, `modalities`) overwrite existing - Generated fields (`name`, `limit`, `reasoning`, `attachment`, `modalities`) overwrite existing
values. Other fields (`variants`, `options`, `cost`, …) and ids not in the catalog are preserved. values. Other fields (`variants`, `options`, `cost`, …) on selected ids are preserved.
Unselected ids are removed from the map.
Options:
```powershell
node scripts/sync-models.mjs --dry-run # print the merged map, write nothing
node scripts/sync-models.mjs --out models.json # write only the models fragment
node scripts/sync-models.mjs --no-probe # skip probing, use the heuristic (fast/offline)
node scripts/sync-models.mjs --concurrency 8 # probe parallelism (default 6)
node scripts/sync-models.mjs --no-preserve # snapshot only: drop user ids/extra fields
node scripts/sync-models.mjs --config <path> # target a different config
```
The API key is read from `COMMANDCODE_API_KEY`, then `provider.commandcode.options.apiKey` in the The API key is read from `COMMANDCODE_API_KEY`, then `provider.commandcode.options.apiKey` in the
target config. global config (`baseURL`/`ccVersion` are read from there too).
## Configuration options ## Configuration options
@ -143,26 +146,131 @@ target config.
`options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent `options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent
unauthenticated and CommandCode will reject them. unauthenticated and CommandCode will reject them.
## Environment variables
All environment variables read by the provider, its scripts, and its TUI plugin. String values in
`options` also accept `{env:NAME}`, which opencode/the TUI expand generically (not a fixed set).
CommandCode-specific:
| Name | Purpose | Default |
| --- | --- | --- |
| `COMMANDCODE_API_KEY` | CommandCode API key for `smoke`/`quota`/`sync-models` and the TUI's key resolution; referenced from config as `{env:COMMANDCODE_API_KEY}`. | — |
| `COMMANDCODE_TOGGLES_FILE` | Overrides the shared toggle-file path. | `$XDG_CONFIG_HOME/opencode/commandcode-toggles.json` |
| `COMMANDCODE_QUOTA_INTERVAL_MS` | Sidebar fallback quota refresh interval in ms. | `300000` (floor `120000`) |
Standard:
| Name | Purpose | Default |
| --- | --- | --- |
| `XDG_CONFIG_HOME` | Base config dir for the toggle file. | `~/.config` |
| `NO_COLOR` | Disables ANSI colors in the `sync-models` picker (any value). | unset |
| `TERM` | `dumb` disables ANSI colors in the `sync-models` picker. | — |
## Debug tracing ## Debug tracing
Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file. Flip the `debug` key in the shared toggle file (via `/cc-debug`) to write a trace of every
Silent (and no file is created) unless enabled. Every line is passed through `redact()` so request and stream event to a log file. Silent (and no file is created) unless enabled; the log
path is `<os tempdir>/commandcode-debug.log`. Every line is passed through `redact()` so
credentials never reach disk. credentials never reach disk.
```powershell
$env:COMMANDCODE_DEBUG = "1"
opencode run "Reply with exactly: pong" -m commandcode/deepseek/deepseek-v4.1-flash
```
| Env var | Default | Description |
| --- | --- | --- |
| `COMMANDCODE_DEBUG` | unset | `1`/`true`/`yes` enables tracing. |
| `COMMANDCODE_DEBUG_FILE` | `<os tempdir>/commandcode-debug.log` | Where the trace is appended. |
The log captures the request (model id, body byte length), each HTTP attempt (status, retry The log captures the request (model id, body byte length), each HTTP attempt (status, retry
wait, elapsed ms), every stream event payload, and the terminal finish reason + usage. It is wait, elapsed ms), every stream event payload, and the terminal finish reason + usage. It is
useful for diagnosing model selection, retry, tool-call, and finish-reason issues. useful for diagnosing model selection, retry, tool-call, and finish-reason issues.
## ZDR header toggle
The `x-cmd-zdr: 1` request header is **omitted by default** because it is rejected by some
models. It can be enabled with the `/cc-zdr` slash command or
`providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
does not require restarting opencode.
Precedence, highest first:
1. `providerOptions.commandcode.zdr` (`true`/`false`), e.g. a model variant or agent option.
2. An explicit `x-cmd-zdr` header in `opencode.json` `options.headers` or the call's `headers`.
3. The toggle file (`zdr` key — see [Runtime toggles](#runtime-toggles)).
With tracing enabled, the trace logs a `zdr on|off` line per request so the toggle state is
observable.
## Runtime toggles
`/cc-zdr` and `/cc-debug` flip the `zdr` and `debug` values in a small JSON file the provider
reads on every request, so both settings change without restarting opencode. `/cc-status` shows
the current state. The file is `~/.config/opencode/commandcode-toggles.json` (overridable with
`COMMANDCODE_TOGGLES_FILE` — see [Environment variables](#environment-variables)).
These commands come from a small TUI plugin shipped in this package (`src/tui.tsx`). It is loaded
from **source** — opencode compiles the TSX and provides the Solid runtime itself — so there is no
build step and no runtime dependency for it. Register it once by adding the plugin file's absolute
`file://` URL to the `plugin` list in `tui.json` (global `~/.config/opencode/tui.json`, or
project-local `.opencode/tui.json`):
```jsonc
// tui.json
{
"plugin": ["file:///C:/dev/opencode-commandcode-provider/src/tui.tsx"]
}
```
Use an absolute path with forward slashes (`file:///C:/...` on Windows, `file:///home/...` on
Linux/macOS). Two rules for the entry:
- It must point at **`src/tui.tsx` itself**. `opencode plugin <module>` cannot install this form:
the CLI requires a `package.json` manifest next to the target, so a bare `.tsx` must be wired in
by hand as above.
- Never point `plugin` at the **repo root**. A directory spec makes opencode also treat the package
as a server plugin (via `package.json` `main` → `dist/index.js`), which is the AI SDK provider,
not a plugin — it loads as dead weight into every session.
Restart opencode after registering the plugin; then type `/cc-` for autocomplete. The plugin also
renders a **CommandCode panel in the session sidebar** (`zdr` / `debug`, plus live quota, above the
built-in panels) that updates live as you toggle. Note that in opencode 1.x the slash menu and the
`Ctrl+P` palette read the same command registry, so these entries appear in both. If you run opencode
against a remote server, the TUI-side file is not visible to the provider on the server host — use
the env vars or `providerOptions` there.
## Quota tracking
`/cc-usage` and the sidebar show CommandCode's **5-hour**, **weekly** and **monthly** usage for the
account behind `provider.commandcode.options.apiKey`. The numbers come live from the same alpha
billing endpoints the `cmd` CLI `/usage` command uses:
| Endpoint | Used for |
| --- | --- |
| `GET /alpha/whoami` | account identity and org id |
| `GET /alpha/billing/credits` | 5-hour/weekly windows (`used`, `cap`, `resetAt`) and credit balances |
| `GET /alpha/billing/subscriptions` | plan id, status, billing period |
| `GET /alpha/usage/summary` | requests/tokens/cost for the current billing period |
The **5-hour** and **weekly** windows show `used / cap` and a live reset countdown. The **monthly**
meter is derived: `usage/summary` cost vs. that cost plus all remaining credits, so it works on any
plan (including org and pay-as-you-go) without a maintained price table. Free and pay-as-you-go
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`, 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,
but never more than once every 2 minutes; every 5 minutes as a fallback
(`COMMANDCODE_QUOTA_INTERVAL_MS` overrides the fallback interval — see
[Environment variables](#environment-variables)) 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:
```powershell
npm run build
npm run quota # formatted
npm run quota -- --json # raw quota object
```
The API key is never logged; quota errors pass through `redact()` like every other surface.
## Features ## Features
| Capability | Status | | Capability | Status |
@ -176,11 +284,15 @@ useful for diagnosing model selection, retry, tool-call, and finish-reason issue
| 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` |
| Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` | | Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After`; also retryable `error` events that arrive inside an HTTP 200 stream before any content (gateway 520s) |
| Credential redaction | Yes — error bodies are scrubbed before surfacing | | Credential redaction | Yes — error bodies are scrubbed before surfacing |
| Runtime toggles | Yes — `/cc-zdr` and `/cc-debug` flip the shared toggle file without a restart |
| Sidebar status panel | Yes — a `sidebar_content` panel shows `zdr`/`debug` and live quota |
| Quota tracking | Yes — 5-hour/weekly/monthly meters via `/cc-usage`, the sidebar, and `npm run quota` |
### `tool_choice` handling ### `tool_choice` handling
@ -213,10 +325,14 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (JSON s
src/events.ts NDJSON/SSE line iterator over the upstream response body. src/events.ts NDJSON/SSE line iterator over the upstream response body.
src/usage.ts finish event -> V3 usage; finish-reason unification. src/usage.ts finish event -> V3 usage; finish-reason unification.
src/redact.ts Credential scrubbing for error surfaces. src/redact.ts Credential scrubbing for error surfaces.
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. src/log.ts Opt-in tracing (toggle file) to a log file.
src/toggles.ts Shared toggle file read by the provider and written by the TUI plugin.
src/quota.ts Live 5-hour/weekly/monthly quota from the alpha billing endpoints.
src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar panel.
src/constants.ts Defaults, header names, passthrough params, static config block. src/constants.ts Defaults, header names, passthrough params, static config block.
scripts/smoke.mjs Live end-to-end check against api.commandcode.ai. scripts/smoke.mjs Live end-to-end check against api.commandcode.ai.
scripts/sync-models.mjs Generate provider.commandcode.models from the live catalog. scripts/quota.mjs Print the live quota headlessly.
scripts/sync-models.mjs Pick models interactively and write provider.commandcode.models.
``` ```
### Request lifecycle ### Request lifecycle
@ -241,9 +357,10 @@ scripts/sync-models.mjs Generate provider.commandcode.models from the live catal
```powershell ```powershell
npm install # installs typescript, @types/node, @ai-sdk/provider npm install # installs typescript, @types/node, @ai-sdk/provider
npm run typecheck # tsc --noEmit npm run typecheck # tsc --noEmit (provider) + tsc -p tsconfig.tui.json (TUI plugin)
npm run build # tsc -> dist/ npm run build # tsc -> dist/ (provider only; the TUI plugin loads from source)
npm run smoke # live request against CommandCode npm run smoke # live request against CommandCode
npm run quota # live 5-hour/weekly/monthly quota
npm run sync-models # regenerate provider.commandcode.models from the catalog npm run sync-models # regenerate provider.commandcode.models from the catalog
``` ```
@ -274,9 +391,10 @@ opencode run "Use the glob tool to list *.mjs and report the filenames." -m comm
| `stream ended ... no finish` / truncated output | Upstream closed the connection early. The provider emits a synthetic `finish`, but the response is incomplete; retry the turn. | | `stream ended ... no finish` / truncated output | Upstream closed the connection early. The provider emits a synthetic `finish`, but the response is incomplete; retry the turn. |
| Images are ignored by the model | The selected model is not vision-capable. Mark it with `"attachment": true` and `modalities.input: ["text","image"]` in `models`, and pick a vision model id. | | Images are ignored by the model | The selected model is not vision-capable. Mark it with `"attachment": true` and `modalities.input: ["text","image"]` in `models`, and pick a vision model id. |
| Upstream 400 about tool calls | A `tool-call` or `tool-result` without a matching pair slipped through. Pairing is enforced in `src/transform.ts`; report a repro if it still occurs. | | Upstream 400 about tool calls | A `tool-call` or `tool-result` without a matching pair slipped through. Pairing is enforced in `src/transform.ts`; report a repro if it still occurs. |
| Need to see what the provider sends/receives | Set `COMMANDCODE_DEBUG=1` and read the appended log file (see [Debug tracing](#debug-tracing)). | | Need to see what the provider sends/receives | Flip `/cc-debug` and read the appended log file (see [Debug tracing](#debug-tracing)). |
| `tool_choice` seemingly ignored | Expected for `required`; upstream cannot force a call. `none` and named-tool are emulated via the tool list. | | `tool_choice` seemingly ignored | Expected for `required`; upstream cannot force a call. `none` and named-tool are emulated via the tool list. |
| Config change had no effect | opencode reads config once at startup. Restart it. | | Config change had no effect | opencode reads config once at startup. Restart it. |
| `/cc-*` commands and the sidebar panel are missing | TUI plugin not registered: add a direct `file://` URL to `src/tui.tsx` in `tui.json` (see [Runtime toggles](#runtime-toggles)). A repo-root or `src/tui.tsx`-less entry does not load. |
## Security ## Security

View File

@ -12,12 +12,19 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"src/tui.tsx",
"src/tui-shims.d.ts",
"src/toggles.ts",
"src/quota.ts",
"src/redact.ts",
"src/constants.ts"
], ],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json",
"smoke": "node scripts/smoke.mjs", "smoke": "node scripts/smoke.mjs",
"quota": "node scripts/quota.mjs",
"sync-models": "node scripts/sync-models.mjs" "sync-models": "node scripts/sync-models.mjs"
}, },
"devDependencies": { "devDependencies": {

1262
scripts/opencode.json Normal file

File diff suppressed because it is too large Load Diff

91
scripts/quota.mjs Normal file
View File

@ -0,0 +1,91 @@
// Print the live CommandCode quota (5-hour, weekly, monthly) from the alpha billing
// endpoints. The same data drives the TUI sidebar and /cc-usage command; this script is
// the headless check and does not need opencode running.
//
// Run: node scripts/quota.mjs [--config <path>] [--base-url <url>] [--json] (build first: npm run build)
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { fetchQuota, formatQuota } from "../dist/quota.js";
function parseArgs(argv) {
const opts = {
config: join(homedir(), ".config", "opencode", "opencode.json"),
baseURL: undefined,
json: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const value = () => {
const next = argv[++i];
if (next === undefined) throw new Error(`Missing value for ${arg}`);
return next;
};
switch (arg) {
case "--config":
opts.config = value();
break;
case "--base-url":
opts.baseURL = value();
break;
case "--json":
opts.json = true;
break;
case "-h":
case "--help":
console.log(
[
"Print CommandCode quota from the alpha billing endpoints.",
"",
"Usage: node scripts/quota.mjs [options]",
" --config <path> opencode config to read (default ~/.config/opencode/opencode.json)",
" --base-url <url> upstream origin (default from config or api.commandcode.ai)",
" --json print the raw quota object instead of formatted text",
].join("\n"),
);
process.exit(0);
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return opts;
}
function readConfig(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return {};
}
}
function resolveKey(config, env) {
const fromEnv = env.COMMANDCODE_API_KEY;
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
const options = config?.provider?.commandcode?.options ?? {};
const fromConfig = options.apiKey;
if (typeof fromConfig === "string" && fromConfig.trim()) return fromConfig.trim();
const auth = options.headers?.Authorization ?? options.headers?.authorization;
if (typeof auth === "string" && auth.trim()) return auth.replace(/^Bearer\s+/i, "").trim();
return undefined;
}
const opts = parseArgs(process.argv.slice(2));
const config = readConfig(opts.config);
const apiKey = resolveKey(config, process.env);
if (!apiKey) {
console.error(
`No CommandCode API key found. Set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey in ${opts.config}.`,
);
process.exit(1);
}
const baseURL = opts.baseURL ?? config?.provider?.commandcode?.options?.baseURL;
const result = await fetchQuota({ apiKey, baseURL: typeof baseURL === "string" ? baseURL : undefined });
if (!result.ok) {
console.error(result.error.message);
process.exit(1);
}
console.log(opts.json ? JSON.stringify(result.quota, null, 2) : formatQuota(result.quota));

View File

@ -1,23 +1,29 @@
// Generate the opencode `provider.commandcode.models` map from CommandCode's live catalog. // Interactive model picker for opencode `provider.commandcode.models`.
// //
// opencode builds `/models` from the configured model map; a custom `file://` provider is only // opencode builds `/models` from the configured model map; a custom `file://` provider is only
// ever asked for `languageModel(id)`, never queried for discovery. So the catalog has to be baked // ever asked for `languageModel(id)`, never queried for discovery. So the catalog has to be baked
// into opencode.json. This script fetches it, probes each model for vision support, and merges the // into opencode.json. This script fetches the live catalog, lets the user pick which models to
// result into the target config. // enable with a builtins-only checkbox UI, probes the selection for vision support, and writes
// the result into the global opencode config. Deselecting a model removes it from the config.
// //
// Run: node scripts/sync-models.mjs [options] (build first: npm run build) // Run: node scripts/sync-models.mjs (build first: npm run build)
import { readFileSync, writeFileSync, renameSync } from "node:fs"; import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import readline from "node:readline";
import { createCommandCode } from "../dist/index.js"; import { createCommandCode } from "../dist/index.js";
import { redact } from "../dist/redact.js"; import { redact } from "../dist/redact.js";
import { DEFAULT_BASE_URL, DEFAULT_CC_VERSION, DEFAULT_MAX_TOKENS, MODELS_PATH } from "../dist/constants.js"; import { DEFAULT_BASE_URL, DEFAULT_CC_VERSION, DEFAULT_MAX_TOKENS, MODELS_PATH } from "../dist/constants.js";
const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json");
const CONSOLE_URL = "https://commandcode.ai/studio/provider";
const CONCURRENCY = 6;
// 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response. // 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response.
const PROBE_IMAGE = { const PROBE_IMAGE = {
type: "file", type: "file",
mediaType: "image/png", mediaType: "image/png",
data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeUlEQVR4nO3PQQkAMAzAwCqpf1ETMxF7HINABFzm7H7dcEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFj13PLIEAOXyUUwAAAABJRU5ErkJggg==", data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeUlEQVR4nO3PQQkAMAzAwCqpf1ETMxF7HINABFzm7H7dcEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFj13PLIEAOXyUUwAAAABJRU5ErkJggg==",
}; };
const VISION_ERROR = /image|vision|multimodal|modality|unsupported|not support|invalid.*content|does not support/i; const VISION_ERROR = /image|vision|multimodal|modality|unsupported|not support|invalid.*content|does not support/i;
@ -26,63 +32,20 @@ const VISION_ERROR = /image|vision|multimodal|modality|unsupported|not support|i
const NO_IMAGE = const NO_IMAGE =
/(?:don'?t|do not|can'?t|cannot|unable to)\s+(?:see|view|detect|find|access|receive|open|process)|no\s+(?:image|picture|attachment|photo)|not\s+(?:see|receive|detect|attach)|image\s+(?:was\s+)?not\s+(?:attach|provid|receiv|includ)/i; /(?:don'?t|do not|can'?t|cannot|unable to)\s+(?:see|view|detect|find|access|receive|open|process)|no\s+(?:image|picture|attachment|photo)|not\s+(?:see|receive|detect|attach)|image\s+(?:was\s+)?not\s+(?:attach|provid|receiv|includ)/i;
// Fallback when a probe is inconclusive or disabled. Ground truth is unavailable from the catalog, // Fallback when a probe is inconclusive. Ground truth is unavailable from the catalog,
// so this is only a best guess for ids that look like known vision families. // so this is only a best guess for ids that look like known vision families.
const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i; const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i;
function parseArgs(argv) {
const opts = {
config: join(homedir(), ".config", "opencode", "opencode.json"),
baseURL: undefined,
out: undefined,
dryRun: false,
probe: true,
concurrency: 6,
preserve: true,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const value = () => {
const next = argv[++i];
if (next === undefined) throw new Error(`Missing value for ${arg}`);
return next;
};
switch (arg) {
case "--config": opts.config = value(); break;
case "--base-url": opts.baseURL = value(); break;
case "--out": opts.out = value(); break;
case "--concurrency": opts.concurrency = Math.max(1, Number(value()) || 1); break;
case "--dry-run": opts.dryRun = true; break;
case "--no-probe": opts.probe = false; break;
case "--no-preserve": opts.preserve = false; break;
case "-h":
case "--help":
console.log(
[
"Generate provider.commandcode.models from the CommandCode catalog.",
"",
"Usage: node scripts/sync-models.mjs [options]",
" --config <path> opencode config to update (default ~/.config/opencode/opencode.json)",
" --base-url <url> upstream origin (default from config or api.commandcode.ai)",
" --out <file> write only the models fragment; do not touch the config",
" --dry-run print the merged map; write nothing",
" --no-probe skip vision probing; use the family heuristic",
" --concurrency <n> vision probe parallelism (default 6)",
" --no-preserve snapshot only; drop user-added ids and extra fields",
].join("\n"),
);
process.exit(0);
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return opts;
}
function readConfig(path) { function readConfig(path) {
try {
const text = readFileSync(path, "utf8"); const text = readFileSync(path, "utf8");
return { text, data: JSON.parse(text) }; return { text, data: JSON.parse(text), exists: true };
} catch (error) {
// A fresh install has no opencode.json yet; treat it as empty so the console
// guidance / env-key path can still run. Bad JSON or unreadable files still throw.
if (error.code === "ENOENT") return { text: "", data: {}, exists: false };
throw error;
}
} }
function detectIndent(text) { function detectIndent(text) {
@ -197,34 +160,248 @@ function modelEntry(catalogModel, vision) {
return entry; return entry;
} }
function mergeModels(existing, generated, preserve) {
const out = {};
if (preserve) {
for (const [id, model] of Object.entries(existing ?? {})) out[id] = model;
}
for (const [id, entry] of Object.entries(generated)) {
out[id] = preserve ? { ...(existing?.[id] ?? {}), ...entry } : entry;
}
return out;
}
function writeAtomic(path, contents) { function writeAtomic(path, contents) {
const tmp = `${path}.${process.pid}.tmp`; const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, contents); writeFileSync(tmp, contents);
renameSync(tmp, path); renameSync(tmp, path);
} }
async function main() { // The catalog carries no pricing fields (id/name/context_length/supported_endpoints only),
const opts = parseArgs(process.argv.slice(2)); // so the picker shows context with any opportunistically present pricing keys omitted.
const { text, data: config } = readConfig(opts.config); function formatContext(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "unknown";
if (n >= 1_000_000 && n % 1_000_000 === 0) return `${n / 1_000_000}M (${n})`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M (${n})`;
if (n >= 1000 && n % 1000 === 0) return `${n / 1000}k (${n})`;
if (n >= 1000) return `${(n / 1000).toFixed(0)}k (${n})`;
return `${n}`;
}
const key = resolveKey(config, process.env); // Builtins-only checkbox picker (no dependencies, so the package stays runtime-dependency-free).
if (!key) { // Nothing is pre-checked; deselected ids are dropped by the caller. Resolves to the selected
console.error("No API key: set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey"); // catalog indices, or null when the user aborts (Esc on empty filter / Ctrl-C).
function pickModels(catalog, existingIds) {
return new Promise((resolve) => {
const checked = new Set();
let filter = "";
let cursor = 0;
let scroll = 0;
let done = false;
const columns = () => process.stdout.columns ?? 80;
// Two terminal lines per model (header + context detail); keep at least one model visible.
const pageSize = () => Math.max(1, Math.floor(Math.max(5, (process.stdout.rows ?? 24) - 7) / 2));
const filtered = () => {
if (!filter) return catalog.map((_, index) => index);
const needle = filter.toLowerCase();
const out = [];
catalog.forEach((model, index) => {
if (`${model.id} ${model.name ?? ""}`.toLowerCase().includes(needle)) out.push(index);
});
return out;
};
const clamp = (list) => {
if (list.length === 0) {
cursor = 0;
scroll = 0;
return;
}
cursor = Math.min(Math.max(0, cursor), list.length - 1);
const size = pageSize();
if (cursor < scroll) scroll = cursor;
if (cursor >= scroll + size) scroll = cursor - size + 1;
};
const line = (text, code) => {
// Slice visible text first so ANSI codes never count toward the terminal
// width or get cut mid-sequence. NO_COLOR and dumb terminals get plain text.
const visible = String(text).slice(0, columns());
if (code === undefined || process.env.NO_COLOR || process.env.TERM === "dumb") {
process.stdout.write(`${visible}\n`);
return;
}
process.stdout.write(`\x1b[${code}m${visible}\x1b[0m\n`);
};
const render = () => {
const list = filtered();
clamp(list);
const size = pageSize();
const view = list.slice(scroll, scroll + size);
process.stdout.write("\x1b[?25l\x1b[2J\x1b[H");
line("Select CommandCode models (type to filter · space toggle · enter confirm)");
line(`Catalog: ${catalog.length} models · Selected: ${checked.size} · Filter: ${filter}\u2588`);
line("");
if (list.length === 0) {
line(" (no matches)");
}
view.forEach((original, i) => {
const model = catalog[original];
const active = scroll + i === cursor;
const selected = checked.has(original);
const mark = selected ? "x" : " ";
const current = existingIds.has(model.id) ? " · current" : "";
// Selected rows render green (both lines); the cursor row renders bold on top.
const code = selected ? (active ? "1;32" : "32") : active ? "1" : undefined;
line(`${active ? "\u203a" : " "} [${mark}] ${model.id} \u2014 ${model.name ?? model.id}${current}`, code);
line(` ctx ${formatContext(model.context_length)}`, code);
});
line("");
line("\u2191/\u2193 navigate · space toggle · ctrl-a all · ctrl-u none · esc back/abort · enter confirm");
};
const cleanup = () => {
if (done) return;
done = true;
process.stdout.removeListener("resize", render);
process.stdin.removeListener("keypress", onKey);
try {
process.stdin.setRawMode(false);
} catch {
// Non-TTY teardown; terminal state is already sane.
}
process.stdin.pause();
process.stdout.write("\x1b[?25h");
};
const finish = (value) => {
cleanup();
resolve(value);
};
const toggleAll = (list, value) => {
for (const index of list) {
if (value) checked.add(index);
else checked.delete(index);
}
};
function onKey(str, key) {
if (done) return;
const list = filtered();
if (key.ctrl && (key.name === "c" || key.name === "d")) {
finish(null);
return;
}
if (key.ctrl && key.name === "a") {
toggleAll(list, true);
render();
return;
}
if (key.ctrl && key.name === "u") {
checked.clear();
render();
return;
}
switch (key.name) {
case "return":
finish([...checked].sort((a, b) => a - b));
return;
case "escape":
// First Esc clears the filter; a second Esc on an empty filter aborts.
if (filter) {
filter = "";
cursor = 0;
scroll = 0;
render();
return;
}
finish(null);
return;
case "backspace":
if (filter) {
filter = filter.slice(0, -1);
cursor = 0;
scroll = 0;
render();
}
return;
case "up":
cursor = list.length === 0 ? 0 : (cursor - 1 + list.length) % list.length;
render();
return;
case "down":
cursor = list.length === 0 ? 0 : (cursor + 1) % list.length;
render();
return;
case "space": {
const current = list[cursor];
if (current !== undefined) {
if (checked.has(current)) checked.delete(current);
else checked.add(current);
}
render();
return;
}
default:
break;
}
// All printable characters feed the filter — letter shortcuts would swallow
// filter input (e.g. typing "qwen" would abort on the "q"). Bulk actions live
// on Ctrl combos above; abort is Esc (empty filter) or Ctrl-C.
if (!key.ctrl && !key.meta && typeof str === "string" && str.length === 1) {
const printable = str >= " " && str <= "~";
if (printable && str !== " ") {
filter += str;
cursor = 0;
scroll = 0;
render();
}
}
}
readline.emitKeypressEvents(process.stdin);
try {
process.stdin.setRawMode(true);
} catch {
resolve(null);
return;
}
process.stdin.resume();
process.stdout.on("resize", render);
process.stdin.on("keypress", onKey);
render();
});
}
async function main() {
let config;
let text;
let exists;
try {
({ text, data: config, exists } = readConfig(CONFIG_PATH));
} catch (error) {
console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
const baseURL = (opts.baseURL ?? config?.provider?.commandcode?.options?.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); // Mandatory first step: without a CommandCode key there is nothing to fetch or
// probe, so point a fresh user at the Console before doing anything else.
const key = resolveKey(config, process.env);
if (!key) {
const where = exists
? `No CommandCode API key in ${CONFIG_PATH}.`
: `No opencode.json found at ${CONFIG_PATH}.`;
console.error(
`${where}\n` +
`Generate an API key in the CommandCode Console: ${CONSOLE_URL}\n` +
(exists
? `Add it as provider.commandcode.options.apiKey, or export COMMANDCODE_API_KEY.`
: `Create that file with provider.commandcode.options.apiKey, or export COMMANDCODE_API_KEY.`) +
`\nNothing was written.`,
);
process.exit(1);
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error("Interactive picker needs a TTY; nothing was written.");
process.exit(1);
}
const baseURL = (config?.provider?.commandcode?.options?.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
const ccVersion = config?.provider?.commandcode?.options?.ccVersion ?? DEFAULT_CC_VERSION; const ccVersion = config?.provider?.commandcode?.options?.ccVersion ?? DEFAULT_CC_VERSION;
console.log(`Fetching catalog: ${baseURL}${MODELS_PATH}`); console.log(`Fetching catalog: ${baseURL}${MODELS_PATH}`);
@ -235,24 +412,40 @@ async function main() {
console.error(`Failed to fetch catalog: ${error instanceof Error ? error.message : String(error)}`); console.error(`Failed to fetch catalog: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
if (catalog.length === 0) {
console.error("Catalog is empty; config unchanged.");
process.exit(1);
}
console.log(`Catalog: ${catalog.length} models`); console.log(`Catalog: ${catalog.length} models`);
const provider = createCommandCode({ name: "commandcode", apiKey: key, baseURL, ccVersion, maxRetries: 1 }); const existing = config?.provider?.commandcode?.models ?? {};
const existingIds = new Set(Object.keys(existing));
let probeResults = []; const picked = await pickModels(catalog, existingIds);
if (opts.probe) { if (picked === null) {
console.log(`Probing vision (${catalog.length} models, concurrency ${opts.concurrency})...`); console.log("Aborted \u2014 config unchanged.");
probeResults = await mapPool(catalog, opts.concurrency, async (model, index) => { return;
const outcome = await probeVisionConfirmed(provider, model.id); }
const tag = outcome.vision === true ? "vision" : outcome.vision === false ? "no-vision" : "unknown"; if (picked.length === 0) {
console.log(` [${index + 1}/${catalog.length}] ${model.id}: ${tag} (${outcome.reason})`); console.log("No models selected \u2014 config unchanged.");
return outcome; return;
});
} }
const generated = {}; const selected = picked.map((index) => catalog[index]).filter(Boolean);
console.log(`Probing vision (${selected.length} models, concurrency ${CONCURRENCY})...`);
const provider = createCommandCode({ name: "commandcode", apiKey: key, baseURL, ccVersion, maxRetries: 1 });
const probeResults = await mapPool(selected, CONCURRENCY, async (model, index) => {
const outcome = await probeVisionConfirmed(provider, model.id);
const tag = outcome.vision === true ? "vision" : outcome.vision === false ? "no-vision" : "unknown";
console.log(` [${index + 1}/${selected.length}] ${model.id}: ${tag} (${outcome.reason})`);
return outcome;
});
// Deselect = remove: the final map holds exactly the picked ids. Extra user fields on
// picked ids are preserved; unpicked ids are dropped.
const merged = {};
const stats = { probed: 0, vision: 0, noVision: 0, unknown: 0, heuristic: 0 }; const stats = { probed: 0, vision: 0, noVision: 0, unknown: 0, heuristic: 0 };
catalog.forEach((model, index) => { selected.forEach((model, index) => {
const outcome = probeResults[index]; const outcome = probeResults[index];
let vision; let vision;
if (outcome && outcome.vision !== undefined) { if (outcome && outcome.vision !== undefined) {
@ -265,41 +458,24 @@ async function main() {
stats.unknown++; stats.unknown++;
if (vision) stats.heuristic++; if (vision) stats.heuristic++;
} }
generated[model.id] = modelEntry(model, vision); merged[model.id] = { ...(existing[model.id] ?? {}), ...modelEntry(model, vision) };
}); });
const existing = config?.provider?.commandcode?.models ?? {}; const removed = [...existingIds].filter((id) => !(id in merged)).length;
const merged = mergeModels(existing, generated, opts.preserve);
const added = Object.keys(generated).filter((id) => !(id in existing)).length;
const preservedIds = Object.keys(existing).filter((id) => !(id in generated)).length;
if (opts.out) {
writeAtomic(opts.out, `${JSON.stringify(generated, null, 2)}\n`);
console.log(`Wrote ${Object.keys(generated).length} models to ${opts.out}`);
return;
}
if (opts.dryRun) {
console.log(JSON.stringify(merged, null, 2));
console.log(
`\n[dry-run] generated=${Object.keys(generated).length} added=${added} ` +
`preserved-ids=${preservedIds} probed=${stats.probed} vision=${stats.vision} ` +
`no-vision=${stats.noVision} unknown=${stats.unknown} heuristic-vision=${stats.heuristic}`,
);
return;
}
config.provider ??= {}; config.provider ??= {};
config.provider.commandcode ??= {}; config.provider.commandcode ??= {};
config.provider.commandcode.models = merged; config.provider.commandcode.models = merged;
const indent = detectIndent(text); const indent = detectIndent(text);
const trailing = text.endsWith("\n") ? "\n" : ""; const trailing = text === "" || text.endsWith("\n") ? "\n" : "";
writeAtomic(opts.config, `${JSON.stringify(config, null, indent)}${trailing}`); // The config directory may not exist yet on a fresh install; create it before the atomic write.
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`);
console.log( console.log(
`Wrote ${Object.keys(merged).length} models to ${opts.config}\n` + `Wrote ${Object.keys(merged).length} models to ${CONFIG_PATH}\n` +
` generated=${Object.keys(generated).length} added=${added} preserved-ids=${preservedIds}\n` + ` selected=${selected.length} removed=${removed}\n` +
` probed=${stats.probed} vision=${stats.vision} no-vision=${stats.noVision} ` + ` probed=${stats.probed} vision=${stats.vision} no-vision=${stats.noVision} ` +
`unknown=${stats.unknown} heuristic-vision=${stats.heuristic}\n` + `unknown=${stats.unknown} heuristic-vision=${stats.heuristic}\n` +
`Restart opencode, then run: opencode models commandcode`, `Restart opencode, then run: opencode models commandcode`,

View File

@ -1,7 +1,10 @@
/** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */ /** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */
import { debug } from "./log.js"; import { debugWhen, isDebugEnabled } from "./log.js";
export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<Record<string, any>> { export async function* iterateEvents(
body: ReadableStream<Uint8Array>,
dbg: boolean = isDebugEnabled(),
): AsyncGenerator<Record<string, any>> {
const reader = body.getReader(); const reader = body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
@ -16,7 +19,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
while ((newline = buffer.indexOf("\n")) >= 0) { while ((newline = buffer.indexOf("\n")) >= 0) {
const raw = buffer.slice(0, newline); const raw = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1); buffer = buffer.slice(newline + 1);
const event = parseLine(raw); const event = parseLine(raw, dbg);
if (event) yield event; if (event) yield event;
} }
} }
@ -24,7 +27,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
// flush any trailing line without a newline // flush any trailing line without a newline
buffer += decoder.decode(); buffer += decoder.decode();
if (buffer.trim()) { if (buffer.trim()) {
const event = parseLine(buffer); const event = parseLine(buffer, dbg);
if (event) yield event; if (event) yield event;
} }
} finally { } finally {
@ -32,7 +35,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
} }
} }
function parseLine(raw: string): Record<string, any> | null { function parseLine(raw: string, dbg: boolean): Record<string, any> | null {
let line = raw.trim(); let line = raw.trim();
if (!line || line.startsWith(":") || line.startsWith("event:")) return null; if (!line || line.startsWith(":") || line.startsWith("event:")) return null;
if (line.startsWith("data:")) line = line.slice(5).trim(); if (line.startsWith("data:")) line = line.slice(5).trim();
@ -41,7 +44,7 @@ function parseLine(raw: string): Record<string, any> | null {
const parsed = JSON.parse(line); const parsed = JSON.parse(line);
return parsed && typeof parsed === "object" ? parsed : null; return parsed && typeof parsed === "object" ? parsed : null;
} catch { } catch {
debug("events", "parse-skip", `line=${line.slice(0, 512)}`); debugWhen(dbg, "events", "parse-skip", `line=${line.slice(0, 512)}`);
return null; return null;
} }
} }

View File

@ -1,13 +1,14 @@
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1; silent (and no file // Opt-in tracing to a log file. Gated on the shared toggle file (debug key);
// created) by default. Uses only Node built-ins so the built output stays dependency-free. // silent (and no file created) by default. Uses only Node built-ins so the built output
// stays dependency-free.
import { appendFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { redact } from "./redact.js"; import { redact } from "./redact.js";
import { debugEnabled } from "./toggles.js";
const ENABLED = /^(1|true|yes)$/i.test(process.env["COMMANDCODE_DEBUG"] ?? ""); const FILE = join(tmpdir(), "commandcode-debug.log");
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log");
function serialize(value: unknown): string { function serialize(value: unknown): string {
if (typeof value === "string") return value; if (typeof value === "string") return value;
@ -18,9 +19,7 @@ function serialize(value: unknown): string {
} }
} }
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */ function emit(scope: string, args: unknown[]): void {
export function debug(scope: string, ...args: unknown[]): void {
if (!ENABLED) return;
const parts = args.map(serialize); const parts = args.map(serialize);
const line = `[commandcode] ${new Date().toISOString()} [${scope}] ${parts.join(" ")}`; const line = `[commandcode] ${new Date().toISOString()} [${scope}] ${parts.join(" ")}`;
try { try {
@ -34,3 +33,20 @@ export function debug(scope: string, ...args: unknown[]): void {
} }
} }
} }
/** Effective default tracing state (toggle file). Re-read on every call. */
export function isDebugEnabled(): boolean {
return debugEnabled();
}
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */
export function debug(scope: string, ...args: unknown[]): void {
if (!debugEnabled()) return;
emit(scope, args);
}
/** Trace with a per-request override, so providerOptions/history state is not global. */
export function debugWhen(enabled: boolean, scope: string, ...args: unknown[]): void {
if (!enabled) return;
emit(scope, args);
}

View File

@ -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 {
@ -18,10 +19,38 @@ import {
GENERATE_PATH, GENERATE_PATH,
} from "./constants.js"; } from "./constants.js";
import { iterateEvents } from "./events.js"; import { iterateEvents } from "./events.js";
import { debug } from "./log.js"; import { debugWhen, isDebugEnabled } from "./log.js";
import { redact } from "./redact.js"; import { redact } from "./redact.js";
import { zdrEnabled } 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).
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file.
// "undefined" means the header is omitted entirely.
function providerScoped(options: LanguageModelV3CallOptions): Record<string, unknown> | undefined {
return options.providerOptions?.["commandcode"] as Record<string, unknown> | undefined;
}
function truthyHeader(value: string): boolean {
return !/^(0|false|no|off)$/i.test(value);
}
function resolveZdr(
options: LanguageModelV3CallOptions,
providerHeaders: Record<string, string>,
): string | undefined {
const scoped = providerScoped(options)?.["zdr"];
if (typeof scoped === "boolean") return scoped ? "1" : "0";
const header = headerValue({ ...providerHeaders, ...(options.headers ?? {}) }, "x-cmd-zdr");
if (header !== undefined) return truthyHeader(header) ? "1" : "0";
return zdrEnabled() ? "1" : undefined;
}
function resolveDebug(options: LanguageModelV3CallOptions): boolean {
const scoped = providerScoped(options)?.["debug"];
return typeof scoped === "boolean" ? scoped : isDebugEnabled();
}
export type CommandCodeOptions = { export type CommandCodeOptions = {
name?: string; name?: string;
@ -43,7 +72,7 @@ type ResolvedOptions = {
retryMaxDelay: number; retryMaxDelay: number;
}; };
function headerValue(headers: Record<string, string>, key: string): string | undefined { function headerValue(headers: Record<string, string | undefined>, key: string): string | undefined {
const wanted = key.toLowerCase(); const wanted = key.toLowerCase();
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v; for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v;
return undefined; return undefined;
@ -80,6 +109,50 @@ function isRetryable(status: number): boolean {
return status === 429 || (status >= 500 && status < 600); return status === 429 || (status >= 500 && status < 600);
} }
// Upstream can answer HTTP 200 and then fail inside the stream (SSE `error` event).
// CommandCode's gateway marks these with `statusCode`/`isRetryable`; retry before content.
type StreamErrorInfo = { message: string; statusCode?: number; isRetryable?: boolean };
function streamErrorInfo(evt: Record<string, any>): StreamErrorInfo | null {
if (evt?.["type"] !== "error") return null;
const err = evt["error"];
if (err === null || typeof err !== "object") return { message: String(err) };
const record = err as Record<string, unknown>;
const message = typeof record["message"] === "string" ? record["message"] : JSON.stringify(err);
return {
message,
...(typeof record["statusCode"] === "number" ? { statusCode: record["statusCode"] } : {}),
...(typeof record["isRetryable"] === "boolean" ? { isRetryable: record["isRetryable"] } : {}),
};
}
function isRetryableStreamError(info: StreamErrorInfo): boolean {
return info.isRetryable === true || (info.statusCode !== undefined && isRetryable(info.statusCode));
}
// Only `start`/`start-step` are safe to buffer while looking for an immediate stream error;
// once real output appears a retry would duplicate the response.
const STREAM_PREAMBLE = new Set(["start", "start-step"]);
const MAX_PEEK_EVENTS = 32;
type OpenedStream = {
response: Response;
prefix: Record<string, any>[];
events: AsyncGenerator<Record<string, any>>;
};
async function* emptyEvents(): AsyncGenerator<Record<string, any>> {
/* no-op */
}
async function* replayEvents(
prefix: Record<string, any>[],
rest: AsyncGenerator<Record<string, any>>,
): AsyncGenerator<Record<string, any>> {
for (const evt of prefix) yield evt;
for await (const evt of rest) yield evt;
}
function retryDelay(attempt: number, retryAfter: string | null, maxDelay: number): number { function retryDelay(attempt: number, retryAfter: string | null, maxDelay: number): number {
if (retryAfter) { if (retryAfter) {
const seconds = Number(retryAfter); const seconds = Number(retryAfter);
@ -131,7 +204,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
supportedUrls: Record<string, RegExp[]> = {}; supportedUrls: Record<string, RegExp[]> = {};
private requestHeaders(extra?: Record<string, string | undefined>): Record<string, string> { private requestHeaders(
extra: Record<string, string | undefined> | undefined,
zdr: string | undefined,
dbg: boolean,
): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-command-code-version": this.opts.ccVersion, "x-command-code-version": this.opts.ccVersion,
@ -141,20 +218,26 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
"x-co-flag": "false", "x-co-flag": "false",
...this.opts.headers, ...this.opts.headers,
}; };
if (zdr !== undefined) headers["x-cmd-zdr"] = zdr;
debugWhen(dbg, "fetch", "zdr", zdr === "1" ? "on" : "off");
const auth = bearer(this.opts.apiKey); const auth = bearer(this.opts.apiKey);
if (auth) headers["Authorization"] = auth; if (auth) headers["Authorization"] = auth;
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v; for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v;
return headers; return headers;
} }
private async fetchWithRetry(body: string, options: LanguageModelV3CallOptions): Promise<Response> { private async fetchWithRetry(
const headers = this.requestHeaders(options.headers); body: string,
options: LanguageModelV3CallOptions,
dbg: boolean,
): Promise<OpenedStream> {
const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg);
let lastError: unknown; let lastError: unknown;
const url = `${this.opts.baseURL}${GENERATE_PATH}`; const url = `${this.opts.baseURL}${GENERATE_PATH}`;
for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) { for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) {
if (options.abortSignal?.aborted) { if (options.abortSignal?.aborted) {
debug("fetch", "aborted", `attempt=${attempt}`); debugWhen(dbg, "fetch", "aborted", `attempt=${attempt}`);
throw options.abortSignal.reason ?? new Error("Aborted"); throw options.abortSignal.reason ?? new Error("Aborted");
} }
const started = Date.now(); const started = Date.now();
@ -169,7 +252,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
} catch (error) { } catch (error) {
lastError = error; lastError = error;
const wait = retryDelay(attempt, null, this.opts.retryMaxDelay); const wait = retryDelay(attempt, null, this.opts.retryMaxDelay);
debug( debugWhen(
dbg,
"fetch", "fetch",
"network-error", "network-error",
`url=${url}`, `url=${url}`,
@ -188,7 +272,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
if (isRetryable(status) && attempt < this.opts.maxRetries) { if (isRetryable(status) && attempt < this.opts.maxRetries) {
const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay); const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay);
if (wait >= 0) { if (wait >= 0) {
debug( debugWhen(
dbg,
"fetch", "fetch",
"retry", "retry",
`url=${url}`, `url=${url}`,
@ -206,14 +291,66 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
continue; continue;
} }
} }
debug("fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`); if (!response.ok || !response.body) {
return response; debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
return { response, prefix: [], events: emptyEvents() };
}
// HTTP 200 can still hide an upstream failure. Buffer only the preamble; if the first
// real event is a retryable stream error, retry before anything reaches the client.
const events = iterateEvents(response.body, dbg);
const prefix: Record<string, any>[] = [];
let streamRetry: StreamErrorInfo | undefined;
while (prefix.length < MAX_PEEK_EVENTS) {
const next = await events.next();
if (next.done) break;
const evt = next.value;
prefix.push(evt);
const info = streamErrorInfo(evt);
if (info) {
if (isRetryableStreamError(info) && attempt < this.opts.maxRetries) streamRetry = info;
break;
}
if (!STREAM_PREAMBLE.has(evt["type"])) break;
}
if (streamRetry) {
const wait = retryDelay(attempt, null, this.opts.retryMaxDelay);
debugWhen(
dbg,
"fetch",
"stream-retry",
`url=${url}`,
`attempt=${attempt}`,
`status=${streamRetry.statusCode ?? "unknown"}`,
`message=${redact(streamRetry.message)}`,
`waitMs=${wait}`,
);
try {
await events.return(undefined as never);
} catch {
/* ignore */
}
try {
await response.body.cancel();
} catch {
/* ignore */
}
if (options.abortSignal?.aborted) {
throw options.abortSignal.reason ?? new Error("Aborted");
}
await sleep(wait);
continue;
}
debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
return { response, prefix, events };
} }
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable"); throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
} }
private async errorFrom(response: Response): Promise<Error> { private async errorFrom(response: Response, dbg: boolean): Promise<Error> {
let body = ""; let body = "";
try { try {
body = await response.text(); body = await response.text();
@ -230,18 +367,19 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
/* keep raw body */ /* keep raw body */
} }
const surfaced = redact(message.slice(0, 2000) || `Upstream returned ${response.status}`); const surfaced = redact(message.slice(0, 2000) || `Upstream returned ${response.status}`);
debug("error", `status=${response.status}`, `body=${surfaced}`); debugWhen(dbg, "error", `status=${response.status}`, `body=${surfaced}`);
return new Error(surfaced); return new Error(surfaced);
} }
async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> { async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> {
const dbg = resolveDebug(options);
const body = transform(options, this.modelId); const body = transform(options, this.modelId);
debug("doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`); debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`);
const response = await this.fetchWithRetry(body, options); const { response, prefix, events } = await this.fetchWithRetry(body, options, dbg);
if (!response.ok || !response.body) throw await this.errorFrom(response); if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
debug("doStream", "ok", `status=${response.status}`); debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
const stream = toReadableStream(this.streamParts(response.body)); const stream = toReadableStream(this.streamParts(prefix, events, dbg));
return { return {
stream, stream,
request: { body: JSON.parse(body) as unknown }, request: { body: JSON.parse(body) as unknown },
@ -250,6 +388,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
} }
async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> { async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> {
const dbg = resolveDebug(options);
const { stream, request, response } = await this.doStream(options); const { stream, request, response } = await this.doStream(options);
let text = ""; let text = "";
@ -257,6 +396,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();
@ -280,6 +420,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));
@ -296,7 +437,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
if (text) content.push({ type: "text", text }); if (text) content.push({ type: "text", text });
content.push(...toolCalls); content.push(...toolCalls);
debug( debugWhen(
dbg,
"doGenerate", "doGenerate",
"done", "done",
`model=${this.modelId}`, `model=${this.modelId}`,
@ -305,12 +447,17 @@ 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>): AsyncGenerator<LanguageModelV3StreamPart> { private async *streamParts(
prefix: Record<string, any>[],
events: AsyncGenerator<Record<string, any>>,
dbg: boolean,
): AsyncGenerator<LanguageModelV3StreamPart> {
yield { type: "stream-start", warnings: [] }; yield { type: "stream-start", warnings: [] };
const textId = "text-0"; const textId = "text-0";
@ -321,11 +468,13 @@ 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)) { for await (const evt of replayEvents(prefix, events)) {
const payload = JSON.stringify(evt); const payload = JSON.stringify(evt);
debug("stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`); debugWhen(dbg, "stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`);
switch (evt.type) { switch (evt.type) {
case "text-start": case "text-start":
if (!textOpen) { if (!textOpen) {
@ -395,6 +544,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;
@ -416,16 +578,27 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
} }
if (errored) { if (errored) {
debug("stream", "terminal", "errored"); debugWhen(dbg, "stream", "terminal", "errored");
return; return;
} }
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 };
debug("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 } : {}),
}; };
} }
} }

431
src/quota.ts Normal file
View File

@ -0,0 +1,431 @@
// Live Command Code quota from the alpha billing API. Shared by the TUI plugin
// (loaded from source) and scripts/quota.mjs. Endpoints, validated against
// api.commandcode.ai:
//
// GET /alpha/whoami
// GET /alpha/billing/credits?orgId=
// GET /alpha/billing/subscriptions?orgId=
// GET /alpha/usage/summary?orgId=
//
// These are the same endpoints the `cmd` CLI `/usage` command reads. This module uses
// global fetch only, so the built provider stays runtime-dependency-free.
import { DEFAULT_BASE_URL } from "./constants.js";
import { redact } from "./redact.js";
export const QUOTA_TIMEOUT_MS = 15_000;
export type QuotaWindowId = "fiveHour" | "weekly" | "monthly";
export type QuotaWindow = {
id: QuotaWindowId;
label: string;
used: number;
cap: number;
resetAtMs: number | null;
exceeded: boolean;
};
export type QuotaCredits = {
monthly: number;
purchased: number;
free: number;
remaining: number;
};
export type QuotaAccount = {
login: string;
keyName?: string;
orgId: string | null;
};
export type QuotaPlan = {
id: string;
status: string;
currentPeriodStart?: string;
currentPeriodEnd?: string;
};
export type QuotaSummary = {
totalCost: number;
totalCount: number;
totalTokens?: number;
periodBasis?: string;
};
export type Quota = {
account: QuotaAccount;
plan: QuotaPlan | null;
credits: QuotaCredits | null;
summary: QuotaSummary | null;
windows: QuotaWindow[];
unavailable: QuotaWindowId[];
fetchedAt: number;
};
export type QuotaErrorKind = "config" | "http" | "network" | "timeout";
export type QuotaResult =
| { ok: true; quota: Quota }
| { ok: false; error: { kind: QuotaErrorKind; message: string } };
export type QuotaFetchOptions = {
apiKey: string;
baseURL?: string;
orgId?: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** Epoch ms. Upstream has shipped both seconds and ms; >= 1e12 is treated as ms. */
function resetAtMs(value: unknown): number | null {
let n: number | undefined;
if (typeof value === "number" && Number.isFinite(value)) n = value;
else if (typeof value === "string" && value.trim().length > 0) {
const text = value.trim();
n = /^\d+$/.test(text) ? Number(text) : Date.parse(text);
}
if (n === undefined || !Number.isFinite(n) || n < 0) return null;
return n >= 1e12 ? Math.round(n) : Math.round(n * 1000);
}
function parseWindow(id: QuotaWindowId, label: string, value: unknown): QuotaWindow | null {
if (!isRecord(value)) return null;
const used = numberValue(value.used);
const cap = numberValue(value.cap);
if (used === undefined || cap === undefined || (used === 0 && cap === 0)) return null;
return {
id,
label,
used,
cap,
resetAtMs: resetAtMs(value.resetAt),
exceeded: value.exceeded === true || (cap > 0 && used >= cap),
};
}
function parseCredits(value: unknown): { credits: QuotaCredits; windows: QuotaWindow[] } | null {
if (!isRecord(value) || !isRecord(value.credits)) return null;
const c = value.credits;
const monthly = numberValue(c.monthlyCredits) ?? 0;
const purchased = numberValue(c.purchasedCredits) ?? 0;
const free = numberValue(c.freeCredits) ?? 0;
const limits = isRecord(value.windowLimits) ? value.windowLimits : {};
const windows: QuotaWindow[] = [];
const fiveHour = parseWindow("fiveHour", "5-hour", limits.fiveHour);
const weekly = parseWindow("weekly", "Weekly", limits.weekly);
if (fiveHour) windows.push(fiveHour);
if (weekly) windows.push(weekly);
return { credits: { monthly, purchased, free, remaining: monthly + purchased + free }, windows };
}
function parseAccount(value: unknown): QuotaAccount | null {
if (!isRecord(value)) return null;
const org = isRecord(value.org) ? value.org : undefined;
const user = isRecord(value.user) ? value.user : undefined;
const login =
(user ? (stringValue(user.userName) ?? stringValue(user.name)) : undefined) ??
(org ? stringValue(org.login) : undefined);
if (!login) return null;
const keyName = user ? (stringValue(user.keyName) ?? stringValue(user.displayName)) : undefined;
return { login, orgId: org ? (stringValue(org.id) ?? null) : null, ...(keyName ? { keyName } : {}) };
}
function parsePlan(value: unknown): QuotaPlan | null {
if (!isRecord(value) || !isRecord(value.data)) return null;
const data = value.data;
const id = stringValue(data.planId);
const status = stringValue(data.status);
if (!id && !status) return null;
const start = stringValue(data.currentPeriodStart);
const end = stringValue(data.currentPeriodEnd);
return {
id: id ?? "unknown",
status: status ?? "unknown",
...(start ? { currentPeriodStart: start } : {}),
...(end ? { currentPeriodEnd: end } : {}),
};
}
function parseSummary(value: unknown): QuotaSummary | null {
if (!isRecord(value)) return null;
const totalCost = numberValue(value.totalCost);
const totalCount = numberValue(value.totalCount);
if (totalCost === undefined || totalCount === undefined) return null;
const totalTokens = numberValue(value.totalTokens);
const periodBasis = stringValue(value.periodBasis);
return {
totalCost,
totalCount,
...(totalTokens === undefined ? {} : { totalTokens }),
...(periodBasis === undefined ? {} : { periodBasis }),
};
}
function periodEndMs(plan: QuotaPlan | null): number | null {
if (!plan?.currentPeriodEnd) return null;
return resetAtMs(plan.currentPeriodEnd);
}
class QuotaHttpError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string) {
super(`HTTP ${status}`);
this.name = "QuotaHttpError";
this.status = status;
this.body = body;
}
}
class QuotaTimeoutError extends Error {
constructor() {
super("Command Code quota request timed out");
this.name = "QuotaTimeoutError";
}
}
function isAuthError(error: unknown): boolean {
return error instanceof QuotaHttpError && (error.status === 401 || error.status === 403);
}
export async function fetchQuota(options: QuotaFetchOptions): Promise<QuotaResult> {
if (!options.apiKey) {
return { ok: false, error: { kind: "config", message: "No Command Code API key found" } };
}
const baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? QUOTA_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const headers = {
accept: "application/json",
authorization: /^Bearer\s/i.test(options.apiKey) ? options.apiKey : `Bearer ${options.apiKey}`,
};
const request = async (path: string, params?: Record<string, string | undefined>): Promise<unknown> => {
if (controller.signal.aborted) throw new QuotaTimeoutError();
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params ?? {})) if (value) search.set(key, value);
const query = search.toString();
let response: Response;
try {
response = await fetchImpl(`${baseURL}${path}${query ? `?${query}` : ""}`, {
method: "GET",
headers,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) throw new QuotaTimeoutError();
throw error;
}
if (!response.ok) throw new QuotaHttpError(response.status, await response.text().catch(() => ""));
return (await response.json()) as unknown;
};
try {
const whoamiRaw = await request("/alpha/whoami");
const account = parseAccount(whoamiRaw);
if (!account) {
return {
ok: false,
error: { kind: "http", message: "Command Code returned an unrecognized account response" },
};
}
const orgId = options.orgId ?? account.orgId ?? undefined;
const unavailable: QuotaWindowId[] = [];
let credits: QuotaCredits | null = null;
let windows: QuotaWindow[] = [];
try {
const parsed = parseCredits(await request("/alpha/billing/credits", { orgId }));
if (parsed) {
credits = parsed.credits;
windows = parsed.windows;
}
} catch (error) {
if (isAuthError(error) || error instanceof QuotaTimeoutError) {
return { ok: false, error: quotaError(error) };
}
}
let plan: QuotaPlan | null = null;
try {
plan = parsePlan(await request("/alpha/billing/subscriptions", { orgId }));
} catch (error) {
if (isAuthError(error) || error instanceof QuotaTimeoutError) {
return { ok: false, error: quotaError(error) };
}
}
let summary: QuotaSummary | null = null;
try {
summary = parseSummary(await request("/alpha/usage/summary", { orgId, since: plan?.currentPeriodStart }));
} catch (error) {
if (isAuthError(error) || error instanceof QuotaTimeoutError) {
return { ok: false, error: quotaError(error) };
}
}
if (credits) {
const spent = summary?.totalCost;
if (spent !== undefined) {
windows.push({
id: "monthly",
label: "Monthly",
used: spent,
cap: spent + credits.remaining,
resetAtMs: periodEndMs(plan),
exceeded: credits.remaining <= 0,
});
} else {
unavailable.push("monthly");
}
} else {
unavailable.push("monthly");
}
if (!credits && !summary) {
return {
ok: false,
error: {
kind: "http",
message: "Command Code returned no recognized usage data for the account",
},
};
}
return {
ok: true,
quota: {
account,
plan,
credits,
summary,
windows,
unavailable,
fetchedAt: Date.now(),
},
};
} catch (error) {
return { ok: false, error: quotaError(error) };
} finally {
clearTimeout(timer);
}
}
function quotaError(error: unknown): { kind: QuotaErrorKind; message: string } {
if (error instanceof QuotaTimeoutError) return { kind: "timeout", message: error.message };
if (error instanceof QuotaHttpError) {
const detail = error.body.trim().slice(0, 200);
const hint = error.status === 401 || error.status === 403 ? " (check the API key)" : "";
return {
kind: "http",
message: redact(`Command Code quota request failed (${error.status})${hint}: ${detail}`),
};
}
return {
kind: "network",
message: redact(`Command Code quota request failed: ${error instanceof Error ? error.message : String(error)}`),
};
}
export function percent(used: number, cap: number): number {
if (!(cap > 0)) return 0;
return Math.round((used / cap) * 100);
}
export function quotaBar(used: number, cap: number, width = 10): string {
const fraction = cap > 0 ? Math.min(1, Math.max(0, used / cap)) : 0;
const filled = Math.round(fraction * width);
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
}
/** Compact countdown, e.g. `3h 12m` / `2d 4h`. Empty when there is no reset. */
export function formatReset(resetAtMs: number | null, now: number = Date.now()): string {
if (resetAtMs === null) return "";
const diff = resetAtMs - now;
if (diff <= 0) return "soon";
const minutes = Math.ceil(diff / 60_000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours < 24) return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const rem = hours % 24;
return rem > 0 ? `${days}d ${rem}h` : `${days}d`;
}
function formatTokens(tokens: number): string {
if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B`;
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
return String(tokens);
}
function formatWindowLine(w: QuotaWindow, now: number): string {
const pct = Math.min(percent(w.used, w.cap), 999);
const reset = formatReset(w.resetAtMs, now);
const amount =
w.id === "monthly"
? `$${w.used.toFixed(2)} / $${w.cap.toFixed(2)}`
: `${w.used.toFixed(2)} / ${w.cap.toFixed(2)} credits`;
return `${w.label.padEnd(8)} ${quotaBar(w.used, w.cap)} ${String(pct).padStart(3)}% ${amount}${
reset ? ` (resets in ${reset})` : ""
}`;
}
function planLine(plan: QuotaPlan, now: number): string {
const name = plan.id.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const status = plan.status ? ` · ${plan.status}` : "";
const end = periodEndMs(plan);
if (end === null) return `Plan: ${name}${status}`;
const remaining = end - now;
const days = Math.ceil(remaining / 86_400_000);
const date = new Date(end).toISOString().slice(0, 10);
const when = days > 0 ? `renews ${date} (${days}d)` : days === 0 ? `renews ${date} (today)` : `renewed ${date}`;
return `Plan: ${name}${status} · ${when}`;
}
/** Human-readable multi-line quota summary for the `/cc-usage` command and CLI. */
export function formatQuota(quota: Quota, now: number = Date.now()): string {
const lines: string[] = [];
lines.push(quota.account.keyName ?? quota.account.login);
if (quota.plan) lines.push(planLine(quota.plan, now));
if (quota.windows.length > 0) {
lines.push("");
for (const w of quota.windows) lines.push(formatWindowLine(w, now));
}
if (quota.credits) {
const parts = [`monthly $${quota.credits.monthly.toFixed(2)}`, `purchased $${quota.credits.purchased.toFixed(2)}`];
if (quota.credits.free > 0) parts.push(`free $${quota.credits.free.toFixed(2)}`);
lines.push("", `Credits: ${parts.join(" / ")}`);
}
if (quota.summary) {
const period = quota.plan?.currentPeriodStart ? "this billing period" : "total";
const tokens = quota.summary.totalTokens === undefined ? "" : ` · ${formatTokens(quota.summary.totalTokens)} tokens`;
lines.push(
`Usage: ${quota.summary.totalCount.toLocaleString("en-US")} requests · $${quota.summary.totalCost.toFixed(2)} (${period})${tokens}`,
);
}
if (quota.unavailable.length > 0) lines.push("", `Unavailable: ${quota.unavailable.join(", ")}`);
return lines.join("\n");
}

55
src/toggles.ts Normal file
View File

@ -0,0 +1,55 @@
// Runtime toggle state shared between the provider (server side) and the slash-command
// TUI plugin (client side) through a small JSON flag file. Node built-ins only so the
// built provider stays dependency-free; never throws on a missing or malformed file.
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export type Toggles = { zdr?: boolean; debug?: boolean };
export type ToggleName = keyof Toggles;
export function togglesPath(): string {
const override = process.env["COMMANDCODE_TOGGLES_FILE"];
if (override) return override;
const configHome = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
return join(configHome, "opencode", "commandcode-toggles.json");
}
export function readToggles(): Toggles {
try {
const parsed = JSON.parse(readFileSync(togglesPath(), "utf8")) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return {};
const out: Toggles = {};
if (typeof parsed["zdr"] === "boolean") out.zdr = parsed["zdr"];
if (typeof parsed["debug"] === "boolean") out.debug = parsed["debug"];
return out;
} catch {
return {};
}
}
export function writeToggles(patch: Toggles): Toggles {
const merged = { ...readToggles(), ...patch };
const path = togglesPath();
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
renameSync(tmp, path);
return merged;
}
export function toggle(name: ToggleName): Toggles {
const current = readToggles();
return writeToggles({ [name]: current[name] !== true });
}
/** Toggle state from the shared flag file; an absent key means off. */
export function zdrEnabled(): boolean {
return readToggles().zdr === true;
}
export function debugEnabled(): boolean {
return readToggles().debug === true;
}

19
src/tui-shims.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
// Ambient shims for the symbols the TUI plugin imports from opencode's bundled runtime.
// opencode resolves `solid-js` and `@opentui/solid` to its own internal modules when it
// loads the `.tsx` plugin, so this package declares just enough surface for `tsc` without
// taking a dependency. The JSX namespace is intentionally permissive: only a handful of
// intrinsic elements are used and their props come from opencode's theme at runtime.
declare module "solid-js" {
export function createSignal<Value>(value: Value): [get: () => Value, set: (value: Value) => void];
}
declare namespace JSX {
type Element = unknown;
interface ElementChildrenAttribute {
children: {};
}
interface IntrinsicElements {
[name: string]: any;
}
}

410
src/tui.tsx Normal file
View File

@ -0,0 +1,410 @@
// opencode TUI plugin exposing /cc-zdr, /cc-debug, /cc-status and /cc-usage, plus a sidebar
// panel showing the current toggle state and live CommandCode quota. It flips the shared toggle
// file (see ./toggles.ts) that the provider reads on every request, so a change takes effect
// without restarting opencode, and reads the alpha billing endpoints (see ./quota.ts) with the
// provider's API key for the 5-hour, weekly and monthly meters.
//
// opencode loads this module's default export (`{ id, tui }`) from a `tui.json` plugin
// entry. It is loaded from source (not dist) so opencode's Bun/Solid transform compiles
// the JSX and maps `solid-js` / `@opentui/solid` to its internal modules; that keeps this
// package free of runtime and build dependencies. The ambient types in ./tui-shims.d.ts
// back the JSX and `createSignal` references for `tsc -p tsconfig.tui.json`.
//
// Note: in this opencode version slash commands and the Ctrl+P palette read the same
// `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 { redact } from "./redact.js";
import { debugEnabled, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
type ToastVariant = "info" | "success" | "warning" | "error";
type TuiCommand = {
name: string;
title?: string;
desc?: string;
category?: string;
namespace?: string;
slashName?: string;
slashAliases?: string[];
suggested?: boolean;
hidden?: boolean;
enabled?: boolean | (() => boolean);
run: () => void | Promise<void>;
};
type TuiTheme = {
current: { text: unknown; textMuted: unknown; success: unknown; warning: unknown; error: unknown };
};
type TuiSlotHandler = (ctx: { theme: TuiTheme }, props: { session_id: string }) => unknown;
type TuiSlotPlugin = { order?: number; slots: Record<string, TuiSlotHandler> };
type TuiProvider = { id?: string; key?: string; options?: Record<string, unknown> };
type TuiProviderConfig = { options?: Record<string, unknown> };
type TuiState = {
config?: { provider?: Record<string, TuiProviderConfig | undefined> };
provider?: ReadonlyArray<TuiProvider>;
};
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: (event?: { type?: string }) => void): () => void };
theme: TuiTheme;
state: TuiState;
lifecycle: { onDispose(fn: () => void): () => void };
};
const ID = "commandcode-toggles";
const CATEGORY = "CommandCode";
const SIDEBAR_ORDER = 90;
const DEFAULT_QUOTA_INTERVAL_MS = 300_000;
const MIN_REFRESH_INTERVAL_MS = 120_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.
// All event-driven refreshes are floored to MIN_REFRESH_INTERVAL_MS (hard skip).
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 (!debugEnabled()) return;
const 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";
}
function summary(toggles: Toggles = readToggles()): string {
return `zdr=${state(toggles.zdr)}, debug=${state(toggles.debug)}`;
}
/** Expands an `{env:NAME}` config reference; other templates are treated as unset. */
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] ? safeEnv(template[1]) : undefined;
if (/^\{.*\}$/.test(trimmed)) return undefined;
return trimmed;
}
function commandCodeOptions(api: TuiApi): Record<string, unknown> | undefined {
try {
return api.state?.config?.provider?.["commandcode"]?.options;
} catch {
return undefined;
}
}
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 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 [source, candidate] of named) {
const value = resolveRef(candidate);
if (value) return { key: value.replace(/^Bearer\s+/i, ""), source };
}
return { source: "none" };
}
function resolveBaseURL(api: TuiApi): string | undefined {
return resolveRef(commandCodeOptions(api)?.["baseURL"]);
}
function quotaIntervalMs(): number {
const raw = Number(safeEnv("COMMANDCODE_QUOTA_INTERVAL_MS"));
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_QUOTA_INTERVAL_MS;
}
function formatClock(ms: number): string {
if (!(ms > 0)) return "--:--:--";
return new Date(ms).toTimeString().slice(0, 8);
}
function shortWindow(w: QuotaWindow, nowMs: number): string {
const label = w.id === "fiveHour" ? "5h" : w.id === "weekly" ? "7d" : "mo";
const reset = formatReset(w.resetAtMs, nowMs);
const tail = reset ? ` · ${reset}` : "";
const amount = w.id === "monthly" ? `$${w.used.toFixed(2)}/$${w.cap.toFixed(2)}` : `${percent(w.used, w.cap)}%`;
return `${label} ${quotaBar(w.used, w.cap, 8)} ${amount}${tail}`;
}
export const tui = async (api: TuiApi): Promise<void> => {
const [toggles, setToggles] = createSignal(readToggles());
const [quota, setQuota] = createSignal<QuotaResult | null>(null);
const [now, setNow] = createSignal(Date.now());
const [updatedAt, setUpdatedAt] = createSignal(0);
let inflight = false;
let pending = false;
let lastRefreshAt = 0;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let lastSessionId: string | undefined;
const refresh = (): void => setToggles(readToggles());
const refreshQuota = async (): Promise<void> => {
if (inflight) {
pending = true;
trace("coalesced", "inflight");
return;
}
const since = Date.now() - lastRefreshAt;
if (lastRefreshAt > 0 && since < MIN_REFRESH_INTERVAL_MS) {
trace("rate-limited", `remainingMs=${MIN_REFRESH_INTERVAL_MS - since}`);
return;
}
inflight = true;
try {
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) {
// 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;
lastRefreshAt = Date.now();
setNow(Date.now());
setUpdatedAt(Date.now());
if (pending) {
pending = false;
trace("flush-pending");
void refreshQuota();
}
}
};
const scheduleQuotaRefresh = (reason: string, delayMs = 800): void => {
trace("scheduled", `reason=${reason}`, `delayMs=${delayMs}`);
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = undefined;
void refreshQuota();
}, delayMs);
};
const flip = (name: ToggleName): void => {
const next = toggle(name);
setToggles(next);
api.ui.toast({
title: CATEGORY,
message: `${name} ${state(next[name])} (${summary(next)})`,
variant: next[name] === true ? "success" : "info",
});
};
function tone(w: QuotaWindow, theme: TuiTheme): unknown {
if (w.exceeded || percent(w.used, w.cap) >= 100) return theme.current.error;
if (percent(w.used, w.cap) >= 80) return theme.current.warning;
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 theme = api.theme;
return (
<box flexDirection="column" gap={0}>
<text fg={theme.current.text}>
<b>CommandCode</b>
</text>
<text fg={theme.current.textMuted}>
zdr:{state(toggles().zdr)} debug:{state(toggles().debug)}
</text>
{windowsOf(quota()).map((w) => (
<text fg={tone(w, theme)}>{shortWindow(w, now())}</text>
))}
{errorOf(quota()) !== undefined ? (
<text fg={theme.current.error}>quota: {errorOf(quota())}</text>
) : quota() === null ? (
<text fg={theme.current.textMuted}>quota: loading…</text>
) : null}
<text fg={theme.current.textMuted}>updated @ {formatClock(updatedAt())}</text>
</box>
);
};
api.slots.register({
order: SIDEBAR_ORDER,
slots: {
sidebar_content(_ctx, props?: { session_id?: string }) {
const id = props?.session_id;
if (id !== undefined && id !== lastSessionId) {
lastSessionId = id;
scheduleQuotaRefresh("session-change");
}
return <Status />;
},
},
});
api.keymap.registerLayer({
commands: [
{
name: `${ID}.zdr`,
title: "CommandCode: toggle ZDR header",
desc: "Send the x-cmd-zdr header on requests",
category: CATEGORY,
namespace: "palette",
slashName: "cc-zdr",
run: () => flip("zdr"),
},
{
name: `${ID}.debug`,
title: "CommandCode: toggle debug tracing",
desc: "Write a redacted request and stream trace to a log file",
category: CATEGORY,
namespace: "palette",
slashName: "cc-debug",
run: () => flip("debug"),
},
{
name: `${ID}.status`,
title: "CommandCode: show toggle status",
desc: "Show the current zdr and debug state",
category: CATEGORY,
namespace: "palette",
slashName: "cc-status",
run: () => api.ui.toast({ title: CATEGORY, message: summary(), variant: "info" }),
},
{
name: `${ID}.usage`,
title: "CommandCode: show usage and quota",
desc: "Show the 5-hour, weekly and monthly quota from the CommandCode API",
category: CATEGORY,
namespace: "palette",
slashName: "cc-usage",
run: async () => {
const { key: apiKey } = resolveApiKeySource(api);
if (!apiKey) {
api.ui.toast({
title: CATEGORY,
message: "No API key found. Set provider.commandcode.options.apiKey or COMMANDCODE_API_KEY.",
variant: "warning",
});
return;
}
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" });
return;
}
setQuota(result);
setNow(Date.now());
setUpdatedAt(Date.now());
api.ui.toast({ title: CATEGORY, message: formatQuota(result.quota), variant: "info", duration: 15000 });
},
},
],
});
void refreshQuota();
const toggleTimer = setInterval(refresh, 1500);
const clockTimer = setInterval(() => setNow(Date.now()), 30_000);
const quotaTimer = setInterval(() => void refreshQuota(), quotaIntervalMs());
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);
clearInterval(toggleTimer);
clearInterval(clockTimer);
clearInterval(quotaTimer);
});
};
export const id = ID;
export default { id: ID, tui };

View File

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

View File

@ -14,5 +14,6 @@
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/tui.tsx", "src/tui-shims.d.ts"]
} }

18
tsconfig.tui.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"jsx": "preserve",
"noEmit": true
},
"include": ["src/tui.tsx", "src/tui-shims.d.ts", "src/quota.ts", "src/toggles.ts", "src/redact.ts", "src/constants.ts"]
}