19 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.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:
$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.
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:
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, …) 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).
$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:
providerOptions.commandcode.zdr(true/false), e.g. a model variant or agent option.- An explicit
x-cmd-zdrheader inopencode.jsonoptions.headersor the call'sheaders. - The toggle file (
zdrkey — see Runtime toggles). COMMANDCODE_ZDR=1|true|yesin 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:
opencode plugin file:///C:/DevTools/pienv/ccprovider/src/tui.tsx
That writes a tui.json plugin entry (project-local .opencode/tui.json, or global with
--global). Restart opencode after installing; 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,
plus every 3 minutes as a fallback (COMMANDCODE_QUOTA_INTERVAL_MS overrides it) 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:
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:
// 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
- 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 (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:
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.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). |
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.