From 5b3d00de30dec39c102792af4b4c4e2285dc3389 Mon Sep 17 00:00:00 2001 From: Ryan Voots Date: Wed, 8 Jul 2026 06:17:51 -0400 Subject: [PATCH] feat: add openai-compatible provider for custom OpenAI API endpoints Add support for any OpenAI-compatible LLM API (e.g. LM Studio, Ollama with OpenAI API, LiteLLM, Azure OpenAI). Users configure a base URL and optional API key via the settings UI, or via OPENAI_COMPAT_BASE_URL and OPENAI_COMPAT_API_KEY env vars. - Added OPENAI_COMPATIBLE to AiProvider enum - Added registry entry with base-url credential type - Added dual-credential resolution (base URL + optional API key) - Added models API route and verifier - Added second input in settings UI for API key - Added helper functions for credential resolution --- .env.example | 4 + src/actions/apiKey.actions.ts | 41 +++++++++ .../api/ai/openai-compatible/models/route.ts | 43 +++++++++ src/app/api/settings/api-keys/verify/route.ts | 8 +- src/components/settings/ApiKeySettings.tsx | 88 ++++++++++++++----- src/lib/ai/provider-registry.server.ts | 24 ++++- src/lib/ai/provider-registry.ts | 22 ++++- src/lib/ai/providers.ts | 8 +- src/lib/api-key-resolver.ts | 1 + src/lib/scraper/runner.ts | 2 + src/models/ai.model.ts | 1 + src/models/apiKey.model.ts | 2 +- src/models/apiKey.schema.ts | 2 +- 13 files changed, 219 insertions(+), 27 deletions(-) create mode 100644 src/app/api/ai/openai-compatible/models/route.ts diff --git a/.env.example b/.env.example index a8c0551c..5d9dfb02 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ NEXTAUTH_URL=http://localhost:3737 OPENAI_API_KEY=your-openai-api-key-here +# OpenAI-compatible provider (e.g. LM Studio, Ollama with OpenAI API, LiteLLM) +OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8000 +OPENAI_COMPAT_API_KEY=your-openai-compatible-api-key-here + # DeepSeek API Key - get from https://platform.deepseek.com DEEPSEEK_API_KEY=your-deepseek-api-key-here diff --git a/src/actions/apiKey.actions.ts b/src/actions/apiKey.actions.ts index 3876db17..20982ef1 100644 --- a/src/actions/apiKey.actions.ts +++ b/src/actions/apiKey.actions.ts @@ -186,3 +186,44 @@ export async function getOllamaBaseUrl(userId?: string): Promise { } return process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434"; } + +async function resolveApiKeyRecord( + userId: string, + provider: string, +): Promise<{ value: string; encrypted: boolean } | null> { + const apiKey = await db.apiKey.findUnique({ + where: { + userId_provider: { userId, provider }, + }, + }); + if (!apiKey) return null; + if (apiKey.iv === "") return { value: apiKey.encryptedKey, encrypted: false }; + const { decrypt } = await import("@/lib/encryption"); + return { value: decrypt(apiKey.encryptedKey, apiKey.iv), encrypted: true }; +} + +export async function getOpenaiCompatibleBaseUrl(userId?: string): Promise { + try { + const resolvedUserId = userId ?? (await getCurrentUser())?.id; + if (resolvedUserId) { + const record = await resolveApiKeyRecord(resolvedUserId, "openai-compatible"); + if (record) return record.value; + } + } catch { + // Fall through to defaults + } + return process.env.OPENAI_COMPAT_BASE_URL || "http://127.0.0.1:8000"; +} + +export async function getOpenaiCompatibleApiKey(userId?: string): Promise { + try { + const resolvedUserId = userId ?? (await getCurrentUser())?.id; + if (resolvedUserId) { + const record = await resolveApiKeyRecord(resolvedUserId, "openai-compatible-key"); + if (record) return record.value; + } + } catch { + // Fall through to defaults + } + return process.env.OPENAI_COMPAT_API_KEY || null; +} 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 00000000..e03eb486 --- /dev/null +++ b/src/app/api/ai/openai-compatible/models/route.ts @@ -0,0 +1,43 @@ +import { auth } from "@/auth"; +import { NextResponse } from "next/server"; +import { resolveApiKey } from "@/lib/api-key-resolver"; + +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 cleanUrl = baseURL.replace(/\/+$/, ""); + const response = await fetch(`${cleanUrl}/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) { + console.error("Error fetching OpenAI-compatible models:", 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 4afd4257..f8734638 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,10 @@ 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 9bc84d22..685f9427 100644 --- a/src/components/settings/ApiKeySettings.tsx +++ b/src/components/settings/ApiKeySettings.tsx @@ -77,6 +77,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); @@ -122,10 +123,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(); @@ -139,12 +144,22 @@ function ApiKeySettings() { } const providerConfig = PROVIDERS.find((p) => p.id === provider); - const saveResult = await saveApiKey({ + const saveBaseUrlResult = await saveApiKey({ provider, key: inputValue, sensitive: providerConfig?.sensitive ?? true, }); - if (saveResult.success) { + + let saveApiKeyResult: { success: boolean; message?: string } | null = null; + if (provider === "openai-compatible" && openaiCompatApiKey.trim()) { + saveApiKeyResult = await saveApiKey({ + provider: "openai-compatible-key", + key: openaiCompatApiKey, + sensitive: true, + }); + } + + if (saveBaseUrlResult.success && (!saveApiKeyResult?.message || saveApiKeyResult.success)) { toast({ variant: "success", title: "API key saved", @@ -152,12 +167,13 @@ function ApiKeySettings() { }); setEditingProvider(null); setInputValue(""); + setOpenaiCompatApiKey(""); await fetchKeys(); } else { toast({ variant: "destructive", title: "Save failed", - description: saveResult.message || "Failed to save API key", + description: (saveBaseUrlResult.message || saveApiKeyResult?.message || "Failed to save API key"), }); } } catch (error) { @@ -175,8 +191,12 @@ 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" as ApiKeyProvider)] : []), + ]); + const allSuccess = results.every((r) => r.success); + if (allSuccess) { toast({ variant: "success", title: "API key deleted", @@ -187,7 +207,7 @@ function ApiKeySettings() { toast({ variant: "destructive", title: "Error", - description: result.message || "Failed to delete API key", + description: "Failed to delete API key", }); } } catch (error) { @@ -200,6 +220,7 @@ function ApiKeySettings() { const handleCancel = () => { setEditingProvider(null); setInputValue(""); + setOpenaiCompatApiKey(""); }; if (isLoading) { @@ -250,19 +271,31 @@ function ApiKeySettings() { )} - {existingKey ? ( - - - {provider.sensitive - ? `····${existingKey.last4}` - : existingKey.displayValue || existingKey.last4} - - ) : ( - Not configured - )} - - - {provider.id === "ollama" && ( + {existingKey ? ( + + + {provider.sensitive + ? `····${existingKey.last4}` + : existingKey.displayValue || existingKey.last4} + + ) : ( + Not configured + )} + {provider.id === "openai-compatible" && ( +
+ {keys.find((k) => k.provider === "openai-compatible-key") ? ( + + + API Key + + ) : ( + No API Key + )} +
+ )} + + + {provider.id === "ollama" && (
{ollamaChecking ? ( @@ -313,6 +346,21 @@ function ApiKeySettings() { className="mt-1" />
+ {provider.id === "openai-compatible" && ( +
+ + setOpenaiCompatApiKey(e.target.value)} + className="mt-1" + /> +
+ )}