Skip to content
Open
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
83 changes: 83 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,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.<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 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`.
17 changes: 13 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 { withZdrHeaders, 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[];
}

/** Caller headers win; `CMD_ZDR=1` only fills the ZDR header when it is absent. */
function resolveHeaders(options: CommandCodeProviderOptions): Record<string, string> | undefined {
const provided =
typeof options.headers === "object" && options.headers !== null
? (options.headers as Record<string, string>)
: 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) {
Expand All @@ -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<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
1 change: 1 addition & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
45 changes: 45 additions & 0 deletions src/zdr.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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<string, string> | undefined {
const current =
typeof existing === "object" && existing !== null
? (existing as Record<string, string>)
: undefined;
const added = zdrHeaders(enabled);
if (!added) return current;
return { ...added, ...current };
}
Loading