# opencode-commandcode-provider A native [AI SDK](https://ai-sdk.dev/) provider that connects [opencode](https://opencode.ai) to the **CommandCode** `/alpha/generate` API. It is the TypeScript successor to the `server.py` proxy in this directory. Where the proxy exposed an OpenAI-compatible `/v1/chat/completions` endpoint that opencode reached with `@ai-sdk/openai-compatible`, this package implements the `LanguageModelV3` interface directly, so opencode talks to CommandCode with no Python process and no local proxy in the middle. ``` opencode ──► @ai-sdk/provider (LanguageModelV3) │ ▼ opencode-commandcode-provider transform → POST /alpha/generate NDJSON events → V3 stream parts │ ▼ api.commandcode.ai ``` `server.py` is kept in the repository as the reference implementation. All wire-shape decisions (error reshaping, tool-call pairing, `tool_choice` emulation, retry policy, credential redaction) originate there and are mirrored here. ## Requirements - **Node.js >= 18** (uses global `fetch`, `ReadableStream`, `TextDecoder`, `structuredClone`). - **opencode >= 1.17** that ships `@ai-sdk/provider@3.0.8` (verified against opencode 1.18.30). - A CommandCode account and API key with access to the `/alpha/generate` endpoint. ## Install and wire into opencode The provider is consumed directly from its build output via a `file://` spec. This bypasses npm install and is the intended development workflow. ```powershell # 1. build the provider npm install npm run build ``` ```jsonc // 2. ~/.config/opencode/opencode.json (or a project opencode.json) { "$schema": "https://opencode.ai/config.json", "provider": { "commandcode": { "name": "Command Code", "npm": "file:///C:/dev/opencode-commandcode-provider/dist/index.js", "options": { "apiKey": "{env:COMMANDCODE_API_KEY}" }, "models": { "deepseek/deepseek-v4.1-flash": { "name": "DeepSeek V4.1 Flash", "limit": { "context": 1048576, "output": 256000 }, "attachment": true, "modalities": { "input": ["text", "image"], "output": ["text"] } } } } } } ``` Then set the key and restart opencode: ```powershell $env:COMMANDCODE_API_KEY = "user_..." opencode models commandcode ``` > opencode loads configuration **once at startup**. After changing `opencode.json`, a plugin, or > a rebuilt provider, quit and relaunch opencode for the change to take effect. The `models` map is required — opencode silently drops a custom provider whose model list is empty. The provider itself only needs `languageModel(id)`, which it implements for any id passed to it, but opencode builds `/models` and the TUI picker from this map and never asks a custom `file://` provider to discover models. Use the sync script below to fill it in. ## Listing all CommandCode models 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 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, 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 () to generate one. It never writes without a key. Then restart opencode and confirm: ```powershell opencode models commandcode ``` Mapping and behaviour: - Every catalog entry gets `limit.context` from its `context_length`, `limit.output` from `DEFAULT_MAX_TOKENS`, and **`reasoning: true`**. - Vision (`attachment` + `modalities.input` with `image`) is **probed** per model: a 64×64 image is sent and the reply is inspected. Models that answer a color are marked vision; models that reply `NO_IMAGE` (retried once) or reject the image are not. Probes that cannot run — plan-gated, temporarily unavailable — fall back to a family heuristic (`claude`, `gpt-5`, `gemini`, `grok`, `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`, …) 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 global config (`baseURL`/`ccVersion` are read from there too). ## Configuration options `provider.commandcode.options` is forwarded to `createCommandCode(options)`. | Option | Type | Default | Description | | --- | --- | --- | --- | | `apiKey` | `string` | — | CommandCode API key. A bare token or `Bearer ` both work. | | `headers` | `Record` | `{}` | Extra request headers, merged over the built-in ones. An `Authorization` header is accepted as an alternative auth source. | | `baseURL` | `string` | `https://api.commandcode.ai` | Upstream origin. Trailing slashes are stripped. | | `ccVersion` | `string` | `1.15.1` | Value of the `x-command-code-version` header. | | `maxRetries` | `number` | `2` | Retry attempts for retryable failures (429/5xx/network). | | `retryMaxDelaySeconds` | `number` | `60` | Longest wait honoured from `Retry-After`; longer values are not retried. | | `name` | `string` | `commandcode` | Provider id reported to the AI SDK. opencode sets this automatically. | **Auth precedence:** `options.apiKey` wins; otherwise the value of an `Authorization` header in `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 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 `/commandcode-debug.log`. Every line is passed through `redact()` so credentials never reach disk. 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 ` 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 | | --- | --- | | Streaming (`doStream`) | Yes — CommandCode is always streamed upstream, then re-emitted as V3 stream parts | | Non-streaming (`doGenerate`) | Yes — buffers the stream internally | | Text deltas | Yes | | Reasoning deltas | Yes — emitted as `reasoning-start` / `reasoning-delta` / `reasoning-end` | | Reasoning replay | Yes — assistant `reasoning` parts are sent back as `{type:"reasoning"}` content parts (required by DeepSeek thinking mode when tools are present); `signature` is forwarded when present | | Tool calls | Yes — `tool-input-start` / `tool-input-delta` / `tool-input-end` / `tool-call` | | 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`; 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 CommandCode accepts exactly one upstream value, `{"type":"auto"}`. The provider maps the AI SDK values accordingly: - `auto` — forwarded as `{"type":"auto"}`. - `none` — emulated by sending **no tools at all**. - `{ type: "tool", toolName }` — emulated by sending only that tool. - `required` — not expressible upstream; tools are sent and the model decides. ### Reasoning effort Set the per-request reasoning effort through provider options: ```jsonc // opencode model options / variant "options": { "providerOptions": { "commandcode": { "reasoningEffort": "high" } } } ``` Both `reasoning_effort` and `reasoningEffort` keys are recognised and forwarded as `params.reasoning_effort`. ## How it works ``` src/index.ts Public exports: createCommandCode + default. src/model.ts CommandCodeLanguageModel (LanguageModelV3): HTTP, retries, stream/generate. src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (JSON string). 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 (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/quota.mjs Print the live quota headlessly. scripts/sync-models.mjs Pick models interactively and write provider.commandcode.models. ``` ### Request lifecycle 1. opencode calls `provider.languageModel(id)`, then `model.doStream(options)`. 2. `transform()` converts the AI SDK prompt into the CommandCode envelope: - `system` text is joined into `params.system`. - user/assistant/tool messages become `params.messages` content parts. - assistant `tool-call` parts and `tool` results are only included when their ids are paired; unmatched ids (common after history truncation) are dropped because upstream rejects them. - image file parts become `{ type: "image", image, mimeType }`. - function tools become `{ type: "function", name, description, input_schema }`. - `stream` is **forced to `true`** — the endpoint answers `stream:false` with "Proxy use detected. This endpoint only serves CLI." 3. `model.ts` POSTs the envelope with the CommandCode headers and retries 429/5xx. 4. `events.ts` parses the NDJSON body (tolerating `data:` prefixes and `[DONE]`). 5. `model.ts` maps each upstream event to a `LanguageModelV3StreamPart` and always terminates with a `finish` part. 6. `doGenerate` drains the same stream and assembles a `LanguageModelV3GenerateResult`. ## Development ```powershell npm install # installs typescript, @types/node, @ai-sdk/provider 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 ``` `scripts/smoke.mjs` reads the key from `COMMANDCODE_API_KEY`, falling back to `provider.commandcode.options.apiKey` in `~/.config/opencode/opencode.json`. It exercises both `doGenerate` and `doStream` and prints content, finish reason, and usage. To smoke-test a specific model: ```powershell node scripts/smoke.mjs deepseek/deepseek-v4.1-flash ``` ### Verifying inside opencode ```powershell opencode models commandcode opencode run "Reply with exactly: pong" -m commandcode/deepseek/deepseek-v4.1-flash opencode run "Use the glob tool to list *.mjs and report the filenames." -m commandcode/deepseek/deepseek-v4.1-flash ``` ## Troubleshooting | Symptom | Likely cause / fix | | --- | --- | | `Provider not found: commandcode` | Provider was dropped because `models` is empty, or the `npm` path is wrong. Confirm `dist/index.js` exists (`npm run build`) and that the `file://` path is absolute. | | Models appear but every call fails auth | `options.apiKey` missing/expired, or `{env:COMMANDCODE_API_KEY}` not set in the environment opencode was launched with. | | `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 | 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 - Prefer `{env:COMMANDCODE_API_KEY}` (or opencode's `/connect` credential store) over an inline key in `opencode.json`. - Upstream error bodies can echo credentials. `src/redact.ts` scrubs `Bearer` tokens, `user_`/`cc_` keys, `sk-…`-style keys, JWTs, and `key=value` secrets from any error surfaced to the client. Do not add logging of raw request/response bodies without passing them through `redact()`. - Never commit real keys. `dist/`, `node_modules/`, `dump/`, and `*.log` are gitignored.