diff --git a/CHANGELOG.md b/CHANGELOG.md
index 787d6fa62..ba49677e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## Unreleased
+- Models/Auth: add OpenCode Zen (multi-model proxy) onboarding. (#623) — thanks @magimetal
- WhatsApp: refactor vCard parsing helper and improve empty contact card summaries. (#624) — thanks @steipete
- WhatsApp: include phone numbers when multiple contacts are shared. (#625) — thanks @mahmoudashraf93
- Agents: warn on small context windows (<32k) and block unusable ones (<16k). — thanks @steipete
diff --git a/docs/cli/index.md b/docs/cli/index.md
index f57050dbd..a2cbb59e3 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -177,7 +177,7 @@ Options:
- `--workspace
`
- `--non-interactive`
- `--mode `
-- `--auth-choice `
+- `--auth-choice `
- `--token-provider ` (non-interactive; used with `--auth-choice token`)
- `--token ` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id ` (non-interactive; default: `:manual`)
@@ -186,6 +186,7 @@ Options:
- `--openai-api-key `
- `--gemini-api-key `
- `--minimax-api-key `
+- `--opencode-zen-api-key `
- `--gateway-port `
- `--gateway-bind `
- `--gateway-auth `
diff --git a/docs/start/wizard.md b/docs/start/wizard.md
index 81ff7fb92..9594c6e7d 100644
--- a/docs/start/wizard.md
+++ b/docs/start/wizard.md
@@ -77,6 +77,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
+ - **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_ZEN_API_KEY` (get it at https://opencode.ai/auth).
- **API key**: stores the key for you.
- **MiniMax M2.1 (minimax.io)**: config is auto‑written for the OpenAI-compatible `/v1` endpoint.
- **MiniMax API (platform.minimax.io)**: config is auto‑written for the Anthropic-compatible `/anthropic` endpoint.
@@ -185,6 +186,17 @@ clawdbot onboard --non-interactive \
--gateway-bind loopback
```
+OpenCode Zen example:
+
+```bash
+clawdbot onboard --non-interactive \
+ --mode local \
+ --auth-choice opencode-zen \
+ --opencode-zen-api-key "$OPENCODE_ZEN_API_KEY" \
+ --gateway-port 18789 \
+ --gateway-bind loopback
+```
+
Add agent (non‑interactive) example:
```bash
diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts
index ad9fa2df1..e69655f37 100644
--- a/src/cli/program.test.ts
+++ b/src/cli/program.test.ts
@@ -133,6 +133,29 @@ describe("cli program", () => {
expect(setupCommand).not.toHaveBeenCalled();
});
+ it("passes opencode-zen api key to onboard", async () => {
+ const program = buildProgram();
+ await program.parseAsync(
+ [
+ "onboard",
+ "--non-interactive",
+ "--auth-choice",
+ "opencode-zen",
+ "--opencode-zen-api-key",
+ "sk-opencode-zen-test",
+ ],
+ { from: "user" },
+ );
+ expect(onboardCommand).toHaveBeenCalledWith(
+ expect.objectContaining({
+ nonInteractive: true,
+ authChoice: "opencode-zen",
+ opencodeZenApiKey: "sk-opencode-zen-test",
+ }),
+ runtime,
+ );
+ });
+
it("runs providers login", async () => {
const program = buildProgram();
await program.parseAsync(["providers", "login", "--account", "work"], {
diff --git a/src/cli/program.ts b/src/cli/program.ts
index cb44f396a..80ae5c7d5 100644
--- a/src/cli/program.ts
+++ b/src/cli/program.ts
@@ -245,7 +245,7 @@ export function buildProgram() {
.option("--mode ", "Wizard mode: local|remote")
.option(
"--auth-choice ",
- "Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
+ "Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax-api|minimax|opencode-zen|skip",
)
.option(
"--token-provider ",
@@ -267,6 +267,7 @@ export function buildProgram() {
.option("--openai-api-key ", "OpenAI API key")
.option("--gemini-api-key ", "Gemini API key")
.option("--minimax-api-key ", "MiniMax API key")
+ .option("--opencode-zen-api-key ", "OpenCode Zen API key")
.option("--gateway-port ", "Gateway port")
.option("--gateway-bind ", "Gateway bind: loopback|lan|tailnet|auto")
.option("--gateway-auth ", "Gateway auth: off|token|password")
@@ -314,7 +315,9 @@ export function buildProgram() {
| "gemini-api-key"
| "apiKey"
| "minimax-cloud"
+ | "minimax-api"
| "minimax"
+ | "opencode-zen"
| "skip"
| undefined,
tokenProvider: opts.tokenProvider as string | undefined,
@@ -325,6 +328,7 @@ export function buildProgram() {
openaiApiKey: opts.openaiApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined,
minimaxApiKey: opts.minimaxApiKey as string | undefined,
+ opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
gatewayPort:
typeof opts.gatewayPort === "string"
? Number.parseInt(opts.gatewayPort, 10)
diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts
index 46f68f981..f903bfc3b 100644
--- a/src/commands/auth-choice.test.ts
+++ b/src/commands/auth-choice.test.ts
@@ -97,4 +97,57 @@ describe("applyAuthChoice", () => {
};
expect(parsed.profiles?.["minimax:default"]?.key).toBe("sk-minimax-test");
});
+
+ it("does not override the default model when selecting opencode-zen without setDefaultModel", async () => {
+ tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
+ process.env.CLAWDBOT_STATE_DIR = tempStateDir;
+ process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
+ process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
+
+ const text = vi.fn().mockResolvedValue("sk-opencode-zen-test");
+ const select: WizardPrompter["select"] = vi.fn(
+ async (params) => params.options[0]?.value as never,
+ );
+ const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
+ const prompter: WizardPrompter = {
+ intro: vi.fn(noopAsync),
+ outro: vi.fn(noopAsync),
+ note: vi.fn(noopAsync),
+ select,
+ multiselect,
+ text,
+ confirm: vi.fn(async () => false),
+ progress: vi.fn(() => ({ update: noop, stop: noop })),
+ };
+ const runtime: RuntimeEnv = {
+ log: vi.fn(),
+ error: vi.fn(),
+ exit: vi.fn((code: number) => {
+ throw new Error(`exit:${code}`);
+ }),
+ };
+
+ const result = await applyAuthChoice({
+ authChoice: "opencode-zen",
+ config: {
+ agents: {
+ defaults: {
+ model: { primary: "anthropic/claude-opus-4-5" },
+ },
+ },
+ },
+ prompter,
+ runtime,
+ setDefaultModel: false,
+ });
+
+ expect(text).toHaveBeenCalledWith(
+ expect.objectContaining({ message: "Enter OpenCode Zen API key" }),
+ );
+ expect(result.config.agents?.defaults?.model?.primary).toBe(
+ "anthropic/claude-opus-4-5",
+ );
+ expect(result.config.models?.providers?.["opencode-zen"]).toBeDefined();
+ expect(result.agentModelOverride).toBe("opencode-zen/claude-opus-4-5");
+ });
});
diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts
index c0c9c5abc..5f292bdd6 100644
--- a/src/commands/auth-choice.ts
+++ b/src/commands/auth-choice.ts
@@ -43,6 +43,7 @@ import {
applyMinimaxHostedProviderConfig,
applyMinimaxProviderConfig,
applyOpencodeZenConfig,
+ applyOpencodeZenProviderConfig,
MINIMAX_HOSTED_MODEL_REF,
setAnthropicApiKey,
setGeminiApiKey,
@@ -678,7 +679,7 @@ export async function applyAuthChoice(params: {
"Model configured",
);
} else {
- nextConfig = applyOpencodeZenConfig(nextConfig);
+ nextConfig = applyOpencodeZenProviderConfig(nextConfig);
agentModelOverride = OPENCODE_ZEN_DEFAULT_MODEL;
await noteAgentModel(OPENCODE_ZEN_DEFAULT_MODEL);
}
diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts
index 5ae385e9a..d210eedbd 100644
--- a/src/commands/onboard-auth.test.ts
+++ b/src/commands/onboard-auth.test.ts
@@ -9,6 +9,8 @@ import {
applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxApiProviderConfig,
+ applyOpencodeZenConfig,
+ applyOpencodeZenProviderConfig,
writeOAuthCredentials,
} from "./onboard-auth.js";
@@ -250,3 +252,61 @@ describe("applyMinimaxApiProviderConfig", () => {
);
});
});
+
+describe("applyOpencodeZenProviderConfig", () => {
+ it("adds opencode-zen provider with correct settings", () => {
+ const cfg = applyOpencodeZenProviderConfig({});
+ expect(cfg.models?.providers?.["opencode-zen"]).toMatchObject({
+ baseUrl: "https://opencode.ai/zen/v1",
+ apiKey: "opencode-zen",
+ api: "openai-completions",
+ });
+ expect(
+ cfg.models?.providers?.["opencode-zen"]?.models.length,
+ ).toBeGreaterThan(0);
+ });
+
+ it("adds allowlist entries for fallback models", () => {
+ const cfg = applyOpencodeZenProviderConfig({});
+ const models = cfg.agents?.defaults?.models ?? {};
+ expect(Object.keys(models)).toContain("opencode-zen/claude-opus-4-5");
+ expect(Object.keys(models)).toContain("opencode-zen/gpt-5.2");
+ });
+
+ it("preserves existing alias for the default model", () => {
+ const cfg = applyOpencodeZenProviderConfig({
+ agents: {
+ defaults: {
+ models: {
+ "opencode-zen/claude-opus-4-5": { alias: "My Opus" },
+ },
+ },
+ },
+ });
+ expect(
+ cfg.agents?.defaults?.models?.["opencode-zen/claude-opus-4-5"]?.alias,
+ ).toBe("My Opus");
+ });
+});
+
+describe("applyOpencodeZenConfig", () => {
+ it("sets correct primary model", () => {
+ const cfg = applyOpencodeZenConfig({});
+ expect(cfg.agents?.defaults?.model?.primary).toBe(
+ "opencode-zen/claude-opus-4-5",
+ );
+ });
+
+ it("preserves existing model fallbacks", () => {
+ const cfg = applyOpencodeZenConfig({
+ agents: {
+ defaults: {
+ model: { fallbacks: ["anthropic/claude-opus-4-5"] },
+ },
+ },
+ });
+ expect(cfg.agents?.defaults?.model?.fallbacks).toEqual([
+ "anthropic/claude-opus-4-5",
+ ]);
+ });
+});
diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts
index c5bffaf4f..303512c5a 100644
--- a/src/commands/onboard-auth.ts
+++ b/src/commands/onboard-auth.ts
@@ -402,18 +402,24 @@ export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
export function applyOpencodeZenProviderConfig(
cfg: ClawdbotConfig,
): ClawdbotConfig {
+ const opencodeModels = getOpencodeZenStaticFallbackModels();
+
const providers = { ...cfg.models?.providers };
providers["opencode-zen"] = {
baseUrl: OPENCODE_ZEN_API_BASE_URL,
apiKey: "opencode-zen",
api: "openai-completions",
- models: getOpencodeZenStaticFallbackModels(),
+ models: opencodeModels,
};
const models = { ...cfg.agents?.defaults?.models };
+ for (const model of opencodeModels) {
+ const key = `opencode-zen/${model.id}`;
+ models[key] = models[key] ?? {};
+ }
models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = {
...models[OPENCODE_ZEN_DEFAULT_MODEL_REF],
- alias: "Opus",
+ alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus",
};
return {