Go to file
2026-09-24 13:52:50 +00:00
scripts Make sync-models an interactive model picker 2026-09-22 12:15:39 +00:00
src rate limit the quota update: once every 2 min 2026-09-24 13:52:50 +00:00
.gitignore Add native AI SDK provider for CommandCode /alpha/generate 2026-09-10 20:32:40 +07:00
AGENTS.md Load tui plugin via direct src/tui.tsx file spec instead of repo root 2026-09-24 07:53:03 +00:00
package-lock.json Add native AI SDK provider for CommandCode /alpha/generate 2026-09-10 20:32:40 +07:00
package.json Load tui plugin via direct src/tui.tsx file spec instead of repo root 2026-09-24 07:53:03 +00:00
README.md rate limit the quota update: once every 2 min 2026-09-24 13:52:50 +00:00
server.py Add sync-models script, server.py reference proxy, and doc updates 2026-09-10 21:25:25 +07:00
tsconfig.json add zdr and debug status to the sidebar 2026-09-16 14:34:49 +00:00
tsconfig.tui.json add usage tracking to sidebar 2026-09-16 15:30:12 +00:00

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/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.

# 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:/dev/opencode-commandcode-provider/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.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, …) 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:

  1. providerOptions.commandcode.zdr (true/false), e.g. a model variant or agent option.
  2. An explicit x-cmd-zdr header in opencode.json options.headers or the call's headers.
  3. The toggle file (zdr key — see Runtime toggles).
  4. COMMANDCODE_ZDR=1|true|yes in 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 by adding the plugin file's absolute file:// URL to the plugin list in tui.json (global ~/.config/opencode/tui.json, or project-local .opencode/tui.json):

// tui.json
{
  "plugin": ["file:///C:/dev/opencode-commandcode-provider/src/tui.tsx"]
}

Use an absolute path with forward slashes (file:///C:/... on Windows, file:///home/... on Linux/macOS). Two rules for the entry:

  • It must point at src/tui.tsx itself. opencode plugin <module> cannot install this form: the CLI requires a package.json manifest next to the target, so a bare .tsx must be wired in by hand as above.
  • Never point plugin at the repo root. A directory spec makes opencode also treat the package as a server plugin (via package.json main → dist/index.js), which is the AI SDK provider, not a plugin — it loads as dead weight into every session.

Restart opencode after registering the plugin; 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, but never more than once every 2 minutes; every 5 minutes as a fallback (COMMANDCODE_QUOTA_INTERVAL_MS overrides the fallback interval) 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

  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

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.
/cc-* commands and the sidebar panel are missing TUI plugin not registered: add a direct file:// URL to src/tui.tsx in tui.json (see Runtime toggles). A repo-root or src/tui.tsx-less entry does not load.

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.