|
| 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`. |
0 commit comments