diff --git a/docs/providers/litellm.md b/docs/providers/litellm.md index 3a71debcac15..7034923df557 100644 --- a/docs/providers/litellm.md +++ b/docs/providers/litellm.md @@ -92,6 +92,12 @@ spend limits, and backend failover without changing OpenClaw config. The default model onboarding writes is `litellm/claude-opus-4-6`. +In merge mode, onboarding with an explicit proxy URL preserves any authored provider models and +otherwise leaves the provider model list empty for discovery. Run +`openclaw models list --provider litellm --refresh --json` to list the proxy's models. +With `models.mode: "replace"`, discovery is disabled, so onboarding keeps the documented default +in the provider model list while preserving existing model definitions. + ## Image generation LiteLLM can back the `image_generate` tool through OpenAI-compatible `/images/generations` and diff --git a/extensions/litellm/index.test.ts b/extensions/litellm/index.test.ts index 9d1bba5f3546..e92e32b21713 100644 --- a/extensions/litellm/index.test.ts +++ b/extensions/litellm/index.test.ts @@ -2,8 +2,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { capturePluginRegistration, + createTestWizardPrompter, registerProviderPlugin, requireRegisteredProvider, runProviderCatalog, @@ -41,6 +43,89 @@ describe("litellm plugin", () => { clearLiveCatalogCacheForTests(); }); + it.each([ + { authMode: "non-interactive", modelsMode: "merge" }, + { authMode: "interactive", modelsMode: "merge" }, + { authMode: "non-interactive", modelsMode: "replace" }, + { authMode: "interactive", modelsMode: "replace" }, + ] as const)( + "preserves an explicit proxy's authored models through registered $authMode auth in $modelsMode mode", + async ({ authMode, modelsMode }) => { + const auth = registerProvider()?.auth?.[0]; + const config = { + models: { + mode: modelsMode, + providers: { + litellm: { + baseUrl: "https://litellm.example/v1", + api: "anthropic-messages", + apiKey: " old-key ", + models: [ + { + id: "custom-model", + name: "Custom", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }, + ], + }, + }, + }, + } satisfies OpenClawConfig; + let result: OpenClawConfig | null | undefined; + if (authMode === "non-interactive") { + result = await auth?.runNonInteractive?.({ + authChoice: "litellm-api-key", + config, + baseConfig: config, + opts: { customBaseUrl: "https://litellm.example/v1/" }, + runtime: createRuntimeSpies(), + resolveApiKey: async () => ({ key: "old-key", source: "profile" }), + toApiKeyCredential: () => null, + }); + } else { + const interactive = await auth?.run({ + config, + opts: { litellmApiKey: "old-key" }, + env: {}, + runtime: createRuntimeSpies(), + prompter: createTestWizardPrompter(), + secretInputMode: "plaintext", + isRemote: false, + openUrl: async () => { + throw new Error("Unexpected browser auth"); + }, + oauth: { + createVpsAwareHandlers: () => { + throw new Error("Unexpected OAuth"); + }, + }, + }); + expect(interactive?.profiles).toEqual([ + { + profileId: "litellm:default", + credential: { type: "api_key", provider: "litellm", key: "old-key" }, + }, + ]); + result = interactive?.configPatch; + } + + expect(result?.models?.mode).toBe(modelsMode); + expect(result?.models?.providers?.litellm).toEqual({ + baseUrl: "https://litellm.example/v1", + api: "openai-completions", + apiKey: "old-key", + models: [ + ...config.models.providers.litellm.models, + ...(modelsMode === "replace" ? [LITELLM_DEFAULT_MODEL] : []), + ], + }); + }, + ); + it.each([ { name: "default proxy base URL", @@ -96,76 +181,102 @@ describe("litellm plugin", () => { }); }); - it("honors --custom-base-url in non-interactive API-key setup", async () => { - const provider = registerProvider(); - const auth = provider?.auth?.[0]; - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-litellm-auth-")); - const resolveApiKey = vi.fn(async () => ({ key: "litellm-test-key", source: "flag" as const })); - const toApiKeyCredential = vi.fn(({ provider: providerId, resolved }) => ({ - type: "api_key" as const, - provider: providerId, - key: resolved.key, - })); + it.each([ + { + modelsMode: undefined, + baseUrl: "https://litellm.example/v1/", + expectedBaseUrl: "https://litellm.example/v1", + expectedModels: [], + }, + { + modelsMode: undefined, + baseUrl: undefined, + expectedBaseUrl: "http://localhost:4000", + expectedModels: [LITELLM_DEFAULT_MODEL], + }, + { + modelsMode: "replace" as const, + baseUrl: "https://litellm.example/v1/", + expectedBaseUrl: "https://litellm.example/v1", + expectedModels: [LITELLM_DEFAULT_MODEL], + }, + ])( + "configures proxy URL $baseUrl in $modelsMode mode", + async ({ modelsMode, baseUrl, expectedBaseUrl, expectedModels }) => { + const provider = registerProvider(); + const auth = provider?.auth?.[0]; + const config = (modelsMode ? { models: { mode: modelsMode } } : {}) satisfies OpenClawConfig; + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-litellm-auth-")); + const resolveApiKey = vi.fn(async () => ({ + key: "litellm-test-key", + source: "flag" as const, + })); + const toApiKeyCredential = vi.fn(({ provider: providerId, resolved }) => ({ + type: "api_key" as const, + provider: providerId, + key: resolved.key, + })); - try { - const result = await auth?.runNonInteractive?.({ - authChoice: "litellm-api-key", - config: {}, - baseConfig: {}, - opts: { - litellmApiKey: "litellm-test-key", - customBaseUrl: "https://litellm.example/v1/", - }, - runtime: createRuntimeSpies(), - agentDir, - resolveApiKey, - toApiKeyCredential, - }); - - expect(result).toStrictEqual({ - auth: { - profiles: { - "litellm:default": { - provider: "litellm", - mode: "api_key", - }, + try { + const result = await auth?.runNonInteractive?.({ + authChoice: "litellm-api-key", + config, + baseConfig: config, + opts: { + litellmApiKey: "litellm-test-key", + customBaseUrl: baseUrl, }, - }, - agents: { - defaults: { - models: { - "litellm/claude-opus-4-6": { - alias: "LiteLLM", + runtime: createRuntimeSpies(), + agentDir, + resolveApiKey, + toApiKeyCredential, + }); + + expect(result).toStrictEqual({ + auth: { + profiles: { + "litellm:default": { + provider: "litellm", + mode: "api_key", }, }, - model: { - primary: "litellm/claude-opus-4-6", + }, + agents: { + defaults: { + models: { + "litellm/claude-opus-4-6": { + alias: "LiteLLM", + }, + }, + model: { + primary: "litellm/claude-opus-4-6", + }, }, }, - }, - models: { - mode: "merge", - providers: { - litellm: { - baseUrl: "https://litellm.example/v1", - api: "openai-completions", - models: [LITELLM_DEFAULT_MODEL], + models: { + mode: modelsMode ?? "merge", + providers: { + litellm: { + baseUrl: expectedBaseUrl, + api: "openai-completions", + models: expectedModels, + }, }, }, - }, - }); - expect(resolveApiKey).toHaveBeenCalledWith({ - provider: "litellm", - flagValue: "litellm-test-key", - flagName: "--litellm-api-key", - envVar: "LITELLM_API_KEY", - }); - expect(toApiKeyCredential).toHaveBeenCalledWith({ - provider: "litellm", - resolved: { key: "litellm-test-key", source: "flag" }, - }); - } finally { - rmSync(agentDir, { recursive: true, force: true }); - } - }); + }); + expect(resolveApiKey).toHaveBeenCalledWith({ + provider: "litellm", + flagValue: "litellm-test-key", + flagName: "--litellm-api-key", + envVar: "LITELLM_API_KEY", + }); + expect(toApiKeyCredential).toHaveBeenCalledWith({ + provider: "litellm", + resolved: { key: "litellm-test-key", source: "flag" }, + }); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }, + ); }); diff --git a/extensions/litellm/onboard.test.ts b/extensions/litellm/onboard.test.ts deleted file mode 100644 index 6be011c4a7b1..000000000000 --- a/extensions/litellm/onboard.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Litellm tests cover onboard plugin behavior. -import { expectProviderOnboardMergedLegacyConfig } from "openclaw/plugin-sdk/provider-test-contracts"; -import { describe, expect, it } from "vitest"; -import { applyLitellmProviderConfig } from "./onboard.js"; - -describe("litellm onboard", () => { - it("preserves existing baseUrl and api key while adding the default model", () => { - const provider = expectProviderOnboardMergedLegacyConfig({ - applyProviderConfig: applyLitellmProviderConfig, - providerId: "litellm", - providerApi: "openai-completions", - baseUrl: "https://litellm.example/v1", - legacyApi: "anthropic-messages", - legacyModelId: "custom-model", - legacyModelName: "Custom", - legacyBaseUrl: "https://litellm.example/v1", - legacyApiKey: " old-key ", - }); - - expect(provider?.models.map((m) => m.id)).toEqual(["custom-model", "claude-opus-4-6"]); - }); -}); diff --git a/extensions/litellm/onboard.ts b/extensions/litellm/onboard.ts index 008ce745e310..f6c1e09fcbf1 100644 --- a/extensions/litellm/onboard.ts +++ b/extensions/litellm/onboard.ts @@ -1,6 +1,6 @@ // Litellm setup module handles plugin onboarding behavior. import { - createDefaultModelPresetAppliers, + createDefaultModelsPresetAppliers, type ModelDefinitionConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; @@ -30,7 +30,7 @@ export function buildLitellmModelDefinition(): ModelDefinitionConfig { } export const { applyConfig: applyLitellmConfig, applyProviderConfig: applyLitellmProviderConfig } = - createDefaultModelPresetAppliers<[]>({ + createDefaultModelsPresetAppliers<[]>({ primaryModelRef: LITELLM_DEFAULT_MODEL_REF, resolveParams: (cfg: OpenClawConfig) => { const existingProvider = cfg.models?.providers?.litellm as { baseUrl?: unknown } | undefined; @@ -41,7 +41,9 @@ export const { applyConfig: applyLitellmConfig, applyProviderConfig: applyLitell providerId: "litellm", api: "openai-completions" as const, baseUrl: resolvedBaseUrl || LITELLM_BASE_URL, - defaultModel: buildLitellmModelDefinition(), + // Replace mode disables discovery, so it still needs the configured default. + defaultModels: + resolvedBaseUrl && cfg.models?.mode !== "replace" ? [] : [buildLitellmModelDefinition()], defaultModelId: LITELLM_DEFAULT_MODEL_ID, aliases: [{ modelRef: LITELLM_DEFAULT_MODEL_REF, alias: "LiteLLM" }], }; diff --git a/src/agents/agent-command-local.test.ts b/src/agents/agent-command-local.test.ts index de5db766df0b..e5669ca412b1 100644 --- a/src/agents/agent-command-local.test.ts +++ b/src/agents/agent-command-local.test.ts @@ -189,6 +189,9 @@ describe("agent command static capabilities", () => { } const baseUrl = `http://127.0.0.1:${address.port}/proxy/v1`; const env = { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath }; + if (testCase.inventory === "replace") { + await fs.writeFile(configPath, JSON.stringify({ models: { mode: "replace" } })); + } const onboard = runBuiltCli( home, [ @@ -215,10 +218,19 @@ describe("agent command static capabilities", () => { expect(onboard.status, onboard.stderr).toBe(0); const config = JSON.parse(await fs.readFile(configPath, "utf8")); if (testCase.inventory === "authored") { - config.models.providers.litellm.models[0].contextWindow = 640_000; - config.models.providers.litellm.models[0].reasoning = false; + config.models.providers.litellm.models = [ + { + id: config.agents.defaults.model.primary.slice("litellm/".length), + name: "Authored fixture", + reasoning: false, + input: ["text", "image"], + contextWindow: 640_000, + maxTokens: 128_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ]; } else if (testCase.inventory === "replace") { - config.models.mode = "replace"; + expect(config.models.providers.litellm.models).toHaveLength(1); } else if (testCase.inventory === "generic") { config.models.providers = { "proxy-fixture": { baseUrl, api: "openai-completions", apiKey: key, models: [] },