Organize/lib/actions/ai.ts

107 lines
3.3 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/auth-helpers";
import { getAiCredentials, joinApiUrl, saveAiSettings } from "@/lib/ai-settings";
export type TestAiConnectionResult = { error?: string; models?: string[] };
function extractModelIds(body: unknown): string[] {
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : undefined;
if (!Array.isArray(data)) return [];
return data
.map((entry) => (entry && typeof entry === "object" ? (entry as { id?: unknown }).id : undefined))
.filter((id): id is string => typeof id === "string")
.sort((a, b) => a.localeCompare(b));
}
/**
* Calls the given (not-necessarily-saved-yet) endpoint's OpenAI-compatible
* `/models` list, so an admin can verify a URL/key pair actually works --
* and get real model ids to pick from -- before committing to it.
*/
export async function testAiConnection(
apiUrl: string,
apiKey: string
): Promise<TestAiConnectionResult> {
await requireAdmin();
const trimmedUrl = apiUrl.trim();
if (!trimmedUrl) return { error: "Enter an API URL first." };
let modelsUrl: URL;
try {
modelsUrl = joinApiUrl(trimmedUrl, "models");
} catch {
return { error: "That doesn't look like a valid URL." };
}
if (modelsUrl.protocol !== "http:" && modelsUrl.protocol !== "https:") {
return { error: "The API URL must be http:// or https://." };
}
// A blank key field means "use whatever's already saved" -- lets an
// admin re-test without having to retype a key they can no longer see.
let effectiveKey = apiKey.trim();
if (!effectiveKey) {
const saved = await getAiCredentials();
effectiveKey = saved.apiKey ?? "";
}
let response: Response;
try {
response = await fetch(modelsUrl, {
headers: effectiveKey ? { Authorization: `Bearer ${effectiveKey}` } : {},
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { error: `Couldn't reach that URL -- ${message}` };
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
return { error: `The server rejected the API key (${response.status}).` };
}
return { error: `The server responded with ${response.status} ${response.statusText}.` };
}
let body: unknown;
try {
body = await response.json();
} catch {
return { error: "Connected, but the response wasn't valid JSON." };
}
const models = extractModelIds(body);
if (models.length === 0) {
return { error: "Connected, but the server didn't list any models." };
}
return { models };
}
export async function saveAiConfig(data: {
apiUrl: string;
apiKey: string;
model: string;
}): Promise<{ error?: string }> {
await requireAdmin();
const apiUrl = data.apiUrl.trim();
const model = data.model.trim();
if (!apiUrl) return { error: "Enter an API URL." };
if (!model) return { error: "Choose a model." };
try {
const url = new URL(apiUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return { error: "The API URL must be http:// or https://." };
}
} catch {
return { error: "That doesn't look like a valid URL." };
}
await saveAiSettings({ apiUrl, apiKey: data.apiKey.trim(), model });
revalidatePath("/admin");
return {};
}