Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
104 changes: 104 additions & 0 deletions docs/specs/2026-10-14-zdr-request-header.md
Original file line number Diff line number Diff line change
@@ -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.<id>.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`.
29 changes: 25 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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 (<dir>/index.ts)
// loads the dual V1+V2 plugin. No cycle: plugin.ts only imports from src/.
Expand All @@ -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<string, string> | 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) {
Expand All @@ -21,15 +31,26 @@ export function createCommandCode(options: CommandCodeProviderOptions = {}) {
);
}

const headers =
typeof options.headers === "object" && options.headers !== null
? (options.headers as Record<string, string>)
: 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<string, string>)
: undefined,
headers,
});
},
};
Expand Down
10 changes: 10 additions & 0 deletions plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -238,18 +239,22 @@ 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";
if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"];
// 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<string, unknown>;
if (!options.baseURL) options.baseURL = PROVIDER_API_BASE_URL;
if (zdr) options.headers = withZdrHeaders(options.headers, true);
}
}

Expand Down Expand Up @@ -293,6 +298,7 @@ export async function server() {
async function setup(ctx: any): Promise<void> {
const pluginCfg = loadPluginConfig();
const debug = pluginCfg.debugStartupLogs === true;
const zdr = zdrEnabled(pluginCfg);
const load = loadCatalogEntries();
persistCatalogSideEffects(load, debug);
const v2models = generateV2Models(load.models);
Expand All @@ -308,6 +314,9 @@ async function setup(ctx: any): Promise<void> {
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,
},
Expand All @@ -321,6 +330,7 @@ async function setup(ctx: any): Promise<void> {
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;
Expand Down
11 changes: 7 additions & 4 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Loading
Loading