Skip to content

Commit 57c8b29

Browse files
authored
feat(zdr): support zero data retention via CMD_ZDR and provider headers (#194)
* feat(zdr): add x-cmd-zdr opt-in via CMD_ZDR and config Add a zero-data-retention opt-in that sends `x-cmd-zdr: 1` on every Command Code request. Enable it with env `CMD_ZDR=1` (1/true/yes/on) or `zdr: true` in the plugin config file. V1 merges the header into provider options.headers, V2 registers it on the commandcode provider, and createCommandCode honors CMD_ZDR. Explicit user headers always win, and custom npm transports are left untouched. * fix(zdr): harden header casing, config, and SDK Match x-cmd-zdr case-insensitively and collapse every spelling into the lowercase key, so a user X-Cmd-Zdr: 0 is kept instead of being sent as "1, 0". Fall back per field in PluginFileConfigSchema so an invalid sibling no longer discards a valid zdr opt-in; a present but invalid zdr value fails safe to on. createCommandCode calls /alpha/generate, which the ZDR docs do not cover, so languageModel() now throws when ZDR is requested instead of sending the header; an explicit x-cmd-zdr: 0 opts out of CMD_ZDR. * test(zdr): cancel live probes at the deadline Tie every live request to an AbortSignal that fires before the test timeout, so a timed-out run cancels in-flight paid probes and schedules no new ones. Drop the live SDK test (the SDK now fails closed) and the log-only skip test. * docs(zdr): document fail-closed SDK and header rules Scope ZDR to /provider/v1/*, describe case-insensitive header handling, per-field config fallback, and the SDK fail-closed behavior, and limit the Messages/Responses claim to what has been verified live. * docs(zdr): correct the SDK guard rationale OpenCode's plugin loader calls only default.server for this package, so it never invokes createCommandCode. The guard lives in languageModel() because every caller, a direct import or the create* lookup for a provider npm set to this package, resolves a model there before it can send a request.
1 parent af0b968 commit 57c8b29

13 files changed

Lines changed: 905 additions & 14 deletions

‎README.md‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi
2828
- 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.
2929
- Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout).
3030
- 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.
31+
- 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.
3132

3233
## How it works
3334

@@ -81,13 +82,45 @@ Catalog patches ship in new npm releases. OpenCode loads the version in its pack
8182

8283
## Plugin config file
8384

84-
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.
85+
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).
8586

8687
| Key | Default | Effect |
8788
|---|---|---|
8889
| `commandCodePackagePath` | `""` | Maintainer override: extract the catalog from a local `command-code` checkout instead of the bundle. Same as env `COMMANDCODE_PACKAGE_PATH`. |
8990
| `debugStartupLogs` | `false` | Also mirror the startup summary to stderr. Default is quiet (`startup.json` only). |
9091
| `disableModelSync` | `false` | Accepted for forward compatibility; currently has no effect. |
92+
| `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. |
93+
94+
## Zero data retention (ZDR)
95+
96+
Command Code enforces [zero data retention](https://commandcode.ai/docs/provider#zero-data-retention-zdr) with the
97+
`x-cmd-zdr: 1` request header; the CLI exposes the same opt-in as `CMD_ZDR=1`. This plugin never invoked the CLI, so
98+
set either:
99+
100+
- env `CMD_ZDR=1` (accepts `1`, `true`, `yes`, `on`; case-insensitive), or
101+
- `"zdr": true` in `~/.config/opencode/opencode-commandcode.json`.
102+
103+
The plugin then adds the header to every Command Code Provider API (`/provider/v1/*`) request: V2 registers it on the
104+
`commandcode` provider, V1 on the provider `options.headers`. A ZDR header you configure yourself
105+
(`providers.commandcode.headers` on V2, `provider.commandcode.options.headers` on V1) wins over the plugin's value.
106+
Header names are matched case-insensitively, and the plugin collapses any spelling (`X-Cmd-Zdr`) into a single lowercase
107+
`x-cmd-zdr` key, so a request never carries two ZDR headers. Custom npm transports keep owning their options.
108+
109+
Behavior to expect from the API: when set, requests route only through ZDR-capable upstreams, and a model without one
110+
**fails with `422 cmd_zdr_no_providers`** instead of falling back to a non-ZDR provider. ZDR can change which upstream
111+
serves a request, so it may cost more. The header is sent on the Chat Completions, Messages, and Responses routes alike;
112+
live verification so far covers Chat Completions only.
113+
114+
The programmatic SDK factory (`createCommandCode`) calls `/alpha/generate`, which the ZDR documentation does not cover,
115+
so it fails closed: with ZDR requested (`CMD_ZDR` on, or an `x-cmd-zdr: 1` header in any casing),
116+
`languageModel()` throws before any request is sent. An explicit `x-cmd-zdr: 0` header opts the SDK out of `CMD_ZDR`.
117+
118+
Live verification against the Provider API lives in `tests/integration/zdr.integration.test.ts` and is skipped unless
119+
`COMMANDCODE_API_KEY` is set:
120+
121+
```bash
122+
COMMANDCODE_API_KEY=... bun run test:integration
123+
```
91124

92125
## Optional local CLI override
93126

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Zero data retention request header
2+
3+
Status: implemented
4+
5+
## Goal
6+
7+
Command Code accounts opt into zero data retention (ZDR) with the `x-cmd-zdr: 1` request header. The CLI exposes that
8+
opt-in as `CMD_ZDR=1`; this plugin talks to the Provider API directly, so before this change nothing in an OpenCode
9+
session could request ZDR. Users on both OpenCode generations must be able to turn ZDR on with the same env var the CLI
10+
documents, without editing provider internals.
11+
12+
Reference: [Command Code Provider API — Zero data retention](https://commandcode.ai/docs/provider#zero-data-retention-zdr).
13+
14+
## Decisions
15+
16+
### Opt-in surface
17+
18+
ZDR is on when either input is true:
19+
20+
- env `CMD_ZDR` parses as `1`, `true`, `yes`, or `on` (trimmed, case-insensitive); every other value — including
21+
`"0"`, `"false"`, `""`, and an unset variable — leaves ZDR off; or
22+
- `~/.config/opencode/opencode-commandcode.json` sets `"zdr": true` (validated by `PluginFileConfigSchema`).
23+
24+
`PluginFileConfigSchema` falls back per field: an invalid sibling (`"debugStartupLogs": "false"`) resets only itself and
25+
never discards a valid `zdr: true`. `zdr` itself fails safe: a missing key is off, but a present invalid value (the
26+
string `"true"`, `1`, `null`) is on. A mistaken opt-in at worst surfaces as `422 cmd_zdr_no_providers`; a silently
27+
dropped one would send prompts to non-ZDR upstreams.
28+
29+
The config flag adds a durable per-machine option; the env var keeps parity with the CLI. No CLI installation is
30+
required for either.
31+
32+
### Scope: Provider API only
33+
34+
The ZDR documentation covers the Provider API (`/provider/v1/*`) only. Both plugin generations route every model there:
35+
the default OpenAI-compatible package, and the Anthropic (Messages) and OpenAI (Responses) packages a model may select,
36+
all inherit the provider's `/provider/v1` base URL. The SDK factory is the one surface on another endpoint.
37+
38+
### Header injection
39+
40+
The header value is always `1` and the key is always the lowercase `x-cmd-zdr`.
41+
42+
- **V1** (`plugin.ts` `server()`): the header is written into `provider.commandcode.options.headers` next to the
43+
existing default `baseURL`. OpenCode V1 merges provider `options` into every model of that provider, whichever
44+
`provider.npm` a model overrides, so one map reaches the OpenAI-compatible (Chat Completions), Anthropic (Messages),
45+
and OpenAI (Responses) routes. Custom npm transports keep owning their options, so the plugin does not add headers
46+
when the user replaced `npm`.
47+
- **V2** (`plugin.ts` `setup()`): the header is written into the registered provider's `headers` on the add path and
48+
merged into the existing provider on the update path. Provider headers apply to every model of the provider,
49+
including models that select the Anthropic or Responses runtime package.
50+
- **SDK factory** (`index.ts createCommandCode`): not injected. The factory calls `/alpha/generate`, where ZDR
51+
enforcement is unestablished, so it fails closed instead: when ZDR is requested, `languageModel()` throws before any
52+
request is sent. A caller ZDR header (any casing) decides, so `x-cmd-zdr: 1` throws and `x-cmd-zdr: 0` opts out of
53+
`CMD_ZDR`; without one, `CMD_ZDR` decides. Every caller resolves a model through `languageModel()` before it can
54+
send a request — a direct import, or OpenCode's `create*` lookup when a provider `npm` points at this package — so
55+
the guard there covers both.
56+
57+
### User override precedence
58+
59+
An existing value always wins: a user who sets `headers["x-cmd-zdr"] = "0"` keeps it, and other headers in the same map
60+
survive untouched. Header names match case-insensitively: every spelling (`X-Cmd-Zdr`, `X-CMD-ZDR`) collapses into the
61+
single lowercase key carrying the user's value (the lowercase spelling wins if several exist), because two spellings
62+
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
63+
byte-identical to the previous behavior.
64+
65+
## Out of scope
66+
67+
- Per-model ZDR selection. The API refuses ZDR-incapable models with `422 cmd_zdr_no_providers`; users who want that
68+
granularity can set `providers.commandcode.models.<id>.headers` themselves.
69+
- Any behavior change for `typesafe/jev`: the decision model is not in the bundled catalog and has no ZDR-capable
70+
upstream.
71+
72+
## How ZDR capability can be known without a request
73+
74+
Checked on 2026-10-14 against `command-code@1.82.1` and the live Provider API:
75+
76+
- `GET /provider/v1/models` carries no ZDR field. The only per-model keys are `context_length`, `created`, `id`, `name`,
77+
`object`, `owned_by`, and `supported_endpoints`.
78+
- The CLI bundle does know. `knownModelSupportsZdr()` and `modelSupportsZdr2()` consult two hardcoded denylists of
79+
models with no ZDR-capable upstream (23 IDs in 1.82.1; 22 are in the current bundled catalog). The CLI uses them to
80+
swap feature defaults so it never sends a request that would be refused.
81+
- Live agreement: probing denylisted models with `x-cmd-zdr: 1` returned `422 cmd_zdr_no_providers` (4 of 5 sampled).
82+
The fifth returned `403 MODEL_NOT_IN_PLAN`, because the entitlement check runs before ZDR routing — so a probe cannot
83+
classify models the account cannot call.
84+
- A refusal never reaches an upstream, so probing is free; an accepted request costs one generation.
85+
86+
Consequence: capability is knowable offline by extracting the denylist at sync time (the catalog job already downloads
87+
that bundle), but the list is only as fresh as the CLI release, and account entitlements can mask it. The live suite
88+
below therefore treats the API as the authority and discovers both outcomes dynamically.
89+
90+
## Verification
91+
92+
- `tests/unit/zdr.test.ts` covers env parsing, config/env precedence, and case-insensitive header merging.
93+
- `tests/unit/schemas.test.ts` covers per-field config fallback and the fail-safe invalid `zdr` value.
94+
- `tests/unit/plugin.test.ts` covers V1 injection, user-header precedence (mixed case included), the config file with an
95+
invalid sibling field or an invalid `zdr` value, the off state, and the custom-npm skip.
96+
- `tests/unit/plugin-v2.test.ts` covers the same cases on the V2 add and update paths.
97+
- `tests/unit/index.test.ts` covers the SDK fail-closed throw (`CMD_ZDR`, explicit header in any casing) with no request
98+
sent, the explicit `x-cmd-zdr: 0` opt-out, and the off state.
99+
- `tests/integration/zdr.integration.test.ts` live-checks the Provider API with no hardcoded model ID: chat-capable IDs
100+
come from `GET /provider/v1/models`, are probed with the plugin's own ZDR header map until both a `200` and a
101+
`422 cmd_zdr_no_providers` are observed, and a no-header control on a refused model proves the header causes the
102+
refusal. A `400`/`401` among the probes fails the suite. Every request carries an `AbortSignal` with a deadline inside
103+
the test timeout, so a timed-out run cancels in-flight probes and starts no new ones. It is skipped unless
104+
`COMMANDCODE_API_KEY` is set and stays out of `bun run check`.

‎index.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { resolveApiKey } from "./src/auth.js";
22
import { CommandCodeLanguageModel } from "./src/model.js";
3+
import { findZdrHeader, zdrFromEnvValue } from "./src/zdr.js";
34

45
// Re-export the plugin definition so a directory plugin entry (<dir>/index.ts)
56
// loads the dual V1+V2 plugin. No cycle: plugin.ts only imports from src/.
@@ -13,6 +14,15 @@ export interface CommandCodeProviderOptions {
1314
authPaths?: string[];
1415
}
1516

17+
/**
18+
* ZDR is documented for the Provider API (`/provider/v1/*`) only, and this SDK
19+
* calls `/alpha/generate`. A caller ZDR header (any casing) decides, so an
20+
* explicit `x-cmd-zdr: 0` opts out of `CMD_ZDR`; otherwise `CMD_ZDR` decides.
21+
*/
22+
function zdrRequested(headers: Record<string, string> | undefined): boolean {
23+
return zdrFromEnvValue(findZdrHeader(headers) ?? process.env.CMD_ZDR);
24+
}
25+
1626
export function createCommandCode(options: CommandCodeProviderOptions = {}) {
1727
const apiKey = resolveApiKey({ apiKey: options.apiKey, authPaths: options.authPaths });
1828
if (!apiKey) {
@@ -21,15 +31,26 @@ export function createCommandCode(options: CommandCodeProviderOptions = {}) {
2131
);
2232
}
2333

34+
const headers =
35+
typeof options.headers === "object" && options.headers !== null
36+
? (options.headers as Record<string, string>)
37+
: undefined;
38+
2439
return {
2540
languageModel(modelId: string): CommandCodeLanguageModel {
41+
// Fail closed before any request: sending without enforced ZDR would leak the
42+
// prompt the caller asked to keep out of retention. Every caller (direct import,
43+
// or OpenCode's `create*` lookup on a provider `npm` set to this package)
44+
// resolves a model here before it can send one.
45+
if (zdrRequested(headers)) {
46+
throw new Error(
47+
"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.",
48+
);
49+
}
2650
return new CommandCodeLanguageModel(modelId, {
2751
apiKey,
2852
baseURL: typeof options.baseURL === "string" ? options.baseURL : undefined,
29-
headers:
30-
typeof options.headers === "object" && options.headers !== null
31-
? (options.headers as Record<string, string>)
32-
: undefined,
53+
headers,
3354
});
3455
},
3556
};

‎plugin.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { CatalogManifest } from "./src/manifest.js";
1313
import { generateV2Models } from "./src/v2models.js";
1414
import { catalogPaths } from "./src/paths.js";
1515
import { ManifestSchema, ModelEntrySchema, PluginFileConfigSchema } from "./src/schemas.js";
16+
import { withZdrHeaders, zdrEnabled } from "./src/zdr.js";
1617

1718
const { models: MODELS_PATH, manifest: MANIFEST_PATH, version: VERSION_PATH } = catalogPaths();
1819

@@ -238,18 +239,22 @@ export async function server() {
238239

239240
const pluginCfg = loadPluginConfig();
240241
const debug = pluginCfg.debugStartupLogs === true;
242+
const zdr = zdrEnabled(pluginCfg);
241243

242244
if (!cc.npm) cc.npm = V1_PROVIDER_SDK_NPM;
243245
if (!cc.name) cc.name = "Command Code";
244246
if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"];
245247
// V1 resolves `npm` through BunProc.install and calls the first `create*` export, so
246248
// the SDK package must be the OpenAI-compatible one; give it the Provider API URL
247249
// unless the user already configured another baseURL (custom npm stays untouched).
250+
// V1 merges `options` into every model of the provider, whichever `provider.npm` the
251+
// model overrides, so one headers map reaches Chat, Messages, and Responses routes.
248252
if (cc.npm === V1_PROVIDER_SDK_NPM) {
249253
if (cc.options === undefined) cc.options = {};
250254
if (typeof cc.options === "object" && cc.options !== null) {
251255
const options = cc.options as Record<string, unknown>;
252256
if (!options.baseURL) options.baseURL = PROVIDER_API_BASE_URL;
257+
if (zdr) options.headers = withZdrHeaders(options.headers, true);
253258
}
254259
}
255260

@@ -293,6 +298,7 @@ export async function server() {
293298
async function setup(ctx: any): Promise<void> {
294299
const pluginCfg = loadPluginConfig();
295300
const debug = pluginCfg.debugStartupLogs === true;
301+
const zdr = zdrEnabled(pluginCfg);
296302
const load = loadCatalogEntries();
297303
persistCatalogSideEffects(load, debug);
298304
const v2models = generateV2Models(load.models);
@@ -308,6 +314,9 @@ async function setup(ctx: any): Promise<void> {
308314
integrationID: "commandcode",
309315
env: ["COMMANDCODE_API_KEY"],
310316
package: V2_PROVIDER_PACKAGE,
317+
// Provider headers reach every model of the provider, including the
318+
// Messages/Responses package a model may select instead of the default.
319+
...(zdr ? { headers: withZdrHeaders(undefined, true) } : {}),
311320
settings: {
312321
baseURL: PROVIDER_API_BASE_URL,
313322
},
@@ -321,6 +330,7 @@ async function setup(ctx: any): Promise<void> {
321330
provider.activation = provider.activation ?? "auto";
322331
provider.integrationID = provider.integrationID ?? "commandcode";
323332
provider.env = provider.env ?? ["COMMANDCODE_API_KEY"];
333+
if (zdr) provider.headers = withZdrHeaders(provider.headers, true);
324334
provider.settings = provider.settings ?? {};
325335
if (typeof provider.settings === "object") {
326336
if (!provider.settings.baseURL) provider.settings.baseURL = PROVIDER_API_BASE_URL;

‎src/schemas.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,13 @@ export const AvailabilityPayloadSchema = z
126126
/**
127127
* Plugin file config (`~/.config/opencode/opencode-commandcode.json`).
128128
* Deliberately lenient: user config files may carry extra keys, so unknown
129-
* keys are stripped and every field falls back to its default.
129+
* keys are stripped, and an invalid field falls back on its own without
130+
* discarding the valid ones. `zdr` fails safe instead: a present but invalid
131+
* value (e.g. the string "true") keeps ZDR on rather than silently disabling it.
130132
*/
131133
export const PluginFileConfigSchema = z.object({
132-
disableModelSync: z.boolean().optional().default(false),
133-
commandCodePackagePath: z.string().optional().default(""),
134-
debugStartupLogs: z.boolean().optional().default(false),
134+
disableModelSync: z.boolean().optional().default(false).catch(false),
135+
commandCodePackagePath: z.string().optional().default("").catch(""),
136+
debugStartupLogs: z.boolean().optional().default(false).catch(false),
137+
zdr: z.boolean().optional().default(false).catch(true),
135138
});

0 commit comments

Comments
 (0)