14 KiB
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/provideris imported withimport typeonly; the built output has no imports of its own.
Commands
Run from the repository root.
npm install # dev deps: 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 is loaded from source)
npm run smoke # live request against CommandCode (needs a key)
npm run quota # live 5-hour/weekly/monthly quota (needs a key)
npm run sync-models # regenerate provider.commandcode.models from the live catalog
There is no unit-test suite. scripts/smoke.mjs is the end-to-end check. scripts/sync-models.mjs
is the interactive model picker (needs dist/ first; needs a TTY; writes the global opencode config).
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/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file.
src/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json).
src/quota.ts Live 5-hour/weekly/monthly quota from the CommandCode alpha billing API.
src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar.
src/tui-shims.d.ts Ambient types for the opencode-provided solid-js/@opentui/solid runtime.
tsconfig.tui.json Typechecks src/tui.tsx (+ quota/toggles) against the shim (no dependencies).
src/constants.ts Defaults, paths, headers, passthrough params, static config block.
scripts/smoke.mjs Live end-to-end check.
scripts/quota.mjs Print the live quota headlessly (reads the same config as smoke).
scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing).
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";. strictTypeScript is on, includingnoUncheckedIndexedAccessandverbatimModuleSyntax. Type-only imports must useimport 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.tsoutput in parity withserver.pytransform(). If you change the envelope, explain whichserver.pybehavior motivated it. - Preserve the single public factory
createCommandCode.index.tsre-exports it as both a named and the default export. - The package must stay runtime-dependency-free. Do not add a runtime
importof a package that would neednode_modulesat load time; opencode importsdist/index.jsdirectly.
Invariants and gotchas
These are load-bearing. Breaking one causes silent failures in opencode.
- Exactly one
create*export reachable from the entrypoint. opencode loads a custom npm provider by importing it, choosing the first export whose name starts withcreate, and calling it asfn({ name: providerID, ...options }). The result must exposelanguageModel(id). params.streamis alwaystrue. Sendingstream: falsemakes upstream answer "Proxy use detected. This endpoint only serves CLI." Non-streaming clients are served by buffering the V3 stream indoGenerate.tool-callstream parts carryinputas a stringified JSON string, not an object (LanguageModelV3ToolCall.input: string). Serialize withJSON.stringify. Passing an object breaks opencode's tool execution with errors likeK.input.trim is not a function.- Always emit a terminal
finishpart. If upstream ends without afinishevent, synthesize one from the accumulated state. A missing finish surfaces as a truncated-response error. - Drop unpaired tool ids. Any
tool-callwithout a matchingtool-result(or vice versa) must be omitted from the envelope; upstream rejects the whole request otherwise. - Usage is nested in V3. Fill
inputTokens.{total,noCache,cacheRead,cacheWrite}andoutputTokens.{total,text,reasoning}— not flatpromptTokens/completionTokens.inputTokens.totalis input-only (parity withserver.py);totalTokensis input+output. supportedUrlsis{}and stays empty. CommandsCode URLs are not fetched by the SDK.- Redact before surfacing errors. Any upstream error text passed to the client must go through
redact(). - Replay assistant reasoning as a
{type:"reasoning"}content part. DeepSeek thinking mode rejects any request carryingtoolswhose prior assistant turns omit their reasoning. Intransform.ts, forward each assistantreasoningpart as{type:"reasoning", text, signature?}; do not drop it and do not fabricate empty reasoning.server.pypredates this requirement and is not the guide here. - Upstream cost never appears on
finish. Per-request USD arrives onfinish-step(usage.raw.cost/market_cost/gateway_cost, numbers) andprovider-metadata(providerMetadata.gateway.cost/marketCost, strings).finish.totalUsagecarries tokens only. Capture both intoproviderMetadata: { commandcode: { cost, marketCost } }on the terminal V3finishpart (anddoGenerateresult), and stash intousage.raw. A reported0is meaningful; onlyundefinedmeans absent. The built-in sidebar still shows$0.00until the fork consumes this field (anomalyco/opencode#43818);/cc-usageis the accurate dollar source meanwhile. file://npm specs bypass install. opencode importsdist/index.jsdirectly, so the repo must be rebuilt for opencode to see source changes./modelsis config-driven, not provider-driven. opencode builds the model list fromprovider.commandcode.modelsinopencode.jsonand never asks a customfile://provider to discover models (only internal providers can registerdiscoverModels). Refresh the map withnpm run sync-models; do not expect a discovery hook insrc/to populate it.- The TUI plugin lives outside the provider entrypoint.
src/tui.tsxis loaded from source (notdist/) via atui.jsonpluginentry that must be a directfile://URL tosrc/tui.tsx(global~/.config/opencode/tui.jsonor project.opencode/tui.json), wired by hand:opencode plugin <path>cannot install a bare.tsx(it needs apackage.jsonmanifest next to the target), and the spec must never point at the repo root — a directory spec is also picked up as a server plugin viapackage.jsonmain→dist/index.js(the provider, not a plugin). Do not re-add a./tuiexport for this; it is deliberately removed. opencode compiles the.tsxat load with its Bun/Solid transform and mapssolid-js/@opentui/solidto its internal modules, so the package stays dependency-free — do not add a build step or real imports of those packages.src/tui-shims.d.tsis the only local stand-in for types; keep it in sync with what the file actually imports. Verified on opencode 1.18.31: the module's default export must be{ id, tui }(a baretuinamed export is imported but never invoked),idis required, andtuiisasync (api) => {}. Commands register viaapi.keymap.registerLayer({ commands })with{ name, run, title, desc, category, namespace: "palette", slashName }. The sidebar panel registers viaapi.slots.register({ order, slots: { sidebar_content } });order: 90keeps it above the built-in panels (100 context, 200 mcp, 300 lsp, 400 todo, 500 files). The slot renderer returns Solid JSX and is reactive; it relies onapi.themeandapi.lifecycle.onDispose. Solid reactivity gotcha: readcreateSignalgetters inside the returned JSX, never hoistsignal()into aconstabove thereturnin the slot component. A hoisted read subscribes once under a non-tracking owner and the panel freezes after first paint (fetch succeeds, Solid never re-renders). The slot handler itself re-runs on session change, which masks the bug — it looks "fixed" after/sessionbut stays stale on fresh launch. The built-ininternal:sidebar-contextplugin reads its memos inside JSX for the same reason. - Slash commands and
Ctrl+Pshare one registry. In opencode 1.x the slash menu queries the samenamespace: "palette"commands the palette lists (both filter outhidden: true). A slash-only entry is not expressible; registering/cc-*also adds them toCtrl+P. - Toggles are resolved per request, never at load.
zdranddebugare read inmodel.ts/log.tson every call from the toggle file / env, so/cc-*changes take effect without restarting opencode. Precedence forzdr:providerOptions.commandcode.zdr>x-cmd-zdrheader > toggle file >COMMANDCODE_ZDR. Do not reintroduce load-time consts. - Quota is live from the alpha billing API, not
server.py.src/quota.tsreads/alpha/whoami,/alpha/billing/credits,/alpha/billing/subscriptions, and/alpha/usage/summary(the same endpoints thecmdCLI/usageuses).server.pypredates these and is not the guide here.resetAthas shipped as both seconds and epoch ms — normalize (>= 1e12 = ms). The monthly meter is derived (spend + remaining credits), so it works on any plan; never hardcode a plan→cap table. The TUI resolves the key fromapi.state(provider entry, thenconfig.provider.commandcode.options, thenCOMMANDCODE_API_KEY), expanding{env:VAR}itself, and must never log the key — quota errors go throughredact(). - Retryable upstream failures can arrive inside an HTTP 200 stream. CommandCode's gateway
answers
200and then emits an SSEerrorevent carryingstatusCode/isRetryable(e.g.{"type":"server_error","message":"Invalid error response format: Gateway request failed", "statusCode":520,"isRetryable":true}).fetchWithRetrybuffers only thestart/start-steppreamble and, if the first real event is such a retryable error and attempts remain, cancels the body and retries with backoff. Once any content event is seen the error is surfaced, not retried (retrying mid-stream would duplicate output). Non-retryable in-stream errors (400s) surface immediately. Keep the preamble set in sync if upstream adds new pre-content events.
Change workflow
- Read
server.pyfor the behavior being ported and note the relevant function. - Make the smallest change in the matching
src/file. npm run typecheck— fix all errors.npm run build.npm run smoke— verify text and, when relevant, tool calls.- If the change affects the opencode integration, verify with
opencode run(see below). - Update
README.mdif options, features, or behavior changed. KeepAGENTS.mdinvariants 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:
npm run typecheck; npm run build; npm run smoke
Quota changes additionally need a live check (needs a key):
npm run quota
Inside opencode (restart it first if config changed):
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
distrequirenode_modules. - Don't force
stream:false, send objecttool-call.input, or omit thefinishpart. - Don't commit unless the user explicitly asks.