diff --git a/README.md b/README.md index 7de3664..a540237 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - Release date, family, input limits, model status, vendor context limits, and matched context-price tiers flow through the bundled catalog. V2 gets native release, family, input, status, and tier fields; V1 keeps its supported fields and base prices, with `context_over_200k` where representable. - 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). +- Zero data retention on request: `CMD_ZDR=1` or `zdr: true` in the plugin config adds `x-cmd-zdr: 1` to every Command Code request on both OpenCode generations. ## How it works @@ -87,6 +88,36 @@ Optional, `~/.config/opencode/opencode-commandcode.json` (legacy fallback name ` | `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 request (zero data retention). Same as env `CMD_ZDR=1`. | + +## 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 request: V2 registers it on the `commandcode` provider, V1 on the +provider `options.headers`. A handler you configure yourself (`providers.commandcode.headers` on V2, +`provider.commandcode.options.headers` on V1) wins over the plugin's value, and 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. Anthropic models on `/v1/messages` are already zero-data-retention at the account +level; the header is accepted there for parity and does not change routing. + +The programmatic SDK factory (`createCommandCode`) applies the same `CMD_ZDR` opt-in, and an explicit `headers` value +always wins over it. + +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..f36f949 --- /dev/null +++ b/docs/specs/2026-10-14-zdr-request-header.md @@ -0,0 +1,83 @@ +# 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`). + +The config flag adds a durable per-machine option; the env var keeps parity with the CLI. No CLI installation is +required for either. + +### Header injection + +The header value is always `1` and the key is always `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`): `CMD_ZDR` fills in the ZDR header when the caller's `headers` do not + define it. + +### 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. 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 header merging. +- `tests/unit/plugin.test.ts` covers V1 injection, user-header precedence, the off state, and the custom-npm skip. +- `tests/unit/plugin-v2.test.ts` covers the V2 add and update paths. +- `tests/unit/index.test.ts` covers `CMD_ZDR` in the SDK factory and the explicit-header win. +- `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. 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..bcbfa5c 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ import { resolveApiKey } from "./src/auth.js"; import { CommandCodeLanguageModel } from "./src/model.js"; +import { withZdrHeaders, 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[]; } +/** Caller headers win; `CMD_ZDR=1` only fills the ZDR header when it is absent. */ +function resolveHeaders(options: CommandCodeProviderOptions): Record | undefined { + const provided = + typeof options.headers === "object" && options.headers !== null + ? (options.headers as Record) + : undefined; + return withZdrHeaders(provided, zdrFromEnvValue(process.env.CMD_ZDR)); +} + export function createCommandCode(options: CommandCodeProviderOptions = {}) { const apiKey = resolveApiKey({ apiKey: options.apiKey, authPaths: options.authPaths }); if (!apiKey) { @@ -21,15 +31,14 @@ export function createCommandCode(options: CommandCodeProviderOptions = {}) { ); } + const headers = resolveHeaders(options); + return { languageModel(modelId: string): CommandCodeLanguageModel { 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..96ed2cf 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -132,4 +132,5 @@ export const PluginFileConfigSchema = z.object({ disableModelSync: z.boolean().optional().default(false), commandCodePackagePath: z.string().optional().default(""), debugStartupLogs: z.boolean().optional().default(false), + zdr: z.boolean().optional().default(false), }); diff --git a/src/zdr.ts b/src/zdr.ts new file mode 100644 index 0000000..837819d --- /dev/null +++ b/src/zdr.ts @@ -0,0 +1,45 @@ +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; +} + +/** + * Add the ZDR header without discarding headers already configured. An explicit + * user value (`headers["x-cmd-zdr"] = "0"`) wins over the plugin's opt-in. + */ +export function withZdrHeaders( + existing: unknown, + enabled: boolean, +): Record | undefined { + const current = + typeof existing === "object" && existing !== null + ? (existing as Record) + : undefined; + const added = zdrHeaders(enabled); + if (!added) return current; + return { ...added, ...current }; +} diff --git a/tests/integration/zdr.integration.test.ts b/tests/integration/zdr.integration.test.ts new file mode 100644 index 0000000..3e87f13 --- /dev/null +++ b/tests/integration/zdr.integration.test.ts @@ -0,0 +1,220 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { createCommandCode } from "@/index.js"; +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. + * + * 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; +/** 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(): Promise { + const response = await fetch(`${PROVIDER_BASE_URL}/models`, { + headers: { Authorization: `Bearer ${API_KEY}` }, + }); + 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) { + 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, + }), + }); + 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. */ +async function discoverZdrOutcomes(ids: string[]): Promise { + const probes: Probe[] = []; + const capable: Probe[] = []; + const denied: Probe[] = []; + let started = 0; + + const header = withZdrHeaders(undefined, true) as Record; + + const worker = async () => { + while (capable.length === 0 || denied.length === 0) { + const index = started++; + if (index >= ids.length || started > MAX_PROBES) return; + const id = ids[index]!; + const result = await postChat(id, header); + 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 () => { + chatIds = await listChatModelIds(); + discovery = await discoverZdrOutcomes(chatIds); + 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, {}); + 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, + ); + + test( + "CMD_ZDR=1 reaches a live SDK request", + async () => { + const sample = discovery?.capable[0]; + if (!sample) throw new Error("no ZDR-capable sample to drive the SDK path"); + + const previous = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const provider = createCommandCode({ apiKey: API_KEY! }); + const model = provider.languageModel(sample.id); + // The SDK path targets the CLI-compatible endpoint, so this asserts the + // opt-in survives a real request rather than re-checking upstream routing. + // Reasoning models may answer with reasoning-only content at a small budget. + const result = await model.doGenerate({ + prompt: [{ role: "user", content: "Reply with exactly the word: hello" }], + maxOutputTokens: 64, + }); + + expect(result.content.length).toBeGreaterThan(0); + expect(result.finishReason.unified).toMatch(/stop|length/); + } finally { + if (previous === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = previous; + } + }, + NETWORK_TIMEOUT_MS, + ); +}); + +test("integration tests are skipped when COMMANDCODE_API_KEY is not set", () => { + if (!hasApiKey) { + console.log("Skipping ZDR integration tests: COMMANDCODE_API_KEY not set"); + } +}); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 899869c..1e3305d 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,5 +1,6 @@ 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(() => { @@ -67,3 +68,58 @@ test("each languageModel call returns a new instance", () => { expect(m1.modelId).toBe("a"); expect(m2.modelId).toBe("b"); }); + +test("CMD_ZDR=1 adds the ZDR header to SDK requests", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + const { calls, restore, respondWith } = mockFetchTrack(); + respondWith({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }); + try { + const provider = createCommandCode({ apiKey: "sk-test" }); + await provider.languageModel("m").doStream(makeCallOptions()); + const headers = calls[0].options.headers as Record; + expect(headers["x-cmd-zdr"]).toBe("1"); + } finally { + restore(); + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + +test("an explicit header wins over the CMD_ZDR opt-in", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + const { calls, restore, respondWith } = mockFetchTrack(); + respondWith({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }); + 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(); + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); diff --git a/tests/unit/plugin-v2.test.ts b/tests/unit/plugin-v2.test.ts index dfc7b04..47b28ba 100644 --- a/tests/unit/plugin-v2.test.ts +++ b/tests/unit/plugin-v2.test.ts @@ -136,3 +136,82 @@ 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"); + }); +}); diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 5c0b009..018ebf4 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -223,6 +223,83 @@ 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("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..c7acd0f 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,12 +252,14 @@ 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, }); }); @@ -268,6 +271,7 @@ describe("PluginFileConfigSchema", () => { disableModelSync: false, commandCodePackagePath: "", debugStartupLogs: false, + zdr: false, }); }); }); diff --git a/tests/unit/zdr.test.ts b/tests/unit/zdr.test.ts new file mode 100644 index 0000000..d4560fe --- /dev/null +++ b/tests/unit/zdr.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { + ZDR_HEADER, + ZDR_HEADER_VALUE, + 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" }); +});