Introduce COMMANDCODE_DEBUG=1 (and COMMANDCODE_DEBUG_FILE) to append a request/stream trace to a log file. Trace points cover HTTP attempts (status, retry wait, elapsed), stream event payloads, terminal finish and usage, and redacted error bodies. Silent and no file created by default.
288 lines
14 KiB
Markdown
288 lines
14 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:/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 <path> # 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 <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.
|
||
|
||
```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.
|
||
|
||
## 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` |
|
||
| 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.
|