opencode-ccgo-provider/README.md

391 lines
20 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:/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:
```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 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.
```powershell
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:
```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`, …) 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](#runtime-toggles)).
```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. 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](#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`):
```jsonc
// 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:
```powershell
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:
```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/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
```powershell
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:
```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. |
| `/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](#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.