opencode-ccgo-provider/README.md

300 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# opencode-commandcode-provider
A native [AI SDK](https://ai-sdk.dev/) provider that connects [opencode](https://opencode.ai)
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.
```powershell
# 1. build the provider
npm install
npm run build
```
```jsonc
// 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:
```powershell
$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.
```powershell
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:
```powershell
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:
```powershell
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.
```powershell
$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:
```jsonc
// 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
```powershell
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:
```powershell
node scripts/smoke.mjs deepseek/deepseek-v4.1-flash
```
### Verifying inside opencode
```powershell
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](#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.