85 lines
2.7 KiB
TypeScript
85 lines
2.7 KiB
TypeScript
import "server-only";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
|
|
// Fixed id of the one-and-only AiSettings row (see prisma/schema.prisma).
|
|
const SETTINGS_ID = "singleton";
|
|
|
|
export interface AiSettingsView {
|
|
apiUrl: string | null;
|
|
model: string | null;
|
|
hasApiKey: boolean;
|
|
// Last 4 characters only (e.g. "••••••••f00d") -- enough to confirm
|
|
// *which* key is configured without ever sending the real one back to
|
|
// the browser. Null when no key is set.
|
|
apiKeyMasked: string | null;
|
|
}
|
|
|
|
function maskKey(key: string): string {
|
|
return `••••••••${key.slice(-4)}`;
|
|
}
|
|
|
|
/**
|
|
* Builds an OpenAI-compatible endpoint URL from an admin-entered base URL
|
|
* (e.g. "https://host/api/v1") plus a path (e.g. "models",
|
|
* "chat/completions"). Shared by every call site that talks to the
|
|
* configured provider, so the trailing-slash handling only lives once --
|
|
* `new URL(path, base)` silently drops the base's last path segment
|
|
* (turning ".../v1" into ".../models" instead of ".../v1/models") unless
|
|
* the base ends in "/".
|
|
*/
|
|
export function joinApiUrl(apiUrl: string, path: string): URL {
|
|
const withTrailingSlash = apiUrl.endsWith("/") ? apiUrl : `${apiUrl}/`;
|
|
return new URL(path, withTrailingSlash);
|
|
}
|
|
|
|
/** The client-safe view of the AI settings -- never includes the real key. */
|
|
export async function getAiSettingsView(): Promise<AiSettingsView> {
|
|
const settings = await prisma.aiSettings.findUnique({ where: { id: SETTINGS_ID } });
|
|
return {
|
|
apiUrl: settings?.apiUrl ?? null,
|
|
model: settings?.model ?? null,
|
|
hasApiKey: !!settings?.apiKey,
|
|
apiKeyMasked: settings?.apiKey ? maskKey(settings.apiKey) : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The real, unmasked key -- for server-side use only (calling the
|
|
* provider). Never pass this to a client component or return it from a
|
|
* Server Action.
|
|
*/
|
|
export async function getAiCredentials(): Promise<{
|
|
apiUrl: string | null;
|
|
apiKey: string | null;
|
|
}> {
|
|
const settings = await prisma.aiSettings.findUnique({ where: { id: SETTINGS_ID } });
|
|
return { apiUrl: settings?.apiUrl ?? null, apiKey: settings?.apiKey ?? null };
|
|
}
|
|
|
|
/**
|
|
* `apiKey` omitted (or empty) means "leave the currently saved key
|
|
* alone" -- the form field is never prefilled with the real key, so
|
|
* there's nothing to resubmit when the admin isn't changing it.
|
|
*/
|
|
export async function saveAiSettings(data: {
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
model: string;
|
|
}): Promise<void> {
|
|
await prisma.aiSettings.upsert({
|
|
where: { id: SETTINGS_ID },
|
|
create: {
|
|
id: SETTINGS_ID,
|
|
apiUrl: data.apiUrl,
|
|
apiKey: data.apiKey || null,
|
|
model: data.model,
|
|
},
|
|
update: {
|
|
apiUrl: data.apiUrl,
|
|
model: data.model,
|
|
...(data.apiKey ? { apiKey: data.apiKey } : {}),
|
|
},
|
|
});
|
|
}
|