This commit is contained in:
Ismail Ghallou 2026-01-29 18:39:10 +02:00 committed by GitHub
commit 13d97dd732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 296 additions and 1 deletions

View File

@ -16,6 +16,7 @@ Status: beta.
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
- Memory Search: allow extra paths for memory indexing (ignores symlinks). (#3600) Thanks @kira-ariaki.
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
- CLI/Onboarding: add LLM Gateway API key auth option in configure/onboard.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.

View File

@ -277,6 +277,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
llmgateway: "LLM_GATEWAY_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
moonshot: "MOONSHOT_API_KEY",
"kimi-code": "KIMICODE_API_KEY",

View File

@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|llmgateway-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
)
.option(
"--token-provider <id>",
@ -67,6 +67,7 @@ export function registerOnboardCommand(program: Command) {
.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("--llmgateway-api-key <key>", "LLM Gateway API key")
.option("--ai-gateway-api-key <key>", "Vercel AI Gateway API key")
.option("--moonshot-api-key <key>", "Moonshot API key")
.option("--kimi-code-api-key <key>", "Kimi Code API key")
@ -117,6 +118,7 @@ export function registerOnboardCommand(program: Command) {
anthropicApiKey: opts.anthropicApiKey as string | undefined,
openaiApiKey: opts.openaiApiKey as string | undefined,
openrouterApiKey: opts.openrouterApiKey as string | undefined,
llmgatewayApiKey: opts.llmgatewayApiKey as string | undefined,
aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined,
moonshotApiKey: opts.moonshotApiKey as string | undefined,
kimiCodeApiKey: opts.kimiCodeApiKey as string | undefined,

View File

@ -13,6 +13,7 @@ export type AuthChoiceGroupId =
| "google"
| "copilot"
| "openrouter"
| "llmgateway"
| "ai-gateway"
| "moonshot"
| "zai"
@ -89,6 +90,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "API key",
choices: ["openrouter-api-key"],
},
{
value: "llmgateway",
label: "LLM Gateway",
hint: "API key",
choices: ["llmgateway-api-key"],
},
{
value: "ai-gateway",
label: "Vercel AI Gateway",
@ -135,6 +142,7 @@ export function buildAuthChoiceOptions(params: {
options.push({ value: "chutes", label: "Chutes (OAuth)" });
options.push({ value: "openai-api-key", label: "OpenAI API key" });
options.push({ value: "openrouter-api-key", label: "OpenRouter API key" });
options.push({ value: "llmgateway-api-key", label: "LLM Gateway API key" });
options.push({
value: "ai-gateway-api-key",
label: "Vercel AI Gateway API key",

View File

@ -15,6 +15,8 @@ import {
applyAuthProfileConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyLlmgatewayConfig,
applyLlmgatewayProviderConfig,
applyMoonshotConfig,
applyMoonshotProviderConfig,
applyOpencodeZenConfig,
@ -29,6 +31,7 @@ import {
applyVercelAiGatewayProviderConfig,
applyZaiConfig,
KIMI_CODE_MODEL_REF,
LLMGATEWAY_DEFAULT_MODEL_REF,
MOONSHOT_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_REF,
@ -36,6 +39,7 @@ import {
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
setGeminiApiKey,
setKimiCodeApiKey,
setLlmgatewayApiKey,
setMoonshotApiKey,
setOpencodeZenApiKey,
setOpenrouterApiKey,
@ -69,6 +73,8 @@ export async function applyAuthChoiceApiProviders(
) {
if (params.opts.tokenProvider === "openrouter") {
authChoice = "openrouter-api-key";
} else if (params.opts.tokenProvider === "llmgateway") {
authChoice = "llmgateway-api-key";
} else if (params.opts.tokenProvider === "vercel-ai-gateway") {
authChoice = "ai-gateway-api-key";
} else if (params.opts.tokenProvider === "moonshot") {
@ -166,6 +172,84 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (authChoice === "llmgateway-api-key") {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profileOrder = resolveAuthProfileOrder({
cfg: nextConfig,
store,
provider: "llmgateway",
});
const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId]));
const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined;
let profileId = "llmgateway: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 && params.opts?.token && params.opts?.tokenProvider === "llmgateway") {
await setLlmgatewayApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
const envKey = resolveEnvApiKey("llmgateway");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing LLM_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await setLlmgatewayApiKey(envKey.apiKey, params.agentDir);
hasCredential = true;
}
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter LLM Gateway API key",
validate: validateApiKeyInput,
});
await setLlmgatewayApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
hasCredential = true;
}
if (hasCredential) {
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider: "llmgateway",
mode,
});
}
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: LLMGATEWAY_DEFAULT_MODEL_REF,
applyDefaultConfig: applyLlmgatewayConfig,
applyProviderConfig: applyLlmgatewayProviderConfig,
noteDefault: LLMGATEWAY_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
}
if (authChoice === "ai-gateway-api-key") {
let hasCredential = false;

View File

@ -11,6 +11,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
chutes: "chutes",
"openai-api-key": "openai",
"openrouter-api-key": "openrouter",
"llmgateway-api-key": "llmgateway",
"ai-gateway-api-key": "vercel-ai-gateway",
"moonshot-api-key": "moonshot",
"kimi-code-api-key": "kimi-code",

View File

@ -32,6 +32,7 @@ describe("applyAuthChoice", () => {
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousOpenrouterKey = process.env.OPENROUTER_API_KEY;
const previousLlmgatewayKey = process.env.LLM_GATEWAY_API_KEY;
const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY;
const previousSshTty = process.env.SSH_TTY;
const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
@ -64,6 +65,11 @@ describe("applyAuthChoice", () => {
} else {
process.env.OPENROUTER_API_KEY = previousOpenrouterKey;
}
if (previousLlmgatewayKey === undefined) {
delete process.env.LLM_GATEWAY_API_KEY;
} else {
process.env.LLM_GATEWAY_API_KEY = previousLlmgatewayKey;
}
if (previousAiGatewayKey === undefined) {
delete process.env.AI_GATEWAY_API_KEY;
} else {
@ -342,6 +348,69 @@ describe("applyAuthChoice", () => {
delete process.env.OPENROUTER_API_KEY;
});
it("uses existing LLM_GATEWAY_API_KEY when selecting llmgateway-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.LLM_GATEWAY_API_KEY = "sk-llmgateway-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: "llmgateway-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("LLM_GATEWAY_API_KEY"),
}),
);
expect(text).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.["llmgateway:default"]).toMatchObject({
provider: "llmgateway",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toBe(
"llmgateway/anthropic/claude-sonnet-4",
);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
};
expect(parsed.profiles?.["llmgateway:default"]?.key).toBe("sk-llmgateway-test");
delete process.env.LLM_GATEWAY_API_KEY;
});
it("uses existing AI_GATEWAY_API_KEY when selecting ai-gateway-api-key", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-auth-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
@ -597,6 +666,10 @@ describe("resolvePreferredProviderForAuthChoice", () => {
expect(resolvePreferredProviderForAuthChoice("qwen-portal")).toBe("qwen-portal");
});
it("maps llmgateway-api-key to the provider", () => {
expect(resolvePreferredProviderForAuthChoice("llmgateway-api-key")).toBe("llmgateway");
});
it("returns undefined for unknown choices", () => {
expect(resolvePreferredProviderForAuthChoice("unknown" as AuthChoice)).toBeUndefined();
});

View File

@ -12,6 +12,7 @@ import {
} from "../agents/venice-models.js";
import type { MoltbotConfig } from "../config/config.js";
import {
LLMGATEWAY_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
ZAI_DEFAULT_MODEL_REF,
@ -137,6 +138,47 @@ export function applyOpenrouterConfig(cfg: MoltbotConfig): MoltbotConfig {
};
}
export function applyLlmgatewayProviderConfig(cfg: MoltbotConfig): MoltbotConfig {
const models = { ...cfg.agents?.defaults?.models };
models[LLMGATEWAY_DEFAULT_MODEL_REF] = {
...models[LLMGATEWAY_DEFAULT_MODEL_REF],
alias: models[LLMGATEWAY_DEFAULT_MODEL_REF]?.alias ?? "LLM Gateway",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
export function applyLlmgatewayConfig(cfg: MoltbotConfig): MoltbotConfig {
const next = applyLlmgatewayProviderConfig(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: LLMGATEWAY_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyMoonshotProviderConfig(cfg: MoltbotConfig): MoltbotConfig {
const models = { ...cfg.agents?.defaults?.models };
models[MOONSHOT_DEFAULT_MODEL_REF] = {

View File

@ -114,6 +114,7 @@ export async function setVeniceApiKey(key: string, agentDir?: string) {
export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export const LLMGATEWAY_DEFAULT_MODEL_REF = "llmgateway/anthropic/claude-sonnet-4";
export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.5";
export async function setZaiApiKey(key: string, agentDir?: string) {
@ -141,6 +142,18 @@ export async function setOpenrouterApiKey(key: string, agentDir?: string) {
});
}
export async function setLlmgatewayApiKey(key: string, agentDir?: string) {
upsertAuthProfile({
profileId: "llmgateway:default",
credential: {
type: "api_key",
provider: "llmgateway",
key,
},
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setVercelAiGatewayApiKey(key: string, agentDir?: string) {
upsertAuthProfile({
profileId: "vercel-ai-gateway:default",

View File

@ -7,6 +7,8 @@ import { afterEach, describe, expect, it } from "vitest";
import {
applyAuthProfileConfig,
applyLlmgatewayConfig,
applyLlmgatewayProviderConfig,
applyMinimaxApiConfig,
applyMinimaxApiProviderConfig,
applyOpencodeZenConfig,
@ -15,6 +17,7 @@ import {
applyOpenrouterProviderConfig,
applySyntheticConfig,
applySyntheticProviderConfig,
LLMGATEWAY_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_ID,
SYNTHETIC_DEFAULT_MODEL_REF,
@ -420,3 +423,42 @@ describe("applyOpenrouterConfig", () => {
expect(cfg.agents?.defaults?.model?.fallbacks).toEqual(["anthropic/claude-opus-4-5"]);
});
});
describe("applyLlmgatewayProviderConfig", () => {
it("adds allowlist entry for the default model", () => {
const cfg = applyLlmgatewayProviderConfig({});
const models = cfg.agents?.defaults?.models ?? {};
expect(Object.keys(models)).toContain(LLMGATEWAY_DEFAULT_MODEL_REF);
});
it("preserves existing alias for the default model", () => {
const cfg = applyLlmgatewayProviderConfig({
agents: {
defaults: {
models: {
[LLMGATEWAY_DEFAULT_MODEL_REF]: { alias: "Gateway" },
},
},
},
});
expect(cfg.agents?.defaults?.models?.[LLMGATEWAY_DEFAULT_MODEL_REF]?.alias).toBe("Gateway");
});
});
describe("applyLlmgatewayConfig", () => {
it("sets correct primary model", () => {
const cfg = applyLlmgatewayConfig({});
expect(cfg.agents?.defaults?.model?.primary).toBe(LLMGATEWAY_DEFAULT_MODEL_REF);
});
it("preserves existing model fallbacks", () => {
const cfg = applyLlmgatewayConfig({
agents: {
defaults: {
model: { fallbacks: ["anthropic/claude-opus-4-5"] },
},
},
});
expect(cfg.agents?.defaults?.model?.fallbacks).toEqual(["anthropic/claude-opus-4-5"]);
});
});

View File

@ -7,6 +7,8 @@ export {
applyAuthProfileConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyLlmgatewayConfig,
applyLlmgatewayProviderConfig,
applyMoonshotConfig,
applyMoonshotProviderConfig,
applyOpenrouterConfig,
@ -33,10 +35,12 @@ export {
applyOpencodeZenProviderConfig,
} from "./onboard-auth.config-opencode.js";
export {
LLMGATEWAY_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
setAnthropicApiKey,
setGeminiApiKey,
setKimiCodeApiKey,
setLlmgatewayApiKey,
setMinimaxApiKey,
setMoonshotApiKey,
setOpencodeZenApiKey,

View File

@ -9,6 +9,7 @@ import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default
import {
applyAuthProfileConfig,
applyKimiCodeConfig,
applyLlmgatewayConfig,
applyMinimaxApiConfig,
applyMinimaxConfig,
applyMoonshotConfig,
@ -21,6 +22,7 @@ import {
setAnthropicApiKey,
setGeminiApiKey,
setKimiCodeApiKey,
setLlmgatewayApiKey,
setMinimaxApiKey,
setMoonshotApiKey,
setOpencodeZenApiKey,
@ -214,6 +216,25 @@ export async function applyNonInteractiveAuthChoice(params: {
return applyOpenrouterConfig(nextConfig);
}
if (authChoice === "llmgateway-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "llmgateway",
cfg: baseConfig,
flagValue: opts.llmgatewayApiKey,
flagName: "--llmgateway-api-key",
envVar: "LLM_GATEWAY_API_KEY",
runtime,
});
if (!resolved) return null;
if (resolved.source !== "profile") await setLlmgatewayApiKey(resolved.key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "llmgateway:default",
provider: "llmgateway",
mode: "api_key",
});
return applyLlmgatewayConfig(nextConfig);
}
if (authChoice === "ai-gateway-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "vercel-ai-gateway",

View File

@ -12,6 +12,7 @@ export type AuthChoice =
| "openai-codex"
| "openai-api-key"
| "openrouter-api-key"
| "llmgateway-api-key"
| "ai-gateway-api-key"
| "moonshot-api-key"
| "kimi-code-api-key"
@ -62,6 +63,7 @@ export type OnboardOptions = {
anthropicApiKey?: string;
openaiApiKey?: string;
openrouterApiKey?: string;
llmgatewayApiKey?: string;
aiGatewayApiKey?: string;
moonshotApiKey?: string;
kimiCodeApiKey?: string;

View File

@ -45,6 +45,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
"GEMINI_API_KEY",
"ZAI_API_KEY",
"OPENROUTER_API_KEY",
"LLM_GATEWAY_API_KEY",
"AI_GATEWAY_API_KEY",
"MINIMAX_API_KEY",
"SYNTHETIC_API_KEY",