diff --git a/CHANGELOG.md b/CHANGELOG.md index 360ff38dc..8d8a7eec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### New Features and Changes - CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe. +- CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88. ### Fixes - CLI/Status: surface gateway provider runtime errors (Signal/iMessage/Slack) in the Providers table. diff --git a/src/agents/openai-responses.reasoning-replay.test.ts b/src/agents/openai-responses.reasoning-replay.test.ts index 3ec326211..9cca7551a 100644 --- a/src/agents/openai-responses.reasoning-replay.test.ts +++ b/src/agents/openai-responses.reasoning-replay.test.ts @@ -52,7 +52,7 @@ function installFailingFetchCapture() { } describe("openai-responses reasoning replay", () => { - it("replays reasoning for tool-call-only turns (required by OpenAI)", async () => { + it("skips reasoning for tool-call-only turns (OpenAI rejects standalone reasoning)", async () => { const cap = installFailingFetchCapture(); try { const model = buildModel(); @@ -141,11 +141,8 @@ describe("openai-responses reasoning replay", () => { ) .filter((t): t is string => typeof t === "string"); - expect(types).toContain("reasoning"); expect(types).toContain("function_call"); - expect(types.indexOf("reasoning")).toBeLessThan( - types.indexOf("function_call"), - ); + expect(types).not.toContain("reasoning"); } finally { cap.restore(); } diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index ff7f9c095..dcf2647f0 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -156,6 +156,29 @@ describe("cli program", () => { ); }); + it("passes openrouter api key to onboard", async () => { + const program = buildProgram(); + await program.parseAsync( + [ + "onboard", + "--non-interactive", + "--auth-choice", + "openrouter-api-key", + "--openrouter-api-key", + "sk-openrouter-test", + ], + { from: "user" }, + ); + expect(onboardCommand).toHaveBeenCalledWith( + expect.objectContaining({ + nonInteractive: true, + authChoice: "openrouter-api-key", + openrouterApiKey: "sk-openrouter-test", + }), + runtime, + ); + }); + it("passes zai api key to onboard", async () => { const program = buildProgram(); await program.parseAsync( diff --git a/src/cli/program.ts b/src/cli/program.ts index 502bfc7bd..3401e2aa1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -249,7 +249,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|zai-api-key|apiKey|minimax-cloud|minimax-api|minimax|opencode-zen|skip", + "Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-cloud|minimax-api|minimax|opencode-zen|skip", ) .option( "--token-provider ", @@ -269,6 +269,7 @@ export function buildProgram() { ) .option("--anthropic-api-key ", "Anthropic API key") .option("--openai-api-key ", "OpenAI API key") + .option("--openrouter-api-key ", "OpenRouter API key") .option("--gemini-api-key ", "Gemini API key") .option("--zai-api-key ", "Z.AI API key") .option("--minimax-api-key ", "MiniMax API key") @@ -315,6 +316,7 @@ export function buildProgram() { | "token" | "openai-codex" | "openai-api-key" + | "openrouter-api-key" | "codex-cli" | "antigravity" | "gemini-api-key" @@ -332,6 +334,7 @@ export function buildProgram() { tokenExpiresIn: opts.tokenExpiresIn as string | undefined, anthropicApiKey: opts.anthropicApiKey as string | undefined, openaiApiKey: opts.openaiApiKey as string | undefined, + openrouterApiKey: opts.openrouterApiKey as string | undefined, geminiApiKey: opts.geminiApiKey as string | undefined, zaiApiKey: opts.zaiApiKey as string | undefined, minimaxApiKey: opts.minimaxApiKey as string | undefined, diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 50115073d..159a00753 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -92,6 +92,7 @@ export function buildAuthChoiceOptions(params: { label: "OpenAI Codex (ChatGPT OAuth)", }); options.push({ value: "openai-api-key", label: "OpenAI API key" }); + options.push({ value: "openrouter-api-key", label: "OpenRouter API key" }); options.push({ value: "antigravity", label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 4cf6c8dc3..c1ba77fb5 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -15,6 +15,7 @@ describe("applyAuthChoice", () => { const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; let tempStateDir: string | null = null; afterEach(async () => { @@ -37,6 +38,11 @@ describe("applyAuthChoice", () => { } else { process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; } + if (previousOpenrouterKey === undefined) { + delete process.env.OPENROUTER_API_KEY; + } else { + process.env.OPENROUTER_API_KEY = previousOpenrouterKey; + } }); it("prompts and writes MiniMax API key when selecting minimax-api", async () => { @@ -150,4 +156,75 @@ describe("applyAuthChoice", () => { expect(result.config.models?.providers?.["opencode-zen"]).toBeUndefined(); expect(result.agentModelOverride).toBe("opencode/claude-opus-4-5"); }); + + it("uses existing OPENROUTER_API_KEY when selecting openrouter-api-key", 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; + process.env.OPENROUTER_API_KEY = "sk-openrouter-test"; + + const text = vi.fn(); + const select: WizardPrompter["select"] = vi.fn( + async (params) => params.options[0]?.value as never, + ); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + const confirm = vi.fn(async () => true); + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select, + multiselect, + text, + confirm, + 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: "openrouter-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("OPENROUTER_API_KEY"), + }), + ); + expect(text).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["openrouter:default"]).toMatchObject({ + provider: "openrouter", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe( + "openrouter/auto", + ); + + const authProfilePath = path.join( + tempStateDir, + "agents", + "main", + "agent", + "auth-profiles.json", + ); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["openrouter:default"]?.key).toBe( + "sk-openrouter-test", + ); + + delete process.env.OPENROUTER_API_KEY; + }); }); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 7c82b1c40..042268c2f 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -9,6 +9,7 @@ import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, + resolveAuthProfileOrder, upsertAuthProfile, } from "../agents/auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; @@ -44,12 +45,16 @@ import { applyMinimaxProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, + applyOpenrouterConfig, + applyOpenrouterProviderConfig, applyZaiConfig, MINIMAX_HOSTED_MODEL_REF, + OPENROUTER_DEFAULT_MODEL_REF, setAnthropicApiKey, setGeminiApiKey, setMinimaxApiKey, setOpencodeZenApiKey, + setOpenrouterApiKey, setZaiApiKey, writeOAuthCredentials, ZAI_DEFAULT_MODEL_REF, @@ -366,6 +371,77 @@ export async function applyAuthChoice(params: { `Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`, "OpenAI API key", ); + } else if (params.authChoice === "openrouter-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "openrouter", + }); + const existingProfileId = profileOrder.find((profileId) => + Boolean(store.profiles[profileId]), + ); + const existingCred = existingProfileId + ? store.profiles[existingProfileId] + : undefined; + let profileId = "openrouter:default"; + let mode: "api_key" | "oauth" | "token" = "api_key"; + let hasCredential = false; + + if (existingProfileId && existingCred?.type) { + profileId = existingProfileId; + mode = + existingCred.type === "oauth" + ? "oauth" + : existingCred.type === "token" + ? "token" + : "api_key"; + hasCredential = true; + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("openrouter"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing OPENROUTER_API_KEY (${envKey.source})?`, + initialValue: true, + }); + if (useExisting) { + await setOpenrouterApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter OpenRouter API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setOpenrouterApiKey(String(key).trim(), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "openrouter", + mode, + }); + } + if (params.setDefaultModel) { + nextConfig = applyOpenrouterConfig(nextConfig); + await params.prompter.note( + `Default model set to ${OPENROUTER_DEFAULT_MODEL_REF}`, + "Model configured", + ); + } else { + nextConfig = applyOpenrouterProviderConfig(nextConfig); + agentModelOverride = OPENROUTER_DEFAULT_MODEL_REF; + await noteAgentModel(OPENROUTER_DEFAULT_MODEL_REF); + } } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); await params.prompter.note( @@ -745,6 +821,8 @@ export function resolvePreferredProviderForAuthChoice( return "openai-codex"; case "openai-api-key": return "openai"; + case "openrouter-api-key": + return "openrouter"; case "gemini-api-key": return "google"; case "antigravity": diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 8791ed0e0..1eb536029 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -11,6 +11,9 @@ import { applyMinimaxApiProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, + applyOpenrouterConfig, + applyOpenrouterProviderConfig, + OPENROUTER_DEFAULT_MODEL_REF, writeOAuthCredentials, } from "./onboard-auth.js"; @@ -301,3 +304,48 @@ describe("applyOpencodeZenConfig", () => { ]); }); }); + +describe("applyOpenrouterProviderConfig", () => { + it("adds allowlist entry for the default model", () => { + const cfg = applyOpenrouterProviderConfig({}); + const models = cfg.agents?.defaults?.models ?? {}; + expect(Object.keys(models)).toContain(OPENROUTER_DEFAULT_MODEL_REF); + }); + + it("preserves existing alias for the default model", () => { + const cfg = applyOpenrouterProviderConfig({ + agents: { + defaults: { + models: { + [OPENROUTER_DEFAULT_MODEL_REF]: { alias: "Router" }, + }, + }, + }, + }); + expect( + cfg.agents?.defaults?.models?.[OPENROUTER_DEFAULT_MODEL_REF]?.alias, + ).toBe("Router"); + }); +}); + +describe("applyOpenrouterConfig", () => { + it("sets correct primary model", () => { + const cfg = applyOpenrouterConfig({}); + expect(cfg.agents?.defaults?.model?.primary).toBe( + OPENROUTER_DEFAULT_MODEL_REF, + ); + }); + + it("preserves existing model fallbacks", () => { + const cfg = applyOpenrouterConfig({ + 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 e6eca06c1..b2c549a09 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -131,6 +131,7 @@ export async function setMinimaxApiKey(key: string, agentDir?: string) { } export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7"; +export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; export async function setZaiApiKey(key: string, agentDir?: string) { // Write to the multi-agent path so gateway finds credentials on startup @@ -145,6 +146,18 @@ export async function setZaiApiKey(key: string, agentDir?: string) { }); } +export async function setOpenrouterApiKey(key: string, agentDir?: string) { + upsertAuthProfile({ + profileId: "openrouter:default", + credential: { + type: "api_key", + provider: "openrouter", + key, + }, + agentDir: agentDir ?? resolveDefaultAgentDir(), + }); +} + export function applyZaiConfig(cfg: ClawdbotConfig): ClawdbotConfig { const models = { ...cfg.agents?.defaults?.models }; models[ZAI_DEFAULT_MODEL_REF] = { @@ -175,6 +188,51 @@ export function applyZaiConfig(cfg: ClawdbotConfig): ClawdbotConfig { }; } +export function applyOpenrouterProviderConfig( + cfg: ClawdbotConfig, +): ClawdbotConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[OPENROUTER_DEFAULT_MODEL_REF] = { + ...models[OPENROUTER_DEFAULT_MODEL_REF], + alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter", + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpenrouterConfig(cfg: ClawdbotConfig): ClawdbotConfig { + const next = applyOpenrouterProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && + "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: OPENROUTER_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} + export function applyAuthProfileConfig( cfg: ClawdbotConfig, params: { diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index ec99b0709..7a2d5d4a7 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -36,11 +36,13 @@ import { applyMinimaxConfig, applyMinimaxHostedConfig, applyOpencodeZenConfig, + applyOpenrouterConfig, applyZaiConfig, setAnthropicApiKey, setGeminiApiKey, setMinimaxApiKey, setOpencodeZenApiKey, + setOpenrouterApiKey, setZaiApiKey, } from "./onboard-auth.js"; import { @@ -264,6 +266,25 @@ export async function runNonInteractiveOnboarding( }); process.env.OPENAI_API_KEY = key; runtime.log(`Saved OPENAI_API_KEY to ${result.path}`); + } else if (authChoice === "openrouter-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "openrouter", + cfg: baseConfig, + flagValue: opts.openrouterApiKey, + flagName: "--openrouter-api-key", + envVar: "OPENROUTER_API_KEY", + runtime, + }); + if (!resolved) return; + if (resolved.source !== "profile") { + await setOpenrouterApiKey(resolved.key); + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "openrouter:default", + provider: "openrouter", + mode: "api_key", + }); + nextConfig = applyOpenrouterConfig(nextConfig); } else if (authChoice === "minimax-cloud") { const resolved = await resolveNonInteractiveApiKey({ provider: "minimax", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 9f8b92d50..bc5cfeebf 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -10,6 +10,7 @@ export type AuthChoice = | "token" | "openai-codex" | "openai-api-key" + | "openrouter-api-key" | "codex-cli" | "antigravity" | "apiKey" @@ -43,6 +44,7 @@ export type OnboardOptions = { tokenExpiresIn?: string; anthropicApiKey?: string; openaiApiKey?: string; + openrouterApiKey?: string; geminiApiKey?: string; zaiApiKey?: string; minimaxApiKey?: string;