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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions src/actions/apiKey.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,44 @@ export async function getOllamaBaseUrl(userId?: string): Promise<string> {
}
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<string> {
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<string | null> {
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;
}
43 changes: 43 additions & 0 deletions src/app/api/ai/openai-compatible/models/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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 },
);
}
}
8 changes: 6 additions & 2 deletions src/app/api/settings/api-keys/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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";
Expand Down
88 changes: 68 additions & 20 deletions src/components/settings/ApiKeySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function ApiKeySettings() {
null,
);
const [inputValue, setInputValue] = useState("");
const [openaiCompatApiKey, setOpenaiCompatApiKey] = useState("");
const [verifying, setVerifying] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [ollamaConnected, setOllamaConnected] = useState<boolean | null>(null);
Expand Down Expand Up @@ -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();

Expand All @@ -139,25 +144,36 @@ 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",
description: `${PROVIDERS.find((p) => p.id === provider)?.name} key verified and saved.`,
});
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) {
Expand All @@ -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",
Expand All @@ -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) {
Expand All @@ -200,6 +220,7 @@ function ApiKeySettings() {
const handleCancel = () => {
setEditingProvider(null);
setInputValue("");
setOpenaiCompatApiKey("");
};

if (isLoading) {
Expand Down Expand Up @@ -250,19 +271,31 @@ function ApiKeySettings() {
)}
</CardDescription>
</div>
{existingKey ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 hover:bg-green-100 dark:hover:bg-green-900">
<CheckCircle className="h-3 w-3 mr-1" />
{provider.sensitive
? `····${existingKey.last4}`
: existingKey.displayValue || existingKey.last4}
</Badge>
) : (
<Badge variant="secondary">Not configured</Badge>
)}
</div>
</CardHeader>
{provider.id === "ollama" && (
{existingKey ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 hover:bg-green-100 dark:hover:bg-green-900">
<CheckCircle className="h-3 w-3 mr-1" />
{provider.sensitive
? `····${existingKey.last4}`
: existingKey.displayValue || existingKey.last4}
</Badge>
) : (
<Badge variant="secondary">Not configured</Badge>
)}
{provider.id === "openai-compatible" && (
<div className="ml-2">
{keys.find((k) => k.provider === "openai-compatible-key") ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<CheckCircle className="h-3 w-3 mr-1" />
API Key
</Badge>
) : (
<Badge variant="outline">No API Key</Badge>
)}
</div>
)}
</div>
</CardHeader>
{provider.id === "ollama" && (
<div className="px-6 pb-3">
<div className="flex items-center gap-2">
{ollamaChecking ? (
Expand Down Expand Up @@ -313,6 +346,21 @@ function ApiKeySettings() {
className="mt-1"
/>
</div>
{provider.id === "openai-compatible" && (
<div>
<Label htmlFor={`key-${provider.id}-api`}>
API Key (optional)
</Label>
<Input
id={`key-${provider.id}-api`}
type="password"
placeholder="sk-..."
value={openaiCompatApiKey}
onChange={(e) => setOpenaiCompatApiKey(e.target.value)}
className="mt-1"
/>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
Expand Down
24 changes: 23 additions & 1 deletion src/lib/ai/provider-registry.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const PROVIDER_FACTORIES: Record<
(credential: string, modelName: string) => any
> = {
openai: (apiKey, model) => createOpenAI({ apiKey })(model),
"openai-compatible": () => {
throw new Error("openai-compatible is handled specially in getModel()");
},
openrouter: (apiKey, model) =>
createOpenAI({ apiKey, baseURL: "https://openrouter.ai/api/v1" })(model),
deepseek: (apiKey, model) => createDeepSeek({ apiKey })(model),
Expand All @@ -21,7 +24,7 @@ export const PROVIDER_FACTORIES: Record<

export const PROVIDER_VERIFIERS: Record<
string,
(key: string) => Promise<{ success: boolean; error?: string }>
(key: any) => Promise<{ success: boolean; error?: string }>
> = {
openai: async (key) => {
const res = await fetch("https://api.openai.com/v1/models", {
Expand All @@ -38,6 +41,25 @@ export const PROVIDER_VERIFIERS: Record<
return { success: true };
},

"openai-compatible": async (key) => {
const params = typeof key === "object" ? key : { baseURL: key, apiKey: undefined };
const headers: Record<string, string> = {};
if (params.apiKey) {
headers["Authorization"] = `Bearer ${params.apiKey}`;
}
const baseUrl = params.baseURL.replace(/\/+$/, "");
const res = await fetch(`${baseUrl}/v1/models`, { headers });
if (!res.ok)
return {
success: false,
error:
res.status === 401
? "Invalid API key"
: `OpenAI-compatible endpoint returned ${res.status}`,
};
return { success: true };
},

openrouter: async (key) => {
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers: { Authorization: `Bearer ${key}` },
Expand Down
22 changes: 21 additions & 1 deletion src/lib/ai/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,26 @@ export const PROVIDER_REGISTRY: Record<string, ProviderRegistryEntry> = {
},
},

"openai-compatible": {
id: "openai-compatible",
displayName: "OpenAI Compatible",
credentialType: "base-url",
category: "local",
envVar: "OPENAI_COMPAT_BASE_URL",
defaultCredential: "http://127.0.0.1:8000",
modelsEndpoint: "openai-compatible/models",
parseModelsResponse: (data) =>
data.data?.map((m: any) => m.id) ?? [],
requiresRunningCheck: false,
supportsKeepAlive: false,
keyConfig: {
placeholder: "http://127.0.0.1:8000",
inputType: "text",
description: "Base URL for any OpenAI-compatible API (e.g. LM Studio, Ollama with OpenAI API, Azure OpenAI)",
sensitive: false,
},
},

deepseek: {
id: "deepseek",
displayName: "DeepSeek",
Expand Down Expand Up @@ -117,7 +137,7 @@ export const PROVIDER_REGISTRY: Record<string, ProviderRegistryEntry> = {
},
};

export const AI_PROVIDERS = ["ollama", "openai", "deepseek", "openrouter", "gemini"] as const;
export const AI_PROVIDERS = ["ollama", "openai", "openai-compatible", "deepseek", "openrouter", "gemini"] as const;
export type AiProviderId = (typeof AI_PROVIDERS)[number];

export function getAiProviders(): ProviderRegistryEntry[] {
Expand Down
8 changes: 7 additions & 1 deletion src/lib/ai/providers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { resolveApiKey } from "@/lib/api-key-resolver";
import { PROVIDER_REGISTRY } from "@/lib/ai/provider-registry";
import { PROVIDER_FACTORIES } from "@/lib/ai/provider-registry.server";
import { createOpenAI } from "@ai-sdk/openai";

export type ProviderType = "openai" | "ollama" | "deepseek" | "openrouter" | "gemini";
export type ProviderType = "openai" | "openai-compatible" | "ollama" | "deepseek" | "openrouter" | "gemini";

export async function getModel(
provider: ProviderType,
Expand All @@ -19,5 +20,10 @@ export async function getModel(
if (!credential)
throw new Error(`${entry.displayName} credential not configured`);

if (provider === "openai-compatible") {
const apiKey = await resolveApiKey(userId, "openai-compatible-key");
return createOpenAI({ baseURL: credential, apiKey: apiKey || "" })(modelName);
}

return factory(credential, modelName);
}
1 change: 1 addition & 0 deletions src/lib/api-key-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PROVIDER_REGISTRY } from "@/lib/ai/provider-registry";
// RapidAPI is not in the AI provider registry but still needs env var resolution
const EXTRA_ENV_VARS: Record<string, string> = {
rapidapi: "RAPIDAPI_KEY",
"openai-compatible-key": "OPENAI_COMPAT_API_KEY",
};

export async function resolveApiKey(
Expand Down
Loading