Files

147 lines
5.4 KiB
TypeScript
Raw Permalink Normal View History

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<AiProvider> {
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<void> {
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<string> {
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
}