diff --git a/.env.example b/.env.example index b86a2105d..c24cd6ef5 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ AUTH_TRUST_HOST=true OPENAI_API_KEY=your-openai-api-key-here +# Local OpenAI-compatible server (LM Studio, llama.cpp, vLLM, etc.) +OPENAI_COMPAT_BASE_URL=http://127.0.0.1:1234 +OPENAI_COMPAT_API_KEY= + # DeepSeek API Key - get from https://platform.deepseek.com DEEPSEEK_API_KEY=your-deepseek-api-key-here diff --git a/__tests__/aiSettings.spec.ts b/__tests__/aiSettings.spec.ts new file mode 100644 index 000000000..14f9e41bc --- /dev/null +++ b/__tests__/aiSettings.spec.ts @@ -0,0 +1,12 @@ +vi.mock("@/lib/db", () => ({ default: {} })); + +import { AiProvider } from "@/models/ai.model"; +import { getDefaultModelForProvider } from "@/lib/scraper/automation-run/aiSettings"; + +describe("getDefaultModelForProvider", () => { + it("requires an explicit model for openai-compatible", () => { + expect(() => + getDefaultModelForProvider(AiProvider.OPENAI_COMPATIBLE), + ).toThrow("Select a model for the OpenAI-compatible provider"); + }); +}); diff --git a/__tests__/api-key-resolver.spec.ts b/__tests__/api-key-resolver.spec.ts index 98252b9c2..70d01fe00 100644 --- a/__tests__/api-key-resolver.spec.ts +++ b/__tests__/api-key-resolver.spec.ts @@ -123,3 +123,38 @@ describe("resolveApiKey", () => { expect(mockFindUnique).not.toHaveBeenCalled(); }); }); + +describe("resolveApiKey – openai-compatible-key", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...OLD_ENV }; + delete process.env.OPENAI_COMPAT_API_KEY; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("returns a stored key", async () => { + mockFindUnique.mockResolvedValue({ + id: "k-compat", + encryptedKey: "cipher", + iv: "saltiv", + }); + + const result = await resolveApiKey("user-1", "openai-compatible-key"); + + expect(result).toBe("decrypted(cipher,saltiv)"); + }); + + it("falls back to OPENAI_COMPAT_API_KEY", async () => { + mockFindUnique.mockResolvedValue(null); + process.env.OPENAI_COMPAT_API_KEY = "env-compat-key"; + + const result = await resolveApiKey("user-1", "openai-compatible-key"); + + expect(result).toBe("env-compat-key"); + }); +}); diff --git a/__tests__/openai-compatible-models-route.spec.ts b/__tests__/openai-compatible-models-route.spec.ts new file mode 100644 index 000000000..ec5152fa4 --- /dev/null +++ b/__tests__/openai-compatible-models-route.spec.ts @@ -0,0 +1,57 @@ +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("@/lib/api-key-resolver", () => ({ resolveApiKey: vi.fn() })); +vi.mock("@/lib/telemetry", () => ({ log: { error: vi.fn() } })); +vi.mock("next/server", () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + json: async () => data, + }), + }, +})); + +import { GET } from "@/app/api/ai/openai-compatible/models/route"; +import { auth } from "@/auth"; +import { resolveApiKey } from "@/lib/api-key-resolver"; + +describe("GET /api/ai/openai-compatible/models", () => { + beforeEach(() => { + vi.clearAllMocks(); + (auth as any).mockResolvedValue({ user: { id: "user-1" } }); + }); + + it("returns 500 when no base URL is configured", async () => { + (resolveApiKey as any).mockResolvedValue(undefined); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toContain("base URL not configured"); + }); + + it("lists models from the local /v1/models endpoint", async () => { + (resolveApiKey as any).mockImplementation( + async (_userId: string | undefined, provider: string) => { + if (provider === "openai-compatible") return "http://127.0.0.1:1234/"; + if (provider === "openai-compatible-key") return "sk-local"; + return undefined; + }, + ); + const mockModels = { data: [{ id: "local-model" }] }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockModels, + }); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockModels); + expect(global.fetch).toHaveBeenCalledWith( + "http://127.0.0.1:1234/v1/models", + { headers: { Authorization: "Bearer sk-local" } }, + ); + }); +}); diff --git a/__tests__/provider-registry-server.spec.ts b/__tests__/provider-registry-server.spec.ts index 968eb3a50..5bfe6dbe4 100644 --- a/__tests__/provider-registry-server.spec.ts +++ b/__tests__/provider-registry-server.spec.ts @@ -145,3 +145,35 @@ describe("PROVIDER_FACTORIES – openrouter", () => { ); }); }); + +describe("PROVIDER_VERIFIERS – openai-compatible", () => { + it("verifies a base URL without an API key", async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + + const result = await PROVIDER_VERIFIERS["openai-compatible"]( + "http://127.0.0.1:1234", + ); + + expect(result).toEqual({ success: true }); + expect(global.fetch).toHaveBeenCalledWith( + "http://127.0.0.1:1234/v1/models", + expect.objectContaining({ headers: {} }), + ); + }); + + it("sends a Bearer token when given { baseURL, apiKey }", async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + + await PROVIDER_VERIFIERS["openai-compatible"]({ + baseURL: "http://127.0.0.1:1234/", + apiKey: "sk-local", + }); + + expect(global.fetch).toHaveBeenCalledWith( + "http://127.0.0.1:1234/v1/models", + expect.objectContaining({ + headers: { Authorization: "Bearer sk-local" }, + }), + ); + }); +}); diff --git a/__tests__/provider-registry.spec.ts b/__tests__/provider-registry.spec.ts index 8915e92aa..0ee505d4a 100644 --- a/__tests__/provider-registry.spec.ts +++ b/__tests__/provider-registry.spec.ts @@ -5,15 +5,16 @@ describe("AI_PROVIDERS", () => { expect(AI_PROVIDERS).toContain("openrouter"); }); - it("contains exactly 5 providers", () => { - expect(AI_PROVIDERS).toHaveLength(5); + it("contains exactly 6 providers", () => { + expect(AI_PROVIDERS).toHaveLength(6); }); - it("contains ollama, openai, deepseek, openrouter, gemini", () => { + it("contains ollama, openai, openai-compatible, deepseek, openrouter, gemini", () => { expect(AI_PROVIDERS).toEqual( expect.arrayContaining([ "ollama", "openai", + "openai-compatible", "deepseek", "openrouter", "gemini", @@ -134,3 +135,19 @@ describe("PROVIDER_REGISTRY – openrouter entry", () => { }); }); }); + +describe("PROVIDER_REGISTRY – openai-compatible entry", () => { + const entry = PROVIDER_REGISTRY["openai-compatible"]; + + it("uses a non-sensitive base URL", () => { + expect(entry.credentialType).toBe("base-url"); + expect(entry.keyConfig.sensitive).toBe(false); + expect(entry.modelsEndpoint).toBe("openai-compatible/models"); + }); + + it("parses OpenAI-style model lists", () => { + expect( + entry.parseModelsResponse!({ data: [{ id: "local-model" }] }), + ).toEqual(["local-model"]); + }); +}); diff --git a/__tests__/providers.spec.ts b/__tests__/providers.spec.ts index 3cda27c59..23c9933d6 100644 --- a/__tests__/providers.spec.ts +++ b/__tests__/providers.spec.ts @@ -11,11 +11,18 @@ vi.mock("@/lib/ai/provider-registry.server", () => ({ ollama: vi.fn(), gemini: vi.fn(), }, + openaiCompatibleRoot: (url: string) => + url.replace(/\/+$/, "").replace(/\/v1$/i, ""), +})); + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(), })); import { getModel } from "@/lib/ai/providers"; import { resolveApiKey } from "@/lib/api-key-resolver"; import { PROVIDER_FACTORIES } from "@/lib/ai/provider-registry.server"; +import { createOpenAI } from "@ai-sdk/openai"; describe("getModel – openrouter", () => { const mockModelInstance = { modelId: "openai/gpt-4o" }; @@ -87,3 +94,40 @@ describe("getModel – provider validation", () => { ).resolves.toBeDefined(); }); }); + +describe("getModel – openai-compatible", () => { + const mockModelInstance = { modelId: "local-model" }; + + beforeEach(() => { + vi.clearAllMocks(); + const mockChat = vi.fn().mockReturnValue(mockModelInstance); + (createOpenAI as any).mockReturnValue({ chat: mockChat }); + (resolveApiKey as any).mockImplementation( + async (_userId: string | undefined, provider: string) => { + if (provider === "openai-compatible") return "http://127.0.0.1:1234"; + if (provider === "openai-compatible-key") return "sk-local"; + return undefined; + }, + ); + }); + + it("builds a Chat Completions client from base URL and optional API key", async () => { + const result = await getModel("openai-compatible", "local-model", "user-1"); + + expect(createOpenAI).toHaveBeenCalledWith({ + baseURL: "http://127.0.0.1:1234/v1", + apiKey: "sk-local", + name: "openai-compatible", + }); + expect((createOpenAI as any).mock.results[0].value.chat).toHaveBeenCalledWith( + "local-model", + ); + expect(result).toBe(mockModelInstance); + }); + + it("throws when no model is selected", async () => { + await expect(getModel("openai-compatible", "", "user-1")).rejects.toThrow( + "Select a model for the OpenAI-compatible provider", + ); + }); +}); diff --git a/__tests__/verify-api-key-route.spec.ts b/__tests__/verify-api-key-route.spec.ts index 372e2bf2b..8a981f69d 100644 --- a/__tests__/verify-api-key-route.spec.ts +++ b/__tests__/verify-api-key-route.spec.ts @@ -18,6 +18,9 @@ vi.mock("@/lib/ai/provider-registry.server", () => ({ get openai() { return mockVerifier; }, + get "openai-compatible"() { + return mockVerifier; + }, }, })); @@ -106,4 +109,21 @@ describe("POST /api/settings/api-keys/verify", () => { expect(data.error).toBe("Invalid API key"); expect(data.reason).toBeUndefined(); }); + + it("passes openai-compatible credentials as { baseURL, apiKey }", async () => { + mockVerifier.mockResolvedValue({ success: true }); + + await POST( + req({ + provider: "openai-compatible", + key: "http://127.0.0.1:1234", + apiKey: "sk-local", + }), + ); + + expect(mockVerifier).toHaveBeenCalledWith({ + baseURL: "http://127.0.0.1:1234", + apiKey: "sk-local", + }); + }); }); diff --git a/src/app/api/ai/openai-compatible/models/route.ts b/src/app/api/ai/openai-compatible/models/route.ts new file mode 100644 index 000000000..5e3aef888 --- /dev/null +++ b/src/app/api/ai/openai-compatible/models/route.ts @@ -0,0 +1,50 @@ +import { auth } from "@/auth"; +import { NextResponse } from "next/server"; +import { resolveApiKey } from "@/lib/api-key-resolver"; +import { openaiCompatibleRoot } from "@/lib/ai/provider-registry.server"; +import { log } from "@/lib/telemetry"; + +export async function GET() { + try { + const session = await auth(); + const userId = session?.user?.id; + + const baseURL = await resolveApiKey(userId, "openai-compatible"); + if (!baseURL) { + return NextResponse.json( + { error: "OpenAI-compatible base URL not configured" }, + { status: 500 }, + ); + } + + const apiKey = await resolveApiKey(userId, "openai-compatible-key"); + const headers: Record = {}; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const response = await fetch( + `${openaiCompatibleRoot(baseURL)}/v1/models`, + { headers }, + ); + + if (!response.ok) { + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status }, + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + log.error("Error fetching models", { + provider: "openai-compatible", + error: String(error), + }); + return NextResponse.json( + { error: "Failed to fetch models from OpenAI-compatible endpoint" }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/settings/api-keys/verify/route.ts b/src/app/api/settings/api-keys/verify/route.ts index 2269e0401..37961ba24 100644 --- a/src/app/api/settings/api-keys/verify/route.ts +++ b/src/app/api/settings/api-keys/verify/route.ts @@ -8,7 +8,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: false, error: "Not authenticated" }, { status: 401 }); } - const { provider, key } = await req.json(); + const body = await req.json(); + const { provider, key, apiKey } = body; if (!provider || !key) { return NextResponse.json( @@ -26,7 +27,9 @@ export async function POST(req: NextRequest) { } try { - const result = await verifier(key); + const verifyKey = + provider === "openai-compatible" ? { baseURL: key, apiKey } : key; + const result = await verifier(verifyKey); return NextResponse.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Verification failed"; diff --git a/src/components/settings/ApiKeySettings.tsx b/src/components/settings/ApiKeySettings.tsx index f09ae93df..d6183e09d 100644 --- a/src/components/settings/ApiKeySettings.tsx +++ b/src/components/settings/ApiKeySettings.tsx @@ -68,6 +68,7 @@ function ApiKeySettings() { null, ); const [inputValue, setInputValue] = useState(""); + const [openaiCompatApiKey, setOpenaiCompatApiKey] = useState(""); const [verifying, setVerifying] = useState(false); const [deleting, setDeleting] = useState(null); const [ollamaConnected, setOllamaConnected] = useState(null); @@ -119,10 +120,14 @@ function ApiKeySettings() { setVerifying(true); try { + const verifyBody = + provider === "openai-compatible" + ? { provider, key: inputValue, apiKey: openaiCompatApiKey || undefined } + : { provider, key: inputValue }; const verifyRes = await fetch("/api/settings/api-keys/verify", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, key: inputValue }), + body: JSON.stringify(verifyBody), }); const verifyData = await verifyRes.json(); @@ -136,18 +141,32 @@ function ApiKeySettings() { } const providerConfig = PROVIDERS.find((p) => p.id === provider); - const saveResult = await saveApiKey({ + const saveBaseResult = await saveApiKey({ provider, key: inputValue, sensitive: providerConfig?.sensitive ?? true, }); - if (saveResult.success) { + + let saveKeyResult: { success: boolean; message?: string } | null = null; + if (provider === "openai-compatible" && openaiCompatApiKey.trim()) { + saveKeyResult = await saveApiKey({ + provider: "openai-compatible-key", + key: openaiCompatApiKey, + sensitive: true, + }); + } + + if (saveBaseResult.success && (!saveKeyResult || saveKeyResult.success)) { toastSuccess(`${PROVIDERS.find((p) => p.id === provider)?.name} key verified and saved.`, "API key saved"); setEditingProvider(null); setInputValue(""); + setOpenaiCompatApiKey(""); await fetchKeys(); } else { - toastError(saveResult.message || "Failed to save API key", "Save failed"); + toastError( + saveBaseResult.message || saveKeyResult?.message || "Failed to save API key", + "Save failed", + ); } } catch (error) { console.error("Error saving API key:", error); @@ -160,12 +179,17 @@ function ApiKeySettings() { const handleDelete = async (provider: ApiKeyProvider) => { setDeleting(provider); try { - const result = await deleteApiKey(provider); - if (result.success) { + const results = await Promise.all([ + deleteApiKey(provider), + ...(provider === "openai-compatible" + ? [deleteApiKey("openai-compatible-key")] + : []), + ]); + if (results.every((r) => r.success)) { toastSuccess(`${PROVIDERS.find((p) => p.id === provider)?.name} key removed.`, "API key deleted"); await fetchKeys(); } else { - toastError(result.message || "Failed to delete API key"); + toastError("Failed to delete API key"); } } catch (error) { console.error("Error deleting API key:", error); @@ -177,6 +201,7 @@ function ApiKeySettings() { const handleCancel = () => { setEditingProvider(null); setInputValue(""); + setOpenaiCompatApiKey(""); }; if (isLoading) { @@ -237,6 +262,18 @@ function ApiKeySettings() { ) : ( Not configured )} + {provider.id === "openai-compatible" && ( +
+ {keys.find((k) => k.provider === "openai-compatible-key") ? ( + + + API Key + + ) : ( + No API Key + )} +
+ )} {provider.id === "ollama" && ( @@ -290,6 +327,21 @@ function ApiKeySettings() { className="mt-1" /> + {provider.id === "openai-compatible" && ( +
+ + setOpenaiCompatApiKey(e.target.value)} + className="mt-1" + /> +
+ )}