add /cc-zdr, /cc-debug, /cc-status slash commands
This commit is contained in:
parent
0872a2099b
commit
41fe0788a7
17
AGENTS.md
17
AGENTS.md
@ -44,6 +44,9 @@ 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/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json).
|
||||||
|
src/tui.ts opencode TUI plugin exposing /cc-zdr, /cc-debug, /cc-status.
|
||||||
src/constants.ts Defaults, paths, headers, passthrough params, static config block.
|
src/constants.ts Defaults, paths, headers, passthrough params, static config block.
|
||||||
scripts/smoke.mjs Live end-to-end check.
|
scripts/smoke.mjs Live end-to-end check.
|
||||||
scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing).
|
scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing).
|
||||||
@ -113,6 +116,20 @@ 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.
|
||||||
|
12. **The TUI plugin lives outside the provider entrypoint.** `src/tui.ts` compiles to
|
||||||
|
`dist/tui.js`, is exposed as `./tui` in `package.json`, and is installed with
|
||||||
|
`opencode plugin <path>`, which writes a `tui.json` `plugin` entry. 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 }`.
|
||||||
|
13. **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`.
|
||||||
|
14. **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.
|
||||||
|
|
||||||
## Change workflow
|
## Change workflow
|
||||||
|
|
||||||
|
|||||||
45
README.md
45
README.md
@ -147,7 +147,8 @@ unauthenticated and CommandCode will reject them.
|
|||||||
|
|
||||||
Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file.
|
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
|
Silent (and no file is created) unless enabled. Every line is passed through `redact()` so
|
||||||
credentials never reach disk.
|
credentials never reach disk. It can also be flipped at runtime with `/cc-debug` (see
|
||||||
|
[Runtime toggles](#runtime-toggles)).
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:COMMANDCODE_DEBUG = "1"
|
$env:COMMANDCODE_DEBUG = "1"
|
||||||
@ -166,13 +167,40 @@ 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. Set `COMMANDCODE_ZDR=1` (or `true`/`yes`) when launching opencode to opt back in. The
|
models. It can be enabled with the `COMMANDCODE_ZDR` environment variable, the `/cc-zdr` slash
|
||||||
value is read once at provider load, so restart opencode after changing it. Per-provider
|
command, or `providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it
|
||||||
overrides still win: `headers: { "x-cmd-zdr": "..." }` in `opencode.json` takes precedence over
|
does not require restarting opencode.
|
||||||
the environment variable.
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Runtime toggles
|
||||||
|
|
||||||
|
`/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`); 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.ts`, built to
|
||||||
|
`dist/tui.js`). Register it once:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
opencode plugin file:///C:/DevTools/pienv/ccprovider/dist/tui.js
|
||||||
|
```
|
||||||
|
|
||||||
|
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. 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 against a remote server, the TUI-side file is not
|
||||||
|
visible to the provider on the server host — use the env vars or `providerOptions` there.
|
||||||
|
|
||||||
With `COMMANDCODE_DEBUG` enabled, the trace logs a `zdr on|off` line per request so the toggle
|
|
||||||
state is observable.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -192,6 +220,7 @@ state is observable.
|
|||||||
| `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` |
|
||||||
| 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 |
|
||||||
|
|
||||||
### `tool_choice` handling
|
### `tool_choice` handling
|
||||||
|
|
||||||
@ -225,6 +254,8 @@ 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 (COMMANDCODE_DEBUG) to a log file.
|
||||||
|
src/toggles.ts Shared toggle file read by the provider and written by the TUI plugin.
|
||||||
|
src/tui.ts opencode TUI plugin registering /cc-zdr, /cc-debug, /cc-status.
|
||||||
src/constants.ts Defaults, header names, passthrough params, static config block.
|
src/constants.ts Defaults, header names, passthrough params, static config block.
|
||||||
scripts/smoke.mjs Live end-to-end check against api.commandcode.ai.
|
scripts/smoke.mjs Live end-to-end check against api.commandcode.ai.
|
||||||
scripts/sync-models.mjs Generate provider.commandcode.models from the live catalog.
|
scripts/sync-models.mjs Generate provider.commandcode.models from the live catalog.
|
||||||
|
|||||||
@ -9,6 +9,10 @@
|
|||||||
".": {
|
".": {
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"./tui": {
|
||||||
|
"import": "./dist/tui.js",
|
||||||
|
"types": "./dist/tui.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
/** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */
|
/** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */
|
||||||
import { debug } from "./log.js";
|
import { debugWhen, isDebugEnabled } from "./log.js";
|
||||||
|
|
||||||
export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<Record<string, any>> {
|
export async function* iterateEvents(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
dbg: boolean = isDebugEnabled(),
|
||||||
|
): AsyncGenerator<Record<string, any>> {
|
||||||
const reader = body.getReader();
|
const reader = body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
@ -16,7 +19,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
|
|||||||
while ((newline = buffer.indexOf("\n")) >= 0) {
|
while ((newline = buffer.indexOf("\n")) >= 0) {
|
||||||
const raw = buffer.slice(0, newline);
|
const raw = buffer.slice(0, newline);
|
||||||
buffer = buffer.slice(newline + 1);
|
buffer = buffer.slice(newline + 1);
|
||||||
const event = parseLine(raw);
|
const event = parseLine(raw, dbg);
|
||||||
if (event) yield event;
|
if (event) yield event;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -24,7 +27,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
|
|||||||
// flush any trailing line without a newline
|
// flush any trailing line without a newline
|
||||||
buffer += decoder.decode();
|
buffer += decoder.decode();
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
const event = parseLine(buffer);
|
const event = parseLine(buffer, dbg);
|
||||||
if (event) yield event;
|
if (event) yield event;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -32,7 +35,7 @@ export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLine(raw: string): Record<string, any> | null {
|
function parseLine(raw: string, dbg: boolean): Record<string, any> | null {
|
||||||
let line = raw.trim();
|
let line = raw.trim();
|
||||||
if (!line || line.startsWith(":") || line.startsWith("event:")) return null;
|
if (!line || line.startsWith(":") || line.startsWith("event:")) return null;
|
||||||
if (line.startsWith("data:")) line = line.slice(5).trim();
|
if (line.startsWith("data:")) line = line.slice(5).trim();
|
||||||
@ -41,7 +44,7 @@ function parseLine(raw: string): Record<string, any> | null {
|
|||||||
const parsed = JSON.parse(line);
|
const parsed = JSON.parse(line);
|
||||||
return parsed && typeof parsed === "object" ? parsed : null;
|
return parsed && typeof parsed === "object" ? parsed : null;
|
||||||
} catch {
|
} catch {
|
||||||
debug("events", "parse-skip", `line=${line.slice(0, 512)}`);
|
debugWhen(dbg, "events", "parse-skip", `line=${line.slice(0, 512)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/log.ts
28
src/log.ts
@ -1,12 +1,13 @@
|
|||||||
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1; silent (and no file
|
// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1 or the shared toggle file;
|
||||||
// created) by default. Uses only Node built-ins so the built output stays dependency-free.
|
// silent (and no file created) by default. Uses only Node built-ins so the built output
|
||||||
|
// stays dependency-free.
|
||||||
|
|
||||||
import { appendFileSync } from "node:fs";
|
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";
|
||||||
|
|
||||||
const ENABLED = /^(1|true|yes)$/i.test(process.env["COMMANDCODE_DEBUG"] ?? "");
|
|
||||||
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log");
|
const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log");
|
||||||
|
|
||||||
function serialize(value: unknown): string {
|
function serialize(value: unknown): string {
|
||||||
@ -18,9 +19,7 @@ function serialize(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */
|
function emit(scope: string, args: unknown[]): void {
|
||||||
export function debug(scope: string, ...args: unknown[]): void {
|
|
||||||
if (!ENABLED) return;
|
|
||||||
const parts = args.map(serialize);
|
const parts = args.map(serialize);
|
||||||
const line = `[commandcode] ${new Date().toISOString()} [${scope}] ${parts.join(" ")}`;
|
const line = `[commandcode] ${new Date().toISOString()} [${scope}] ${parts.join(" ")}`;
|
||||||
try {
|
try {
|
||||||
@ -34,3 +33,20 @@ export function debug(scope: string, ...args: unknown[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Effective default tracing state (env var or toggle file). Re-read on every call. */
|
||||||
|
export function isDebugEnabled(): boolean {
|
||||||
|
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 (!debugFromEnvOrFile()) return;
|
||||||
|
emit(scope, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trace with a per-request override, so providerOptions/history state is not global. */
|
||||||
|
export function debugWhen(enabled: boolean, scope: string, ...args: unknown[]): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
emit(scope, args);
|
||||||
|
}
|
||||||
|
|||||||
89
src/model.ts
89
src/model.ts
@ -18,14 +18,38 @@ import {
|
|||||||
GENERATE_PATH,
|
GENERATE_PATH,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { iterateEvents } from "./events.js";
|
import { iterateEvents } from "./events.js";
|
||||||
import { debug } 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 { transform } from "./transform.js";
|
import { transform } from "./transform.js";
|
||||||
import { finishReasonFrom, usageFromFinish, type FinishEvent } from "./usage.js";
|
import { finishReasonFrom, usageFromFinish, type FinishEvent } 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).
|
||||||
// COMMANDCODE_ZDR=1|true|yes opts back in. Read at load, like COMMANDCODE_DEBUG.
|
// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR.
|
||||||
const ZDR_ENABLED = /^(1|true|yes)$/i.test(process.env["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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truthyHeader(value: string): boolean {
|
||||||
|
return !/^(0|false|no|off)$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveZdr(
|
||||||
|
options: LanguageModelV3CallOptions,
|
||||||
|
providerHeaders: Record<string, string>,
|
||||||
|
): string | undefined {
|
||||||
|
const scoped = providerScoped(options)?.["zdr"];
|
||||||
|
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 zdrFromEnvOrFile() ? "1" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDebug(options: LanguageModelV3CallOptions): boolean {
|
||||||
|
const scoped = providerScoped(options)?.["debug"];
|
||||||
|
return typeof scoped === "boolean" ? scoped : isDebugEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
export type CommandCodeOptions = {
|
export type CommandCodeOptions = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -47,7 +71,7 @@ type ResolvedOptions = {
|
|||||||
retryMaxDelay: number;
|
retryMaxDelay: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function headerValue(headers: Record<string, string>, key: string): string | undefined {
|
function headerValue(headers: Record<string, string | undefined>, key: string): string | undefined {
|
||||||
const wanted = key.toLowerCase();
|
const wanted = key.toLowerCase();
|
||||||
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v;
|
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v;
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -135,7 +159,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
|
|
||||||
supportedUrls: Record<string, RegExp[]> = {};
|
supportedUrls: Record<string, RegExp[]> = {};
|
||||||
|
|
||||||
private requestHeaders(extra?: Record<string, string | undefined>): Record<string, string> {
|
private requestHeaders(
|
||||||
|
extra: Record<string, string | undefined> | undefined,
|
||||||
|
zdr: string | undefined,
|
||||||
|
dbg: boolean,
|
||||||
|
): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"x-command-code-version": this.opts.ccVersion,
|
"x-command-code-version": this.opts.ccVersion,
|
||||||
@ -143,24 +171,28 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
"x-project-slug": "project",
|
"x-project-slug": "project",
|
||||||
"x-taste-learning": "true",
|
"x-taste-learning": "true",
|
||||||
"x-co-flag": "false",
|
"x-co-flag": "false",
|
||||||
...(ZDR_ENABLED ? { "x-cmd-zdr": "1" } : {}),
|
|
||||||
...this.opts.headers,
|
...this.opts.headers,
|
||||||
};
|
};
|
||||||
debug("fetch", "zdr", ZDR_ENABLED ? "on" : "off");
|
if (zdr !== undefined) headers["x-cmd-zdr"] = zdr;
|
||||||
|
debugWhen(dbg, "fetch", "zdr", zdr === "1" ? "on" : "off");
|
||||||
const auth = bearer(this.opts.apiKey);
|
const auth = bearer(this.opts.apiKey);
|
||||||
if (auth) headers["Authorization"] = auth;
|
if (auth) headers["Authorization"] = auth;
|
||||||
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v;
|
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v;
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchWithRetry(body: string, options: LanguageModelV3CallOptions): Promise<Response> {
|
private async fetchWithRetry(
|
||||||
const headers = this.requestHeaders(options.headers);
|
body: string,
|
||||||
|
options: LanguageModelV3CallOptions,
|
||||||
|
dbg: boolean,
|
||||||
|
): Promise<Response> {
|
||||||
|
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}`;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) {
|
||||||
if (options.abortSignal?.aborted) {
|
if (options.abortSignal?.aborted) {
|
||||||
debug("fetch", "aborted", `attempt=${attempt}`);
|
debugWhen(dbg, "fetch", "aborted", `attempt=${attempt}`);
|
||||||
throw options.abortSignal.reason ?? new Error("Aborted");
|
throw options.abortSignal.reason ?? new Error("Aborted");
|
||||||
}
|
}
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
@ -175,7 +207,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
const wait = retryDelay(attempt, null, this.opts.retryMaxDelay);
|
const wait = retryDelay(attempt, null, this.opts.retryMaxDelay);
|
||||||
debug(
|
debugWhen(
|
||||||
|
dbg,
|
||||||
"fetch",
|
"fetch",
|
||||||
"network-error",
|
"network-error",
|
||||||
`url=${url}`,
|
`url=${url}`,
|
||||||
@ -194,7 +227,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
if (isRetryable(status) && attempt < this.opts.maxRetries) {
|
if (isRetryable(status) && attempt < this.opts.maxRetries) {
|
||||||
const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay);
|
const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay);
|
||||||
if (wait >= 0) {
|
if (wait >= 0) {
|
||||||
debug(
|
debugWhen(
|
||||||
|
dbg,
|
||||||
"fetch",
|
"fetch",
|
||||||
"retry",
|
"retry",
|
||||||
`url=${url}`,
|
`url=${url}`,
|
||||||
@ -212,14 +246,14 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug("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;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
|
throw lastError instanceof Error ? lastError : new Error("Upstream unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async errorFrom(response: Response): Promise<Error> {
|
private async errorFrom(response: Response, dbg: boolean): Promise<Error> {
|
||||||
let body = "";
|
let body = "";
|
||||||
try {
|
try {
|
||||||
body = await response.text();
|
body = await response.text();
|
||||||
@ -236,18 +270,19 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
/* keep raw body */
|
/* keep raw body */
|
||||||
}
|
}
|
||||||
const surfaced = redact(message.slice(0, 2000) || `Upstream returned ${response.status}`);
|
const surfaced = redact(message.slice(0, 2000) || `Upstream returned ${response.status}`);
|
||||||
debug("error", `status=${response.status}`, `body=${surfaced}`);
|
debugWhen(dbg, "error", `status=${response.status}`, `body=${surfaced}`);
|
||||||
return new Error(surfaced);
|
return new Error(surfaced);
|
||||||
}
|
}
|
||||||
|
|
||||||
async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> {
|
async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> {
|
||||||
|
const dbg = resolveDebug(options);
|
||||||
const body = transform(options, this.modelId);
|
const body = transform(options, this.modelId);
|
||||||
debug("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);
|
const response = await this.fetchWithRetry(body, options, dbg);
|
||||||
if (!response.ok || !response.body) throw await this.errorFrom(response);
|
if (!response.ok || !response.body) throw await this.errorFrom(response, dbg);
|
||||||
debug("doStream", "ok", `status=${response.status}`);
|
debugWhen(dbg, "doStream", "ok", `status=${response.status}`);
|
||||||
|
|
||||||
const stream = toReadableStream(this.streamParts(response.body));
|
const stream = toReadableStream(this.streamParts(response.body, dbg));
|
||||||
return {
|
return {
|
||||||
stream,
|
stream,
|
||||||
request: { body: JSON.parse(body) as unknown },
|
request: { body: JSON.parse(body) as unknown },
|
||||||
@ -256,6 +291,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> {
|
async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> {
|
||||||
|
const dbg = resolveDebug(options);
|
||||||
const { stream, request, response } = await this.doStream(options);
|
const { stream, request, response } = await this.doStream(options);
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
@ -302,7 +338,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
if (text) content.push({ type: "text", text });
|
if (text) content.push({ type: "text", text });
|
||||||
content.push(...toolCalls);
|
content.push(...toolCalls);
|
||||||
|
|
||||||
debug(
|
debugWhen(
|
||||||
|
dbg,
|
||||||
"doGenerate",
|
"doGenerate",
|
||||||
"done",
|
"done",
|
||||||
`model=${this.modelId}`,
|
`model=${this.modelId}`,
|
||||||
@ -316,7 +353,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
return { content, finishReason, usage, warnings, request, response };
|
return { content, finishReason, usage, warnings, request, response };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async *streamParts(body: ReadableStream<Uint8Array>): AsyncGenerator<LanguageModelV3StreamPart> {
|
private async *streamParts(body: ReadableStream<Uint8Array>, dbg: boolean): AsyncGenerator<LanguageModelV3StreamPart> {
|
||||||
yield { type: "stream-start", warnings: [] };
|
yield { type: "stream-start", warnings: [] };
|
||||||
|
|
||||||
const textId = "text-0";
|
const textId = "text-0";
|
||||||
@ -329,9 +366,9 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
let finishReason: LanguageModelV3FinishReason | undefined;
|
let finishReason: LanguageModelV3FinishReason | undefined;
|
||||||
let errored = false;
|
let errored = false;
|
||||||
|
|
||||||
for await (const evt of iterateEvents(body)) {
|
for await (const evt of iterateEvents(body, dbg)) {
|
||||||
const payload = JSON.stringify(evt);
|
const payload = JSON.stringify(evt);
|
||||||
debug("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) {
|
||||||
case "text-start":
|
case "text-start":
|
||||||
if (!textOpen) {
|
if (!textOpen) {
|
||||||
@ -422,12 +459,12 @@ class CommandCodeLanguageModel implements LanguageModelV3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errored) {
|
if (errored) {
|
||||||
debug("stream", "terminal", "errored");
|
debugWhen(dbg, "stream", "terminal", "errored");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (textOpen) yield { type: "text-end", id: textId };
|
if (textOpen) yield { type: "text-end", id: textId };
|
||||||
if (reasoningOpen) yield { type: "reasoning-end", id: reasoningId };
|
if (reasoningOpen) yield { type: "reasoning-end", id: reasoningId };
|
||||||
debug("stream", "terminal", `finish=${finishReason ? (finishReason.unified ?? "?") : "synthesized"}`, `usage=${JSON.stringify(usage ?? zeroUsage())}`);
|
debugWhen(dbg, "stream", "terminal", `finish=${finishReason ? (finishReason.unified ?? "?") : "synthesized"}`, `usage=${JSON.stringify(usage ?? zeroUsage())}`);
|
||||||
yield {
|
yield {
|
||||||
type: "finish",
|
type: "finish",
|
||||||
usage: usage ?? zeroUsage(),
|
usage: usage ?? zeroUsage(),
|
||||||
|
|||||||
61
src/toggles.ts
Normal file
61
src/toggles.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// Runtime toggle state shared between the provider (server side) and the slash-command
|
||||||
|
// TUI plugin (client side) through a small JSON flag file. Node built-ins only so the
|
||||||
|
// built provider stays dependency-free; never throws on a missing or malformed file.
|
||||||
|
|
||||||
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export type Toggles = { zdr?: boolean; debug?: boolean };
|
||||||
|
|
||||||
|
export type ToggleName = keyof Toggles;
|
||||||
|
|
||||||
|
export function togglesPath(): string {
|
||||||
|
const override = process.env["COMMANDCODE_TOGGLES_FILE"];
|
||||||
|
if (override) return override;
|
||||||
|
const configHome = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
|
||||||
|
return join(configHome, "opencode", "commandcode-toggles.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readToggles(): Toggles {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(togglesPath(), "utf8")) as Record<string, unknown>;
|
||||||
|
if (!parsed || typeof parsed !== "object") return {};
|
||||||
|
const out: Toggles = {};
|
||||||
|
if (typeof parsed["zdr"] === "boolean") out.zdr = parsed["zdr"];
|
||||||
|
if (typeof parsed["debug"] === "boolean") out.debug = parsed["debug"];
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeToggles(patch: Toggles): Toggles {
|
||||||
|
const merged = { ...readToggles(), ...patch };
|
||||||
|
const path = togglesPath();
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const tmp = `${path}.${process.pid}.tmp`;
|
||||||
|
writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
||||||
|
renameSync(tmp, path);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggle(name: ToggleName): Toggles {
|
||||||
|
const current = readToggles();
|
||||||
|
return writeToggles({ [name]: current[name] !== true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function envFlag(name: string): boolean {
|
||||||
|
return /^(1|true|yes)$/i.test(process.env[name] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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");
|
||||||
|
}
|
||||||
91
src/tui.ts
Normal file
91
src/tui.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// opencode TUI plugin exposing /cc-zdr, /cc-debug and /cc-status. It flips the shared
|
||||||
|
// toggle file (see ./toggles.ts) that the provider reads on every request, so a change
|
||||||
|
// takes effect without restarting opencode.
|
||||||
|
//
|
||||||
|
// opencode loads this module's default export (`{ id, tui }`) from a `tui.json` plugin
|
||||||
|
// entry; verified against @opencode-ai/plugin@1.18.31. The local structural types below
|
||||||
|
// keep this package runtime-dependency-free. Note: in this opencode version slash
|
||||||
|
// commands and the Ctrl+P palette read the same `namespace: "palette"` command list, so
|
||||||
|
// these entries appear in both; `hidden: true` would remove them from both.
|
||||||
|
|
||||||
|
import { readToggles, toggle, type ToggleName } from "./toggles.js";
|
||||||
|
|
||||||
|
type ToastVariant = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
type TuiCommand = {
|
||||||
|
name: string;
|
||||||
|
title?: string;
|
||||||
|
desc?: string;
|
||||||
|
category?: string;
|
||||||
|
namespace?: string;
|
||||||
|
slashName?: string;
|
||||||
|
slashAliases?: string[];
|
||||||
|
suggested?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
enabled?: boolean | (() => boolean);
|
||||||
|
run: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TuiApi = {
|
||||||
|
keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void };
|
||||||
|
ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void };
|
||||||
|
};
|
||||||
|
|
||||||
|
const ID = "commandcode-toggles";
|
||||||
|
const CATEGORY = "CommandCode";
|
||||||
|
|
||||||
|
function state(value: boolean | undefined): string {
|
||||||
|
return value === true ? "on" : "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(): string {
|
||||||
|
const toggles = readToggles();
|
||||||
|
return `zdr=${state(toggles.zdr)}, debug=${state(toggles.debug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tui = async (api: TuiApi): Promise<void> => {
|
||||||
|
const flip = (name: ToggleName): void => {
|
||||||
|
const next = toggle(name);
|
||||||
|
api.ui.toast({
|
||||||
|
title: CATEGORY,
|
||||||
|
message: `${name} ${state(next[name])} (${summary()})`,
|
||||||
|
variant: next[name] === true ? "success" : "info",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
api.keymap.registerLayer({
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: `${ID}.zdr`,
|
||||||
|
title: "CommandCode: toggle ZDR header",
|
||||||
|
desc: "Send the x-cmd-zdr header on requests",
|
||||||
|
category: CATEGORY,
|
||||||
|
namespace: "palette",
|
||||||
|
slashName: "cc-zdr",
|
||||||
|
run: () => flip("zdr"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${ID}.debug`,
|
||||||
|
title: "CommandCode: toggle debug tracing",
|
||||||
|
desc: "Write a redacted request and stream trace to a log file",
|
||||||
|
category: CATEGORY,
|
||||||
|
namespace: "palette",
|
||||||
|
slashName: "cc-debug",
|
||||||
|
run: () => flip("debug"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${ID}.status`,
|
||||||
|
title: "CommandCode: show toggle status",
|
||||||
|
desc: "Show the current zdr and debug state",
|
||||||
|
category: CATEGORY,
|
||||||
|
namespace: "palette",
|
||||||
|
slashName: "cc-status",
|
||||||
|
run: () => api.ui.toast({ title: CATEGORY, message: summary(), variant: "info" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const id = ID;
|
||||||
|
|
||||||
|
export default { id: ID, tui };
|
||||||
Loading…
Reference in New Issue
Block a user