From 81f9a5a515c712b72bc45376dc0d26dd2b3f7da5 Mon Sep 17 00:00:00 2001 From: RheagalFire Date: Thu, 9 Jul 2026 20:03:11 +0530 Subject: [PATCH 1/4] feat: add LiteLLM as AI gateway provider --- src/__tests__/providerRuntimeSchema.test.ts | 5 ++++- src/components/settings/ProviderSettings.tsx | 4 ++++ src/lib/api/schemas.ts | 2 +- src/lib/providers/providerTypes.ts | 12 +++++++++--- src/lib/providers/types.ts | 2 +- src/lib/security/urlPolicy.ts | 2 ++ 6 files changed, 21 insertions(+), 6 deletions(-) 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/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/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..b292ee3d 100644 --- a/src/lib/providers/types.ts +++ b/src/lib/providers/types.ts @@ -1,6 +1,6 @@ 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; From 68c655da0190af14db47258db2dc6011f26f6a0e Mon Sep 17 00:00:00 2001 From: RheagalFire Date: Thu, 9 Jul 2026 22:15:06 +0530 Subject: [PATCH 2/4] fix: use provider.type for base URL resolution so LiteLLM gets its own default --- src/lib/providers/base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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")); } From 366f45b0949b1d2747123b615515d041eaa6d9e0 Mon Sep 17 00:00:00 2001 From: RheagalFire Date: Thu, 9 Jul 2026 23:19:59 +0530 Subject: [PATCH 3/4] test: add LiteLLM URL policy and provider config tests --- src/__tests__/providerConfig.test.ts | 22 ++++++++++++++ src/__tests__/providerOutbound.test.ts | 42 ++++++++++++++++++++++++++ src/__tests__/urlPolicy.test.ts | 30 ++++++++++++++++++ 3 files changed, 94 insertions(+) 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__/urlPolicy.test.ts b/src/__tests__/urlPolicy.test.ts index 99311552..b30b7b8d 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( From ac5b149b2721e805868a4fead69ad15dcd774610 Mon Sep 17 00:00:00 2001 From: RheagalFire Date: Fri, 10 Jul 2026 00:28:17 +0530 Subject: [PATCH 4/4] fix: formatting and add LiteLLM to apiHelpers ProviderConfig type --- src/__tests__/urlPolicy.test.ts | 6 +++--- src/lib/providers/types.ts | 3 ++- src/utils/apiHelpers.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/__tests__/urlPolicy.test.ts b/src/__tests__/urlPolicy.test.ts index b30b7b8d..d2a1c3e7 100644 --- a/src/__tests__/urlPolicy.test.ts +++ b/src/__tests__/urlPolicy.test.ts @@ -63,9 +63,9 @@ describe("url policy and provider runtime helpers", () => { expect(getProviderModelsUrl(undefined, "LiteLLM")).toBe( "http://localhost:4000/v1/models", ); - expect( - getProviderModelsUrl("https://litellm.example.com", "LiteLLM"), - ).toBe("https://litellm.example.com/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", () => { diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts index b292ee3d..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" | "LiteLLM" | "OpenAI" | "OpenAI Compatible"; +export type ProviderType = + "Gemini" | "LiteLLM" | "OpenAI" | "OpenAI Compatible"; export interface ModelProvider { id: string; 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; }