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
3 changes: 3 additions & 0 deletions apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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<HTMLFormElement>(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);
Expand All @@ -56,6 +79,7 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider
function resetForm() {
setSelectedProvider(PROVIDER_TYPES[0].id);
setApiKey("");
setBaseUrl("");
}

function handleCancel() {
Expand All @@ -77,7 +101,7 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
{t("llm.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={!apiKey.trim()}>
<button type="submit" className="btn btn-primary" disabled={!canSubmit}>
{t("llm.add_provider")}
</button>
</>
Expand All @@ -93,6 +117,23 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider
/>
</FormGroup>

<FormGroup
name="base-url"
label={t("llm.base_url")}
description={
!baseUrlIsValid
? <span className="text-danger">{t("llm.base_url_invalid")}</span>
: t("llm.base_url_description")
}
>
<FormTextBox
type="text"
currentValue={baseUrl}
onChange={setBaseUrl}
placeholder={providerType?.defaultBaseUrl}
/>
</FormGroup>

<FormGroup name="api-key" label={t("llm.api_key")}>
<FormTextBox
type="password"
Expand Down
12 changes: 7 additions & 5 deletions apps/server/src/services/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ export interface LlmProviderSetup {
name: string;
provider: string;
apiKey: string;
/** Optional override for the SDK's default API endpoint (e.g. for self-hosted Ollama, vLLM, or proxies). */
baseURL?: string;
}

/** Factory functions for creating provider instances */
const providerFactories: Record<string, (apiKey: string) => LlmProvider> = {
anthropic: (apiKey) => new AnthropicProvider(apiKey),
openai: (apiKey) => new OpenAiProvider(apiKey),
google: (apiKey) => new GoogleProvider(apiKey)
const providerFactories: Record<string, (apiKey: string, baseURL?: string) => 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 */
Expand Down Expand Up @@ -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;
}
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/services/llm/providers/anthropic.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
4 changes: 2 additions & 2 deletions apps/server/src/services/llm/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/services/llm/providers/google.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
4 changes: 2 additions & 2 deletions apps/server/src/services/llm/providers/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/services/llm/providers/openai.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
4 changes: 2 additions & 2 deletions apps/server/src/services/llm/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading