# 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:/DevTools/pienv/ccprovider/dist/index.js", "options": { "apiKey": "{env:COMMANDCODE_API_KEY}" }, "models": { "deepseek/deepseek-v4-flash-vision-exp": { "name": "DeepSeek V4 Flash (Vision)", "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 writes the full map into your opencode config. ```powershell npm run build # the script imports the compiled provider npm run sync-models # fetch catalog, probe vision, merge into ~/.config/opencode/opencode.json ``` 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`, …) 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 # target a different config ``` The API key is read from `COMMANDCODE_API_KEY`, then `provider.commandcode.options.apiKey` in the target config. ## 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. ## 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. ```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` | `/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. ## 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 | | 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` | | Credential redaction | Yes — error bodies are scrubbed before surfacing | ### `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/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. ``` ### 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 npm run build # tsc -> dist/ npm run smoke # live request against CommandCode 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-flash-vision-exp opencode run "Use the glob tool to list *.mjs and report the filenames." -m commandcode/deepseek/deepseek-v4-flash-vision-exp ``` ## 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. | ## 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.