Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/__tests__/providerConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
42 changes: 42 additions & 0 deletions src/__tests__/providerOutbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
5 changes: 4 additions & 1 deletion src/__tests__/providerRuntimeSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/urlPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/components/settings/ProviderSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ const ProviderSettings = () => {
value: "OpenAI",
label: t("openaiResponses"),
},
{
value: "LiteLLM",
label: "LiteLLM",
},
{
value: "Gemini",
label: "Gemini",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/lib/providers/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down
12 changes: 9 additions & 3 deletions src/lib/providers/providerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
3 changes: 2 additions & 1 deletion src/lib/providers/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/lib/security/urlPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/utils/apiHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down