From e974a374750fe3abd79394c28806f8e7a37b206b Mon Sep 17 00:00:00 2001 From: "nhat.nguyenhong" Date: Thu, 10 Sep 2026 20:39:12 +0700 Subject: [PATCH] Document provider usage and agent guidance --- AGENTS.md | 145 ++++++++++++++++++++++++++++++++++++ README.md | 217 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 AGENTS.md create mode 100644 README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ef0d43a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,145 @@ +# AGENTS.md + +Instructions for LLM coding agents working in this repository. + +## Project overview + +`opencode-commandcode-provider` is a native AI SDK provider that connects opencode to the +CommandCode `/alpha/generate` API. It implements `LanguageModelV3` from `@ai-sdk/provider@3.0.8` +and is loaded by opencode from `dist/index.js` via a `file://` npm spec. + +`server.py` in this directory is the **reference implementation** of the same translation +(an OpenAI-compatible HTTP proxy). When behavior is ambiguous, `server.py` is the source of +truth for the upstream wire shape. This package is the TypeScript port; it does not run Python. + +- Language/tooling: TypeScript, ESM, `tsc`. +- Runtime target: opencode's provider loader, `@ai-sdk/provider@3.0.8` (V3 specification). +- No runtime dependencies. `@ai-sdk/provider` is imported with `import type` only; the built + output has no imports of its own. + +## Commands + +Run from the repository root. + +```powershell +npm install # dev deps: typescript, @types/node, @ai-sdk/provider +npm run typecheck # tsc --noEmit +npm run build # tsc -> dist/ +npm run smoke # live request against CommandCode (needs a key) +``` + +There is no unit-test suite. `scripts/smoke.mjs` is the end-to-end check. + +After any change under `src/`, run `npm run typecheck`, then `npm run build`, then `npm run smoke`. +A change is not done until it typechecks and the smoke test passes. + +## Layout + +``` +src/index.ts Public exports: createCommandCode (named) and default. +src/model.ts CommandCodeLanguageModel: HTTP, retries, doStream/doGenerate, event->part mapping. +src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (returns a JSON string). +src/events.ts Async iterator over the NDJSON/SSE response body. +src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification. +src/redact.ts Credential scrubbing for error surfaces. +src/constants.ts Defaults, paths, headers, passthrough params, static config block. +scripts/smoke.mjs Live end-to-end check. +server.py Reference Python proxy (do not modify unless explicitly asked). +``` + +## Architecture / data flow + +``` +opencode + └─ provider.languageModel(id).doStream(options) + ├─ transform(options, id) ──► JSON envelope + ├─ fetch POST {baseURL}/alpha/generate (retry 429/5xx) + ├─ iterateEvents(response.body) (NDJSON lines) + └─ map each event ──► LanguageModelV3StreamPart* + ... always ending with a `finish` part +``` + +`doGenerate` calls `doStream` and drains the resulting stream into a +`LanguageModelV3GenerateResult`. + +## Conventions + +- ESM only. Imports of local files **must** end in `.js` (NodeNext resolution), e.g. + `import { transform } from "./transform.js";`. +- `strict` TypeScript is on, including `noUncheckedIndexedAccess` and `verbatimModuleSyntax`. + Type-only imports must use `import type`. +- Do **not** add comments unless they explain a non-obvious decision. Existing comments document + upstream quirks and are intentional; match that spirit, not volume. +- Keep `transform.ts` output in parity with `server.py` `transform()`. If you change the envelope, + explain which `server.py` behavior motivated it. +- Preserve the single public factory `createCommandCode`. `index.ts` re-exports it as both a named + and the default export. +- The package must stay runtime-dependency-free. Do not add a runtime `import` of a package that + would need `node_modules` at load time; opencode imports `dist/index.js` directly. + +## Invariants and gotchas + +These are load-bearing. Breaking one causes silent failures in opencode. + +1. **Exactly one `create*` export reachable from the entrypoint.** opencode loads a custom npm + provider by importing it, choosing the first export whose name starts with `create`, and calling + it as `fn({ name: providerID, ...options })`. The result must expose `languageModel(id)`. +2. **`params.stream` is always `true`.** Sending `stream: false` makes upstream answer + "Proxy use detected. This endpoint only serves CLI." Non-streaming clients are served by + buffering the V3 stream in `doGenerate`. +3. **`tool-call` stream parts carry `input` as a stringified JSON string**, not an object + (`LanguageModelV3ToolCall.input: string`). Serialize with `JSON.stringify`. Passing an object + breaks opencode's tool execution with errors like `K.input.trim is not a function`. +4. **Always emit a terminal `finish` part.** If upstream ends without a `finish` event, synthesize + one from the accumulated state. A missing finish surfaces as a truncated-response error. +5. **Drop unpaired tool ids.** Any `tool-call` without a matching `tool-result` (or vice versa) must + be omitted from the envelope; upstream rejects the whole request otherwise. +6. **Usage is nested in V3.** Fill `inputTokens.{total,noCache,cacheRead,cacheWrite}` and + `outputTokens.{total,text,reasoning}` — not flat `promptTokens`/`completionTokens`. +7. **`supportedUrls` is `{}` and stays empty.** CommandsCode URLs are not fetched by the SDK. +8. **Redact before surfacing errors.** Any upstream error text passed to the client must go through + `redact()`. +9. **`file://` npm specs bypass install.** opencode imports `dist/index.js` directly, so the repo + must be rebuilt for opencode to see source changes. + +## Change workflow + +1. Read `server.py` for the behavior being ported and note the relevant function. +2. Make the smallest change in the matching `src/` file. +3. `npm run typecheck` — fix all errors. +4. `npm run build`. +5. `npm run smoke` — verify text and, when relevant, tool calls. +6. If the change affects the opencode integration, verify with `opencode run` (see below). +7. Update `README.md` if options, features, or behavior changed. Keep `AGENTS.md` invariants + current if you discover a new one. + +Do not edit `dist/` by hand; it is generated. Do not modify `server.py` unless explicitly asked. + +## Verification + +Automated: + +```powershell +npm run typecheck; npm run build; npm run smoke +``` + +Inside opencode (restart it first if config changed): + +```powershell +opencode models commandcode +opencode run "Reply with exactly: pong" -m commandcode/deepseek/deepseek-v4-flash-vision-exp +opencode run "Use the glob tool to list *.mjs in this directory and report the filenames." -m commandcode/deepseek/deepseek-v4-flash-vision-exp +``` + +The third command confirms the full tool-call round trip (tool-call emitted, executed, result +replayed). Run it after touching `transform.ts` or the tool-call mapping in `model.ts`. + +## Do / Don't + +- **Do** keep the wire envelope identical to `server.py`. +- **Do** run typecheck, build, and smoke after every source change. +- **Do** prefer `{env:COMMANDCODE_API_KEY}` in docs and examples. +- **Don't** commit real API keys, `dist/`, `node_modules/`, `dump/`, or logs (all gitignored). +- **Don't** add runtime dependencies or make `dist` require `node_modules`. +- **Don't** force `stream:false`, send object `tool-call.input`, or omit the `finish` part. +- **Don't** commit unless the user explicitly asks. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1993498 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# 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-flash-vision-exp": { + "name": "DeepSeek V4 Flash (Vision)", + "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. List every model you want selectable; the provider itself only needs `languageModel(id)`, +which it implements for any id passed to it. + +## Configuration options + +`provider.commandcode.options` is forwarded to `createCommandCode(options)`. + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `apiKey` | `string` | — | CommandCode API key. A bare token or `Bearer ` both work. | +| `headers` | `Record` | `{}` | 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. + +## 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` | +| 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/constants.ts Defaults, header names, passthrough params, static config block. +scripts/smoke.mjs Live end-to-end check against api.commandcode.ai. +``` + +### 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 +``` + +`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-flash-vision-exp +opencode run "Use the glob tool to list *.mjs and report the filenames." -m commandcode/deepseek/deepseek-v4-flash-vision-exp +``` + +## 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. | +| `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.