import { eq } from "drizzle-orm" import type OpenAI from "openai" import { db } from "@/lib/db" import { app_settings } from "@/lib/db/schema" import { openai } from "@/lib/ai/client" import { getAnthropic } from "@/lib/ai/anthropic" // ============================================================================ // AI provider abstraction — one call site for every AI feature, backed by // EITHER OpenAI or Anthropic (Claude). The active provider is chosen by an admin // in Settings → System (persisted in app_settings), falling back to whichever // provider actually has an API key configured on the server. // ============================================================================ export type AiProvider = "openai" | "anthropic" export type AiRole = "system" | "user" | "assistant" export type AiMessage = { role: AiRole; content: string } export const AI_PROVIDER_KEY = "ai_provider" // Models are env-overridable. Both default to each provider's cheapest tier to // keep token spend low: OpenAI gpt-4o-mini, Anthropic Claude Haiku 4.5 ($1/$5 // per 1M). Pin a stronger model via OPENAI_MODEL / ANTHROPIC_MODEL if desired. export const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini" export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5" export function openaiConfigured(): boolean { return Boolean(process.env.OPENAI_API_KEY) } export function anthropicConfigured(): boolean { return Boolean(process.env.ANTHROPIC_API_KEY) } function isProvider(v: unknown): v is AiProvider { return v === "openai" || v === "anthropic" } /** The admin-selected provider (defaults to openai). Fails safe to openai. */ export async function getAiProvider(): Promise { try { const row = await db.query.app_settings.findFirst({ where: eq(app_settings.key, AI_PROVIDER_KEY), }) const v = (row?.value as { provider?: string } | null)?.provider return isProvider(v) ? v : "openai" } catch { return "openai" } } /** Persist the admin's provider choice. Admin-gated by the calling action. */ export async function setAiProvider(provider: AiProvider): Promise { await db .insert(app_settings) .values({ key: AI_PROVIDER_KEY, value: { provider } }) .onConflictDoUpdate({ target: app_settings.key, set: { value: { provider }, updated_at: new Date().toISOString() }, }) } /** * The provider actually used for a request: the selected one, unless it has no * API key on the server and the other provider does — then we fall back so AI * features keep working after a provider switch even if the key isn't set yet. */ function resolveEffective(selected: AiProvider): AiProvider { if (selected === "anthropic" && !anthropicConfigured() && openaiConfigured()) return "openai" if (selected === "openai" && !openaiConfigured() && anthropicConfigured()) return "anthropic" return selected } /** Everything the admin UI needs to render the provider picker. */ export async function aiProviderStatus() { const selected = await getAiProvider() return { selected, effective: resolveEffective(selected), openaiConfigured: openaiConfigured(), anthropicConfigured: anthropicConfigured(), openaiModel: OPENAI_MODEL, anthropicModel: ANTHROPIC_MODEL, } } /** Strip a ```json fenced code block, if the model wrapped its JSON in one. */ function stripFences(s: string): string { return s .replace(/^\s*```(?:json)?\s*/i, "") .replace(/```\s*$/i, "") .trim() } /** * Provider-agnostic single-shot completion. Returns the model's text output. * * `json: true` asks for a JSON object (OpenAI uses response_format; both * providers rely on the prompt saying "JSON only") and strips any code fences * so the caller can `JSON.parse` the result directly. */ export async function aiComplete(opts: { messages: AiMessage[] maxTokens?: number json?: boolean }): Promise { const provider = resolveEffective(await getAiProvider()) const maxTokens = opts.maxTokens ?? 1024 let text: string if (provider === "anthropic") { // Anthropic takes a top-level `system`; the rest are user/assistant turns. const system = opts.messages .filter((m) => m.role === "system") .map((m) => m.content) .join("\n\n") const convo = opts.messages .filter((m) => m.role !== "system") .map((m) => ({ role: (m.role === "assistant" ? "assistant" : "user") as "assistant" | "user", content: m.content })) if (convo.length === 0) convo.push({ role: "user", content: system || "Continue." }) const res = await getAnthropic().messages.create({ model: ANTHROPIC_MODEL, max_tokens: maxTokens, ...(system ? { system } : {}), messages: convo, }) text = res.content.map((b) => (b.type === "text" ? b.text : "")).join("") } else { const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = opts.messages.map((m) => m.role === "system" ? { role: "system", content: m.content } : m.role === "assistant" ? { role: "assistant", content: m.content } : { role: "user", content: m.content } ) const res = await openai.chat.completions.create({ model: OPENAI_MODEL, max_tokens: maxTokens, messages, ...(opts.json ? { response_format: { type: "json_object" as const } } : {}), }) text = res.choices[0]?.message?.content ?? "" } return opts.json ? stripFences(text) : text }