391 lines
20 KiB
Markdown
391 lines
20 KiB
Markdown
# 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.
|
||
|
||
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 <token>` both work. |
|
||
| `headers` | `Record<string,string>` | `{}` | 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.
|
||
|
||
## 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
|
||
credentials never reach disk. It can also be flipped at runtime with `/cc-debug` (see
|
||
[Runtime toggles](#runtime-toggles)).
|
||
|
||
```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 `COMMANDCODE_ZDR` environment variable, 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)).
|
||
4. `COMMANDCODE_ZDR=1|true|yes` in the environment.
|
||
|
||
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`); the environment variables above are used when a key is absent.
|
||
|
||
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) 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 (COMMANDCODE_DEBUG) 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 | Set `COMMANDCODE_DEBUG=1` 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.
|