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; timeoutMs?: number } ): Promise { 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(opts?.timeoutMs ?? 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() }; }