Compare commits
No commits in common. "master" and "v0.0.1c" have entirely different histories.
25
AGENTS.md
25
AGENTS.md
@ -45,7 +45,7 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (return
|
||||
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 (toggle file) to a log file.
|
||||
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.
|
||||
@ -130,14 +130,9 @@ 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
|
||||
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 a `tui.json` `plugin` entry that must be a **direct `file://` URL
|
||||
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
|
||||
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
|
||||
@ -159,9 +154,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`).
|
||||
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, so `/cc-*` changes take effect
|
||||
`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. Do not reintroduce load-time consts.
|
||||
`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
|
||||
@ -170,14 +165,6 @@ 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
|
||||
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()`.
|
||||
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
|
||||
|
||||
|
||||
88
README.md
88
README.md
@ -48,7 +48,7 @@ npm run build
|
||||
"provider": {
|
||||
"commandcode": {
|
||||
"name": "Command Code",
|
||||
"npm": "file:///C:/dev/opencode-commandcode-provider/dist/index.js",
|
||||
"npm": "file:///C:/DevTools/pienv/ccprovider/dist/index.js",
|
||||
"options": {
|
||||
"apiKey": "{env:COMMANDCODE_API_KEY}"
|
||||
},
|
||||
@ -99,11 +99,6 @@ 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
|
||||
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:
|
||||
|
||||
```powershell
|
||||
@ -146,33 +141,22 @@ global config (`baseURL`/`ccVersion` are read from there too).
|
||||
`options.headers` (with a leading `Bearer ` stripped). If neither is present, requests are sent
|
||||
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
|
||||
|
||||
Flip the `debug` key in the shared toggle file (via `/cc-debug`) to write a trace of every
|
||||
request and stream event to a log file. Silent (and no file is created) unless enabled; the log
|
||||
path is `<os tempdir>/commandcode-debug.log`. Every line is passed through `redact()` so
|
||||
credentials never reach disk.
|
||||
Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file.
|
||||
Silent (and no file is created) unless enabled. Every line is passed through `redact()` so
|
||||
credentials never reach disk. It can also be flipped at runtime with `/cc-debug` (see
|
||||
[Runtime toggles](#runtime-toggles)).
|
||||
|
||||
```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
|
||||
wait, elapsed ms), every stream event payload, and the terminal finish reason + usage. It is
|
||||
@ -181,8 +165,8 @@ useful for diagnosing model selection, retry, tool-call, and finish-reason issue
|
||||
## ZDR header toggle
|
||||
|
||||
The `x-cmd-zdr: 1` request header is **omitted by default** because it is rejected by some
|
||||
models. It can be enabled with the `/cc-zdr` slash command or
|
||||
`providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
|
||||
models. It can be enabled with the `COMMANDCODE_ZDR` environment variable, the `/cc-zdr` slash
|
||||
command, or `providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
|
||||
does not require restarting opencode.
|
||||
|
||||
Precedence, highest first:
|
||||
@ -190,6 +174,7 @@ Precedence, highest first:
|
||||
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`.
|
||||
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
|
||||
observable.
|
||||
@ -199,32 +184,18 @@ observable.
|
||||
`/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
|
||||
the current state. The file is `~/.config/opencode/commandcode-toggles.json` (overridable with
|
||||
`COMMANDCODE_TOGGLES_FILE` — see [Environment variables](#environment-variables)).
|
||||
`COMMANDCODE_TOGGLES_FILE`); the environment variables above are used when a key is absent.
|
||||
|
||||
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
|
||||
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`):
|
||||
build step and no runtime dependency for it. Register it once:
|
||||
|
||||
```jsonc
|
||||
// tui.json
|
||||
{
|
||||
"plugin": ["file:///C:/dev/opencode-commandcode-provider/src/tui.tsx"]
|
||||
}
|
||||
```powershell
|
||||
opencode plugin file:///C:/DevTools/pienv/ccprovider/src/tui.tsx
|
||||
```
|
||||
|
||||
Use an absolute path with forward slashes (`file:///C:/...` on Windows, `file:///home/...` on
|
||||
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
|
||||
That writes a `tui.json` `plugin` entry (project-local `.opencode/tui.json`, or global with
|
||||
`--global`). Restart opencode after installing; then type `/cc-` for autocomplete. The plugin also
|
||||
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
|
||||
`Ctrl+P` palette read the same command registry, so these entries appear in both. If you run opencode
|
||||
@ -253,9 +224,7 @@ instead of being shown as zero.
|
||||
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
|
||||
event that may never reach the TUI bus — all debounced) and when the active session changes,
|
||||
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
|
||||
plus every 3 minutes as a fallback (`COMMANDCODE_QUOTA_INTERVAL_MS` overrides it) and the
|
||||
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
|
||||
(`tui-quota`) are appended to the debug log only when debug tracing is on.
|
||||
@ -288,7 +257,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 |
|
||||
| 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`; also retryable `error` events that arrive inside an HTTP 200 stream before any content (gateway 520s) |
|
||||
| Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` |
|
||||
| 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 |
|
||||
| Sidebar status panel | Yes — a `sidebar_content` panel shows `zdr`/`debug` and live quota |
|
||||
@ -325,7 +294,7 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (JSON s
|
||||
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/log.ts Opt-in tracing (toggle file) to a log file.
|
||||
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file.
|
||||
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/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar panel.
|
||||
@ -391,10 +360,9 @@ 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. |
|
||||
| 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. |
|
||||
| Need to see what the provider sends/receives | Flip `/cc-debug` and read the appended log file (see [Debug tracing](#debug-tracing)). |
|
||||
| Need to see what the provider sends/receives | Set `COMMANDCODE_DEBUG=1` 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. |
|
||||
| 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
|
||||
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./tui": {
|
||||
"import": "./src/tui.tsx"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@ -7,16 +7,15 @@
|
||||
// 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)
|
||||
import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { createCommandCode } from "../dist/index.js";
|
||||
import { redact } from "../dist/redact.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 CONSOLE_URL = "https://commandcode.ai/studio/provider";
|
||||
const CONCURRENCY = 6;
|
||||
|
||||
// 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response.
|
||||
@ -37,15 +36,8 @@ const NO_IMAGE =
|
||||
const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i;
|
||||
|
||||
function readConfig(path) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
const text = readFileSync(path, "utf8");
|
||||
return { text, data: JSON.parse(text) };
|
||||
}
|
||||
|
||||
function detectIndent(text) {
|
||||
@ -368,36 +360,23 @@ function pickModels(catalog, existingIds) {
|
||||
}
|
||||
|
||||
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 text;
|
||||
let exists;
|
||||
try {
|
||||
({ text, data: config, exists } = readConfig(CONFIG_PATH));
|
||||
({ text, data: config } = readConfig(CONFIG_PATH));
|
||||
} catch (error) {
|
||||
console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
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);
|
||||
if (!key) {
|
||||
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.");
|
||||
console.error("No API key: set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -468,9 +447,7 @@ async function main() {
|
||||
config.provider.commandcode.models = merged;
|
||||
|
||||
const indent = detectIndent(text);
|
||||
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 });
|
||||
const trailing = text.endsWith("\n") ? "\n" : "";
|
||||
writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`);
|
||||
|
||||
console.log(
|
||||
|
||||
12
src/log.ts
12
src/log.ts
@ -1,4 +1,4 @@
|
||||
// Opt-in tracing to a log file. Gated on the shared toggle file (debug key);
|
||||
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1 or the shared toggle file;
|
||||
// silent (and no file created) by default. Uses only Node built-ins so the built output
|
||||
// stays dependency-free.
|
||||
|
||||
@ -6,9 +6,9 @@ import { appendFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { redact } from "./redact.js";
|
||||
import { debugEnabled } from "./toggles.js";
|
||||
import { debugFromEnvOrFile } from "./toggles.js";
|
||||
|
||||
const FILE = join(tmpdir(), "commandcode-debug.log");
|
||||
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log");
|
||||
|
||||
function serialize(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
@ -34,14 +34,14 @@ function emit(scope: string, args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Effective default tracing state (toggle file). Re-read on every call. */
|
||||
/** Effective default tracing state (env var or toggle file). Re-read on every call. */
|
||||
export function isDebugEnabled(): boolean {
|
||||
return debugEnabled();
|
||||
return debugFromEnvOrFile();
|
||||
}
|
||||
|
||||
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */
|
||||
export function debug(scope: string, ...args: unknown[]): void {
|
||||
if (!debugEnabled()) return;
|
||||
if (!debugFromEnvOrFile()) return;
|
||||
emit(scope, args);
|
||||
}
|
||||
|
||||
|
||||
118
src/model.ts
118
src/model.ts
@ -21,12 +21,12 @@ import {
|
||||
import { iterateEvents } from "./events.js";
|
||||
import { debugWhen, isDebugEnabled } from "./log.js";
|
||||
import { redact } from "./redact.js";
|
||||
import { zdrEnabled } from "./toggles.js";
|
||||
import { zdrFromEnvOrFile } from "./toggles.js";
|
||||
import { transform } from "./transform.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).
|
||||
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file.
|
||||
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR.
|
||||
// "undefined" means the header is omitted entirely.
|
||||
function providerScoped(options: LanguageModelV3CallOptions): 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";
|
||||
const header = headerValue({ ...providerHeaders, ...(options.headers ?? {}) }, "x-cmd-zdr");
|
||||
if (header !== undefined) return truthyHeader(header) ? "1" : "0";
|
||||
return zdrEnabled() ? "1" : undefined;
|
||||
return zdrFromEnvOrFile() ? "1" : undefined;
|
||||
}
|
||||
|
||||
function resolveDebug(options: LanguageModelV3CallOptions): boolean {
|
||||
@ -109,50 +109,6 @@ function isRetryable(status: number): boolean {
|
||||
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 {
|
||||
if (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
@ -230,7 +186,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
body: string,
|
||||
options: LanguageModelV3CallOptions,
|
||||
dbg: boolean,
|
||||
): Promise<OpenedStream> {
|
||||
): Promise<Response> {
|
||||
const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg);
|
||||
let lastError: unknown;
|
||||
const url = `${this.opts.baseURL}${GENERATE_PATH}`;
|
||||
@ -291,60 +247,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
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}`);
|
||||
return { response, prefix, events };
|
||||
return response;
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
|
||||
@ -375,11 +279,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
const dbg = resolveDebug(options);
|
||||
const body = transform(options, this.modelId);
|
||||
debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`);
|
||||
const { response, prefix, events } = await this.fetchWithRetry(body, options, dbg);
|
||||
const response = await this.fetchWithRetry(body, options, dbg);
|
||||
if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
|
||||
debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
|
||||
|
||||
const stream = toReadableStream(this.streamParts(prefix, events, dbg));
|
||||
const stream = toReadableStream(this.streamParts(response.body, dbg));
|
||||
return {
|
||||
stream,
|
||||
request: { body: JSON.parse(body) as unknown },
|
||||
@ -453,11 +357,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
return { content, finishReason, usage, warnings, request, response, ...(providerMetadata ? { providerMetadata } : {}) };
|
||||
}
|
||||
|
||||
private async *streamParts(
|
||||
prefix: Record<string, any>[],
|
||||
events: AsyncGenerator<Record<string, any>>,
|
||||
dbg: boolean,
|
||||
): AsyncGenerator<LanguageModelV3StreamPart> {
|
||||
private async *streamParts(body: ReadableStream<Uint8Array>, dbg: boolean): AsyncGenerator<LanguageModelV3StreamPart> {
|
||||
yield { type: "stream-start", warnings: [] };
|
||||
|
||||
const textId = "text-0";
|
||||
@ -472,7 +372,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
||||
let marketCost: number | undefined;
|
||||
let errored = false;
|
||||
|
||||
for await (const evt of replayEvents(prefix, events)) {
|
||||
for await (const evt of iterateEvents(body, dbg)) {
|
||||
const payload = JSON.stringify(evt);
|
||||
debugWhen(dbg, "stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`);
|
||||
switch (evt.type) {
|
||||
|
||||
@ -45,11 +45,17 @@ export function toggle(name: ToggleName): Toggles {
|
||||
return writeToggles({ [name]: current[name] !== true });
|
||||
}
|
||||
|
||||
/** Toggle state from the shared flag file; an absent key means off. */
|
||||
export function zdrEnabled(): boolean {
|
||||
return readToggles().zdr === true;
|
||||
export function envFlag(name: string): boolean {
|
||||
return /^(1|true|yes)$/i.test(process.env[name] ?? "");
|
||||
}
|
||||
|
||||
export function debugEnabled(): boolean {
|
||||
return readToggles().debug === true;
|
||||
/** Flag file wins over the environment; unset file falls back to the env var. */
|
||||
export function zdrFromEnvOrFile(): boolean {
|
||||
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");
|
||||
}
|
||||
|
||||
26
src/tui.tsx
26
src/tui.tsx
@ -21,7 +21,7 @@ import { createSignal } from "solid-js";
|
||||
|
||||
import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js";
|
||||
import { redact } from "./redact.js";
|
||||
import { debugEnabled, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
|
||||
import { debugFromEnvOrFile, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
|
||||
|
||||
type ToastVariant = "info" | "success" | "warning" | "error";
|
||||
|
||||
@ -69,11 +69,9 @@ type TuiApi = {
|
||||
const ID = "commandcode-toggles";
|
||||
const CATEGORY = "CommandCode";
|
||||
const SIDEBAR_ORDER = 90;
|
||||
const DEFAULT_QUOTA_INTERVAL_MS = 300_000;
|
||||
const MIN_REFRESH_INTERVAL_MS = 120_000;
|
||||
const DEFAULT_QUOTA_INTERVAL_MS = 180_000;
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
function safeEnv(name: string): string | undefined {
|
||||
@ -86,8 +84,8 @@ function safeEnv(name: string): string | undefined {
|
||||
|
||||
/** Debug-only trace to the shared debug file. Silent unless debug is on; never logs the key. */
|
||||
function trace(...args: unknown[]): void {
|
||||
if (!debugEnabled()) return;
|
||||
const file = join(tmpdir(), "commandcode-debug.log");
|
||||
if (!debugFromEnvOrFile()) return;
|
||||
const file = safeEnv("COMMANDCODE_DEBUG_FILE") ?? join(tmpdir(), "commandcode-debug.log");
|
||||
const parts = args.map((value) => (typeof value === "string" ? value : safeJson(value)));
|
||||
const line = `[commandcode] ${new Date().toISOString()} [tui-quota] ${parts.join(" ")}`;
|
||||
try {
|
||||
@ -170,11 +168,6 @@ function quotaIntervalMs(): number {
|
||||
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 {
|
||||
const label = w.id === "fiveHour" ? "5h" : w.id === "weekly" ? "7d" : "mo";
|
||||
const reset = formatReset(w.resetAtMs, nowMs);
|
||||
@ -187,11 +180,9 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
||||
const [toggles, setToggles] = createSignal(readToggles());
|
||||
const [quota, setQuota] = createSignal<QuotaResult | null>(null);
|
||||
const [now, setNow] = createSignal(Date.now());
|
||||
const [updatedAt, setUpdatedAt] = createSignal(0);
|
||||
|
||||
let inflight = false;
|
||||
let pending = false;
|
||||
let lastRefreshAt = 0;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastSessionId: string | undefined;
|
||||
|
||||
@ -203,11 +194,6 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
||||
trace("coalesced", "inflight");
|
||||
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;
|
||||
try {
|
||||
const { key: apiKey, source } = resolveApiKeySource(api);
|
||||
@ -236,9 +222,7 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
||||
trace("fetch-throw", message);
|
||||
} finally {
|
||||
inflight = false;
|
||||
lastRefreshAt = Date.now();
|
||||
setNow(Date.now());
|
||||
setUpdatedAt(Date.now());
|
||||
if (pending) {
|
||||
pending = false;
|
||||
trace("flush-pending");
|
||||
@ -300,7 +284,6 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
||||
) : quota() === null ? (
|
||||
<text fg={theme.current.textMuted}>quota: loading…</text>
|
||||
) : null}
|
||||
<text fg={theme.current.textMuted}>updated @ {formatClock(updatedAt())}</text>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
@ -382,7 +365,6 @@ export const tui = async (api: TuiApi): Promise<void> => {
|
||||
}
|
||||
setQuota(result);
|
||||
setNow(Date.now());
|
||||
setUpdatedAt(Date.now());
|
||||
api.ui.toast({ title: CATEGORY, message: formatQuota(result.quota), variant: "info", duration: 15000 });
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user