From a399fa36c81a0a9f55edc57a37152122e92109e8 Mon Sep 17 00:00:00 2001 From: Magi Metal Date: Fri, 9 Jan 2026 18:12:07 -0500 Subject: [PATCH 1/4] feat(onboard): add OpenCode Zen as model provider --- src/agents/model-auth.ts | 1 + src/agents/opencode-zen-models.test.ts | 88 ++++++ src/agents/opencode-zen-models.ts | 285 ++++++++++++++++++ src/commands/auth-choice-options.ts | 11 +- src/commands/auth-choice.ts | 97 ++++-- src/commands/configure.ts | 30 ++ src/commands/onboard-auth.ts | 74 +++++ src/commands/onboard-non-interactive.ts | 21 ++ src/commands/onboard-types.ts | 2 + .../opencode-zen-model-default.test.ts | 57 ++++ src/commands/opencode-zen-model-default.ts | 45 +++ 11 files changed, 676 insertions(+), 35 deletions(-) create mode 100644 src/agents/opencode-zen-models.test.ts create mode 100644 src/agents/opencode-zen-models.ts create mode 100644 src/commands/opencode-zen-model-default.test.ts create mode 100644 src/commands/opencode-zen-model-default.ts 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/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 23db01adb..fcc6ae2e8 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -14,7 +14,7 @@ export type AuthChoiceOption = { function formatOAuthHint( expires?: number, - opts?: { allowStale?: boolean }, + opts?: { allowStale?: boolean } ): string { const rich = isRich(); if (!expires) { @@ -33,8 +33,8 @@ function formatOAuthHint( minutes >= 120 ? `${Math.round(minutes / 60)}h` : minutes >= 60 - ? "1h" - : `${Math.max(minutes, 1)}m`; + ? "1h" + : `${Math.max(minutes, 1)}m`; const label = `token ok · expires in ${duration}`; if (minutes <= 10) { return colorize(rich, theme.warn, label); @@ -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.ts b/src/commands/auth-choice.ts index 35bfd5de2..88349e7bf 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -42,10 +42,12 @@ import { applyMinimaxHostedConfig, applyMinimaxHostedProviderConfig, applyMinimaxProviderConfig, + applyOpencodeZenConfig, MINIMAX_HOSTED_MODEL_REF, setAnthropicApiKey, setGeminiApiKey, setMinimaxApiKey, + setOpencodeZenApiKey, writeOAuthCredentials, } from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; @@ -54,11 +56,12 @@ 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, prompter: WizardPrompter, - options?: { agentId?: string; agentDir?: string }, + options?: { agentId?: string; agentDir?: string } ) { const agentModelOverride = options?.agentId ? resolveAgentConfig(config, options.agentId)?.model?.trim() @@ -93,11 +96,11 @@ export async function warnIfModelConfigLooksOff( }); if (catalog.length > 0) { const known = catalog.some( - (entry) => entry.provider === ref.provider && entry.id === ref.model, + (entry) => entry.provider === ref.provider && entry.id === ref.model ); if (!known) { warnings.push( - `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, + `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.` ); } } @@ -108,7 +111,7 @@ export async function warnIfModelConfigLooksOff( const customKey = getCustomProviderApiKey(config, ref.provider); if (!hasProfile && !envKey && !customKey) { warnings.push( - `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`, + `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.` ); } @@ -116,7 +119,7 @@ export async function warnIfModelConfigLooksOff( const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; if (hasCodex) { warnings.push( - `Detected OpenAI Codex OAuth. Consider setting agents.defaults.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`, + `Detected OpenAI Codex OAuth. Consider setting agents.defaults.model to ${OPENAI_CODEX_DEFAULT_MODEL}.` ); } } @@ -142,7 +145,7 @@ export async function applyAuthChoice(params: { if (!params.agentId) return; await params.prompter.note( `Default model set to ${model} for agent "${params.agentId}".`, - "Model configured", + "Model configured" ); }; @@ -158,7 +161,7 @@ export async function applyAuthChoice(params: { 'Choose "Always Allow" so the launchd gateway can start without prompts.', 'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.', ].join("\n"), - "Claude CLI Keychain", + "Claude CLI Keychain" ); const proceed = await params.prompter.confirm({ message: "Check Keychain for Claude CLI credentials now?", @@ -189,14 +192,14 @@ export async function applyAuthChoice(params: { if (res.error) { await params.prompter.note( `Failed to run claude: ${String(res.error)}`, - "Claude setup-token", + "Claude setup-token" ); } } } else { await params.prompter.note( "`claude setup-token` requires an interactive TTY.", - "Claude setup-token", + "Claude setup-token" ); } @@ -208,7 +211,7 @@ export async function applyAuthChoice(params: { process.platform === "darwin" ? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.' : "No Claude CLI credentials found at ~/.claude/.credentials.json.", - "Claude CLI OAuth", + "Claude CLI OAuth" ); return { config: nextConfig, agentModelOverride }; } @@ -227,13 +230,13 @@ export async function applyAuthChoice(params: { "This will run `claude setup-token` to create a long-lived Anthropic token.", "Requires an interactive TTY and a Claude Pro/Max subscription.", ].join("\n"), - "Anthropic setup-token", + "Anthropic setup-token" ); if (!process.stdin.isTTY) { await params.prompter.note( "`claude setup-token` requires an interactive TTY.", - "Anthropic setup-token", + "Anthropic setup-token" ); return { config: nextConfig, agentModelOverride }; } @@ -251,14 +254,14 @@ export async function applyAuthChoice(params: { if (res.error) { await params.prompter.note( `Failed to run claude: ${String(res.error)}`, - "Anthropic setup-token", + "Anthropic setup-token" ); return { config: nextConfig, agentModelOverride }; } if (typeof res.status === "number" && res.status !== 0) { await params.prompter.note( `claude setup-token failed (exit ${res.status})`, - "Anthropic setup-token", + "Anthropic setup-token" ); return { config: nextConfig, agentModelOverride }; } @@ -269,7 +272,7 @@ export async function applyAuthChoice(params: { if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { await params.prompter.note( `No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`, - "Anthropic setup-token", + "Anthropic setup-token" ); return { config: nextConfig, agentModelOverride }; } @@ -289,7 +292,7 @@ export async function applyAuthChoice(params: { "Run `claude setup-token` in your terminal.", "Then paste the generated token below.", ].join("\n"), - "Anthropic token", + "Anthropic token" ); const tokenRaw = await params.prompter.text({ @@ -339,7 +342,7 @@ export async function applyAuthChoice(params: { } await params.prompter.note( `Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`, - "OpenAI API key", + "OpenAI API key" ); return { config: nextConfig, agentModelOverride }; } @@ -357,7 +360,7 @@ export async function applyAuthChoice(params: { process.env.OPENAI_API_KEY = trimmed; await params.prompter.note( `Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`, - "OpenAI API key", + "OpenAI API key" ); } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); @@ -373,7 +376,7 @@ export async function applyAuthChoice(params: { "If the callback doesn't auto-complete, paste the redirect URL.", "OpenAI OAuth uses localhost:1455 for the callback.", ].join("\n"), - "OpenAI Codex OAuth", + "OpenAI Codex OAuth" ); const spin = params.prompter.progress("Starting OAuth flow…"); let manualCodePromise: Promise | undefined; @@ -383,7 +386,7 @@ export async function applyAuthChoice(params: { if (isRemote) { spin.stop("OAuth URL ready"); params.runtime.log( - `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, + `\nOpen this URL in your LOCAL browser:\n\n${url}\n` ); manualCodePromise = params.prompter .text({ @@ -415,7 +418,7 @@ export async function applyAuthChoice(params: { await writeOAuthCredentials( "openai-codex" as unknown as OAuthProvider, creds, - params.agentDir, + params.agentDir ); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "openai-codex:default", @@ -428,7 +431,7 @@ export async function applyAuthChoice(params: { if (applied.changed) { await params.prompter.note( `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, - "Model configured", + "Model configured" ); } } else { @@ -441,7 +444,7 @@ export async function applyAuthChoice(params: { params.runtime.error(String(err)); await params.prompter.note( "Trouble with OAuth? See https://docs.clawd.bot/start/faq", - "OAuth help", + "OAuth help" ); } } else if (params.authChoice === "codex-cli") { @@ -449,7 +452,7 @@ export async function applyAuthChoice(params: { if (!store.profiles[CODEX_CLI_PROFILE_ID]) { await params.prompter.note( "No Codex CLI credentials found at ~/.codex/auth.json.", - "Codex CLI OAuth", + "Codex CLI OAuth" ); return { config: nextConfig, agentModelOverride }; } @@ -464,7 +467,7 @@ export async function applyAuthChoice(params: { if (applied.changed) { await params.prompter.note( `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, - "Model configured", + "Model configured" ); } } else { @@ -485,7 +488,7 @@ export async function applyAuthChoice(params: { "Sign in with your Google account that has Antigravity access.", "The callback will be captured automatically on localhost:51121.", ].join("\n"), - "Google Antigravity OAuth", + "Google Antigravity OAuth" ); const spin = params.prompter.progress("Starting OAuth flow…"); let oauthCreds: OAuthCredentials | null = null; @@ -495,7 +498,7 @@ export async function applyAuthChoice(params: { if (isRemote) { spin.stop("OAuth URL ready"); params.runtime.log( - `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, + `\nOpen this URL in your LOCAL browser:\n\n${url}\n` ); } else { spin.update("Complete sign-in in browser…"); @@ -503,14 +506,14 @@ export async function applyAuthChoice(params: { params.runtime.log(`Open: ${url}`); } }, - (msg) => spin.update(msg), + (msg) => spin.update(msg) ); spin.stop("Antigravity OAuth complete"); if (oauthCreds) { await writeOAuthCredentials( "google-antigravity", oauthCreds, - params.agentDir, + params.agentDir ); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: `google-antigravity:${oauthCreds.email ?? "default"}`, @@ -555,7 +558,7 @@ export async function applyAuthChoice(params: { }; await params.prompter.note( `Default model set to ${modelKey}`, - "Model configured", + "Model configured" ); } else { agentModelOverride = modelKey; @@ -567,7 +570,7 @@ export async function applyAuthChoice(params: { params.runtime.error(String(err)); await params.prompter.note( "Trouble with OAuth? See https://docs.clawd.bot/start/faq", - "OAuth help", + "OAuth help" ); } } else if (params.authChoice === "gemini-api-key") { @@ -587,7 +590,7 @@ export async function applyAuthChoice(params: { if (applied.changed) { await params.prompter.note( `Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`, - "Model configured", + "Model configured" ); } } else { @@ -649,6 +652,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 = applyOpencodeZenConfig(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.ts b/src/commands/onboard-auth.ts index 2945a25c4..c5bffaf4f 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,72 @@ 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 providers = { ...cfg.models?.providers }; + providers["opencode-zen"] = { + baseUrl: OPENCODE_ZEN_API_BASE_URL, + apiKey: "opencode-zen", + api: "openai-completions", + models: getOpencodeZenStaticFallbackModels(), + }; + + const models = { ...cfg.agents?.defaults?.models }; + models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = { + ...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, + }; +} From 05bd100f7a28c092441a48acf32a0425d675bd2e Mon Sep 17 00:00:00 2001 From: Magi Metal Date: Fri, 9 Jan 2026 18:19:41 -0500 Subject: [PATCH 2/4] style: fix formatting --- src/commands/auth-choice-options.ts | 6 +-- src/commands/auth-choice.ts | 68 ++++++++++++++--------------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index fcc6ae2e8..c154ef4a4 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -14,7 +14,7 @@ export type AuthChoiceOption = { function formatOAuthHint( expires?: number, - opts?: { allowStale?: boolean } + opts?: { allowStale?: boolean }, ): string { const rich = isRich(); if (!expires) { @@ -33,8 +33,8 @@ function formatOAuthHint( minutes >= 120 ? `${Math.round(minutes / 60)}h` : minutes >= 60 - ? "1h" - : `${Math.max(minutes, 1)}m`; + ? "1h" + : `${Math.max(minutes, 1)}m`; const label = `token ok · expires in ${duration}`; if (minutes <= 10) { return colorize(rich, theme.warn, label); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 88349e7bf..c0c9c5abc 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -61,7 +61,7 @@ import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; export async function warnIfModelConfigLooksOff( config: ClawdbotConfig, prompter: WizardPrompter, - options?: { agentId?: string; agentDir?: string } + options?: { agentId?: string; agentDir?: string }, ) { const agentModelOverride = options?.agentId ? resolveAgentConfig(config, options.agentId)?.model?.trim() @@ -96,11 +96,11 @@ export async function warnIfModelConfigLooksOff( }); if (catalog.length > 0) { const known = catalog.some( - (entry) => entry.provider === ref.provider && entry.id === ref.model + (entry) => entry.provider === ref.provider && entry.id === ref.model, ); if (!known) { warnings.push( - `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.` + `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, ); } } @@ -111,7 +111,7 @@ export async function warnIfModelConfigLooksOff( const customKey = getCustomProviderApiKey(config, ref.provider); if (!hasProfile && !envKey && !customKey) { warnings.push( - `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.` + `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`, ); } @@ -119,7 +119,7 @@ export async function warnIfModelConfigLooksOff( const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; if (hasCodex) { warnings.push( - `Detected OpenAI Codex OAuth. Consider setting agents.defaults.model to ${OPENAI_CODEX_DEFAULT_MODEL}.` + `Detected OpenAI Codex OAuth. Consider setting agents.defaults.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`, ); } } @@ -145,7 +145,7 @@ export async function applyAuthChoice(params: { if (!params.agentId) return; await params.prompter.note( `Default model set to ${model} for agent "${params.agentId}".`, - "Model configured" + "Model configured", ); }; @@ -161,7 +161,7 @@ export async function applyAuthChoice(params: { 'Choose "Always Allow" so the launchd gateway can start without prompts.', 'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.', ].join("\n"), - "Claude CLI Keychain" + "Claude CLI Keychain", ); const proceed = await params.prompter.confirm({ message: "Check Keychain for Claude CLI credentials now?", @@ -192,14 +192,14 @@ export async function applyAuthChoice(params: { if (res.error) { await params.prompter.note( `Failed to run claude: ${String(res.error)}`, - "Claude setup-token" + "Claude setup-token", ); } } } else { await params.prompter.note( "`claude setup-token` requires an interactive TTY.", - "Claude setup-token" + "Claude setup-token", ); } @@ -211,7 +211,7 @@ export async function applyAuthChoice(params: { process.platform === "darwin" ? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.' : "No Claude CLI credentials found at ~/.claude/.credentials.json.", - "Claude CLI OAuth" + "Claude CLI OAuth", ); return { config: nextConfig, agentModelOverride }; } @@ -230,13 +230,13 @@ export async function applyAuthChoice(params: { "This will run `claude setup-token` to create a long-lived Anthropic token.", "Requires an interactive TTY and a Claude Pro/Max subscription.", ].join("\n"), - "Anthropic setup-token" + "Anthropic setup-token", ); if (!process.stdin.isTTY) { await params.prompter.note( "`claude setup-token` requires an interactive TTY.", - "Anthropic setup-token" + "Anthropic setup-token", ); return { config: nextConfig, agentModelOverride }; } @@ -254,14 +254,14 @@ export async function applyAuthChoice(params: { if (res.error) { await params.prompter.note( `Failed to run claude: ${String(res.error)}`, - "Anthropic setup-token" + "Anthropic setup-token", ); return { config: nextConfig, agentModelOverride }; } if (typeof res.status === "number" && res.status !== 0) { await params.prompter.note( `claude setup-token failed (exit ${res.status})`, - "Anthropic setup-token" + "Anthropic setup-token", ); return { config: nextConfig, agentModelOverride }; } @@ -272,7 +272,7 @@ export async function applyAuthChoice(params: { if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { await params.prompter.note( `No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`, - "Anthropic setup-token" + "Anthropic setup-token", ); return { config: nextConfig, agentModelOverride }; } @@ -292,7 +292,7 @@ export async function applyAuthChoice(params: { "Run `claude setup-token` in your terminal.", "Then paste the generated token below.", ].join("\n"), - "Anthropic token" + "Anthropic token", ); const tokenRaw = await params.prompter.text({ @@ -342,7 +342,7 @@ export async function applyAuthChoice(params: { } await params.prompter.note( `Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`, - "OpenAI API key" + "OpenAI API key", ); return { config: nextConfig, agentModelOverride }; } @@ -360,7 +360,7 @@ export async function applyAuthChoice(params: { process.env.OPENAI_API_KEY = trimmed; await params.prompter.note( `Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`, - "OpenAI API key" + "OpenAI API key", ); } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); @@ -376,7 +376,7 @@ export async function applyAuthChoice(params: { "If the callback doesn't auto-complete, paste the redirect URL.", "OpenAI OAuth uses localhost:1455 for the callback.", ].join("\n"), - "OpenAI Codex OAuth" + "OpenAI Codex OAuth", ); const spin = params.prompter.progress("Starting OAuth flow…"); let manualCodePromise: Promise | undefined; @@ -386,7 +386,7 @@ export async function applyAuthChoice(params: { if (isRemote) { spin.stop("OAuth URL ready"); params.runtime.log( - `\nOpen this URL in your LOCAL browser:\n\n${url}\n` + `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, ); manualCodePromise = params.prompter .text({ @@ -418,7 +418,7 @@ export async function applyAuthChoice(params: { await writeOAuthCredentials( "openai-codex" as unknown as OAuthProvider, creds, - params.agentDir + params.agentDir, ); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "openai-codex:default", @@ -431,7 +431,7 @@ export async function applyAuthChoice(params: { if (applied.changed) { await params.prompter.note( `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, - "Model configured" + "Model configured", ); } } else { @@ -444,7 +444,7 @@ export async function applyAuthChoice(params: { params.runtime.error(String(err)); await params.prompter.note( "Trouble with OAuth? See https://docs.clawd.bot/start/faq", - "OAuth help" + "OAuth help", ); } } else if (params.authChoice === "codex-cli") { @@ -452,7 +452,7 @@ export async function applyAuthChoice(params: { if (!store.profiles[CODEX_CLI_PROFILE_ID]) { await params.prompter.note( "No Codex CLI credentials found at ~/.codex/auth.json.", - "Codex CLI OAuth" + "Codex CLI OAuth", ); return { config: nextConfig, agentModelOverride }; } @@ -467,7 +467,7 @@ export async function applyAuthChoice(params: { if (applied.changed) { await params.prompter.note( `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, - "Model configured" + "Model configured", ); } } else { @@ -488,7 +488,7 @@ export async function applyAuthChoice(params: { "Sign in with your Google account that has Antigravity access.", "The callback will be captured automatically on localhost:51121.", ].join("\n"), - "Google Antigravity OAuth" + "Google Antigravity OAuth", ); const spin = params.prompter.progress("Starting OAuth flow…"); let oauthCreds: OAuthCredentials | null = null; @@ -498,7 +498,7 @@ export async function applyAuthChoice(params: { if (isRemote) { spin.stop("OAuth URL ready"); params.runtime.log( - `\nOpen this URL in your LOCAL browser:\n\n${url}\n` + `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, ); } else { spin.update("Complete sign-in in browser…"); @@ -506,14 +506,14 @@ export async function applyAuthChoice(params: { params.runtime.log(`Open: ${url}`); } }, - (msg) => spin.update(msg) + (msg) => spin.update(msg), ); spin.stop("Antigravity OAuth complete"); if (oauthCreds) { await writeOAuthCredentials( "google-antigravity", oauthCreds, - params.agentDir + params.agentDir, ); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: `google-antigravity:${oauthCreds.email ?? "default"}`, @@ -558,7 +558,7 @@ export async function applyAuthChoice(params: { }; await params.prompter.note( `Default model set to ${modelKey}`, - "Model configured" + "Model configured", ); } else { agentModelOverride = modelKey; @@ -570,7 +570,7 @@ export async function applyAuthChoice(params: { params.runtime.error(String(err)); await params.prompter.note( "Trouble with OAuth? See https://docs.clawd.bot/start/faq", - "OAuth help" + "OAuth help", ); } } else if (params.authChoice === "gemini-api-key") { @@ -590,7 +590,7 @@ export async function applyAuthChoice(params: { if (applied.changed) { await params.prompter.note( `Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`, - "Model configured" + "Model configured", ); } } else { @@ -659,7 +659,7 @@ export async function applyAuthChoice(params: { "Get your API key at: https://opencode.ai/auth", "Requires an active OpenCode Zen subscription.", ].join("\n"), - "OpenCode Zen" + "OpenCode Zen", ); const key = await params.prompter.text({ message: "Enter OpenCode Zen API key", @@ -675,7 +675,7 @@ export async function applyAuthChoice(params: { nextConfig = applyOpencodeZenConfig(nextConfig); await params.prompter.note( `Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`, - "Model configured" + "Model configured", ); } else { nextConfig = applyOpencodeZenConfig(nextConfig); From c69c4caa33e6a503c8e7bbed1aeb1ee901406497 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 01:07:56 +0100 Subject: [PATCH 3/4] fix: polish opencode-zen onboarding (#623) (thanks @magimetal) --- CHANGELOG.md | 1 + docs/cli/index.md | 3 +- docs/start/wizard.md | 12 +++++++ src/cli/program.test.ts | 23 ++++++++++++ src/cli/program.ts | 6 +++- src/commands/auth-choice.test.ts | 53 +++++++++++++++++++++++++++ src/commands/auth-choice.ts | 3 +- src/commands/onboard-auth.test.ts | 60 +++++++++++++++++++++++++++++++ src/commands/onboard-auth.ts | 10 ++++-- 9 files changed, 166 insertions(+), 5 deletions(-) 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 { From d75b3026995339eeedbb77e077ffd2c73999293f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 01:12:22 +0100 Subject: [PATCH 4/4] style: fix pi-embedded-runner formatting (#623) (thanks @magimetal) --- src/agents/pi-embedded-runner.ts | 1 - 1 file changed, 1 deletion(-) 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.