147 lines
5.4 KiB
TypeScript
147 lines
5.4 KiB
TypeScript
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
|
||
|
|
}
|