diff --git a/src/__tests__/providerConfig.test.ts b/src/__tests__/providerConfig.test.ts index 7cf12bd5..6bf38f52 100644 --- a/src/__tests__/providerConfig.test.ts +++ b/src/__tests__/providerConfig.test.ts @@ -63,6 +63,28 @@ describe("provider config normalization", () => { ).toBe("OpenAI Compatible"); }); + it("accepts LiteLLM as a valid provider type", () => { + const provider = normalizeModelProvider({ + id: "LITELLM", + name: "LiteLLM Proxy", + type: "LiteLLM", + baseUrl: "http://localhost:4000", + apiKey: "sk-litellm-key", + models: ["gpt-4o", "claude-sonnet-4-20250514"], + modelsList: ["gpt-4o", "claude-sonnet-4-20250514"], + }); + + expect(provider).toMatchObject({ + id: "LITELLM", + name: "LiteLLM Proxy", + type: "LiteLLM", + baseUrl: "http://localhost:4000", + apiKey: "sk-litellm-key", + models: ["gpt-4o", "claude-sonnet-4-20250514"], + modelsList: ["gpt-4o", "claude-sonnet-4-20250514"], + }); + }); + it("defaults unknown provider types to OpenAI Compatible", () => { expect( normalizeModelProvider({ diff --git a/src/__tests__/providerOutbound.test.ts b/src/__tests__/providerOutbound.test.ts index 2a386e69..d0f1739d 100644 --- a/src/__tests__/providerOutbound.test.ts +++ b/src/__tests__/providerOutbound.test.ts @@ -62,4 +62,46 @@ describe("provider outbound policy", () => { }), ).not.toThrow(); }); + + it("creates an OpenAI-compatible client for LiteLLM with a custom base URL", async () => { + const { ProviderFactory } = await import("../lib/providers/base"); + + expect(() => + ProviderFactory.createOpenAIClient({ + type: "LiteLLM", + baseUrl: "https://litellm.example.com/v1", + apiKey: "sk-litellm-key", + }), + ).not.toThrow(); + }); + + it("blocks LiteLLM default localhost URL in hosted mode", async () => { + vi.stubEnv("DEPLOYMENT_MODE", "hosted"); + lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]); + + const { ProviderFactory } = await import("../lib/providers/base"); + + await expect( + ProviderFactory.assertProviderOutboundAllowed({ + type: "LiteLLM", + baseUrl: "http://localhost:4000/v1", + apiKey: "sk-litellm-key", + }), + ).rejects.toMatchObject({ + code: "HOSTED_PROXY_BLOCKED", + }); + }); + + it("allows LiteLLM with a public base URL in hosted mode", async () => { + vi.stubEnv("DEPLOYMENT_MODE", "hosted"); + const { ProviderFactory } = await import("../lib/providers/base"); + + expect(() => + ProviderFactory.createOpenAIClient({ + type: "LiteLLM", + baseUrl: "https://litellm.example.com/v1", + apiKey: "sk-litellm-key", + }), + ).not.toThrow(); + }); }); diff --git a/src/__tests__/providerRuntimeSchema.test.ts b/src/__tests__/providerRuntimeSchema.test.ts index e2d8a7b3..945d6d70 100644 --- a/src/__tests__/providerRuntimeSchema.test.ts +++ b/src/__tests__/providerRuntimeSchema.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest"; import { ProviderRuntimeConfigSchema } from "../lib/api/schemas"; describe("provider runtime schema", () => { - it("accepts Gemini, OpenAI, and OpenAI Compatible provider types", () => { + it("accepts Gemini, OpenAI, LiteLLM, and OpenAI Compatible provider types", () => { expect(ProviderRuntimeConfigSchema.parse({ type: "Gemini" }).type).toBe( "Gemini", ); expect(ProviderRuntimeConfigSchema.parse({ type: "OpenAI" }).type).toBe( "OpenAI", ); + expect(ProviderRuntimeConfigSchema.parse({ type: "LiteLLM" }).type).toBe( + "LiteLLM", + ); expect( ProviderRuntimeConfigSchema.parse({ type: "OpenAI Compatible" }).type, ).toBe("OpenAI Compatible"); diff --git a/src/__tests__/urlPolicy.test.ts b/src/__tests__/urlPolicy.test.ts index 99311552..d2a1c3e7 100644 --- a/src/__tests__/urlPolicy.test.ts +++ b/src/__tests__/urlPolicy.test.ts @@ -38,6 +38,36 @@ describe("url policy and provider runtime helpers", () => { ).toBe("https://compat.example.com/v1"); }); + it("returns default LiteLLM base URL when no URL is provided", () => { + expect(normalizeProviderBaseUrl(undefined, "LiteLLM")).toBe( + "http://localhost:4000/v1", + ); + expect(normalizeProviderBaseUrl("default", "LiteLLM")).toBe( + "http://localhost:4000/v1", + ); + }); + + it("preserves /v1 suffix on custom LiteLLM base URLs", () => { + expect( + normalizeProviderBaseUrl("https://litellm.example.com/v1", "LiteLLM"), + ).toBe("https://litellm.example.com/v1"); + }); + + it("appends /v1 to custom LiteLLM base URLs without it", () => { + expect( + normalizeProviderBaseUrl("https://litellm.example.com", "LiteLLM"), + ).toBe("https://litellm.example.com/v1"); + }); + + it("returns LiteLLM models URL under /v1/models", () => { + expect(getProviderModelsUrl(undefined, "LiteLLM")).toBe( + "http://localhost:4000/v1/models", + ); + expect(getProviderModelsUrl("https://litellm.example.com", "LiteLLM")).toBe( + "https://litellm.example.com/v1/models", + ); + }); + it("keeps Gemini SDK base URL at the service root", () => { expect( normalizeProviderBaseUrl( diff --git a/src/components/settings/ProviderSettings.tsx b/src/components/settings/ProviderSettings.tsx index 1707718d..f874eefe 100644 --- a/src/components/settings/ProviderSettings.tsx +++ b/src/components/settings/ProviderSettings.tsx @@ -116,6 +116,10 @@ const ProviderSettings = () => { value: "OpenAI", label: t("openaiResponses"), }, + { + value: "LiteLLM", + label: "LiteLLM", + }, { value: "Gemini", label: "Gemini", diff --git a/src/lib/api/schemas.ts b/src/lib/api/schemas.ts index 9ae808f2..faeaa582 100644 --- a/src/lib/api/schemas.ts +++ b/src/lib/api/schemas.ts @@ -51,7 +51,7 @@ function omitPlainSecretField< export const ProviderRuntimeConfigSchema = z .object({ - type: z.enum(["OpenAI", "Gemini", "OpenAI Compatible"]), + type: z.enum(["OpenAI", "Gemini", "LiteLLM", "OpenAI Compatible"]), source: z.literal("server-default").optional(), apiKey: z.unknown().optional(), apiKeySecret: EncryptedSecretEnvelopeSchema.optional(), diff --git a/src/lib/providers/base.ts b/src/lib/providers/base.ts index a16d7710..7888f0c9 100644 --- a/src/lib/providers/base.ts +++ b/src/lib/providers/base.ts @@ -67,7 +67,7 @@ export class ProviderFactory { */ static createOpenAIClient(provider: ProviderConfig): OpenAI { const apiKey = this.validateApiKey(provider); - const baseURL = this.getEffectiveBaseUrl(provider.baseUrl, "OpenAI"); + const baseURL = this.getEffectiveBaseUrl(provider.baseUrl, provider.type); if (baseURL) { validateOutboundUrl(baseURL, getSafeUrlPolicy("provider")); } diff --git a/src/lib/providers/providerTypes.ts b/src/lib/providers/providerTypes.ts index f47af086..73bbcabb 100644 --- a/src/lib/providers/providerTypes.ts +++ b/src/lib/providers/providerTypes.ts @@ -3,20 +3,26 @@ import type { ProviderType } from "../../types"; export const OPENAI_PROVIDER_TYPE = "OpenAI" as const; export const OPENAI_COMPATIBLE_PROVIDER_TYPE = "OpenAI Compatible" as const; export const GEMINI_PROVIDER_TYPE = "Gemini" as const; +export const LITELLM_PROVIDER_TYPE = "LiteLLM" as const; export function isProviderType(value: unknown): value is ProviderType { return ( value === GEMINI_PROVIDER_TYPE || value === OPENAI_PROVIDER_TYPE || - value === OPENAI_COMPATIBLE_PROVIDER_TYPE + value === OPENAI_COMPATIBLE_PROVIDER_TYPE || + value === LITELLM_PROVIDER_TYPE ); } export function isOpenAIProviderType( value: unknown, ): value is - typeof OPENAI_PROVIDER_TYPE | typeof OPENAI_COMPATIBLE_PROVIDER_TYPE { + | typeof OPENAI_PROVIDER_TYPE + | typeof OPENAI_COMPATIBLE_PROVIDER_TYPE + | typeof LITELLM_PROVIDER_TYPE { return ( - value === OPENAI_PROVIDER_TYPE || value === OPENAI_COMPATIBLE_PROVIDER_TYPE + value === OPENAI_PROVIDER_TYPE || + value === OPENAI_COMPATIBLE_PROVIDER_TYPE || + value === LITELLM_PROVIDER_TYPE ); } diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts index fb432e1f..1e93869f 100644 --- a/src/lib/providers/types.ts +++ b/src/lib/providers/types.ts @@ -1,6 +1,7 @@ import type { LocalEncryptedSecretEnvelope } from "../security/localSecrets"; -export type ProviderType = "Gemini" | "OpenAI" | "OpenAI Compatible"; +export type ProviderType = + "Gemini" | "LiteLLM" | "OpenAI" | "OpenAI Compatible"; export interface ModelProvider { id: string; diff --git a/src/lib/security/urlPolicy.ts b/src/lib/security/urlPolicy.ts index 764eaffd..c6ce3557 100644 --- a/src/lib/security/urlPolicy.ts +++ b/src/lib/security/urlPolicy.ts @@ -51,12 +51,14 @@ export interface ProviderRuntimeConfig { const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com"; +const DEFAULT_LITELLM_BASE_URL = "http://localhost:4000/v1"; export function normalizeProviderBaseUrl( baseUrl: string | undefined, providerType: ProviderRuntimeConfig["type"] | string, ): string { if (!baseUrl || baseUrl === "default") { + if (providerType === "LiteLLM") return DEFAULT_LITELLM_BASE_URL; return isOpenAIProviderType(providerType) ? DEFAULT_OPENAI_BASE_URL : DEFAULT_GEMINI_BASE_URL; diff --git a/src/utils/apiHelpers.ts b/src/utils/apiHelpers.ts index 17e4c9c9..ec159189 100644 --- a/src/utils/apiHelpers.ts +++ b/src/utils/apiHelpers.ts @@ -17,7 +17,7 @@ import { logDevError } from "../lib/utils/devLogger"; * Provider 配置接口 */ export interface ProviderConfig { - type: "OpenAI" | "Gemini" | "OpenAI Compatible"; + type: "OpenAI" | "Gemini" | "LiteLLM" | "OpenAI Compatible"; apiKey?: string; baseUrl?: string; }