Feat/litellm provider (#12823)

* feat: add LiteLLM provider types, env var, credentials, and auth choice

Add litellm-api-key auth choice, LITELLM_API_KEY env var mapping,
setLitellmApiKey() credential storage, and LITELLM_DEFAULT_MODEL_REF.

* feat: add LiteLLM onboarding handler and provider config

Add applyLitellmProviderConfig which properly registers
models.providers.litellm with baseUrl, api type, and model definitions.
This fixes the critical bug from PR #6488 where the provider entry was
never created, causing model resolution to fail at runtime.

* docs: add LiteLLM provider documentation

Add setup guide covering onboarding, manual config, virtual keys,
model routing, and usage tracking. Link from provider index.

* docs: add LiteLLM to sidebar navigation in docs.json

Add providers/litellm to both English and Chinese provider page lists
so the docs page appears in the sidebar navigation.

* test: add LiteLLM non-interactive onboarding test

Wire up litellmApiKey flag inference and auth-choice handler for the
non-interactive onboarding path, and add an integration test covering
profile, model default, and credential storage.

* fix: register --litellm-api-key CLI flag and add preferred provider mapping

Wire up the missing Commander CLI option, action handler mapping, and
help text for --litellm-api-key. Add litellm-api-key to the preferred
provider map for consistency with other providers.

* fix: remove zh-CN sidebar entry for litellm (no localized page yet)

* style: format buildLitellmModelDefinition return type

* fix(onboarding): harden LiteLLM provider setup (#12823)

* refactor(onboarding): keep auth-choice provider dispatcher under size limit

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ryan-crabbe
2026-02-11 02:46:56 -08:00
committed by GitHub
parent 5741b6cb3f
commit a36b9be245
19 changed files with 612 additions and 1 deletions

View File

@@ -32,6 +32,7 @@ describe("applyAuthChoice", () => {
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousOpenrouterKey = process.env.OPENROUTER_API_KEY;
const previousLitellmKey = process.env.LITELLM_API_KEY;
const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY;
const previousCloudflareGatewayKey = process.env.CLOUDFLARE_AI_GATEWAY_API_KEY;
const previousSshTty = process.env.SSH_TTY;
@@ -65,6 +66,11 @@ describe("applyAuthChoice", () => {
} else {
process.env.OPENROUTER_API_KEY = previousOpenrouterKey;
}
if (previousLitellmKey === undefined) {
delete process.env.LITELLM_API_KEY;
} else {
process.env.LITELLM_API_KEY = previousLitellmKey;
}
if (previousAiGatewayKey === undefined) {
delete process.env.AI_GATEWAY_API_KEY;
} else {
@@ -402,6 +408,96 @@ describe("applyAuthChoice", () => {
delete process.env.OPENROUTER_API_KEY;
});
it("ignores legacy LiteLLM oauth profiles when selecting litellm-api-key", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
process.env.LITELLM_API_KEY = "sk-litellm-test";
const authProfilePath = authProfilePathFor(requireAgentDir());
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
await fs.writeFile(
authProfilePath,
JSON.stringify(
{
version: 1,
profiles: {
"litellm:legacy": {
type: "oauth",
provider: "litellm",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
null,
2,
),
"utf8",
);
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: "litellm-api-key",
config: {
auth: {
profiles: {
"litellm:legacy": { provider: "litellm", mode: "oauth" },
},
order: { litellm: ["litellm:legacy"] },
},
},
prompter,
runtime,
setDefaultModel: true,
});
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("LITELLM_API_KEY"),
}),
);
expect(text).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.["litellm:default"]).toMatchObject({
provider: "litellm",
mode: "api_key",
});
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { type?: string; key?: string }>;
};
expect(parsed.profiles?.["litellm:default"]).toMatchObject({
type: "api_key",
key: "sk-litellm-test",
});
});
it("uses existing AI_GATEWAY_API_KEY when selecting ai-gateway-api-key", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;