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/agents/model-auth.ts b/src/agents/model-auth.ts
index 2f36654ba..ef6c7f383 100644
--- a/src/agents/model-auth.ts
+++ b/src/agents/model-auth.ts
@@ -139,6 +139,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
minimax: "MINIMAX_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
+ "opencode-zen": "OPENCODE_ZEN_API_KEY",
};
const envVar = envMap[provider];
if (!envVar) return null;
diff --git a/src/agents/opencode-zen-models.test.ts b/src/agents/opencode-zen-models.test.ts
new file mode 100644
index 000000000..e176bd309
--- /dev/null
+++ b/src/agents/opencode-zen-models.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getOpencodeZenStaticFallbackModels,
+ OPENCODE_ZEN_MODEL_ALIASES,
+ resolveOpencodeZenAlias,
+ resolveOpencodeZenModelApi,
+} from "./opencode-zen-models.js";
+
+describe("resolveOpencodeZenAlias", () => {
+ it("resolves opus alias", () => {
+ expect(resolveOpencodeZenAlias("opus")).toBe("claude-opus-4-5");
+ });
+
+ it("resolves gpt5 alias", () => {
+ expect(resolveOpencodeZenAlias("gpt5")).toBe("gpt-5.2");
+ });
+
+ it("resolves gemini alias", () => {
+ expect(resolveOpencodeZenAlias("gemini")).toBe("gemini-3-pro");
+ });
+
+ it("returns input if no alias exists", () => {
+ expect(resolveOpencodeZenAlias("some-unknown-model")).toBe(
+ "some-unknown-model",
+ );
+ });
+
+ it("is case-insensitive", () => {
+ expect(resolveOpencodeZenAlias("OPUS")).toBe("claude-opus-4-5");
+ expect(resolveOpencodeZenAlias("Gpt5")).toBe("gpt-5.2");
+ });
+});
+
+describe("resolveOpencodeZenModelApi", () => {
+ it("returns openai-completions for all models (OpenCode Zen is OpenAI-compatible)", () => {
+ expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe(
+ "openai-completions",
+ );
+ expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-completions");
+ expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe(
+ "openai-completions",
+ );
+ expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe(
+ "openai-completions",
+ );
+ });
+});
+
+describe("getOpencodeZenStaticFallbackModels", () => {
+ it("returns an array of models", () => {
+ const models = getOpencodeZenStaticFallbackModels();
+ expect(Array.isArray(models)).toBe(true);
+ expect(models.length).toBeGreaterThan(0);
+ });
+
+ it("includes Claude, GPT, and Gemini models", () => {
+ const models = getOpencodeZenStaticFallbackModels();
+ const ids = models.map((m) => m.id);
+
+ expect(ids).toContain("claude-opus-4-5");
+ expect(ids).toContain("gpt-5.2");
+ expect(ids).toContain("gemini-3-pro");
+ });
+
+ it("returns valid ModelDefinitionConfig objects", () => {
+ const models = getOpencodeZenStaticFallbackModels();
+ for (const model of models) {
+ expect(model.id).toBeDefined();
+ expect(model.name).toBeDefined();
+ expect(typeof model.reasoning).toBe("boolean");
+ expect(Array.isArray(model.input)).toBe(true);
+ expect(model.cost).toBeDefined();
+ expect(typeof model.contextWindow).toBe("number");
+ expect(typeof model.maxTokens).toBe("number");
+ }
+ });
+});
+
+describe("OPENCODE_ZEN_MODEL_ALIASES", () => {
+ it("has expected aliases", () => {
+ expect(OPENCODE_ZEN_MODEL_ALIASES.opus).toBe("claude-opus-4-5");
+ expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-sonnet-4-20250514");
+ expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
+ expect(OPENCODE_ZEN_MODEL_ALIASES.o1).toBe("o1-2025-04-16");
+ expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
+ });
+});
diff --git a/src/agents/opencode-zen-models.ts b/src/agents/opencode-zen-models.ts
new file mode 100644
index 000000000..d00ddfd1b
--- /dev/null
+++ b/src/agents/opencode-zen-models.ts
@@ -0,0 +1,285 @@
+/**
+ * OpenCode Zen model catalog with dynamic fetching, caching, and static fallback.
+ *
+ * OpenCode Zen is a $200/month subscription that provides proxy access to multiple
+ * AI models (Claude, GPT, Gemini, etc.) through a single API endpoint.
+ *
+ * API endpoint: https://opencode.ai/zen/v1
+ * Auth URL: https://opencode.ai/auth
+ */
+
+import type { ModelApi, ModelDefinitionConfig } from "../config/types.js";
+
+export const OPENCODE_ZEN_API_BASE_URL = "https://opencode.ai/zen/v1";
+export const OPENCODE_ZEN_DEFAULT_MODEL = "claude-opus-4-5";
+export const OPENCODE_ZEN_DEFAULT_MODEL_REF = `opencode-zen/${OPENCODE_ZEN_DEFAULT_MODEL}`;
+
+// Cache for fetched models (1 hour TTL)
+let cachedModels: ModelDefinitionConfig[] | null = null;
+let cacheTimestamp = 0;
+const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
+
+/**
+ * Model aliases for convenient shortcuts.
+ * Users can use "opus" instead of "claude-opus-4-5", etc.
+ */
+export const OPENCODE_ZEN_MODEL_ALIASES: Record = {
+ // Claude aliases
+ opus: "claude-opus-4-5",
+ "opus-4.5": "claude-opus-4-5",
+ "opus-4": "claude-opus-4-5",
+ sonnet: "claude-sonnet-4-20250514",
+ "sonnet-4": "claude-sonnet-4-20250514",
+ haiku: "claude-haiku-3-5-20241022",
+ "haiku-3.5": "claude-haiku-3-5-20241022",
+
+ // GPT aliases
+ gpt5: "gpt-5.2",
+ "gpt-5": "gpt-5.2",
+ gpt4: "gpt-4.1",
+ "gpt-4": "gpt-4.1",
+ "gpt-mini": "gpt-4.1-mini",
+
+ // O-series aliases
+ o1: "o1-2025-04-16",
+ o3: "o3-2025-04-16",
+ "o3-mini": "o3-mini-2025-04-16",
+
+ // Gemini aliases
+ gemini: "gemini-3-pro",
+ "gemini-pro": "gemini-3-pro",
+ "gemini-3": "gemini-3-pro",
+ "gemini-2.5": "gemini-2.5-pro",
+};
+
+/**
+ * Resolve a model alias to its full model ID.
+ * Returns the input if no alias exists.
+ */
+export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
+ const normalized = modelIdOrAlias.toLowerCase().trim();
+ return OPENCODE_ZEN_MODEL_ALIASES[normalized] ?? modelIdOrAlias;
+}
+
+/**
+ * OpenCode Zen is an OpenAI-compatible proxy for all models.
+ * All requests go through /chat/completions regardless of the underlying model.
+ */
+export function resolveOpencodeZenModelApi(_modelId: string): ModelApi {
+ return "openai-completions";
+}
+
+/**
+ * Check if a model is a reasoning model (extended thinking).
+ */
+function isReasoningModel(modelId: string): boolean {
+ const lower = modelId.toLowerCase();
+ return (
+ lower.includes("opus") ||
+ lower.startsWith("o1-") ||
+ lower.startsWith("o3-") ||
+ lower.startsWith("o4-") ||
+ lower.includes("-thinking")
+ );
+}
+
+/**
+ * Check if a model supports image input.
+ */
+function supportsImageInput(modelId: string): boolean {
+ const lower = modelId.toLowerCase();
+ // Most modern models support images, except some reasoning-only models
+ if (lower.startsWith("o1-") || lower.startsWith("o3-")) {
+ return false;
+ }
+ return true;
+}
+
+// Default cost structure (per million tokens, in USD cents)
+// These are approximate; actual costs depend on OpenCode Zen pricing
+const DEFAULT_COST = {
+ input: 0, // Included in subscription
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+};
+
+// Default context windows by model family
+function getDefaultContextWindow(modelId: string): number {
+ const lower = modelId.toLowerCase();
+ if (lower.includes("opus")) return 200000;
+ if (lower.includes("sonnet")) return 200000;
+ if (lower.includes("haiku")) return 200000;
+ if (lower.includes("gpt-5")) return 256000;
+ if (lower.includes("gpt-4")) return 128000;
+ if (lower.startsWith("o1-") || lower.startsWith("o3-")) return 200000;
+ if (lower.includes("gemini-3")) return 1000000;
+ if (lower.includes("gemini-2.5")) return 1000000;
+ if (lower.includes("gemini")) return 128000;
+ return 128000; // Conservative default
+}
+
+function getDefaultMaxTokens(modelId: string): number {
+ const lower = modelId.toLowerCase();
+ if (lower.includes("opus")) return 32000;
+ if (lower.includes("sonnet")) return 16000;
+ if (lower.includes("haiku")) return 8192;
+ if (lower.startsWith("o1-") || lower.startsWith("o3-")) return 100000;
+ if (lower.includes("gpt")) return 16384;
+ if (lower.includes("gemini")) return 8192;
+ return 8192;
+}
+
+/**
+ * Build a ModelDefinitionConfig from a model ID.
+ */
+function buildModelDefinition(modelId: string): ModelDefinitionConfig {
+ return {
+ id: modelId,
+ name: formatModelName(modelId),
+ api: resolveOpencodeZenModelApi(modelId),
+ reasoning: isReasoningModel(modelId),
+ input: supportsImageInput(modelId) ? ["text", "image"] : ["text"],
+ cost: DEFAULT_COST,
+ contextWindow: getDefaultContextWindow(modelId),
+ maxTokens: getDefaultMaxTokens(modelId),
+ };
+}
+
+/**
+ * Format a model ID into a human-readable name.
+ */
+function formatModelName(modelId: string): string {
+ // Handle common patterns
+ const replacements: Record = {
+ "claude-opus-4-5": "Claude Opus 4.5",
+ "claude-sonnet-4-20250514": "Claude Sonnet 4",
+ "claude-haiku-3-5-20241022": "Claude Haiku 3.5",
+ "gpt-5.2": "GPT-5.2",
+ "gpt-4.1": "GPT-4.1",
+ "gpt-4.1-mini": "GPT-4.1 Mini",
+ "o1-2025-04-16": "O1",
+ "o3-2025-04-16": "O3",
+ "o3-mini-2025-04-16": "O3 Mini",
+ "gemini-3-pro": "Gemini 3 Pro",
+ "gemini-2.5-pro": "Gemini 2.5 Pro",
+ "gemini-2.5-flash": "Gemini 2.5 Flash",
+ };
+
+ if (replacements[modelId]) {
+ return replacements[modelId];
+ }
+
+ // Generic formatting: capitalize and replace dashes
+ return modelId
+ .split("-")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+/**
+ * Static fallback models when API is unreachable.
+ * These are the most commonly used models.
+ */
+export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
+ const modelIds = [
+ // Claude models
+ "claude-opus-4-5",
+ "claude-sonnet-4-20250514",
+ "claude-haiku-3-5-20241022",
+
+ // GPT models
+ "gpt-5.2",
+ "gpt-4.1",
+ "gpt-4.1-mini",
+
+ // O-series reasoning models
+ "o1-2025-04-16",
+ "o3-2025-04-16",
+ "o3-mini-2025-04-16",
+
+ // Gemini models
+ "gemini-3-pro",
+ "gemini-2.5-pro",
+ "gemini-2.5-flash",
+ ];
+
+ return modelIds.map(buildModelDefinition);
+}
+
+/**
+ * Response shape from OpenCode Zen /models endpoint.
+ * Returns OpenAI-compatible format.
+ */
+interface ZenModelsResponse {
+ data: Array<{
+ id: string;
+ object: "model";
+ created?: number;
+ owned_by?: string;
+ }>;
+}
+
+/**
+ * Fetch models from the OpenCode Zen API.
+ * Uses caching with 1-hour TTL.
+ *
+ * @param apiKey - OpenCode Zen API key for authentication
+ * @returns Array of model definitions, or static fallback on failure
+ */
+export async function fetchOpencodeZenModels(
+ apiKey?: string,
+): Promise {
+ // Return cached models if still valid
+ const now = Date.now();
+ if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) {
+ return cachedModels;
+ }
+
+ try {
+ const headers: Record = {
+ Accept: "application/json",
+ };
+ if (apiKey) {
+ headers.Authorization = `Bearer ${apiKey}`;
+ }
+
+ const response = await fetch(`${OPENCODE_ZEN_API_BASE_URL}/models`, {
+ method: "GET",
+ headers,
+ signal: AbortSignal.timeout(10000), // 10 second timeout
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `API returned ${response.status}: ${response.statusText}`,
+ );
+ }
+
+ const data = (await response.json()) as ZenModelsResponse;
+
+ if (!data.data || !Array.isArray(data.data)) {
+ throw new Error("Invalid response format from /models endpoint");
+ }
+
+ const models = data.data.map((model) => buildModelDefinition(model.id));
+
+ cachedModels = models;
+ cacheTimestamp = now;
+
+ return models;
+ } catch (error) {
+ console.warn(
+ `[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`,
+ );
+ return getOpencodeZenStaticFallbackModels();
+ }
+}
+
+/**
+ * Clear the model cache (useful for testing or forcing refresh).
+ */
+export function clearOpencodeZenModelCache(): void {
+ cachedModels = null;
+ cacheTimestamp = 0;
+}
diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts
index a95d5f38d..0a23ae9d4 100644
--- a/src/agents/pi-embedded-runner.ts
+++ b/src/agents/pi-embedded-runner.ts
@@ -109,7 +109,6 @@ import {
// Optional features can be implemented as Pi extensions that run in the same Node process.
-
/**
* Resolve provider-specific extraParams from model config.
* Auto-enables thinking mode for GLM-4.x models unless explicitly disabled.
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-options.ts b/src/commands/auth-choice-options.ts
index 23db01adb..c154ef4a4 100644
--- a/src/commands/auth-choice-options.ts
+++ b/src/commands/auth-choice-options.ts
@@ -99,6 +99,11 @@ export function buildAuthChoiceOptions(params: {
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers.
+ options.push({
+ value: "opencode-zen",
+ label: "OpenCode Zen (multi-model proxy)",
+ hint: "Claude, GPT, Gemini via opencode.ai/zen",
+ });
options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
options.push({
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 35bfd5de2..5f292bdd6 100644
--- a/src/commands/auth-choice.ts
+++ b/src/commands/auth-choice.ts
@@ -42,10 +42,13 @@ import {
applyMinimaxHostedConfig,
applyMinimaxHostedProviderConfig,
applyMinimaxProviderConfig,
+ applyOpencodeZenConfig,
+ applyOpencodeZenProviderConfig,
MINIMAX_HOSTED_MODEL_REF,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
+ setOpencodeZenApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
@@ -54,6 +57,7 @@ import {
applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL,
} from "./openai-codex-model-default.js";
+import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
export async function warnIfModelConfigLooksOff(
config: ClawdbotConfig,
@@ -649,6 +653,36 @@ export async function applyAuthChoice(params: {
agentModelOverride = "minimax/MiniMax-M2.1";
await noteAgentModel("minimax/MiniMax-M2.1");
}
+ } else if (params.authChoice === "opencode-zen") {
+ await params.prompter.note(
+ [
+ "OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
+ "Get your API key at: https://opencode.ai/auth",
+ "Requires an active OpenCode Zen subscription.",
+ ].join("\n"),
+ "OpenCode Zen",
+ );
+ const key = await params.prompter.text({
+ message: "Enter OpenCode Zen API key",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ await setOpencodeZenApiKey(String(key).trim(), params.agentDir);
+ nextConfig = applyAuthProfileConfig(nextConfig, {
+ profileId: "opencode-zen:default",
+ provider: "opencode-zen",
+ mode: "api_key",
+ });
+ if (params.setDefaultModel) {
+ nextConfig = applyOpencodeZenConfig(nextConfig);
+ await params.prompter.note(
+ `Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`,
+ "Model configured",
+ );
+ } else {
+ nextConfig = applyOpencodeZenProviderConfig(nextConfig);
+ agentModelOverride = OPENCODE_ZEN_DEFAULT_MODEL;
+ await noteAgentModel(OPENCODE_ZEN_DEFAULT_MODEL);
+ }
}
return { config: nextConfig, agentModelOverride };
diff --git a/src/commands/configure.ts b/src/commands/configure.ts
index 4c5660d17..8ab618a62 100644
--- a/src/commands/configure.ts
+++ b/src/commands/configure.ts
@@ -71,9 +71,11 @@ import {
applyAuthProfileConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
+ applyOpencodeZenConfig,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
+ setOpencodeZenApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import {
@@ -95,6 +97,7 @@ import {
applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL,
} from "./openai-codex-model-default.js";
+import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
export const CONFIGURE_WIZARD_SECTIONS = [
@@ -366,6 +369,7 @@ async function promptAuthConfig(
| "apiKey"
| "minimax-cloud"
| "minimax"
+ | "opencode-zen"
| "skip";
let next = cfg;
@@ -783,6 +787,32 @@ async function promptAuthConfig(
next = applyMinimaxHostedConfig(next);
} else if (authChoice === "minimax") {
next = applyMinimaxConfig(next);
+ } else if (authChoice === "opencode-zen") {
+ note(
+ [
+ "OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
+ "Get your API key at: https://opencode.ai/auth",
+ ].join("\n"),
+ "OpenCode Zen",
+ );
+ const key = guardCancel(
+ await text({
+ message: "Enter OpenCode Zen API key",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ runtime,
+ );
+ await setOpencodeZenApiKey(String(key).trim());
+ next = applyAuthProfileConfig(next, {
+ profileId: "opencode-zen:default",
+ provider: "opencode-zen",
+ mode: "api_key",
+ });
+ next = applyOpencodeZenConfig(next);
+ note(
+ `Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`,
+ "Model configured",
+ );
}
const currentModel =
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 2945a25c4..303512c5a 100644
--- a/src/commands/onboard-auth.ts
+++ b/src/commands/onboard-auth.ts
@@ -1,6 +1,11 @@
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
+import {
+ getOpencodeZenStaticFallbackModels,
+ OPENCODE_ZEN_API_BASE_URL,
+ OPENCODE_ZEN_DEFAULT_MODEL_REF,
+} from "../agents/opencode-zen-models.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelDefinitionConfig } from "../config/types.js";
@@ -381,3 +386,78 @@ export function applyMinimaxApiConfig(
},
};
}
+
+export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
+ upsertAuthProfile({
+ profileId: "opencode-zen:default",
+ credential: {
+ type: "api_key",
+ provider: "opencode-zen",
+ key,
+ },
+ agentDir: agentDir ?? resolveDefaultAgentDir(),
+ });
+}
+
+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: 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: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus",
+ };
+
+ return {
+ ...cfg,
+ agents: {
+ ...cfg.agents,
+ defaults: {
+ ...cfg.agents?.defaults,
+ models,
+ },
+ },
+ models: {
+ mode: cfg.models?.mode ?? "merge",
+ providers,
+ },
+ };
+}
+
+export function applyOpencodeZenConfig(cfg: ClawdbotConfig): ClawdbotConfig {
+ const next = applyOpencodeZenProviderConfig(cfg);
+ return {
+ ...next,
+ agents: {
+ ...next.agents,
+ defaults: {
+ ...next.agents?.defaults,
+ model: {
+ ...(next.agents?.defaults?.model &&
+ "fallbacks" in (next.agents.defaults.model as Record)
+ ? {
+ fallbacks: (
+ next.agents.defaults.model as { fallbacks?: string[] }
+ ).fallbacks,
+ }
+ : undefined),
+ primary: OPENCODE_ZEN_DEFAULT_MODEL_REF,
+ },
+ },
+ },
+ };
+}
diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts
index b32e24262..d0e930585 100644
--- a/src/commands/onboard-non-interactive.ts
+++ b/src/commands/onboard-non-interactive.ts
@@ -35,9 +35,11 @@ import {
applyMinimaxApiConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
+ applyOpencodeZenConfig,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
+ setOpencodeZenApiKey,
} from "./onboard-auth.js";
import {
applyWizardMetadata,
@@ -312,6 +314,25 @@ export async function runNonInteractiveOnboarding(
nextConfig = applyOpenAICodexModelDefault(nextConfig).next;
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
+ } else if (authChoice === "opencode-zen") {
+ const resolved = await resolveNonInteractiveApiKey({
+ provider: "opencode-zen",
+ cfg: baseConfig,
+ flagValue: opts.opencodeZenApiKey,
+ flagName: "--opencode-zen-api-key",
+ envVar: "OPENCODE_ZEN_API_KEY",
+ runtime,
+ });
+ if (!resolved) return;
+ if (resolved.source !== "profile") {
+ await setOpencodeZenApiKey(resolved.key);
+ }
+ nextConfig = applyAuthProfileConfig(nextConfig, {
+ profileId: "opencode-zen:default",
+ provider: "opencode-zen",
+ mode: "api_key",
+ });
+ nextConfig = applyOpencodeZenConfig(nextConfig);
} else if (
authChoice === "token" ||
authChoice === "oauth" ||
diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts
index ca741c156..09375bcb7 100644
--- a/src/commands/onboard-types.ts
+++ b/src/commands/onboard-types.ts
@@ -17,6 +17,7 @@ export type AuthChoice =
| "minimax-cloud"
| "minimax"
| "minimax-api"
+ | "opencode-zen"
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
@@ -43,6 +44,7 @@ export type OnboardOptions = {
openaiApiKey?: string;
geminiApiKey?: string;
minimaxApiKey?: string;
+ opencodeZenApiKey?: string;
gatewayPort?: number;
gatewayBind?: GatewayBind;
gatewayAuth?: GatewayAuthChoice;
diff --git a/src/commands/opencode-zen-model-default.test.ts b/src/commands/opencode-zen-model-default.test.ts
new file mode 100644
index 000000000..da6db0ac4
--- /dev/null
+++ b/src/commands/opencode-zen-model-default.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+
+import type { ClawdbotConfig } from "../config/config.js";
+import {
+ applyOpencodeZenModelDefault,
+ OPENCODE_ZEN_DEFAULT_MODEL,
+} from "./opencode-zen-model-default.js";
+
+describe("applyOpencodeZenModelDefault", () => {
+ it("sets opencode-zen default when model is unset", () => {
+ const cfg: ClawdbotConfig = { agents: { defaults: {} } };
+ const applied = applyOpencodeZenModelDefault(cfg);
+ expect(applied.changed).toBe(true);
+ expect(applied.next.agents?.defaults?.model).toEqual({
+ primary: OPENCODE_ZEN_DEFAULT_MODEL,
+ });
+ });
+
+ it("overrides existing model", () => {
+ const cfg = {
+ agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
+ } as ClawdbotConfig;
+ const applied = applyOpencodeZenModelDefault(cfg);
+ expect(applied.changed).toBe(true);
+ expect(applied.next.agents?.defaults?.model).toEqual({
+ primary: OPENCODE_ZEN_DEFAULT_MODEL,
+ });
+ });
+
+ it("no-ops when already opencode-zen default", () => {
+ const cfg = {
+ agents: { defaults: { model: OPENCODE_ZEN_DEFAULT_MODEL } },
+ } as ClawdbotConfig;
+ const applied = applyOpencodeZenModelDefault(cfg);
+ expect(applied.changed).toBe(false);
+ expect(applied.next).toEqual(cfg);
+ });
+
+ it("preserves fallbacks when setting primary", () => {
+ const cfg: ClawdbotConfig = {
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-5",
+ fallbacks: ["google/gemini-3-pro"],
+ },
+ },
+ },
+ };
+ const applied = applyOpencodeZenModelDefault(cfg);
+ expect(applied.changed).toBe(true);
+ expect(applied.next.agents?.defaults?.model).toEqual({
+ primary: OPENCODE_ZEN_DEFAULT_MODEL,
+ fallbacks: ["google/gemini-3-pro"],
+ });
+ });
+});
diff --git a/src/commands/opencode-zen-model-default.ts b/src/commands/opencode-zen-model-default.ts
new file mode 100644
index 000000000..c34d2f425
--- /dev/null
+++ b/src/commands/opencode-zen-model-default.ts
@@ -0,0 +1,45 @@
+import type { ClawdbotConfig } from "../config/config.js";
+import type { AgentModelListConfig } from "../config/types.js";
+
+export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode-zen/claude-opus-4-5";
+
+function resolvePrimaryModel(
+ model?: AgentModelListConfig | string,
+): string | undefined {
+ if (typeof model === "string") return model;
+ if (model && typeof model === "object" && typeof model.primary === "string") {
+ return model.primary;
+ }
+ return undefined;
+}
+
+export function applyOpencodeZenModelDefault(cfg: ClawdbotConfig): {
+ next: ClawdbotConfig;
+ changed: boolean;
+} {
+ const current = resolvePrimaryModel(cfg.agents?.defaults?.model)?.trim();
+ if (current === OPENCODE_ZEN_DEFAULT_MODEL) {
+ return { next: cfg, changed: false };
+ }
+
+ return {
+ next: {
+ ...cfg,
+ agents: {
+ ...cfg.agents,
+ defaults: {
+ ...cfg.agents?.defaults,
+ model:
+ cfg.agents?.defaults?.model &&
+ typeof cfg.agents.defaults.model === "object"
+ ? {
+ ...cfg.agents.defaults.model,
+ primary: OPENCODE_ZEN_DEFAULT_MODEL,
+ }
+ : { primary: OPENCODE_ZEN_DEFAULT_MODEL },
+ },
+ },
+ },
+ changed: true,
+ };
+}