Merge pull request #703 from mteam88/openrouter-auth-config

Openrouter auth config (AI)
This commit is contained in:
Peter Steinberger
2026-01-11 02:44:24 +00:00
committed by GitHub
11 changed files with 315 additions and 6 deletions

View File

@@ -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.

View File

@@ -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();
}

View File

@@ -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(

View File

@@ -249,7 +249,7 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <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 <id>",
@@ -269,6 +269,7 @@ export function buildProgram() {
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--openai-api-key <key>", "OpenAI API key")
.option("--openrouter-api-key <key>", "OpenRouter API key")
.option("--gemini-api-key <key>", "Gemini API key")
.option("--zai-api-key <key>", "Z.AI API key")
.option("--minimax-api-key <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,

View File

@@ -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.)",

View File

@@ -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<string, { key?: string }>;
};
expect(parsed.profiles?.["openrouter:default"]?.key).toBe(
"sk-openrouter-test",
);
delete process.env.OPENROUTER_API_KEY;
});
});

View File

@@ -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":

View File

@@ -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",
]);
});
});

View File

@@ -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<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: OPENROUTER_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyAuthProfileConfig(
cfg: ClawdbotConfig,
params: {

View File

@@ -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",

View File

@@ -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;