From d4f003b9621631119f20073c6235527cf676cb8f Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Sun, 10 May 2026 11:00:53 -0700 Subject: [PATCH] feat(llm): support baseurls for llm providers --- .../src/translations/en/translation.json | 3 + .../options/llm/AddProviderModal.tsx | 57 ++++++++++++++++--- apps/server/src/services/llm/index.ts | 12 ++-- .../services/llm/providers/anthropic.spec.ts | 43 ++++++++++++++ .../src/services/llm/providers/anthropic.ts | 4 +- .../src/services/llm/providers/google.spec.ts | 43 ++++++++++++++ .../src/services/llm/providers/google.ts | 4 +- .../src/services/llm/providers/openai.spec.ts | 43 ++++++++++++++ .../src/services/llm/providers/openai.ts | 4 +- 9 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/services/llm/providers/anthropic.spec.ts create mode 100644 apps/server/src/services/llm/providers/google.spec.ts create mode 100644 apps/server/src/services/llm/providers/openai.spec.ts diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index cf997391e7a..5b0883ea5c0 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2524,6 +2524,9 @@ "delete_provider_confirmation": "Are you sure you want to delete the provider \"{{name}}\"?", "api_key": "API Key", "api_key_placeholder": "Enter your API key", + "base_url": "Base URL", + "base_url_description": "Optional. Override the default API endpoint — useful for self-hosted models (Ollama, LM Studio, vLLM) or proxies.", + "base_url_invalid": "Base URL must be a valid http:// or https:// URL", "cancel": "Cancel", "mcp_title": "MCP (Model Context Protocol)", "mcp_enabled": "MCP server", diff --git a/apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx b/apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx index 4538cde3b88..4cfe0a9fcb3 100644 --- a/apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx +++ b/apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx @@ -1,5 +1,5 @@ import { createPortal } from "preact/compat"; -import { useState, useRef } from "preact/hooks"; +import { useMemo, useRef, useState } from "preact/hooks"; import Modal from "../../../react/Modal"; import FormGroup from "../../../react/FormGroup"; import FormSelect from "../../../react/FormSelect"; @@ -11,19 +11,33 @@ export interface LlmProviderConfig { name: string; provider: string; apiKey: string; + baseURL?: string; } export interface ProviderType { id: string; name: string; + defaultBaseUrl: string; } export const PROVIDER_TYPES: ProviderType[] = [ - { id: "anthropic", name: "Anthropic" }, - { id: "openai", name: "OpenAI" }, - { id: "google", name: "Google Gemini" } + { id: "anthropic", name: "Anthropic", defaultBaseUrl: "https://api.anthropic.com/v1" }, + { id: "openai", name: "OpenAI", defaultBaseUrl: "https://api.openai.com/v1" }, + { id: "google", name: "Google Gemini", defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta" } ]; +function isValidBaseUrl(value: string): boolean { + if (!value) { + return true; + } + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + interface AddProviderModalProps { show: boolean; onHidden: () => void; @@ -33,19 +47,28 @@ interface AddProviderModalProps { export default function AddProviderModal({ show, onHidden, onSave }: AddProviderModalProps) { const [selectedProvider, setSelectedProvider] = useState(PROVIDER_TYPES[0].id); const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); const formRef = useRef(null); + const providerType = useMemo( + () => PROVIDER_TYPES.find(p => p.id === selectedProvider), + [selectedProvider] + ); + const trimmedBaseUrl = baseUrl.trim(); + const baseUrlIsValid = isValidBaseUrl(trimmedBaseUrl); + const canSubmit = !!apiKey.trim() && baseUrlIsValid; + function handleSubmit() { - if (!apiKey.trim()) { + if (!canSubmit) { return; } - const providerType = PROVIDER_TYPES.find(p => p.id === selectedProvider); const newProvider: LlmProviderConfig = { id: `${selectedProvider}_${Date.now()}`, name: providerType?.name || selectedProvider, provider: selectedProvider, - apiKey: apiKey.trim() + apiKey: apiKey.trim(), + ...(trimmedBaseUrl && { baseURL: trimmedBaseUrl }) }; onSave(newProvider); @@ -56,6 +79,7 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider function resetForm() { setSelectedProvider(PROVIDER_TYPES[0].id); setApiKey(""); + setBaseUrl(""); } function handleCancel() { @@ -77,7 +101,7 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider - @@ -93,6 +117,23 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider /> + {t("llm.base_url_invalid")} + : t("llm.base_url_description") + } + > + + + LlmProvider> = { - anthropic: (apiKey) => new AnthropicProvider(apiKey), - openai: (apiKey) => new OpenAiProvider(apiKey), - google: (apiKey) => new GoogleProvider(apiKey) +const providerFactories: Record LlmProvider> = { + anthropic: (apiKey, baseURL) => new AnthropicProvider(apiKey, baseURL), + openai: (apiKey, baseURL) => new OpenAiProvider(apiKey, baseURL), + google: (apiKey, baseURL) => new GoogleProvider(apiKey, baseURL) }; /** Cache of instantiated providers by their config ID */ @@ -73,7 +75,7 @@ export function getProvider(providerId?: string): LlmProvider { throw new Error(`Unknown LLM provider type: ${config.provider}. Available: ${Object.keys(providerFactories).join(", ")}`); } - const provider = factory(config.apiKey); + const provider = factory(config.apiKey, config.baseURL); cachedProviders[config.id] = provider; return provider; } diff --git a/apps/server/src/services/llm/providers/anthropic.spec.ts b/apps/server/src/services/llm/providers/anthropic.spec.ts new file mode 100644 index 00000000000..96b6eb6af16 --- /dev/null +++ b/apps/server/src/services/llm/providers/anthropic.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const createAnthropicMock = vi.fn(); + +vi.mock("@ai-sdk/anthropic", () => ({ + createAnthropic: (opts: unknown) => { + createAnthropicMock(opts); + const fn: any = () => ({}); + fn.tools = { webSearch_20250305: () => ({}) }; + return fn; + } +})); + +import { AnthropicProvider } from "./anthropic.js"; + +describe("AnthropicProvider construction", () => { + beforeEach(() => { + createAnthropicMock.mockClear(); + }); + + it("forwards apiKey only when no baseURL provided", () => { + new AnthropicProvider("sk-ant-test"); + expect(createAnthropicMock).toHaveBeenCalledTimes(1); + expect(createAnthropicMock).toHaveBeenCalledWith({ apiKey: "sk-ant-test" }); + }); + + it("forwards apiKey and baseURL when both provided", () => { + new AnthropicProvider("sk-ant-test", "https://proxy.example.com/v1"); + expect(createAnthropicMock).toHaveBeenCalledWith({ + apiKey: "sk-ant-test", + baseURL: "https://proxy.example.com/v1" + }); + }); + + it("omits baseURL when empty string is provided", () => { + new AnthropicProvider("sk-ant-test", ""); + expect(createAnthropicMock).toHaveBeenCalledWith({ apiKey: "sk-ant-test" }); + }); + + it("throws when apiKey is missing", () => { + expect(() => new AnthropicProvider("")).toThrow(/API key is required/); + }); +}); diff --git a/apps/server/src/services/llm/providers/anthropic.ts b/apps/server/src/services/llm/providers/anthropic.ts index 3e57ceb5e60..6ce79a243de 100644 --- a/apps/server/src/services/llm/providers/anthropic.ts +++ b/apps/server/src/services/llm/providers/anthropic.ts @@ -84,12 +84,12 @@ export class AnthropicProvider extends BaseProvider { private anthropic: AnthropicSDKProvider; - constructor(apiKey: string) { + constructor(apiKey: string, baseURL?: string) { super(); if (!apiKey) { throw new Error("API key is required for Anthropic provider"); } - this.anthropic = createAnthropic({ apiKey }); + this.anthropic = createAnthropic({ apiKey, ...(baseURL && { baseURL }) }); } protected createModel(modelId: string) { diff --git a/apps/server/src/services/llm/providers/google.spec.ts b/apps/server/src/services/llm/providers/google.spec.ts new file mode 100644 index 00000000000..88974f22f17 --- /dev/null +++ b/apps/server/src/services/llm/providers/google.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const createGoogleMock = vi.fn(); + +vi.mock("@ai-sdk/google", () => ({ + createGoogleGenerativeAI: (opts: unknown) => { + createGoogleMock(opts); + const fn: any = () => ({}); + fn.tools = { googleSearch: () => ({}) }; + return fn; + } +})); + +import { GoogleProvider } from "./google.js"; + +describe("GoogleProvider construction", () => { + beforeEach(() => { + createGoogleMock.mockClear(); + }); + + it("forwards apiKey only when no baseURL provided", () => { + new GoogleProvider("test-key"); + expect(createGoogleMock).toHaveBeenCalledTimes(1); + expect(createGoogleMock).toHaveBeenCalledWith({ apiKey: "test-key" }); + }); + + it("forwards apiKey and baseURL when both provided", () => { + new GoogleProvider("test-key", "https://proxy.example.com/v1beta"); + expect(createGoogleMock).toHaveBeenCalledWith({ + apiKey: "test-key", + baseURL: "https://proxy.example.com/v1beta" + }); + }); + + it("omits baseURL when empty string is provided", () => { + new GoogleProvider("test-key", ""); + expect(createGoogleMock).toHaveBeenCalledWith({ apiKey: "test-key" }); + }); + + it("throws when apiKey is missing", () => { + expect(() => new GoogleProvider("")).toThrow(/API key is required/); + }); +}); diff --git a/apps/server/src/services/llm/providers/google.ts b/apps/server/src/services/llm/providers/google.ts index e33b1bcccab..564a5d42b05 100644 --- a/apps/server/src/services/llm/providers/google.ts +++ b/apps/server/src/services/llm/providers/google.ts @@ -48,12 +48,12 @@ export class GoogleProvider extends BaseProvider { private google: GoogleGenerativeAIProvider; - constructor(apiKey: string) { + constructor(apiKey: string, baseURL?: string) { super(); if (!apiKey) { throw new Error("API key is required for Google provider"); } - this.google = createGoogleGenerativeAI({ apiKey }); + this.google = createGoogleGenerativeAI({ apiKey, ...(baseURL && { baseURL }) }); } protected createModel(modelId: string) { diff --git a/apps/server/src/services/llm/providers/openai.spec.ts b/apps/server/src/services/llm/providers/openai.spec.ts new file mode 100644 index 00000000000..3a56467493c --- /dev/null +++ b/apps/server/src/services/llm/providers/openai.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const createOpenAIMock = vi.fn(); + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: (opts: unknown) => { + createOpenAIMock(opts); + const fn: any = () => ({}); + fn.tools = { webSearch: () => ({}) }; + return fn; + } +})); + +import { OpenAiProvider } from "./openai.js"; + +describe("OpenAiProvider construction", () => { + beforeEach(() => { + createOpenAIMock.mockClear(); + }); + + it("forwards apiKey only when no baseURL provided", () => { + new OpenAiProvider("sk-test"); + expect(createOpenAIMock).toHaveBeenCalledTimes(1); + expect(createOpenAIMock).toHaveBeenCalledWith({ apiKey: "sk-test" }); + }); + + it("forwards apiKey and baseURL when both provided", () => { + new OpenAiProvider("sk-test", "http://localhost:11434/v1"); + expect(createOpenAIMock).toHaveBeenCalledWith({ + apiKey: "sk-test", + baseURL: "http://localhost:11434/v1" + }); + }); + + it("omits baseURL when empty string is provided", () => { + new OpenAiProvider("sk-test", ""); + expect(createOpenAIMock).toHaveBeenCalledWith({ apiKey: "sk-test" }); + }); + + it("throws when apiKey is missing", () => { + expect(() => new OpenAiProvider("")).toThrow(/API key is required/); + }); +}); diff --git a/apps/server/src/services/llm/providers/openai.ts b/apps/server/src/services/llm/providers/openai.ts index 759d31c7e71..1a48981ce5e 100644 --- a/apps/server/src/services/llm/providers/openai.ts +++ b/apps/server/src/services/llm/providers/openai.ts @@ -66,12 +66,12 @@ export class OpenAiProvider extends BaseProvider { private openai: OpenAISDKProvider; - constructor(apiKey: string) { + constructor(apiKey: string, baseURL?: string) { super(); if (!apiKey) { throw new Error("API key is required for OpenAI provider"); } - this.openai = createOpenAI({ apiKey }); + this.openai = createOpenAI({ apiKey, ...(baseURL && { baseURL }) }); } protected createModel(modelId: string) {