opencode-ccgo-provider/AGENTS.md
nhat.nguyenhong 7e77c3c6fe Fix sidebar quota stuck on loading; forward upstream cost
Sidebar (src/tui.tsx):
- Read quota()/now() signals inside the returned JSX instead of hoisting
  quota() into a const above the return. The hoisted read subscribed once
  under a non-tracking Solid owner, freezing the panel after first paint;
  the slot handler re-runs on session change, which masked the bug as
  '/session fixes it'.
- Harden refreshQuota: whole refresh (key resolve + fetch) inside
  try/catch so any throw surfaces inline instead of wedging on loading.
  Defensive api.state access (non-array provider tolerated) and
  process.env access via safeEnv; key source tracked by name, never logged.
- Refresh triggers: keep session.idle, add session.status/session.updated/
  message.updated (debounced) plus refresh on sidebar session_id change.
- Debug-gated tui-quota trace lines to the shared debug file (silent
  unless debug is on); /cc-usage hardened the same way.

Provider cost (src/model.ts, src/usage.ts):
- Forward per-request USD from finish-step (usage.raw cost/market_cost/
  gateway_cost) and provider-metadata (gateway.cost/marketCost) into
  providerMetadata.commandcode.{cost,marketCost} on the terminal finish
  part and doGenerate result; also stash into usage.raw. A reported 0 is
  preserved; only undefined means absent.

Docs: README sidebar interval corrected (3 min, not 60 s) and trigger
list updated; AGENTS.md invariants 10 (cost) and 13 (Solid gotcha).
2026-09-22 08:39:15 +00:00

216 lines
13 KiB
Markdown

# 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 (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 model-catalog generator (needs `dist/` first; writes the opencode config unless `--out`/`--dry-run`).
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:
```powershell
npm run typecheck; npm run build; npm run smoke
```
Quota changes additionally need a live check (needs a key):
```powershell
npm run quota
```
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.