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.
14 KiB
opencode-commandcode-provider
A native AI SDK provider that connects opencode
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/generateendpoint.
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.
# 1. build the provider
npm install
npm run build
// 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:
$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.
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:
opencode models commandcode
Mapping and behaviour:
- Every catalog entry gets
limit.contextfrom itscontext_length,limit.outputfromDEFAULT_MAX_TOKENS, andreasoning: true. - Vision (
attachment+modalities.inputwithimage) 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 replyNO_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:
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.
$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:
// 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
- opencode calls
provider.languageModel(id), thenmodel.doStream(options). transform()converts the AI SDK prompt into the CommandCode envelope:systemtext is joined intoparams.system.- user/assistant/tool messages become
params.messagescontent parts. - assistant
tool-callparts andtoolresults 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 }. streamis forced totrue— the endpoint answersstream:falsewith "Proxy use detected. This endpoint only serves CLI."
model.tsPOSTs the envelope with the CommandCode headers and retries 429/5xx.events.tsparses the NDJSON body (toleratingdata:prefixes and[DONE]).model.tsmaps each upstream event to aLanguageModelV3StreamPartand always terminates with afinishpart.doGeneratedrains the same stream and assembles aLanguageModelV3GenerateResult.
Development
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:
node scripts/smoke.mjs deepseek/deepseek-v4.1-flash
Verifying inside opencode
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). |
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/connectcredential store) over an inline key inopencode.json. - Upstream error bodies can echo credentials.
src/redact.tsscrubsBearertokens,user_/cc_keys,sk-…-style keys, JWTs, andkey=valuesecrets from any error surfaced to the client. Do not add logging of raw request/response bodies without passing them throughredact(). - Never commit real keys.
dist/,node_modules/,dump/, and*.logare gitignored.