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 {