diff --git a/README.md b/README.md index dd5b8f9..1a39f58 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - The provider's `supported_endpoints` metadata chooses each model's API route. Messages is preferred when advertised; otherwise Chat Completions is preferred, and Responses is used only when it is the sole advertised route. This avoids the malformed Responses function-call start event observed on DeepSeek V4.1 Flash `#max` in OpenCode V2.0.18: `response.output_item.added` contained a `function_call` item without its required `arguments` string. That exact tool-call flow now passes through Chat Completions on V1 and V2. Responses mappings remain available for response-only models, but the current catalog has none. Claude Sonnet 4.6 returned `MODEL_NOT_IN_PLAN` with the message “available in Pro and above plans or extra on-demand usage”; entitled Claude access has not been live-verified. If a model fails on an account entitled to use it, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). - Free models show `(Free)` after their display name on V1 and V2 when all catalog prices are zero. Model IDs and routing stay unchanged; this is a name suffix, not a native desktop badge. Names that already end in `Free` or `(Free)` keep their existing label. +- Zero data retention on request: `CMD_ZDR=1` or `zdr: true` in the plugin config adds `x-cmd-zdr: 1` to every Command Code Provider API (`/provider/v1/*`) request on both OpenCode generations. ## How it works @@ -81,13 +82,45 @@ Catalog patches ship in new npm releases. OpenCode loads the version in its pack ## Plugin config file -Optional, `~/.config/opencode/opencode-commandcode.json` (legacy fallback name `commandcode-go-opencode-provider.json` still loads). Unknown keys are ignored; every field has a default. +Optional, `~/.config/opencode/opencode-commandcode.json` (legacy fallback name `commandcode-go-opencode-provider.json` still loads). Unknown keys are ignored; every field has a default, and an invalid field falls back on its own without discarding the valid ones (an invalid `zdr` value keeps ZDR on). | Key | Default | Effect | |---|---|---| | `commandCodePackagePath` | `""` | Maintainer override: extract the catalog from a local `command-code` checkout instead of the bundle. Same as env `COMMANDCODE_PACKAGE_PATH`. | | `debugStartupLogs` | `false` | Also mirror the startup summary to stderr. Default is quiet (`startup.json` only). | | `disableModelSync` | `false` | Accepted for forward compatibility; currently has no effect. | +| `zdr` | `false` | Send `x-cmd-zdr: 1` on every Provider API request (zero data retention). Same as env `CMD_ZDR=1`. A present but invalid value (e.g. the string `"true"`) counts as on. | + +## Zero data retention (ZDR) + +Command Code enforces [zero data retention](https://commandcode.ai/docs/provider#zero-data-retention-zdr) with the +`x-cmd-zdr: 1` request header; the CLI exposes the same opt-in as `CMD_ZDR=1`. This plugin never invoked the CLI, so +set either: + +- env `CMD_ZDR=1` (accepts `1`, `true`, `yes`, `on`; case-insensitive), or +- `"zdr": true` in `~/.config/opencode/opencode-commandcode.json`. + +The plugin then adds the header to every Command Code Provider API (`/provider/v1/*`) request: V2 registers it on the +`commandcode` provider, V1 on the provider `options.headers`. A ZDR header you configure yourself +(`providers.commandcode.headers` on V2, `provider.commandcode.options.headers` on V1) wins over the plugin's value. +Header names are matched case-insensitively, and the plugin collapses any spelling (`X-Cmd-Zdr`) into a single lowercase +`x-cmd-zdr` key, so a request never carries two ZDR headers. Custom npm transports keep owning their options. + +Behavior to expect from the API: when set, requests route only through ZDR-capable upstreams, and a model without one +**fails with `422 cmd_zdr_no_providers`** instead of falling back to a non-ZDR provider. ZDR can change which upstream +serves a request, so it may cost more. The header is sent on the Chat Completions, Messages, and Responses routes alike; +live verification so far covers Chat Completions only. + +The programmatic SDK factory (`createCommandCode`) calls `/alpha/generate`, which the ZDR documentation does not cover, +so it fails closed: with ZDR requested (`CMD_ZDR` on, or an `x-cmd-zdr: 1` header in any casing), +`languageModel()` throws before any request is sent. An explicit `x-cmd-zdr: 0` header opts the SDK out of `CMD_ZDR`. + +Live verification against the Provider API lives in `tests/integration/zdr.integration.test.ts` and is skipped unless +`COMMANDCODE_API_KEY` is set: + +```bash +COMMANDCODE_API_KEY=... bun run test:integration +``` ## Optional local CLI override diff --git a/docs/specs/2026-10-14-zdr-request-header.md b/docs/specs/2026-10-14-zdr-request-header.md new file mode 100644 index 0000000..4b2ab81 --- /dev/null +++ b/docs/specs/2026-10-14-zdr-request-header.md @@ -0,0 +1,104 @@ +# Zero data retention request header + +Status: implemented + +## Goal + +Command Code accounts opt into zero data retention (ZDR) with the `x-cmd-zdr: 1` request header. The CLI exposes that +opt-in as `CMD_ZDR=1`; this plugin talks to the Provider API directly, so before this change nothing in an OpenCode +session could request ZDR. Users on both OpenCode generations must be able to turn ZDR on with the same env var the CLI +documents, without editing provider internals. + +Reference: [Command Code Provider API — Zero data retention](https://commandcode.ai/docs/provider#zero-data-retention-zdr). + +## Decisions + +### Opt-in surface + +ZDR is on when either input is true: + +- env `CMD_ZDR` parses as `1`, `true`, `yes`, or `on` (trimmed, case-insensitive); every other value — including + `"0"`, `"false"`, `""`, and an unset variable — leaves ZDR off; or +- `~/.config/opencode/opencode-commandcode.json` sets `"zdr": true` (validated by `PluginFileConfigSchema`). + +`PluginFileConfigSchema` falls back per field: an invalid sibling (`"debugStartupLogs": "false"`) resets only itself and +never discards a valid `zdr: true`. `zdr` itself fails safe: a missing key is off, but a present invalid value (the +string `"true"`, `1`, `null`) is on. A mistaken opt-in at worst surfaces as `422 cmd_zdr_no_providers`; a silently +dropped one would send prompts to non-ZDR upstreams. + +The config flag adds a durable per-machine option; the env var keeps parity with the CLI. No CLI installation is +required for either. + +### Scope: Provider API only + +The ZDR documentation covers the Provider API (`/provider/v1/*`) only. Both plugin generations route every model there: +the default OpenAI-compatible package, and the Anthropic (Messages) and OpenAI (Responses) packages a model may select, +all inherit the provider's `/provider/v1` base URL. The SDK factory is the one surface on another endpoint. + +### Header injection + +The header value is always `1` and the key is always the lowercase `x-cmd-zdr`. + +- **V1** (`plugin.ts` `server()`): the header is written into `provider.commandcode.options.headers` next to the + existing default `baseURL`. OpenCode V1 merges provider `options` into every model of that provider, whichever + `provider.npm` a model overrides, so one map reaches the OpenAI-compatible (Chat Completions), Anthropic (Messages), + and OpenAI (Responses) routes. Custom npm transports keep owning their options, so the plugin does not add headers + when the user replaced `npm`. +- **V2** (`plugin.ts` `setup()`): the header is written into the registered provider's `headers` on the add path and + merged into the existing provider on the update path. Provider headers apply to every model of the provider, + including models that select the Anthropic or Responses runtime package. +- **SDK factory** (`index.ts createCommandCode`): not injected. The factory calls `/alpha/generate`, where ZDR + enforcement is unestablished, so it fails closed instead: when ZDR is requested, `languageModel()` throws before any + request is sent. A caller ZDR header (any casing) decides, so `x-cmd-zdr: 1` throws and `x-cmd-zdr: 0` opts out of + `CMD_ZDR`; without one, `CMD_ZDR` decides. Every caller resolves a model through `languageModel()` before it can + send a request — a direct import, or OpenCode's `create*` lookup when a provider `npm` points at this package — so + the guard there covers both. + +### User override precedence + +An existing value always wins: a user who sets `headers["x-cmd-zdr"] = "0"` keeps it, and other headers in the same map +survive untouched. Header names match case-insensitively: every spelling (`X-Cmd-Zdr`, `X-CMD-ZDR`) collapses into the +single lowercase key carrying the user's value (the lowercase spelling wins if several exist), because two spellings +would reach the wire as `x-cmd-zdr: 1, 0`. When ZDR is off the plugin never creates a `headers` key, so configurations without the opt-in are +byte-identical to the previous behavior. + +## Out of scope + +- Per-model ZDR selection. The API refuses ZDR-incapable models with `422 cmd_zdr_no_providers`; users who want that + granularity can set `providers.commandcode.models..headers` themselves. +- Any behavior change for `typesafe/jev`: the decision model is not in the bundled catalog and has no ZDR-capable + upstream. + +## How ZDR capability can be known without a request + +Checked on 2026-10-14 against `command-code@1.82.1` and the live Provider API: + +- `GET /provider/v1/models` carries no ZDR field. The only per-model keys are `context_length`, `created`, `id`, `name`, + `object`, `owned_by`, and `supported_endpoints`. +- The CLI bundle does know. `knownModelSupportsZdr()` and `modelSupportsZdr2()` consult two hardcoded denylists of + models with no ZDR-capable upstream (23 IDs in 1.82.1; 22 are in the current bundled catalog). The CLI uses them to + swap feature defaults so it never sends a request that would be refused. +- Live agreement: probing denylisted models with `x-cmd-zdr: 1` returned `422 cmd_zdr_no_providers` (4 of 5 sampled). + The fifth returned `403 MODEL_NOT_IN_PLAN`, because the entitlement check runs before ZDR routing — so a probe cannot + classify models the account cannot call. +- A refusal never reaches an upstream, so probing is free; an accepted request costs one generation. + +Consequence: capability is knowable offline by extracting the denylist at sync time (the catalog job already downloads +that bundle), but the list is only as fresh as the CLI release, and account entitlements can mask it. The live suite +below therefore treats the API as the authority and discovers both outcomes dynamically. + +## Verification + +- `tests/unit/zdr.test.ts` covers env parsing, config/env precedence, and case-insensitive header merging. +- `tests/unit/schemas.test.ts` covers per-field config fallback and the fail-safe invalid `zdr` value. +- `tests/unit/plugin.test.ts` covers V1 injection, user-header precedence (mixed case included), the config file with an + invalid sibling field or an invalid `zdr` value, the off state, and the custom-npm skip. +- `tests/unit/plugin-v2.test.ts` covers the same cases on the V2 add and update paths. +- `tests/unit/index.test.ts` covers the SDK fail-closed throw (`CMD_ZDR`, explicit header in any casing) with no request + sent, the explicit `x-cmd-zdr: 0` opt-out, and the off state. +- `tests/integration/zdr.integration.test.ts` live-checks the Provider API with no hardcoded model ID: chat-capable IDs + come from `GET /provider/v1/models`, are probed with the plugin's own ZDR header map until both a `200` and a + `422 cmd_zdr_no_providers` are observed, and a no-header control on a refused model proves the header causes the + refusal. A `400`/`401` among the probes fails the suite. Every request carries an `AbortSignal` with a deadline inside + the test timeout, so a timed-out run cancels in-flight probes and starts no new ones. It is skipped unless + `COMMANDCODE_API_KEY` is set and stays out of `bun run check`. diff --git a/index.ts b/index.ts index a4974e9..1e533f9 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ import { resolveApiKey } from "./src/auth.js"; import { CommandCodeLanguageModel } from "./src/model.js"; +import { findZdrHeader, zdrFromEnvValue } from "./src/zdr.js"; // Re-export the plugin definition so a directory plugin entry (/index.ts) // loads the dual V1+V2 plugin. No cycle: plugin.ts only imports from src/. @@ -13,6 +14,15 @@ export interface CommandCodeProviderOptions { authPaths?: string[]; } +/** + * ZDR is documented for the Provider API (`/provider/v1/*`) only, and this SDK + * calls `/alpha/generate`. A caller ZDR header (any casing) decides, so an + * explicit `x-cmd-zdr: 0` opts out of `CMD_ZDR`; otherwise `CMD_ZDR` decides. + */ +function zdrRequested(headers: Record | undefined): boolean { + return zdrFromEnvValue(findZdrHeader(headers) ?? process.env.CMD_ZDR); +} + export function createCommandCode(options: CommandCodeProviderOptions = {}) { const apiKey = resolveApiKey({ apiKey: options.apiKey, authPaths: options.authPaths }); if (!apiKey) { @@ -21,15 +31,26 @@ export function createCommandCode(options: CommandCodeProviderOptions = {}) { ); } + const headers = + typeof options.headers === "object" && options.headers !== null + ? (options.headers as Record) + : undefined; + return { languageModel(modelId: string): CommandCodeLanguageModel { + // Fail closed before any request: sending without enforced ZDR would leak the + // prompt the caller asked to keep out of retention. Every caller (direct import, + // or OpenCode's `create*` lookup on a provider `npm` set to this package) + // resolves a model here before it can send one. + if (zdrRequested(headers)) { + throw new Error( + "Zero data retention is only supported on the Command Code Provider API (/provider/v1/*); createCommandCode uses /alpha/generate. Unset CMD_ZDR, or use the OpenCode provider plugin.", + ); + } return new CommandCodeLanguageModel(modelId, { apiKey, baseURL: typeof options.baseURL === "string" ? options.baseURL : undefined, - headers: - typeof options.headers === "object" && options.headers !== null - ? (options.headers as Record) - : undefined, + headers, }); }, }; diff --git a/plugin.ts b/plugin.ts index afa2a39..1e5e440 100644 --- a/plugin.ts +++ b/plugin.ts @@ -13,6 +13,7 @@ import type { CatalogManifest } from "./src/manifest.js"; import { generateV2Models } from "./src/v2models.js"; import { catalogPaths } from "./src/paths.js"; import { ManifestSchema, ModelEntrySchema, PluginFileConfigSchema } from "./src/schemas.js"; +import { withZdrHeaders, zdrEnabled } from "./src/zdr.js"; const { models: MODELS_PATH, manifest: MANIFEST_PATH, version: VERSION_PATH } = catalogPaths(); @@ -238,6 +239,7 @@ export async function server() { const pluginCfg = loadPluginConfig(); const debug = pluginCfg.debugStartupLogs === true; + const zdr = zdrEnabled(pluginCfg); if (!cc.npm) cc.npm = V1_PROVIDER_SDK_NPM; if (!cc.name) cc.name = "Command Code"; @@ -245,11 +247,14 @@ export async function server() { // V1 resolves `npm` through BunProc.install and calls the first `create*` export, so // the SDK package must be the OpenAI-compatible one; give it the Provider API URL // unless the user already configured another baseURL (custom npm stays untouched). + // V1 merges `options` into every model of the provider, whichever `provider.npm` the + // model overrides, so one headers map reaches Chat, Messages, and Responses routes. if (cc.npm === V1_PROVIDER_SDK_NPM) { if (cc.options === undefined) cc.options = {}; if (typeof cc.options === "object" && cc.options !== null) { const options = cc.options as Record; if (!options.baseURL) options.baseURL = PROVIDER_API_BASE_URL; + if (zdr) options.headers = withZdrHeaders(options.headers, true); } } @@ -293,6 +298,7 @@ export async function server() { async function setup(ctx: any): Promise { const pluginCfg = loadPluginConfig(); const debug = pluginCfg.debugStartupLogs === true; + const zdr = zdrEnabled(pluginCfg); const load = loadCatalogEntries(); persistCatalogSideEffects(load, debug); const v2models = generateV2Models(load.models); @@ -308,6 +314,9 @@ async function setup(ctx: any): Promise { integrationID: "commandcode", env: ["COMMANDCODE_API_KEY"], package: V2_PROVIDER_PACKAGE, + // Provider headers reach every model of the provider, including the + // Messages/Responses package a model may select instead of the default. + ...(zdr ? { headers: withZdrHeaders(undefined, true) } : {}), settings: { baseURL: PROVIDER_API_BASE_URL, }, @@ -321,6 +330,7 @@ async function setup(ctx: any): Promise { provider.activation = provider.activation ?? "auto"; provider.integrationID = provider.integrationID ?? "commandcode"; provider.env = provider.env ?? ["COMMANDCODE_API_KEY"]; + if (zdr) provider.headers = withZdrHeaders(provider.headers, true); provider.settings = provider.settings ?? {}; if (typeof provider.settings === "object") { if (!provider.settings.baseURL) provider.settings.baseURL = PROVIDER_API_BASE_URL; diff --git a/src/schemas.ts b/src/schemas.ts index f754834..78b7eac 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -126,10 +126,13 @@ export const AvailabilityPayloadSchema = z /** * Plugin file config (`~/.config/opencode/opencode-commandcode.json`). * Deliberately lenient: user config files may carry extra keys, so unknown - * keys are stripped and every field falls back to its default. + * keys are stripped, and an invalid field falls back on its own without + * discarding the valid ones. `zdr` fails safe instead: a present but invalid + * value (e.g. the string "true") keeps ZDR on rather than silently disabling it. */ export const PluginFileConfigSchema = z.object({ - disableModelSync: z.boolean().optional().default(false), - commandCodePackagePath: z.string().optional().default(""), - debugStartupLogs: z.boolean().optional().default(false), + disableModelSync: z.boolean().optional().default(false).catch(false), + commandCodePackagePath: z.string().optional().default("").catch(""), + debugStartupLogs: z.boolean().optional().default(false).catch(false), + zdr: z.boolean().optional().default(false).catch(true), }); diff --git a/src/zdr.ts b/src/zdr.ts new file mode 100644 index 0000000..0670eec --- /dev/null +++ b/src/zdr.ts @@ -0,0 +1,65 @@ +import { liveEnv, type EnvDeps } from "./env.js"; + +/** + * Command Code zero-data-retention opt-in. The Provider API enforces ZDR with + * the `x-cmd-zdr: 1` request header (the CLI exposes the same opt-in as + * `CMD_ZDR=1`); ZDR-incapable models fail with `422 cmd_zdr_no_providers` + * instead of falling back to a non-ZDR upstream. + */ +export const ZDR_HEADER = "x-cmd-zdr"; +export const ZDR_HEADER_VALUE = "1"; + +/** Values that turn `CMD_ZDR` on; everything else (including "0") leaves ZDR off. */ +const ZDR_TRUTHY = new Set(["1", "true", "yes", "on"]); + +/** Parse the `CMD_ZDR` environment value. */ +export function zdrFromEnvValue(value: string | undefined): boolean { + return typeof value === "string" && ZDR_TRUTHY.has(value.trim().toLowerCase()); +} + +/** ZDR is on when the plugin config opts in or `CMD_ZDR` is truthy. */ +export function zdrEnabled(config: { zdr?: boolean }, deps: EnvDeps = liveEnv): boolean { + return config.zdr === true || zdrFromEnvValue(deps.getEnv("CMD_ZDR")); +} + +/** ZDR header map, or undefined when off so callers never create an empty `headers` key. */ +export function zdrHeaders(enabled: boolean): Record | undefined { + return enabled ? { [ZDR_HEADER]: ZDR_HEADER_VALUE } : undefined; +} + +function asHeaderMap(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +/** + * The caller's ZDR header value, matched case-insensitively (HTTP header names + * are). The lowercase spelling wins when several spellings are present. + */ +export function findZdrHeader(headers: unknown): string | undefined { + const current = asHeaderMap(headers); + if (!current) return undefined; + if (ZDR_HEADER in current) return String(current[ZDR_HEADER]); + const key = Object.keys(current).find((name) => name.toLowerCase() === ZDR_HEADER); + return key === undefined ? undefined : String(current[key]); +} + +/** + * Add the ZDR header without discarding headers already configured. An explicit + * user value in any casing (`X-Cmd-Zdr: 0`) wins over the plugin's opt-in, and + * every spelling collapses into the single lowercase key so a request never + * carries two ZDR headers (`1, 0`). + */ +export function withZdrHeaders( + existing: unknown, + enabled: boolean, +): Record | undefined { + const current = asHeaderMap(existing); + if (!enabled) return current; + const value = findZdrHeader(current) ?? ZDR_HEADER_VALUE; + const rest = Object.fromEntries( + Object.entries(current ?? {}).filter(([name]) => name.toLowerCase() !== ZDR_HEADER), + ); + return { [ZDR_HEADER]: value, ...rest }; +} diff --git a/tests/helpers/plugin-config.ts b/tests/helpers/plugin-config.ts new file mode 100644 index 0000000..f9836ea --- /dev/null +++ b/tests/helpers/plugin-config.ts @@ -0,0 +1,30 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { liveEnv } from "@/src/env.ts"; + +/** + * Run `fn` with `~/.config/opencode/opencode-commandcode.json` holding `config` + * and `CMD_ZDR` unset. The plugin hooks read the file through `liveEnv`, so its + * `homedir` is pointed at a temp home for the duration (Bun's `os.homedir()` + * ignores a runtime `HOME` change). + */ +export async function withPluginConfigFile(config: unknown, fn: () => Promise): Promise { + const home = mkdtempSync(join(tmpdir(), "cc-plugin-home-")); + const dir = join(home, ".config", "opencode"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencode-commandcode.json"), JSON.stringify(config)); + + const prevHomedir = liveEnv.homedir; + const prevZdr = process.env.CMD_ZDR; + liveEnv.homedir = () => home; + delete process.env.CMD_ZDR; + try { + return await fn(); + } finally { + liveEnv.homedir = prevHomedir; + if (prevZdr === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prevZdr; + rmSync(home, { recursive: true, force: true }); + } +} diff --git a/tests/integration/zdr.integration.test.ts b/tests/integration/zdr.integration.test.ts new file mode 100644 index 0000000..f23e514 --- /dev/null +++ b/tests/integration/zdr.integration.test.ts @@ -0,0 +1,201 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { withZdrHeaders } from "@/src/zdr.js"; + +/** + * Live zero-data-retention checks against api.commandcode.ai. + * + * The unit suites prove the plugin emits `x-cmd-zdr: 1`; this suite proves the + * Provider API honors that exact header: some chat model answers, and some + * model without a ZDR-capable upstream is refused with `cmd_zdr_no_providers`. + * + * No model ID is hardcoded. Chat-capable IDs come from `GET /provider/v1/models` + * and are probed with `max_tokens: 1` until both outcomes appear (a 422 response + * never reaches an upstream, so refusals are free). 403 (account entitlement), + * 429, and 5xx are tolerated along the way; a 400/401 would be a real defect. + * + * Every request carries an AbortSignal tied to a deadline inside the test + * timeout, so a timed-out run cancels in-flight probes and schedules no more. + * + * Skipped unless COMMANDCODE_API_KEY is set, and outside `bun run check` because + * ZDR coverage depends on live upstream availability and account entitlements. + */ + +const API_KEY = process.env.COMMANDCODE_API_KEY?.trim(); +const hasApiKey = !!API_KEY; + +const skipIfNoKey = hasApiKey ? describe : describe.skip; + +const PROVIDER_BASE_URL = "https://api.commandcode.ai/provider/v1"; +const ZDR_REFUSAL = "cmd_zdr_no_providers"; + +/** Stop after this many probes even if a branch is still missing. */ +const MAX_PROBES = 40; +const CONCURRENCY = 4; +const NETWORK_TIMEOUT_MS = 180_000; +/** Abort requests before the test timeout fires, so none outlive their test. */ +const REQUEST_BUDGET_MS = NETWORK_TIMEOUT_MS - 15_000; +/** OpenAI-routed models reject `max_tokens` below 16, so keep the probe above it. */ +const PROBE_MAX_TOKENS = 64; + +type Probe = { id: string; status: number; code?: string }; +type Discovery = { probes: Probe[]; capable: Probe[]; denied: Probe[] }; + +async function listChatModelIds(signal: AbortSignal): Promise { + const response = await fetch(`${PROVIDER_BASE_URL}/models`, { + headers: { Authorization: `Bearer ${API_KEY}` }, + signal, + }); + if (response.status !== 200) { + throw new Error(`GET /models: ${response.status} ${(await response.text()).slice(0, 300)}`); + } + const payload = (await response.json()) as { + data?: Array<{ id?: string; supported_endpoints?: string[] }>; + }; + return (payload.data ?? []) + .filter((entry) => + (entry.supported_endpoints ?? []).some((endpoint) => endpoint.includes("chat/completions")), + ) + .map((entry) => entry.id) + .filter((id): id is string => typeof id === "string" && id.length > 0); +} + +/** One minimal chat request; `extraHeaders` carries the plugin's ZDR map. */ +async function postChat(model: string, extraHeaders: Record, signal: AbortSignal) { + const response = await fetch(`${PROVIDER_BASE_URL}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: "hi" }], + max_tokens: PROBE_MAX_TOKENS, + }), + signal, + }); + const body = await response.text(); + let code: string | undefined; + if (response.status !== 200) { + try { + code = (JSON.parse(body) as { error?: { code?: string } }).error?.code; + } catch { + code = undefined; + } + } + return { status: response.status, body, code }; +} + +/** + * Probe chat models until one ZDR-capable and one ZDR-refused sample exist, or + * until `signal` aborts: an aborted probe is dropped and no new one starts. + */ +async function discoverZdrOutcomes(ids: string[], signal: AbortSignal): Promise { + const probes: Probe[] = []; + const capable: Probe[] = []; + const denied: Probe[] = []; + let started = 0; + + const header = withZdrHeaders(undefined, true) as Record; + + const worker = async () => { + while (!signal.aborted && (capable.length === 0 || denied.length === 0)) { + const index = started++; + if (index >= ids.length || started > MAX_PROBES) return; + const id = ids[index]!; + let result: Awaited>; + try { + result = await postChat(id, header, signal); + } catch (error) { + if (signal.aborted) return; + throw error; + } + const probe: Probe = { + id, + status: result.status, + ...(result.code ? { code: result.code } : {}), + }; + probes.push(probe); + if (probe.status === 200) capable.push(probe); + else if (probe.status === 422 && probe.code === ZDR_REFUSAL) denied.push(probe); + } + }; + + await Promise.all(Array.from({ length: CONCURRENCY }, worker)); + return { probes, capable, denied }; +} + +skipIfNoKey("integration: zero data retention", () => { + let chatIds: string[] = []; + let discovery: Discovery | undefined; + + beforeAll(async () => { + const signal = AbortSignal.timeout(REQUEST_BUDGET_MS); + chatIds = await listChatModelIds(signal); + discovery = await discoverZdrOutcomes(chatIds, signal); + console.log( + `ZDR probe: ${discovery.probes.length}/${chatIds.length} chat models probed, ` + + `${discovery.capable.length} served, ${discovery.denied.length} refused`, + ); + }, NETWORK_TIMEOUT_MS); + + test("at least one chat model serves a ZDR request", () => { + const capable = discovery?.capable ?? []; + console.log( + "ZDR-capable sample:", + capable + .slice(0, 3) + .map((probe) => probe.id) + .join(", "), + ); + if (capable.length === 0) { + throw new Error( + `no ZDR-capable chat model among ${discovery?.probes.length ?? 0} probes: ` + + `${JSON.stringify(discovery?.probes.slice(0, 10))}`, + ); + } + for (const probe of capable) expect(probe.status).toBe(200); + }); + + test("a model without a ZDR-capable upstream is refused with cmd_zdr_no_providers", () => { + const denied = discovery?.denied ?? []; + console.log( + "ZDR-refused sample:", + denied + .slice(0, 3) + .map((probe) => probe.id) + .join(", "), + ); + if (denied.length === 0) { + throw new Error( + `no ${ZDR_REFUSAL} response among ${discovery?.probes.length ?? 0} probes; ` + + `upstream ZDR coverage may have widened, or MAX_PROBES is too low`, + ); + } + for (const probe of denied) expect(probe.code).toBe(ZDR_REFUSAL); + }); + + test("the header never produces a malformed request or an auth error", () => { + const rejected = (discovery?.probes ?? []).filter( + (probe) => probe.status === 400 || probe.status === 401, + ); + expect(rejected).toEqual([]); + }); + + test( + "the same model answers without the header (control)", + async () => { + const sample = discovery?.denied[0]; + if (!sample) throw new Error("no ZDR-refused sample to use as a control"); + const result = await postChat(sample.id, {}, AbortSignal.timeout(REQUEST_BUDGET_MS)); + if (result.status !== 200) { + throw new Error( + `control request for ${sample.id} returned ${result.status}: ${result.body.slice(0, 300)}`, + ); + } + expect(result.status).toBe(200); + }, + NETWORK_TIMEOUT_MS, + ); +}); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 899869c..6acfcba 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,14 +1,19 @@ import { expect, test, beforeAll, afterAll } from "bun:test"; import { createCommandCode } from "@/index.js"; +import { makeCallOptions, mockFetchTrack } from "@/tests/helpers/mocks.js"; let originalEnv: Record = {}; beforeAll(() => { originalEnv.COMMANDCODE_API_KEY = process.env.COMMANDCODE_API_KEY; delete process.env.COMMANDCODE_API_KEY; + // A developer's CMD_ZDR=1 would make every languageModel() call fail closed. + originalEnv.CMD_ZDR = process.env.CMD_ZDR; + delete process.env.CMD_ZDR; }); afterAll(() => { if (originalEnv.COMMANDCODE_API_KEY) process.env.COMMANDCODE_API_KEY = originalEnv.COMMANDCODE_API_KEY; + if (originalEnv.CMD_ZDR !== undefined) process.env.CMD_ZDR = originalEnv.CMD_ZDR; }); const noAuthPaths = { authPaths: [] as string[] }; @@ -67,3 +72,81 @@ test("each languageModel call returns a new instance", () => { expect(m1.modelId).toBe("a"); expect(m2.modelId).toBe("b"); }); + +async function withCmdZdr(value: string | undefined, fn: () => Promise | T): Promise { + const prev = process.env.CMD_ZDR; + if (value === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = value; + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +} + +function emptyStream(respondWith: (response: Partial) => void): void { + respondWith({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }); +} + +test("CMD_ZDR=1 fails closed in the SDK without sending a request", async () => { + await withCmdZdr("1", () => { + const { calls, restore } = mockFetchTrack(); + try { + // The guard sits on model resolution, so the factory itself still succeeds. + const provider = createCommandCode({ apiKey: "sk-test" }); + expect(() => provider.languageModel("m")).toThrow("/provider/v1/*"); + expect(calls).toHaveLength(0); + } finally { + restore(); + } + }); +}); + +test("an explicit ZDR header in any casing fails closed in the SDK", async () => { + await withCmdZdr(undefined, () => { + for (const headers of [{ "x-cmd-zdr": "1" }, { "X-Cmd-Zdr": "1" }]) { + const provider = createCommandCode({ apiKey: "sk-test", headers }); + expect(() => provider.languageModel("m")).toThrow("/alpha/generate"); + } + }); +}); + +test("an explicit x-cmd-zdr: 0 opts the SDK out of CMD_ZDR", async () => { + await withCmdZdr("1", async () => { + const { calls, restore, respondWith } = mockFetchTrack(); + emptyStream(respondWith); + try { + const provider = createCommandCode({ apiKey: "sk-test", headers: { "X-Cmd-Zdr": "0" } }); + await provider.languageModel("m").doStream(makeCallOptions()); + const headers = calls[0].options.headers as Record; + expect(headers["X-Cmd-Zdr"]).toBe("0"); + } finally { + restore(); + } + }); +}); + +test("the SDK sends no ZDR header when ZDR is off", async () => { + await withCmdZdr(undefined, async () => { + const { calls, restore, respondWith } = mockFetchTrack(); + emptyStream(respondWith); + try { + const provider = createCommandCode({ apiKey: "sk-test" }); + await provider.languageModel("m").doStream(makeCallOptions()); + const headers = calls[0].options.headers as Record; + expect(Object.keys(headers).some((name) => name.toLowerCase() === "x-cmd-zdr")).toBe(false); + } finally { + restore(); + } + }); +}); diff --git a/tests/unit/plugin-v2.test.ts b/tests/unit/plugin-v2.test.ts index dfc7b04..8956202 100644 --- a/tests/unit/plugin-v2.test.ts +++ b/tests/unit/plugin-v2.test.ts @@ -2,6 +2,7 @@ import { expect, test, beforeAll, afterAll } from "bun:test"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { withPluginConfigFile } from "@/tests/helpers/plugin-config.ts"; let dual: { id: string; setup: (ctx: unknown) => Promise; server: () => Promise }; let prevStateDir: string | undefined; @@ -136,3 +137,111 @@ test("V2 setup preserves existing package and sets models", async () => { expect(setModels.id).toBe("commandcode"); expect(setModels.models.length).toBeGreaterThan(20); }); + +type Captured = { added: any; updated: any; setModels: any }; + +/** Minimal V2 context; `existing` seeds the update path, absence exercises the add path. */ +function makeCtx(existing?: any): { ctx: any; captured: Captured } { + const captured: Captured = { added: null, updated: null, setModels: null }; + const ctx = { + provider: { + transform: async (fn: (editor: any) => void) => { + const editor = { + get: () => existing, + add: (input: any) => { + captured.added = input; + }, + update: (id: string, fn2: (p: any) => void) => { + captured.updated = existing ?? { id, settings: {} }; + fn2(captured.updated); + }, + models: { + set: (id: string, models: any) => { + captured.setModels = { id, models }; + }, + }, + }; + fn(editor); + }, + }, + integration: { + transform: async (fn: (editor: any) => void) => { + fn({ method: { update: () => {} } }); + }, + }, + }; + return { ctx, captured }; +} + +async function withEnvZdr(value: string | undefined, fn: () => Promise): Promise { + const prev = process.env.CMD_ZDR; + if (value === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = value; + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +} + +test("V2 setup leaves headers unset without a ZDR opt-in", async () => { + await withEnvZdr(undefined, async () => { + const { ctx, captured } = makeCtx(); + await dual.setup(ctx); + expect(captured.added.info.headers).toBeUndefined(); + }); +}); + +test("CMD_ZDR=1 adds the ZDR header to a new V2 provider", async () => { + await withEnvZdr("1", async () => { + const { ctx, captured } = makeCtx(); + await dual.setup(ctx); + expect(captured.added.info.headers).toEqual({ "x-cmd-zdr": "1" }); + expect(captured.added.info.settings.baseURL).toBe("https://api.commandcode.ai/provider/v1"); + }); +}); + +test("V2 setup merges the ZDR header into existing provider headers", async () => { + await withEnvZdr("1", async () => { + const existing = { + id: "commandcode", + headers: { "x-other": "yes", "x-cmd-zdr": "0" }, + settings: {}, + }; + const { ctx, captured } = makeCtx(existing); + await dual.setup(ctx); + // An explicit user value survives the plugin opt-in. + expect(captured.updated.headers).toEqual({ "x-cmd-zdr": "0", "x-other": "yes" }); + expect(captured.setModels.id).toBe("commandcode"); + }); +}); + +test("a mixed-case user ZDR header wins and collapses to lowercase on V2", async () => { + await withEnvZdr("1", async () => { + const existing = { + id: "commandcode", + headers: { "X-Cmd-Zdr": "0", "x-other": "yes" }, + settings: {}, + }; + const { ctx, captured } = makeCtx(existing); + await dual.setup(ctx); + expect(captured.updated.headers).toEqual({ "x-cmd-zdr": "0", "x-other": "yes" }); + }); +}); + +test("config zdr survives an invalid sibling field on V2", async () => { + await withPluginConfigFile({ zdr: true, debugStartupLogs: "false" }, async () => { + const { ctx, captured } = makeCtx(); + await dual.setup(ctx); + expect(captured.added.info.headers).toEqual({ "x-cmd-zdr": "1" }); + }); +}); + +test("an invalid config zdr value fails safe to on for V2", async () => { + await withPluginConfigFile({ zdr: "true" }, async () => { + const { ctx, captured } = makeCtx(); + await dual.setup(ctx); + expect(captured.added.info.headers).toEqual({ "x-cmd-zdr": "1" }); + }); +}); diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 5c0b009..4fab326 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { writeCatalogCache, type ModelEntry } from "@/src/startup.ts"; +import { withPluginConfigFile } from "@/tests/helpers/plugin-config.ts"; type PluginResult = { config: (config: Record) => Promise; @@ -223,6 +224,126 @@ test("config hook creates provider block if missing", async () => { ); }); +test("CMD_ZDR=1 adds the ZDR header to the V1 provider options", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.baseURL).toBe("https://api.commandcode.ai/provider/v1"); + expect(options.headers).toEqual({ "x-cmd-zdr": "1" }); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + +test("ZDR opt-in keeps user headers, and an explicit user ZDR value wins", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const plugin = await pluginFn(); + const config: Record = { + provider: { + commandcode: { + npm: "@ai-sdk/openai-compatible", + options: { headers: { "x-other": "yes", "x-cmd-zdr": "0" } }, + }, + }, + }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.baseURL).toBe("https://api.commandcode.ai/provider/v1"); + expect(options.headers).toEqual({ "x-cmd-zdr": "0", "x-other": "yes" }); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + +test("a mixed-case user ZDR header wins and collapses to lowercase on V1", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const plugin = await pluginFn(); + const config: Record = { + provider: { commandcode: { options: { headers: { "X-Cmd-Zdr": "0", "x-other": "yes" } } } }, + }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.headers).toEqual({ "x-cmd-zdr": "0", "x-other": "yes" }); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + +test("config zdr survives an invalid sibling field on V1", async () => { + await withPluginConfigFile({ zdr: true, debugStartupLogs: "false" }, async () => { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.headers).toEqual({ "x-cmd-zdr": "1" }); + }); +}); + +test("an invalid config zdr value fails safe to on for V1", async () => { + await withPluginConfigFile({ zdr: "true" }, async () => { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.headers).toEqual({ "x-cmd-zdr": "1" }); + }); +}); + +test("no ZDR header without CMD_ZDR or the config flag", async () => { + const prev = process.env.CMD_ZDR; + delete process.env.CMD_ZDR; + try { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + expect((cc.options as Record).headers).toBeUndefined(); + } finally { + if (prev !== undefined) process.env.CMD_ZDR = prev; + } +}); + +test("CMD_ZDR=1 leaves custom npm transports untouched", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const plugin = await pluginFn(); + const config: Record = { + provider: { commandcode: { npm: "custom-package" } }, + }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + expect(cc.npm).toBe("custom-package"); + expect(cc.options).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + test("startup summary uses bundled manifest version and status", async () => { const plugin = await pluginFn(); const config: Record = { provider: { commandcode: {} } }; diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts index 385bfae..5a1ea00 100644 --- a/tests/unit/schemas.test.ts +++ b/tests/unit/schemas.test.ts @@ -243,6 +243,7 @@ describe("PluginFileConfigSchema", () => { disableModelSync: false, commandCodePackagePath: "", debugStartupLogs: false, + zdr: false, }); }); @@ -251,23 +252,47 @@ describe("PluginFileConfigSchema", () => { PluginFileConfigSchema.parse({ debugStartupLogs: true, commandCodePackagePath: "/opt/command-code", + zdr: true, extraKey: "ignored", }), ).toEqual({ disableModelSync: false, commandCodePackagePath: "/opt/command-code", debugStartupLogs: true, + zdr: true, }); }); - test("wrong-typed values fail validation so callers fall back to defaults", () => { - const raw = { debugStartupLogs: "yes" }; - const parsed = PluginFileConfigSchema.safeParse(raw); - const config = parsed.success ? parsed.data : PluginFileConfigSchema.parse({}); - expect(config).toEqual({ + test("a wrong-typed field falls back to its default on its own", () => { + expect(PluginFileConfigSchema.parse({ debugStartupLogs: "yes" })).toEqual({ disableModelSync: false, commandCodePackagePath: "", debugStartupLogs: false, + zdr: false, }); }); + + test("an invalid field never discards a valid zdr opt-in", () => { + expect(PluginFileConfigSchema.parse({ zdr: true, debugStartupLogs: "false" })).toEqual({ + disableModelSync: false, + commandCodePackagePath: "", + debugStartupLogs: false, + zdr: true, + }); + expect( + PluginFileConfigSchema.parse({ commandCodePackagePath: "/opt/cc", disableModelSync: 1 }), + ).toEqual({ + disableModelSync: false, + commandCodePackagePath: "/opt/cc", + debugStartupLogs: false, + zdr: false, + }); + }); + + test("a present but invalid zdr value fails safe to on", () => { + for (const zdr of ["true", "false", 1, null]) { + expect(PluginFileConfigSchema.parse({ zdr }).zdr).toBe(true); + } + expect(PluginFileConfigSchema.parse({ zdr: false }).zdr).toBe(false); + }); }); diff --git a/tests/unit/zdr.test.ts b/tests/unit/zdr.test.ts new file mode 100644 index 0000000..b4abe07 --- /dev/null +++ b/tests/unit/zdr.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { + ZDR_HEADER, + ZDR_HEADER_VALUE, + findZdrHeader, + withZdrHeaders, + zdrEnabled, + zdrFromEnvValue, + zdrHeaders, +} from "@/src/zdr.ts"; + +test("CMD_ZDR truthy spellings turn ZDR on", () => { + for (const value of ["1", "true", "TRUE", "Yes", "on", " 1 "]) { + expect(zdrFromEnvValue(value)).toBe(true); + } +}); + +test("CMD_ZDR falsey or absent values leave ZDR off", () => { + for (const value of [undefined, "", "0", "false", "no", "off", "banana"]) { + expect(zdrFromEnvValue(value)).toBe(false); + } +}); + +test("zdrEnabled reads the config flag or CMD_ZDR", () => { + const env = (value: string | undefined) => ({ + homedir: () => "/tmp", + getEnv: (key: string) => (key === "CMD_ZDR" ? value : undefined), + }); + + expect(zdrEnabled({}, env(undefined))).toBe(false); + expect(zdrEnabled({}, env("1"))).toBe(true); + expect(zdrEnabled({ zdr: true }, env(undefined))).toBe(true); + expect(zdrEnabled({ zdr: false }, env("1"))).toBe(true); + // A false config value never overrides a truthy env opt-in, and other env keys are ignored. + expect(zdrEnabled({ zdr: false }, env("0"))).toBe(false); +}); + +test("zdrHeaders returns the header map only when enabled", () => { + expect(zdrHeaders(true)).toEqual({ [ZDR_HEADER]: ZDR_HEADER_VALUE }); + expect(zdrHeaders(false)).toBeUndefined(); +}); + +test("withZdrHeaders keeps existing headers and never creates an empty map", () => { + expect(withZdrHeaders(undefined, false)).toBeUndefined(); + expect(withZdrHeaders(undefined, true)).toEqual({ [ZDR_HEADER]: ZDR_HEADER_VALUE }); + expect(withZdrHeaders({ "x-other": "1" }, true)).toEqual({ + [ZDR_HEADER]: ZDR_HEADER_VALUE, + "x-other": "1", + }); + expect(withZdrHeaders({ "x-other": "1" }, false)).toEqual({ "x-other": "1" }); +}); + +test("withZdrHeaders lets an explicit user value win over the opt-in", () => { + expect(withZdrHeaders({ [ZDR_HEADER]: "0" }, true)).toEqual({ [ZDR_HEADER]: "0" }); +}); + +test("findZdrHeader matches the header name case-insensitively", () => { + expect(findZdrHeader(undefined)).toBeUndefined(); + expect(findZdrHeader({ "x-other": "1" })).toBeUndefined(); + expect(findZdrHeader({ "X-Cmd-Zdr": "0" })).toBe("0"); + expect(findZdrHeader({ "X-CMD-ZDR": "1" })).toBe("1"); + // The lowercase spelling wins when several are present. + expect(findZdrHeader({ "X-Cmd-Zdr": "1", [ZDR_HEADER]: "0" })).toBe("0"); +}); + +test("withZdrHeaders keeps a mixed-case user value and collapses it to lowercase", () => { + expect(withZdrHeaders({ "X-Cmd-Zdr": "0" }, true)).toEqual({ [ZDR_HEADER]: "0" }); + expect(withZdrHeaders({ "X-CMD-ZDR": "0", "x-other": "yes" }, true)).toEqual({ + [ZDR_HEADER]: "0", + "x-other": "yes", + }); + expect(withZdrHeaders({ "X-Cmd-Zdr": "1", [ZDR_HEADER]: "0" }, true)).toEqual({ + [ZDR_HEADER]: "0", + }); +}); + +test("withZdrHeaders leaves headers untouched when ZDR is off", () => { + const headers = { "X-Cmd-Zdr": "0" }; + expect(withZdrHeaders(headers, false)).toBe(headers); +}); + +test("withZdrHeaders does not mutate the caller's map", () => { + const headers = { "X-Cmd-Zdr": "0", "x-other": "yes" }; + withZdrHeaders(headers, true); + expect(headers).toEqual({ "X-Cmd-Zdr": "0", "x-other": "yes" }); +});