Compare commits

..

6 Commits

8 changed files with 256 additions and 79 deletions

View File

@ -45,7 +45,7 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (return
src/events.ts Async iterator over the NDJSON/SSE response body. src/events.ts Async iterator over the NDJSON/SSE response body.
src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification. src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification.
src/redact.ts Credential scrubbing for error surfaces. src/redact.ts Credential scrubbing for error surfaces.
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. src/log.ts Opt-in tracing (toggle file) to a log file.
src/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json). 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/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.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar.
@ -130,9 +130,14 @@ These are load-bearing. Breaking one causes silent failures in opencode.
`provider.commandcode.models` in `opencode.json` and never asks a custom `file://` provider to `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 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. `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 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` **source** (not `dist/`) via a `tui.json` `plugin` entry that must be a **direct `file://` URL
entry; it is exposed as `./tui` in `package.json`. opencode compiles the `.tsx` at load with to `src/tui.tsx`** (global `~/.config/opencode/tui.json` or project `.opencode/tui.json`),
wired by hand: `opencode plugin <path>` cannot install a bare `.tsx` (it needs a `package.json`
manifest 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 via `package.json` `main` → `dist/index.js` (the
provider, not a plugin). Do not re-add a `./tui` export for this; it is deliberately removed.
opencode compiles the `.tsx` at load with
its Bun/Solid transform and maps `solid-js` / `@opentui/solid` to its internal modules, so the 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. 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 `src/tui-shims.d.ts` is the only local stand-in for types; keep it in sync with what the file
@ -154,9 +159,9 @@ These are load-bearing. Breaking one causes silent failures in opencode.
the same `namespace: "palette"` commands the palette lists (both filter out `hidden: true`). 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`. 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 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 `model.ts`/`log.ts` on every call from the toggle file, so `/cc-*` changes take effect
without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` > without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` >
`x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts. `x-cmd-zdr` header > toggle file. Do not reintroduce load-time consts.
16. **Quota is live from the alpha billing API, not `server.py`.** `src/quota.ts` reads 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/whoami`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`, and
`/alpha/usage/summary` (the same endpoints the `cmd` CLI `/usage` uses). `server.py` predates `/alpha/usage/summary` (the same endpoints the `cmd` CLI `/usage` uses). `server.py` predates
@ -165,6 +170,14 @@ These are load-bearing. Breaking one causes silent failures in opencode.
plan; never hardcode a plan→cap table. The TUI resolves the key from `api.state` (provider 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 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()`. `{env:VAR}` itself, and must never log the key — quota errors go through `redact()`.
17. **Retryable upstream failures can arrive inside an HTTP 200 stream.** CommandCode's gateway
answers `200` and then emits an SSE `error` event carrying `statusCode`/`isRetryable` (e.g.
`{"type":"server_error","message":"Invalid error response format: Gateway request failed",
"statusCode":520,"isRetryable":true}`). `fetchWithRetry` buffers only the `start`/`start-step`
preamble 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 ## Change workflow

View File

@ -48,7 +48,7 @@ npm run build
"provider": { "provider": {
"commandcode": { "commandcode": {
"name": "Command Code", "name": "Command Code",
"npm": "file:///C:/DevTools/pienv/ccprovider/dist/index.js", "npm": "file:///C:/dev/opencode-commandcode-provider/dist/index.js",
"options": { "options": {
"apiKey": "{env:COMMANDCODE_API_KEY}" "apiKey": "{env:COMMANDCODE_API_KEY}"
}, },
@ -99,6 +99,11 @@ without writing. Each row shows the model id and name plus a second line with it
(`ctx 1M (1000000)`); the catalog carries no pricing fields, so no cost is shown. Needs an (`ctx 1M (1000000)`); the catalog carries no pricing fields, so no cost is shown. Needs an
interactive terminal. interactive terminal.
Before anything else the script requires a CommandCode API key (from
`provider.commandcode.options.apiKey` or `COMMANDCODE_API_KEY`). If neither is present — including
when `opencode.json` does not exist yet — it exits and points you at the CommandCode Console
(<https://commandcode.ai/studio/provider>) to generate one. It never writes without a key.
Then restart opencode and confirm: Then restart opencode and confirm:
```powershell ```powershell
@ -141,22 +146,33 @@ global config (`baseURL`/`ccVersion` are read from there too).
`options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent `options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent
unauthenticated and CommandCode will reject them. unauthenticated and CommandCode will reject them.
## Environment variables
All environment variables read by the provider, its scripts, and its TUI plugin. String values in
`options` also accept `{env:NAME}`, which opencode/the TUI expand generically (not a fixed set).
CommandCode-specific:
| Name | Purpose | Default |
| --- | --- | --- |
| `COMMANDCODE_API_KEY` | CommandCode API key for `smoke`/`quota`/`sync-models` and the TUI's key resolution; referenced from config as `{env:COMMANDCODE_API_KEY}`. | — |
| `COMMANDCODE_TOGGLES_FILE` | Overrides the shared toggle-file path. | `$XDG_CONFIG_HOME/opencode/commandcode-toggles.json` |
| `COMMANDCODE_QUOTA_INTERVAL_MS` | Sidebar fallback quota refresh interval in ms. | `300000` (floor `120000`) |
Standard:
| Name | Purpose | Default |
| --- | --- | --- |
| `XDG_CONFIG_HOME` | Base config dir for the toggle file. | `~/.config` |
| `NO_COLOR` | Disables ANSI colors in the `sync-models` picker (any value). | unset |
| `TERM` | `dumb` disables ANSI colors in the `sync-models` picker. | — |
## Debug tracing ## Debug tracing
Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file. Flip the `debug` key in the shared toggle file (via `/cc-debug`) to write a trace of every
Silent (and no file is created) unless enabled. Every line is passed through `redact()` so request and stream event to a log file. Silent (and no file is created) unless enabled; the log
credentials never reach disk. It can also be flipped at runtime with `/cc-debug` (see path is `<os tempdir>/commandcode-debug.log`. Every line is passed through `redact()` so
[Runtime toggles](#runtime-toggles)). credentials never reach disk.
```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 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 wait, elapsed ms), every stream event payload, and the terminal finish reason + usage. It is
@ -165,8 +181,8 @@ useful for diagnosing model selection, retry, tool-call, and finish-reason issue
## ZDR header toggle ## ZDR header toggle
The `x-cmd-zdr: 1` request header is **omitted by default** because it is rejected by some 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 models. It can be enabled with the `/cc-zdr` slash command or
command, or `providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it `providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
does not require restarting opencode. does not require restarting opencode.
Precedence, highest first: Precedence, highest first:
@ -174,7 +190,6 @@ Precedence, highest first:
1. `providerOptions.commandcode.zdr` (`true`/`false`), e.g. a model variant or agent option. 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`. 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)). 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 With tracing enabled, the trace logs a `zdr on|off` line per request so the toggle state is
observable. observable.
@ -184,18 +199,32 @@ observable.
`/cc-zdr` and `/cc-debug` flip the `zdr` and `debug` values in a small JSON file the provider `/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 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 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. `COMMANDCODE_TOGGLES_FILE` — see [Environment variables](#environment-variables)).
These commands come from a small TUI plugin shipped in this package (`src/tui.tsx`). It is loaded 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 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: 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`):
```powershell ```jsonc
opencode plugin file:///C:/DevTools/pienv/ccprovider/src/tui.tsx // tui.json
{
"plugin": ["file:///C:/dev/opencode-commandcode-provider/src/tui.tsx"]
}
``` ```
That writes a `tui.json` `plugin` entry (project-local `.opencode/tui.json`, or global with Use an absolute path with forward slashes (`file:///C:/...` on Windows, `file:///home/...` on
`--global`). Restart opencode after installing; then type `/cc-` for autocomplete. The plugin also 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 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 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 `Ctrl+P` palette read the same command registry, so these entries appear in both. If you run opencode
@ -224,7 +253,9 @@ instead of being shown as zero.
The sidebar refreshes after each completed turn (`session.idle`, plus `session.status`, 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 `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, event that may never reach the TUI bus — all debounced) and when the active session changes,
plus every 3 minutes as a fallback (`COMMANDCODE_QUOTA_INTERVAL_MS` overrides it) and the but never more than once every 2 minutes; every 5 minutes as a fallback
(`COMMANDCODE_QUOTA_INTERVAL_MS` overrides the fallback interval — see
[Environment variables](#environment-variables)) and the
countdown ticks every 30 seconds. Key resolution and fetch failures are redacted and shown 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 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. (`tui-quota`) are appended to the debug log only when debug tracing is on.
@ -257,7 +288,7 @@ The API key is never logged; quota errors pass through `redact()` like every oth
| Finish reasons | Yes — unified (`stop`, `length`, `tool-calls`, `content-filter`, `error`, `other`) plus raw | | 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 | | Sampling parameters | Yes — `temperature`, `topP`, `topK`, `stopSequences`, `seed`, presence/frequency penalties |
| `reasoning_effort` | Yes — via `providerOptions.commandcode` | | `reasoning_effort` | Yes — via `providerOptions.commandcode` |
| Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` | | 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 | | 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 | | 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 | | Sidebar status panel | Yes — a `sidebar_content` panel shows `zdr`/`debug` and live quota |
@ -294,7 +325,7 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (JSON s
src/events.ts NDJSON/SSE line iterator over the upstream response body. src/events.ts NDJSON/SSE line iterator over the upstream response body.
src/usage.ts finish event -> V3 usage; finish-reason unification. src/usage.ts finish event -> V3 usage; finish-reason unification.
src/redact.ts Credential scrubbing for error surfaces. src/redact.ts Credential scrubbing for error surfaces.
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. src/log.ts Opt-in tracing (toggle file) to a log file.
src/toggles.ts Shared toggle file read by the provider and written by the TUI plugin. 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/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/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar panel.
@ -360,9 +391,10 @@ opencode run "Use the glob tool to list *.mjs and report the filenames." -m comm
| `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. | | `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. | | 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. | | 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)). | | Need to see what the provider sends/receives | Flip `/cc-debug` 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. | | `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. | | 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 ## Security

View File

@ -9,9 +9,6 @@
".": { ".": {
"import": "./dist/index.js", "import": "./dist/index.js",
"types": "./dist/index.d.ts" "types": "./dist/index.d.ts"
},
"./tui": {
"import": "./src/tui.tsx"
} }
}, },
"files": [ "files": [

View File

@ -7,15 +7,16 @@
// the result into the global opencode config. Deselecting a model removes it from the config. // the result into the global opencode config. Deselecting a model removes it from the config.
// //
// Run: node scripts/sync-models.mjs (build first: npm run build) // Run: node scripts/sync-models.mjs (build first: npm run build)
import { readFileSync, writeFileSync, renameSync } from "node:fs"; import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import readline from "node:readline"; import readline from "node:readline";
import { createCommandCode } from "../dist/index.js"; import { createCommandCode } from "../dist/index.js";
import { redact } from "../dist/redact.js"; import { redact } from "../dist/redact.js";
import { DEFAULT_BASE_URL, DEFAULT_CC_VERSION, DEFAULT_MAX_TOKENS, MODELS_PATH } from "../dist/constants.js"; import { DEFAULT_BASE_URL, DEFAULT_CC_VERSION, DEFAULT_MAX_TOKENS, MODELS_PATH } from "../dist/constants.js";
const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json"); const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json");
const CONSOLE_URL = "https://commandcode.ai/studio/provider";
const CONCURRENCY = 6; const CONCURRENCY = 6;
// 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response. // 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response.
@ -36,8 +37,15 @@ const NO_IMAGE =
const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i; const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i;
function readConfig(path) { function readConfig(path) {
const text = readFileSync(path, "utf8"); try {
return { text, data: JSON.parse(text) }; const text = readFileSync(path, "utf8");
return { text, data: JSON.parse(text), exists: true };
} catch (error) {
// A fresh install has no opencode.json yet; treat it as empty so the console
// guidance / env-key path can still run. Bad JSON or unreadable files still throw.
if (error.code === "ENOENT") return { text: "", data: {}, exists: false };
throw error;
}
} }
function detectIndent(text) { function detectIndent(text) {
@ -360,23 +368,36 @@ function pickModels(catalog, existingIds) {
} }
async function main() { async function main() {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error("Interactive picker needs a TTY; nothing was written.");
process.exit(1);
}
let config; let config;
let text; let text;
let exists;
try { try {
({ text, data: config } = readConfig(CONFIG_PATH)); ({ text, data: config, exists } = readConfig(CONFIG_PATH));
} catch (error) { } catch (error) {
console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`); console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
// Mandatory first step: without a CommandCode key there is nothing to fetch or
// probe, so point a fresh user at the Console before doing anything else.
const key = resolveKey(config, process.env); const key = resolveKey(config, process.env);
if (!key) { if (!key) {
console.error("No API key: set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey"); const where = exists
? `No CommandCode API key in ${CONFIG_PATH}.`
: `No opencode.json found at ${CONFIG_PATH}.`;
console.error(
`${where}\n` +
`Generate an API key in the CommandCode Console: ${CONSOLE_URL}\n` +
(exists
? `Add it as provider.commandcode.options.apiKey, or export COMMANDCODE_API_KEY.`
: `Create that file with provider.commandcode.options.apiKey, or export COMMANDCODE_API_KEY.`) +
`\nNothing was written.`,
);
process.exit(1);
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error("Interactive picker needs a TTY; nothing was written.");
process.exit(1); process.exit(1);
} }
@ -447,7 +468,9 @@ async function main() {
config.provider.commandcode.models = merged; config.provider.commandcode.models = merged;
const indent = detectIndent(text); const indent = detectIndent(text);
const trailing = text.endsWith("\n") ? "\n" : ""; const trailing = text === "" || text.endsWith("\n") ? "\n" : "";
// The config directory may not exist yet on a fresh install; create it before the atomic write.
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`); writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`);
console.log( console.log(

View File

@ -1,4 +1,4 @@
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1 or the shared toggle file; // Opt-in tracing to a log file. Gated on the shared toggle file (debug key);
// silent (and no file created) by default. Uses only Node built-ins so the built output // silent (and no file created) by default. Uses only Node built-ins so the built output
// stays dependency-free. // stays dependency-free.
@ -6,9 +6,9 @@ import { appendFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { redact } from "./redact.js"; import { redact } from "./redact.js";
import { debugFromEnvOrFile } from "./toggles.js"; import { debugEnabled } from "./toggles.js";
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log"); const FILE = join(tmpdir(), "commandcode-debug.log");
function serialize(value: unknown): string { function serialize(value: unknown): string {
if (typeof value === "string") return value; if (typeof value === "string") return value;
@ -34,14 +34,14 @@ function emit(scope: string, args: unknown[]): void {
} }
} }
/** Effective default tracing state (env var or toggle file). Re-read on every call. */ /** Effective default tracing state (toggle file). Re-read on every call. */
export function isDebugEnabled(): boolean { export function isDebugEnabled(): boolean {
return debugFromEnvOrFile(); return debugEnabled();
} }
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */ /** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */
export function debug(scope: string, ...args: unknown[]): void { export function debug(scope: string, ...args: unknown[]): void {
if (!debugFromEnvOrFile()) return; if (!debugEnabled()) return;
emit(scope, args); emit(scope, args);
} }

View File

@ -21,12 +21,12 @@ import {
import { iterateEvents } from "./events.js"; import { iterateEvents } from "./events.js";
import { debugWhen, isDebugEnabled } from "./log.js"; import { debugWhen, isDebugEnabled } from "./log.js";
import { redact } from "./redact.js"; import { redact } from "./redact.js";
import { zdrFromEnvOrFile } from "./toggles.js"; import { zdrEnabled } from "./toggles.js";
import { transform } from "./transform.js"; import { transform } from "./transform.js";
import { finishReasonFrom, usageFromFinish, costFromFinishStep, costFromProviderMetadata, type FinishEvent, type FinishStepEvent, type ProviderMetadataEvent } from "./usage.js"; import { finishReasonFrom, usageFromFinish, costFromFinishStep, costFromProviderMetadata, type FinishEvent, type FinishStepEvent, type ProviderMetadataEvent } from "./usage.js";
// x-cmd-zdr breaks some models; off by default (server.py never sends it). // x-cmd-zdr breaks some models; off by default (server.py never sends it).
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR. // Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file.
// "undefined" means the header is omitted entirely. // "undefined" means the header is omitted entirely.
function providerScoped(options: LanguageModelV3CallOptions): Record<string, unknown> | undefined { function providerScoped(options: LanguageModelV3CallOptions): Record<string, unknown> | undefined {
return options.providerOptions?.["commandcode"] as Record<string, unknown> | undefined; return options.providerOptions?.["commandcode"] as Record<string, unknown> | undefined;
@ -44,7 +44,7 @@ function resolveZdr(
if (typeof scoped === "boolean") return scoped ? "1" : "0"; if (typeof scoped === "boolean") return scoped ? "1" : "0";
const header = headerValue({ ...providerHeaders, ...(options.headers ?? {}) }, "x-cmd-zdr"); const header = headerValue({ ...providerHeaders, ...(options.headers ?? {}) }, "x-cmd-zdr");
if (header !== undefined) return truthyHeader(header) ? "1" : "0"; if (header !== undefined) return truthyHeader(header) ? "1" : "0";
return zdrFromEnvOrFile() ? "1" : undefined; return zdrEnabled() ? "1" : undefined;
} }
function resolveDebug(options: LanguageModelV3CallOptions): boolean { function resolveDebug(options: LanguageModelV3CallOptions): boolean {
@ -109,6 +109,50 @@ function isRetryable(status: number): boolean {
return status === 429 || (status >= 500 && status < 600); return status === 429 || (status >= 500 && status < 600);
} }
// Upstream can answer HTTP 200 and then fail inside the stream (SSE `error` event).
// CommandCode's gateway marks these with `statusCode`/`isRetryable`; retry before content.
type StreamErrorInfo = { message: string; statusCode?: number; isRetryable?: boolean };
function streamErrorInfo(evt: Record<string, any>): StreamErrorInfo | null {
if (evt?.["type"] !== "error") return null;
const err = evt["error"];
if (err === null || typeof err !== "object") return { message: String(err) };
const record = err as Record<string, unknown>;
const message = typeof record["message"] === "string" ? record["message"] : JSON.stringify(err);
return {
message,
...(typeof record["statusCode"] === "number" ? { statusCode: record["statusCode"] } : {}),
...(typeof record["isRetryable"] === "boolean" ? { isRetryable: record["isRetryable"] } : {}),
};
}
function isRetryableStreamError(info: StreamErrorInfo): boolean {
return info.isRetryable === true || (info.statusCode !== undefined && isRetryable(info.statusCode));
}
// Only `start`/`start-step` are safe to buffer while looking for an immediate stream error;
// once real output appears a retry would duplicate the response.
const STREAM_PREAMBLE = new Set(["start", "start-step"]);
const MAX_PEEK_EVENTS = 32;
type OpenedStream = {
response: Response;
prefix: Record<string, any>[];
events: AsyncGenerator<Record<string, any>>;
};
async function* emptyEvents(): AsyncGenerator<Record<string, any>> {
/* no-op */
}
async function* replayEvents(
prefix: Record<string, any>[],
rest: AsyncGenerator<Record<string, any>>,
): AsyncGenerator<Record<string, any>> {
for (const evt of prefix) yield evt;
for await (const evt of rest) yield evt;
}
function retryDelay(attempt: number, retryAfter: string | null, maxDelay: number): number { function retryDelay(attempt: number, retryAfter: string | null, maxDelay: number): number {
if (retryAfter) { if (retryAfter) {
const seconds = Number(retryAfter); const seconds = Number(retryAfter);
@ -186,7 +230,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
body: string, body: string,
options: LanguageModelV3CallOptions, options: LanguageModelV3CallOptions,
dbg: boolean, dbg: boolean,
): Promise<Response> { ): Promise<OpenedStream> {
const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg); const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg);
let lastError: unknown; let lastError: unknown;
const url = `${this.opts.baseURL}${GENERATE_PATH}`; const url = `${this.opts.baseURL}${GENERATE_PATH}`;
@ -247,8 +291,60 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
continue; continue;
} }
} }
if (!response.ok || !response.body) {
debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
return { response, prefix: [], events: emptyEvents() };
}
// HTTP 200 can still hide an upstream failure. Buffer only the preamble; if the first
// real event is a retryable stream error, retry before anything reaches the client.
const events = iterateEvents(response.body, dbg);
const prefix: Record<string, any>[] = [];
let streamRetry: StreamErrorInfo | undefined;
while (prefix.length < MAX_PEEK_EVENTS) {
const next = await events.next();
if (next.done) break;
const evt = next.value;
prefix.push(evt);
const info = streamErrorInfo(evt);
if (info) {
if (isRetryableStreamError(info) && attempt < this.opts.maxRetries) streamRetry = info;
break;
}
if (!STREAM_PREAMBLE.has(evt["type"])) break;
}
if (streamRetry) {
const wait = retryDelay(attempt, null, this.opts.retryMaxDelay);
debugWhen(
dbg,
"fetch",
"stream-retry",
`url=${url}`,
`attempt=${attempt}`,
`status=${streamRetry.statusCode ?? "unknown"}`,
`message=${redact(streamRetry.message)}`,
`waitMs=${wait}`,
);
try {
await events.return(undefined as never);
} catch {
/* ignore */
}
try {
await response.body.cancel();
} catch {
/* ignore */
}
if (options.abortSignal?.aborted) {
throw options.abortSignal.reason ?? new Error("Aborted");
}
await sleep(wait);
continue;
}
debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`); debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`);
return response; return { response, prefix, events };
} }
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable"); throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
@ -279,11 +375,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
const dbg = resolveDebug(options); const dbg = resolveDebug(options);
const body = transform(options, this.modelId); const body = transform(options, this.modelId);
debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`); debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`);
const response = await this.fetchWithRetry(body, options, dbg); const { response, prefix, events } = await this.fetchWithRetry(body, options, dbg);
if (!response.ok || !response.body) throw await this.errorFrom(response, dbg); if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
debugWhen(dbg, "doStream", "ok", `status=${response.status}`); debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
const stream = toReadableStream(this.streamParts(response.body, dbg)); const stream = toReadableStream(this.streamParts(prefix, events, dbg));
return { return {
stream, stream,
request: { body: JSON.parse(body) as unknown }, request: { body: JSON.parse(body) as unknown },
@ -357,7 +453,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
return { content, finishReason, usage, warnings, request, response, ...(providerMetadata ? { providerMetadata } : {}) }; return { content, finishReason, usage, warnings, request, response, ...(providerMetadata ? { providerMetadata } : {}) };
} }
private async *streamParts(body: ReadableStream<Uint8Array>, dbg: boolean): AsyncGenerator<LanguageModelV3StreamPart> { private async *streamParts(
prefix: Record<string, any>[],
events: AsyncGenerator<Record<string, any>>,
dbg: boolean,
): AsyncGenerator<LanguageModelV3StreamPart> {
yield { type: "stream-start", warnings: [] }; yield { type: "stream-start", warnings: [] };
const textId = "text-0"; const textId = "text-0";
@ -372,7 +472,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
let marketCost: number | undefined; let marketCost: number | undefined;
let errored = false; let errored = false;
for await (const evt of iterateEvents(body, dbg)) { for await (const evt of replayEvents(prefix, events)) {
const payload = JSON.stringify(evt); const payload = JSON.stringify(evt);
debugWhen(dbg, "stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`); debugWhen(dbg, "stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`);
switch (evt.type) { switch (evt.type) {

View File

@ -45,17 +45,11 @@ export function toggle(name: ToggleName): Toggles {
return writeToggles({ [name]: current[name] !== true }); return writeToggles({ [name]: current[name] !== true });
} }
export function envFlag(name: string): boolean { /** Toggle state from the shared flag file; an absent key means off. */
return /^(1|true|yes)$/i.test(process.env[name] ?? ""); export function zdrEnabled(): boolean {
return readToggles().zdr === true;
} }
/** Flag file wins over the environment; unset file falls back to the env var. */ export function debugEnabled(): boolean {
export function zdrFromEnvOrFile(): boolean { return readToggles().debug === true;
const file = readToggles().zdr;
return typeof file === "boolean" ? file : envFlag("COMMANDCODE_ZDR");
}
export function debugFromEnvOrFile(): boolean {
const file = readToggles().debug;
return typeof file === "boolean" ? file : envFlag("COMMANDCODE_DEBUG");
} }

View File

@ -21,7 +21,7 @@ import { createSignal } from "solid-js";
import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js"; import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js";
import { redact } from "./redact.js"; import { redact } from "./redact.js";
import { debugFromEnvOrFile, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js"; import { debugEnabled, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
type ToastVariant = "info" | "success" | "warning" | "error"; type ToastVariant = "info" | "success" | "warning" | "error";
@ -69,9 +69,11 @@ type TuiApi = {
const ID = "commandcode-toggles"; const ID = "commandcode-toggles";
const CATEGORY = "CommandCode"; const CATEGORY = "CommandCode";
const SIDEBAR_ORDER = 90; const SIDEBAR_ORDER = 90;
const DEFAULT_QUOTA_INTERVAL_MS = 180_000; const DEFAULT_QUOTA_INTERVAL_MS = 300_000;
const MIN_REFRESH_INTERVAL_MS = 120_000;
// Extra TUI-bus signals that mark the end of a turn. `session.idle` is a server // Extra TUI-bus signals that mark the end of a turn. `session.idle` is a server
// plugin event and may never fire here; these keep the panel fresh regardless. // plugin event and may never fire here; these keep the panel fresh regardless.
// All event-driven refreshes are floored to MIN_REFRESH_INTERVAL_MS (hard skip).
const QUOTA_TRIGGER_EVENTS = ["session.idle", "session.status", "session.updated", "message.updated"] as const; const QUOTA_TRIGGER_EVENTS = ["session.idle", "session.status", "session.updated", "message.updated"] as const;
function safeEnv(name: string): string | undefined { function safeEnv(name: string): string | undefined {
@ -84,8 +86,8 @@ function safeEnv(name: string): string | undefined {
/** Debug-only trace to the shared debug file. Silent unless debug is on; never logs the key. */ /** Debug-only trace to the shared debug file. Silent unless debug is on; never logs the key. */
function trace(...args: unknown[]): void { function trace(...args: unknown[]): void {
if (!debugFromEnvOrFile()) return; if (!debugEnabled()) return;
const file = safeEnv("COMMANDCODE_DEBUG_FILE") ?? join(tmpdir(), "commandcode-debug.log"); const file = join(tmpdir(), "commandcode-debug.log");
const parts = args.map((value) => (typeof value === "string" ? value : safeJson(value))); const parts = args.map((value) => (typeof value === "string" ? value : safeJson(value)));
const line = `[commandcode] ${new Date().toISOString()} [tui-quota] ${parts.join(" ")}`; const line = `[commandcode] ${new Date().toISOString()} [tui-quota] ${parts.join(" ")}`;
try { try {
@ -168,6 +170,11 @@ function quotaIntervalMs(): number {
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_QUOTA_INTERVAL_MS; return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_QUOTA_INTERVAL_MS;
} }
function formatClock(ms: number): string {
if (!(ms > 0)) return "--:--:--";
return new Date(ms).toTimeString().slice(0, 8);
}
function shortWindow(w: QuotaWindow, nowMs: number): string { function shortWindow(w: QuotaWindow, nowMs: number): string {
const label = w.id === "fiveHour" ? "5h" : w.id === "weekly" ? "7d" : "mo"; const label = w.id === "fiveHour" ? "5h" : w.id === "weekly" ? "7d" : "mo";
const reset = formatReset(w.resetAtMs, nowMs); const reset = formatReset(w.resetAtMs, nowMs);
@ -180,9 +187,11 @@ export const tui = async (api: TuiApi): Promise<void> => {
const [toggles, setToggles] = createSignal(readToggles()); const [toggles, setToggles] = createSignal(readToggles());
const [quota, setQuota] = createSignal<QuotaResult | null>(null); const [quota, setQuota] = createSignal<QuotaResult | null>(null);
const [now, setNow] = createSignal(Date.now()); const [now, setNow] = createSignal(Date.now());
const [updatedAt, setUpdatedAt] = createSignal(0);
let inflight = false; let inflight = false;
let pending = false; let pending = false;
let lastRefreshAt = 0;
let debounceTimer: ReturnType<typeof setTimeout> | undefined; let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let lastSessionId: string | undefined; let lastSessionId: string | undefined;
@ -194,6 +203,11 @@ export const tui = async (api: TuiApi): Promise<void> => {
trace("coalesced", "inflight"); trace("coalesced", "inflight");
return; return;
} }
const since = Date.now() - lastRefreshAt;
if (lastRefreshAt > 0 && since < MIN_REFRESH_INTERVAL_MS) {
trace("rate-limited", `remainingMs=${MIN_REFRESH_INTERVAL_MS - since}`);
return;
}
inflight = true; inflight = true;
try { try {
const { key: apiKey, source } = resolveApiKeySource(api); const { key: apiKey, source } = resolveApiKeySource(api);
@ -222,7 +236,9 @@ export const tui = async (api: TuiApi): Promise<void> => {
trace("fetch-throw", message); trace("fetch-throw", message);
} finally { } finally {
inflight = false; inflight = false;
lastRefreshAt = Date.now();
setNow(Date.now()); setNow(Date.now());
setUpdatedAt(Date.now());
if (pending) { if (pending) {
pending = false; pending = false;
trace("flush-pending"); trace("flush-pending");
@ -284,6 +300,7 @@ export const tui = async (api: TuiApi): Promise<void> => {
) : quota() === null ? ( ) : quota() === null ? (
<text fg={theme.current.textMuted}>quota: loading…</text> <text fg={theme.current.textMuted}>quota: loading…</text>
) : null} ) : null}
<text fg={theme.current.textMuted}>updated @ {formatClock(updatedAt())}</text>
</box> </box>
); );
}; };
@ -365,6 +382,7 @@ export const tui = async (api: TuiApi): Promise<void> => {
} }
setQuota(result); setQuota(result);
setNow(Date.now()); setNow(Date.now());
setUpdatedAt(Date.now());
api.ui.toast({ title: CATEGORY, message: formatQuota(result.quota), variant: "info", duration: 15000 }); api.ui.toast({ title: CATEGORY, message: formatQuota(result.quota), variant: "info", duration: 15000 });
}, },
}, },