Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d518871b | |||
| 053e34d350 | |||
| 78a4f42bc0 | |||
| ac29d26e12 | |||
| a1fe7c7a87 | |||
| cc7b9fc600 | |||
| ec7f32feea | |||
| 7e77c3c6fe | |||
| ef9a944924 | |||
| a52efe1f70 | |||
| 41fe0788a7 | |||
| 0872a2099b | |||
| c89943df1c |
81
AGENTS.md
81
AGENTS.md
@ -23,14 +23,15 @@ Run from the repository root.
|
||||
|
||||
```powershell
|
||||
npm install # dev deps: typescript, @types/node, @ai-sdk/provider
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run build # tsc -> dist/
|
||||
npm run typecheck # tsc --noEmit (provider) + tsc -p tsconfig.tui.json (TUI plugin)
|
||||
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 quota # live 5-hour/weekly/monthly quota (needs a key)
|
||||
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`
|
||||
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`.
|
||||
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/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification.
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
6. **Usage is nested in V3.** Fill `inputTokens.{total,noCache,cacheRead,cacheWrite}` and
|
||||
`outputTokens.{total,text,reasoning}` — not flat `promptTokens`/`completionTokens`.
|
||||
`inputTokens.total` is input-only (parity with `server.py`); `totalTokens` is input+output.
|
||||
7. **`supportedUrls` is `{}` and stays empty.** CommandsCode URLs are not fetched by the SDK.
|
||||
8. **Redact before surfacing errors.** Any upstream error text passed to the client must go through
|
||||
`redact()`.
|
||||
@ -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?}`;
|
||||
do not drop it and do not fabricate empty reasoning. `server.py` predates this requirement and is
|
||||
not the guide here.
|
||||
10. **`file://` npm specs bypass install.** opencode imports `dist/index.js` directly, so the repo
|
||||
10. **Upstream cost never appears on `finish`.** Per-request USD arrives on `finish-step`
|
||||
(`usage.raw.cost/market_cost/gateway_cost`, numbers) and `provider-metadata`
|
||||
(`providerMetadata.gateway.cost/marketCost`, strings). `finish.totalUsage` carries tokens
|
||||
only. Capture both into `providerMetadata: { commandcode: { cost, marketCost } }` on the
|
||||
terminal V3 `finish` part (and `doGenerate` result), and stash into `usage.raw`. A reported
|
||||
`0` is meaningful; only `undefined` means absent. The built-in sidebar still shows `$0.00`
|
||||
until the fork consumes this field (anomalyco/opencode#43818); `/cc-usage` is the accurate
|
||||
dollar source meanwhile.
|
||||
11. **`file://` npm specs bypass install.** opencode imports `dist/index.js` directly, so the repo
|
||||
must be rebuilt for opencode to see source changes.
|
||||
11. **`/models` is config-driven, not provider-driven.** opencode builds the model list from
|
||||
12. **`/models` is config-driven, not provider-driven.** opencode builds the model list from
|
||||
`provider.commandcode.models` in `opencode.json` and never asks a custom `file://` provider to
|
||||
discover models (only internal providers can register `discoverModels`). Refresh the map with
|
||||
`npm run sync-models`; do not expect a discovery hook in `src/` to populate it.
|
||||
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
|
||||
|
||||
@ -135,6 +200,12 @@ Automated:
|
||||
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):
|
||||
|
||||
```powershell
|
||||
|
||||
186
README.md
186
README.md
@ -48,7 +48,7 @@ npm run build
|
||||
"provider": {
|
||||
"commandcode": {
|
||||
"name": "Command Code",
|
||||
"npm": "file:///C:/DevTools/pienv/ccprovider/dist/index.js",
|
||||
"npm": "file:///C:/dev/opencode-commandcode-provider/dist/index.js",
|
||||
"options": {
|
||||
"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
|
||||
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
|
||||
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:
|
||||
|
||||
```powershell
|
||||
@ -108,21 +121,11 @@ Mapping and behaviour:
|
||||
`qwen…vl`, …). Probing is best-effort: upstream is nondeterministic and a re-run may flip a
|
||||
borderline model.
|
||||
- Generated fields (`name`, `limit`, `reasoning`, `attachment`, `modalities`) overwrite existing
|
||||
values. Other fields (`variants`, `options`, `cost`, …) and ids not in the catalog are preserved.
|
||||
|
||||
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
|
||||
```
|
||||
values. Other fields (`variants`, `options`, `cost`, …) on selected ids are preserved.
|
||||
Unselected ids are removed from the map.
|
||||
|
||||
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
|
||||
@ -143,26 +146,131 @@ target config.
|
||||
`options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent
|
||||
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
|
||||
|
||||
Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file.
|
||||
Silent (and no file is created) unless enabled. Every line is passed through `redact()` so
|
||||
Flip the `debug` key in the shared toggle file (via `/cc-debug`) to write a trace of every
|
||||
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.
|
||||
|
||||
```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
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
| 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 |
|
||||
| Multiple images (vision) | Yes — `data:` URIs, raw base64, `Uint8Array`, and remote URLs |
|
||||
| Token usage | Yes — input/output totals, cache read, reasoning tokens |
|
||||
| Provider-reported cost | Yes — per-request USD from `finish-step`/`provider-metadata` is forwarded as `providerMetadata.commandcode.cost` (plus `marketCost`); also stashed in `usage.raw`. The built-in sidebar still shows `$0.00` until opencode itself consumes this field (upstream `anomalyco/opencode#43818`); `/cc-usage` remains the accurate dollar source |
|
||||
| Finish reasons | Yes — unified (`stop`, `length`, `tool-calls`, `content-filter`, `error`, `other`) plus raw |
|
||||
| Sampling parameters | Yes — `temperature`, `topP`, `topK`, `stopSequences`, `seed`, presence/frequency penalties |
|
||||
| `reasoning_effort` | Yes — via `providerOptions.commandcode` |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@ -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/usage.ts finish event -> V3 usage; finish-reason unification.
|
||||
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.
|
||||
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
|
||||
@ -241,9 +357,10 @@ scripts/sync-models.mjs Generate provider.commandcode.models from the live catal
|
||||
|
||||
```powershell
|
||||
npm install # installs typescript, @types/node, @ai-sdk/provider
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run build # tsc -> dist/
|
||||
npm run typecheck # tsc --noEmit (provider) + tsc -p tsconfig.tui.json (TUI plugin)
|
||||
npm run build # tsc -> dist/ (provider only; the TUI plugin loads from source)
|
||||
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
|
||||
```
|
||||
|
||||
@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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
|
||||
|
||||
|
||||
11
package.json
11
package.json
@ -12,12 +12,19 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"src/tui.tsx",
|
||||
"src/tui-shims.d.ts",
|
||||
"src/toggles.ts",
|
||||
"src/quota.ts",
|
||||
"src/redact.ts",
|
||||
"src/constants.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"quota": "node scripts/quota.mjs",
|
||||
"sync-models": "node scripts/sync-models.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
91
scripts/quota.mjs
Normal file
91
scripts/quota.mjs
Normal 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));
|
||||
@ -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
|
||||
// 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
|
||||
// result into the target config.
|
||||
// into opencode.json. This script fetches the live catalog, lets the user pick which models to
|
||||
// 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)
|
||||
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||
// Run: node scripts/sync-models.mjs (build first: npm run build)
|
||||
import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||
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 { redact } from "../dist/redact.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.
|
||||
const PROBE_IMAGE = {
|
||||
type: "file",
|
||||
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;
|
||||
@ -26,63 +32,20 @@ const VISION_ERROR = /image|vision|multimodal|modality|unsupported|not support|i
|
||||
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;
|
||||
|
||||
// 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.
|
||||
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) {
|
||||
try {
|
||||
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) {
|
||||
@ -197,34 +160,248 @@ function modelEntry(catalogModel, vision) {
|
||||
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) {
|
||||
const tmp = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, contents);
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const { text, data: config } = readConfig(opts.config);
|
||||
// The catalog carries no pricing fields (id/name/context_length/supported_endpoints only),
|
||||
// so the picker shows context with any opportunistically present pricing keys omitted.
|
||||
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);
|
||||
if (!key) {
|
||||
console.error("No API key: set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey");
|
||||
// Builtins-only checkbox picker (no dependencies, so the package stays runtime-dependency-free).
|
||||
// Nothing is pre-checked; deselected ids are dropped by the caller. Resolves to the selected
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (catalog.length === 0) {
|
||||
console.error("Catalog is empty; config unchanged.");
|
||||
process.exit(1);
|
||||
}
|
||||
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 = [];
|
||||
if (opts.probe) {
|
||||
console.log(`Probing vision (${catalog.length} models, concurrency ${opts.concurrency})...`);
|
||||
probeResults = await mapPool(catalog, opts.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}/${catalog.length}] ${model.id}: ${tag} (${outcome.reason})`);
|
||||
return outcome;
|
||||
});
|
||||
const picked = await pickModels(catalog, existingIds);
|
||||
if (picked === null) {
|
||||
console.log("Aborted \u2014 config unchanged.");
|
||||
return;
|
||||
}
|
||||
if (picked.length === 0) {
|
||||
console.log("No models selected \u2014 config unchanged.");
|
||||
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 };
|
||||
catalog.forEach((model, index) => {
|
||||
selected.forEach((model, index) => {
|
||||
const outcome = probeResults[index];
|
||||
let vision;
|
||||
if (outcome && outcome.vision !== undefined) {
|
||||
@ -265,41 +458,24 @@ async function main() {
|
||||
stats.unknown++;
|
||||
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 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;
|
||||
}
|
||||
const removed = [...existingIds].filter((id) => !(id in merged)).length;
|
||||
|
||||
config.provider ??= {};
|
||||
config.provider.commandcode ??= {};
|
||||
config.provider.commandcode.models = merged;
|
||||
|
||||
const indent = detectIndent(text);
|
||||
const trailing = text.endsWith("\n") ? "\n" : "";
|
||||
writeAtomic(opts.config, `${JSON.stringify(config, null, indent)}${trailing}`);
|
||||
const trailing = text === "" || text.endsWith("\n") ? "\n" : "";
|
||||
// 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(
|
||||
`Wrote ${Object.keys(merged).length} models to ${opts.config}\n` +
|
||||
` generated=${Object.keys(generated).length} added=${added} preserved-ids=${preservedIds}\n` +
|
||||
`Wrote ${Object.keys(merged).length} models to ${CONFIG_PATH}\n` +
|
||||
` selected=${selected.length} removed=${removed}\n` +
|
||||
` probed=${stats.probed} vision=${stats.vision} no-vision=${stats.noVision} ` +
|
||||
`unknown=${stats.unknown} heuristic-vision=${stats.heuristic}\n` +
|
||||
`Restart opencode, then run: opencode models commandcode`,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
/** 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 decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
@ -16,7 +19,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
|
||||
while ((newline = buffer.indexOf("\n")) >= 0) {
|
||||
const raw = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
const event = parseLine(raw);
|
||||
const event = parseLine(raw, dbg);
|
||||
if (event) yield event;
|
||||
}
|
||||
}
|
||||
@ -24,7 +27,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
|
||||
// flush any trailing line without a newline
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
const event = parseLine(buffer);
|
||||
const event = parseLine(buffer, dbg);
|
||||
if (event) yield event;
|
||||
}
|
||||
} 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();
|
||||
if (!line || line.startsWith(":") || line.startsWith("event:")) return null;
|
||||
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);
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
debug("events", "parse-skip", `line=${line.slice(0, 512)}`);
|
||||
debugWhen(dbg, "events", "parse-skip", `line=${line.slice(0, 512)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
30
src/log.ts
30
src/log.ts
@ -1,13 +1,14 @@
|
||||
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1; silent (and no file
|
||||
// created) by default. Uses only Node built-ins so the built output stays dependency-free.
|
||||
// Opt-in tracing to a log file. Gated on the shared toggle file (debug key);
|
||||
// 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 { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { redact } from "./redact.js";
|
||||
import { debugEnabled } from "./toggles.js";
|
||||
|
||||
const ENABLED = /^(1|true|yes)$/i.test(process.env["COMMANDCODE_DEBUG"] ?? "");
|
||||
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log");
|
||||
const FILE = join(tmpdir(), "commandcode-debug.log");
|
||||
|
||||
function serialize(value: unknown): string {
|
||||
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. */
|
||||
export function debug(scope: string, ...args: unknown[]): void {
|
||||
if (!ENABLED) return;
|
||||
function emit(scope: string, args: unknown[]): void {
|
||||
const parts = args.map(serialize);
|
||||
const line = `[commandcode] ${new Date().toISOString()} [${scope}] ${parts.join(" ")}`;
|
||||
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);
|
||||
}
|
||||
|
||||
225
src/model.ts
225
src/model.ts
@ -7,6 +7,7 @@ import type {
|
||||
LanguageModelV3StreamPart,
|
||||
LanguageModelV3StreamResult,
|
||||
LanguageModelV3Usage,
|
||||
SharedV3ProviderMetadata,
|
||||
SharedV3Warning,
|
||||
} from "@ai-sdk/provider";
|
||||
import {
|
||||
@ -18,10 +19,38 @@ import {
|
||||
GENERATE_PATH,
|
||||
} from "./constants.js";
|
||||
import { iterateEvents } from "./events.js";
|
||||
import { debug } from "./log.js";
|
||||
import { debugWhen, isDebugEnabled } from "./log.js";
|
||||
import { redact } from "./redact.js";
|
||||
import { zdrEnabled } from "./toggles.js";
|
||||
import { transform } from "./transform.js";
|
||||
import { finishReasonFrom, usageFromFinish, type FinishEvent } from "./usage.js";
|
||||
import { finishReasonFrom, usageFromFinish, costFromFinishStep, costFromProviderMetadata, type FinishEvent, type FinishStepEvent, type ProviderMetadataEvent } from "./usage.js";
|
||||
|
||||
// x-cmd-zdr breaks some models; off by default (server.py never sends it).
|
||||
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file.
|
||||
// "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 = {
|
||||
name?: string;
|
||||
@ -43,7 +72,7 @@ type ResolvedOptions = {
|
||||
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();
|
||||
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v;
|
||||
return undefined;
|
||||
@ -80,6 +109,50 @@ function isRetryable(status: number): boolean {
|
||||
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 {
|
||||
if (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
@ -131,7 +204,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
|
||||
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> = {
|
||||
"Content-Type": "application/json",
|
||||
"x-command-code-version": this.opts.ccVersion,
|
||||
@ -141,20 +218,26 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
"x-co-flag": "false",
|
||||
...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);
|
||||
if (auth) headers["Authorization"] = auth;
|
||||
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v;
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async fetchWithRetry(body: string, options: LanguageModelV3CallOptions): Promise<Response> {
|
||||
const headers = this.requestHeaders(options.headers);
|
||||
private async fetchWithRetry(
|
||||
body: string,
|
||||
options: LanguageModelV3CallOptions,
|
||||
dbg: boolean,
|
||||
): Promise<OpenedStream> {
|
||||
const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg);
|
||||
let lastError: unknown;
|
||||
const url = `${this.opts.baseURL}${GENERATE_PATH}`;
|
||||
|
||||
for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) {
|
||||
if (options.abortSignal?.aborted) {
|
||||
debug("fetch", "aborted", `attempt=${attempt}`);
|
||||
debugWhen(dbg, "fetch", "aborted", `attempt=${attempt}`);
|
||||
throw options.abortSignal.reason ?? new Error("Aborted");
|
||||
}
|
||||
const started = Date.now();
|
||||
@ -169,7 +252,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const wait = retryDelay(attempt, null, this.opts.retryMaxDelay);
|
||||
debug(
|
||||
debugWhen(
|
||||
dbg,
|
||||
"fetch",
|
||||
"network-error",
|
||||
`url=${url}`,
|
||||
@ -188,7 +272,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
if (isRetryable(status) && attempt < this.opts.maxRetries) {
|
||||
const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay);
|
||||
if (wait >= 0) {
|
||||
debug(
|
||||
debugWhen(
|
||||
dbg,
|
||||
"fetch",
|
||||
"retry",
|
||||
`url=${url}`,
|
||||
@ -206,14 +291,66 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
debug("fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
|
||||
return response;
|
||||
if (!response.ok || !response.body) {
|
||||
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");
|
||||
}
|
||||
|
||||
private async errorFrom(response: Response): Promise<Error> {
|
||||
private async errorFrom(response: Response, dbg: boolean): Promise<Error> {
|
||||
let body = "";
|
||||
try {
|
||||
body = await response.text();
|
||||
@ -230,18 +367,19 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
/* keep raw body */
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> {
|
||||
const dbg = resolveDebug(options);
|
||||
const body = transform(options, this.modelId);
|
||||
debug("doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`);
|
||||
const response = await this.fetchWithRetry(body, options);
|
||||
if (!response.ok || !response.body) throw await this.errorFrom(response);
|
||||
debug("doStream", "ok", `status=${response.status}`);
|
||||
debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`);
|
||||
const { response, prefix, events } = await this.fetchWithRetry(body, options, dbg);
|
||||
if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
|
||||
debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
|
||||
|
||||
const stream = toReadableStream(this.streamParts(response.body));
|
||||
const stream = toReadableStream(this.streamParts(prefix, events, dbg));
|
||||
return {
|
||||
stream,
|
||||
request: { body: JSON.parse(body) as unknown },
|
||||
@ -250,6 +388,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> {
|
||||
const dbg = resolveDebug(options);
|
||||
const { stream, request, response } = await this.doStream(options);
|
||||
|
||||
let text = "";
|
||||
@ -257,6 +396,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
const toolCalls: Array<Extract<LanguageModelV3Content, { type: "tool-call" }>> = [];
|
||||
let usage: LanguageModelV3Usage = zeroUsage();
|
||||
let finishReason: LanguageModelV3FinishReason = { unified: "other", raw: undefined };
|
||||
let providerMetadata: SharedV3ProviderMetadata | undefined;
|
||||
let warnings: SharedV3Warning[] = [];
|
||||
|
||||
const reader = stream.getReader();
|
||||
@ -280,6 +420,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
case "finish":
|
||||
usage = value.usage;
|
||||
finishReason = value.finishReason;
|
||||
providerMetadata = value.providerMetadata;
|
||||
break;
|
||||
case "error":
|
||||
throw value.error instanceof Error ? value.error : new Error(String(value.error));
|
||||
@ -296,7 +437,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
if (text) content.push({ type: "text", text });
|
||||
content.push(...toolCalls);
|
||||
|
||||
debug(
|
||||
debugWhen(
|
||||
dbg,
|
||||
"doGenerate",
|
||||
"done",
|
||||
`model=${this.modelId}`,
|
||||
@ -305,12 +447,17 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
`toolCalls=${toolCalls.length}`,
|
||||
`finish=${finishReason.unified ?? "?"}`,
|
||||
`usage=${JSON.stringify(usage)}`,
|
||||
`providerMetadata=${JSON.stringify(providerMetadata ?? {})}`,
|
||||
);
|
||||
|
||||
return { content, finishReason, usage, warnings, request, response };
|
||||
return { content, finishReason, usage, warnings, request, response, ...(providerMetadata ? { providerMetadata } : {}) };
|
||||
}
|
||||
|
||||
private async *streamParts(body: ReadableStream<Uint8Array>): AsyncGenerator<LanguageModelV3StreamPart> {
|
||||
private async *streamParts(
|
||||
prefix: Record<string, any>[],
|
||||
events: AsyncGenerator<Record<string, any>>,
|
||||
dbg: boolean,
|
||||
): AsyncGenerator<LanguageModelV3StreamPart> {
|
||||
yield { type: "stream-start", warnings: [] };
|
||||
|
||||
const textId = "text-0";
|
||||
@ -321,11 +468,13 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
let hadToolCalls = false;
|
||||
let usage: LanguageModelV3Usage | undefined;
|
||||
let finishReason: LanguageModelV3FinishReason | undefined;
|
||||
let cost: number | undefined;
|
||||
let marketCost: number | undefined;
|
||||
let errored = false;
|
||||
|
||||
for await (const evt of iterateEvents(body)) {
|
||||
for await (const evt of replayEvents(prefix, events)) {
|
||||
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) {
|
||||
case "text-start":
|
||||
if (!textOpen) {
|
||||
@ -395,6 +544,19 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
usage = usageFromFinish(evt as FinishEvent);
|
||||
finishReason = finishReasonFrom(evt as FinishEvent, hadToolCalls);
|
||||
break;
|
||||
case "finish-step": {
|
||||
// Upstream dollar cost rides here (usage.raw.cost), never on `finish`.
|
||||
const found = costFromFinishStep(evt as unknown as FinishStepEvent);
|
||||
if (found.cost !== undefined) cost = found.cost;
|
||||
if (found.marketCost !== undefined) marketCost = found.marketCost;
|
||||
break;
|
||||
}
|
||||
case "provider-metadata": {
|
||||
const found = costFromProviderMetadata(evt as unknown as ProviderMetadataEvent);
|
||||
if (cost === undefined && found.cost !== undefined) cost = found.cost;
|
||||
if (marketCost === undefined && found.marketCost !== undefined) marketCost = found.marketCost;
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
errored = true;
|
||||
const err = evt.error;
|
||||
@ -416,16 +578,27 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
if (errored) {
|
||||
debug("stream", "terminal", "errored");
|
||||
debugWhen(dbg, "stream", "terminal", "errored");
|
||||
return;
|
||||
}
|
||||
if (textOpen) yield { type: "text-end", id: textId };
|
||||
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 {
|
||||
type: "finish",
|
||||
usage: usage ?? zeroUsage(),
|
||||
usage: finalUsage,
|
||||
finishReason: finishReason ?? { unified: hadToolCalls ? "tool-calls" : "stop", raw: undefined },
|
||||
...(finishMetadata ? { providerMetadata: finishMetadata } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
431
src/quota.ts
Normal file
431
src/quota.ts
Normal 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
55
src/toggles.ts
Normal 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
19
src/tui-shims.d.ts
vendored
Normal 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
410
src/tui.tsx
Normal 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 };
|
||||
78
src/usage.ts
78
src/usage.ts
@ -6,8 +6,15 @@ export type CommandCodeUsage = {
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cost?: number | string;
|
||||
market_cost?: number | string;
|
||||
marketCost?: number | string;
|
||||
gateway_cost?: number | string;
|
||||
gatewayCost?: number | string;
|
||||
inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number };
|
||||
outputTokenDetails?: { textTokens?: number; reasoningTokens?: number };
|
||||
raw?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FinishEvent = {
|
||||
@ -16,6 +23,73 @@ export type FinishEvent = {
|
||||
totalUsage?: CommandCodeUsage;
|
||||
};
|
||||
|
||||
export type FinishStepEvent = {
|
||||
usage?: CommandCodeUsage;
|
||||
providerMetadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ProviderMetadataEvent = {
|
||||
providerMetadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Finite numbers pass through; numeric strings (gateway `cost: "0.0003"`) are coerced. */
|
||||
function numberFrom(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const n = Number(value.trim());
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream dollar cost arrives on `finish-step`, not `finish`:
|
||||
* `usage.raw.{cost,market_cost,gateway_cost}` (numbers). A reported `0` is
|
||||
* meaningful (flat-fee routed request) and must be preserved.
|
||||
*/
|
||||
export function costFromFinishStep(evt: FinishStepEvent): { cost?: number; marketCost?: number } {
|
||||
const fallback = costFromProviderMetadata(evt);
|
||||
const usage = evt.usage;
|
||||
if (!usage || typeof usage !== "object") return fallback;
|
||||
const raw = isRecord(usage.raw) ? usage.raw : {};
|
||||
const cost = numberFrom(usage.cost ?? raw["cost"]) ?? fallback.cost;
|
||||
const marketCost =
|
||||
numberFrom(
|
||||
usage.market_cost ??
|
||||
usage.marketCost ??
|
||||
raw["market_cost"] ??
|
||||
raw["marketCost"] ??
|
||||
usage.gateway_cost ??
|
||||
usage.gatewayCost ??
|
||||
raw["gateway_cost"] ??
|
||||
raw["gatewayCost"],
|
||||
) ?? fallback.marketCost;
|
||||
return {
|
||||
...(cost !== undefined ? { cost } : {}),
|
||||
...(marketCost !== undefined ? { marketCost } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fallback/supplement: `provider-metadata` carries gateway cost as strings. */
|
||||
export function costFromProviderMetadata(evt: ProviderMetadataEvent): {
|
||||
cost?: number;
|
||||
marketCost?: number;
|
||||
} {
|
||||
const pm = evt.providerMetadata;
|
||||
if (!isRecord(pm)) return {};
|
||||
const gateway = isRecord(pm["gateway"]) ? (pm["gateway"] as Record<string, unknown>) : {};
|
||||
const cost = numberFrom(gateway["cost"]);
|
||||
const marketCost = numberFrom(gateway["marketCost"] ?? gateway["market_cost"]);
|
||||
return {
|
||||
...(cost !== undefined ? { cost } : {}),
|
||||
...(marketCost !== undefined ? { marketCost } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** CommandCode `finish` -> LanguageModelV3Usage. */
|
||||
export function usageFromFinish(evt: FinishEvent): LanguageModelV3Usage {
|
||||
const tu = evt.totalUsage;
|
||||
@ -26,10 +100,12 @@ export function usageFromFinish(evt: FinishEvent): LanguageModelV3Usage {
|
||||
const details = tu.inputTokenDetails ?? {};
|
||||
const outDetails = tu.outputTokenDetails ?? {};
|
||||
const cacheRead = tu.cachedInputTokens ?? details.cacheReadTokens;
|
||||
// inputTokens.total is input-only (parity with server.py); totalTokens is input+output.
|
||||
const total = tu.totalTokens ?? input + output;
|
||||
|
||||
return {
|
||||
inputTokens: {
|
||||
total: tu.totalTokens ?? input + output,
|
||||
total: tu.inputTokens ?? total - output,
|
||||
noCache: details.noCacheTokens,
|
||||
cacheRead,
|
||||
cacheWrite: undefined,
|
||||
|
||||
@ -14,5 +14,6 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/tui.tsx", "src/tui-shims.d.ts"]
|
||||
}
|
||||
|
||||
18
tsconfig.tui.json
Normal file
18
tsconfig.tui.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user