opencode-ccgo-provider/README.md

14 KiB
Raw Blame History

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

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.

ZDR header toggle

The x-cmd-zdr: 1 request header is omitted by default because it is rejected by some models. Set COMMANDCODE_ZDR=1 (or true/yes) when launching opencode to opt back in. The value is read once at provider load, so restart opencode after changing it. Per-provider overrides still win: headers: { "x-cmd-zdr": "..." } in opencode.json takes precedence over the environment variable.

With COMMANDCODE_DEBUG enabled, the trace logs a zdr on|off line per request so the toggle state is observable.

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

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