Organize/lib/ai/chat-completion.ts

92 lines
3.1 KiB
TypeScript

import "server-only";
import { getAiCredentials, getAiSettingsView, joinApiUrl } from "@/lib/ai-settings";
export interface ChatCompletionMessage {
role: "system" | "user" | "assistant";
content: string;
}
export type ChatCompletionResult = { content: string } | { error: string };
function extractMessageContent(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const choices = (body as { choices?: unknown }).choices;
if (!Array.isArray(choices) || !choices[0]) return null;
const message = (choices[0] as { message?: unknown }).message;
if (!message || typeof message !== "object") return null;
const content = (message as { content?: unknown }).content;
return typeof content === "string" ? content : null;
}
/**
* Shared fetch/parse/error-handling for calling the configured
* OpenAI-compatible AI provider (see lib/ai-settings.ts) with a plain chat
* completion -- no structured ask/finalize contract, just "here's a
* conversation, give me back the assistant's reply". Used by features
* that want the model to answer freely (global chat, group status
* updates) rather than produce a JSON artifact to review-and-save; those
* artifact-producing features (lib/actions/group-ai.ts,
* lib/actions/todo-ai.ts) predate this helper and have their own
* ask/finalize parsing layered on top of the same fetch shape, so they're
* left as-is rather than retrofitted onto this.
*/
export async function callChatCompletion(
messages: ChatCompletionMessage[],
opts?: { temperature?: number }
): Promise<ChatCompletionResult> {
const [{ apiUrl, apiKey }, settings] = await Promise.all([
getAiCredentials(),
getAiSettingsView(),
]);
if (!apiUrl || !settings.model) {
return { error: "AI generation isn't configured yet. Ask an administrator to set it up." };
}
let chatUrl: URL;
try {
chatUrl = joinApiUrl(apiUrl, "chat/completions");
} catch {
return { error: "The configured AI API URL is invalid." };
}
let response: Response;
try {
response = await fetch(chatUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
model: settings.model,
messages,
temperature: opts?.temperature ?? 0.4,
}),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { error: `Couldn't reach the AI provider -- ${message}` };
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
return { error: "The AI provider rejected the configured API key." };
}
return { error: `The AI provider responded with ${response.status} ${response.statusText}.` };
}
let body: unknown;
try {
body = await response.json();
} catch {
return { error: "The AI provider didn't return valid JSON." };
}
const content = extractMessageContent(body);
if (!content) return { error: "The AI provider's response was empty." };
return { content: content.trim() };
}