113 lines
3.6 KiB
TypeScript
113 lines
3.6 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
import { requireAdmin } from "@/lib/auth-helpers";
|
|
import { getAiCredentials, saveAiSettings } from "@/lib/ai-settings";
|
|
|
|
export type TestAiConnectionResult = { error?: string; models?: string[] };
|
|
|
|
/** Builds the provider's `/models` endpoint from an admin-entered base URL. */
|
|
function buildModelsUrl(apiUrl: string): URL {
|
|
const withTrailingSlash = apiUrl.endsWith("/") ? apiUrl : `${apiUrl}/`;
|
|
return new URL("models", withTrailingSlash);
|
|
}
|
|
|
|
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 = buildModelsUrl(trimmedUrl);
|
|
} 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 {};
|
|
}
|