opencode-ccgo-provider/AGENTS.md

13 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/provider is imported with import type only; 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";.
  • 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. inputTokens.total is input-only (parity with server.py); totalTokens is input+output.
  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. Replay assistant reasoning as a {type:"reasoning"} content part. DeepSeek thinking mode rejects any request carrying tools whose prior assistant turns omit their reasoning. In transform.ts, forward each assistant reasoning part as {type:"reasoning", text, signature?}; do not drop it and do not fabricate empty reasoning. server.py predates this requirement and is not the guide here.
  10. Upstream cost never appears on finish. Per-request USD arrives on finish-step (usage.raw.cost/market_cost/gateway_cost, numbers) and provider-metadata (providerMetadata.gateway.cost/marketCost, strings). finish.totalUsage carries tokens only. Capture both into providerMetadata: { commandcode: { cost, marketCost } } on the terminal V3 finish part (and doGenerate result), and stash into usage.raw. A reported 0 is meaningful; only undefined means absent. The built-in sidebar still shows $0.00 until the fork consumes this field (anomalyco/opencode#43818); /cc-usage is the accurate dollar source meanwhile.
  11. file:// npm specs bypass install. opencode imports dist/index.js directly, so the repo must be rebuilt for opencode to see source changes.
  12. /models is config-driven, not provider-driven. opencode builds the model list from provider.commandcode.models in opencode.json and never asks a custom file:// provider to discover models (only internal providers can register discoverModels). Refresh the map with npm run sync-models; do not expect a discovery hook in src/ to populate it.
  13. The TUI plugin lives outside the provider entrypoint. src/tui.tsx is loaded from source (not dist/) via opencode plugin <path>, which writes a tui.json plugin entry; it is exposed as ./tui in package.json. opencode compiles the .tsx at load with its Bun/Solid transform and maps solid-js / @opentui/solid to 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.ts is 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 bare tui named export is imported but never invoked), id is required, and tui is async (api) => {}. Commands register via api.keymap.registerLayer({ commands }) with { name, run, title, desc, category, namespace: "palette", slashName }. The sidebar panel registers via api.slots.register({ order, slots: { sidebar_content } }); order: 90 keeps 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 on api.theme and api.lifecycle.onDispose. Solid reactivity gotcha: read createSignal getters inside the returned JSX, never hoist signal() into a const above the return in 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 /session but stays stale on fresh launch. The built-in internal:sidebar-context plugin reads its memos inside JSX for the same reason.
  14. Slash commands and Ctrl+P share one registry. In opencode 1.x the slash menu queries the same namespace: "palette" commands the palette lists (both filter out hidden: true). A slash-only entry is not expressible; registering /cc-* also adds them to Ctrl+P.
  15. Toggles are resolved per request, never at load. zdr and debug are read in model.ts/log.ts on every call from the toggle file / env, so /cc-* changes take effect without restarting opencode. Precedence for zdr: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR. Do not reintroduce load-time consts.
  16. Quota is live from the alpha billing API, not server.py. src/quota.ts reads /alpha/whoami, /alpha/billing/credits, /alpha/billing/subscriptions, and /alpha/usage/summary (the same endpoints the cmd CLI /usage uses). server.py predates these and is not the guide here. resetAt has 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 from api.state (provider entry, then config.provider.commandcode.options, then COMMANDCODE_API_KEY), expanding {env:VAR} itself, and must never log the key — quota errors go through redact().

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:

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