Forward each assistant reasoning part to /alpha/generate as
{type:"reasoning", text, signature?} instead of dropping it. DeepSeek
thinking mode rejects any request carrying tools whose prior assistant
turns omit their reasoning, so multi-turn tool-call conversations 400'd.
Only real (non-empty) reasoning is forwarded; no fabricated injection.
Update README and AGENTS.md accordingly.
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 |
| 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:
// 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.