SPB Git
7.1 KB · 83 lines typescript
1import { listModels, type ModelRow, providerConfigured } from "./catalog";2import type { Provider, TaskClass, Effort } from "./types";3import { db, schema } from "@/db";4import { eq, gte, sql as dsql } from "drizzle-orm";56/** Default routes — see docs/ai-routing-strategy.md (measured 2026-11-01). Read from settings `ai.routes` when overridden. */7export const DEFAULT_ROUTES: Record<TaskClass, { primary: string; fallback: string; effort: Effort; categories: string[]; avoid?: string[] }> = {8  chat:    { primary: "gpt-6-sol",         fallback: "claude-sonnet-5-5", effort: "medium", categories: ["knowledge_synthesis", "simple_reasoning", "document_analysis"] },9  quick:   { primary: "claude-haiku-5-5",  fallback: "gpt-5.4-mini",      effort: "low",    categories: ["simple_reasoning", "json_generation"] },10  write:   { primary: "gpt-6-sol",         fallback: "claude-sonnet-4-6", effort: "medium", categories: ["french_writing", "english_writing"] },11  reason:  { primary: "claude-opus-5-5",   fallback: "gpt-6-astra",       effort: "high",   categories: ["complex_reasoning", "mathematics"] },12  deep:    { primary: "claude-fable-5-1",  fallback: "gpt-6-astra",       effort: "high",   categories: ["complex_reasoning", "knowledge_synthesis", "mathematics"] },13  code:    { primary: "gpt-5.3-codex",     fallback: "claude-sonnet-5-5", effort: "medium", categories: ["programming"] },14  extract: { primary: "gpt-5.4-mini",      fallback: "claude-haiku-5-5",  effort: "low",    categories: ["json_generation", "document_analysis", "tool_calling"] },15  longctx: { primary: "gpt-6.1-terra",     fallback: "claude-sonnet-5-5", effort: "medium", categories: ["long_context"], avoid: ["gpt-5.6-terra", "gpt-6-luna", "claude-fable-5"] },16  agent:   { primary: "claude-sonnet-5-5", fallback: "gpt-5.4",           effort: "medium", categories: ["tool_calling", "complex_reasoning"] },17  judge:   { primary: "claude-haiku-5-5",  fallback: "gpt-5.4-mini",      effort: "low",    categories: ["json_generation"] },18};19export const EMBEDDING_MODEL = "text-embedding-3-small";20export const TASK_LABELS: Record<TaskClass, string> = { chat: "Chat & Q&A", quick: "Quick tasks", write: "Writing", reason: "Reasoning", deep: "Deep research", code: "Code", extract: "Extraction", longctx: "Long context", agent: "Agents", judge: "Validation" };2122export interface RouteNeeds { task: TaskClass; tools?: boolean; schema?: boolean; vision?: boolean; estTokens?: number; preferred?: string | null; }23export interface RouteDecision { model: ModelRow; provider: Provider; effort: Effort; reason: string; alternatives: string[] }2425function eligible(m: ModelRow, n: RouteNeeds) {26  if (!m.available || !m.enabled || m.category === "embedding" || !providerConfigured(m.provider as Provider)) return false;27  const p = m.probes ?? {};28  if (p.text?.status !== "ok") return false;29  if (n.tools && p.tool_use?.status !== "ok") return false;30  if (n.schema && p.structured_output?.status !== "ok") return false;31  if (n.vision && p.vision?.status !== "ok") return false;32  if (n.estTokens && m.contextWindow && m.contextWindow < n.estTokens * 1.2) return false;33  return true;34}35function measured(m: ModelRow, cats: string[]) {36  const by = (m.benchmark as any)?.by_category ?? {}; const vals = cats.map((c) => by[c]?.score).filter((x) => typeof x === "number");37  return vals.length ? vals.reduce((a: number, b: number) => a + b, 0) / vals.length : -1;38}3940export async function getRoutes(): Promise<typeof DEFAULT_ROUTES> {41  const [row] = await db.select().from(schema.settings).where(eq(schema.settings.key, "ai.routes"));42  return row ? { ...DEFAULT_ROUTES, ...(row.value as any) } : DEFAULT_ROUTES;43}4445/** Daily spend guard: downgrade expensive classes when over budget. */46async function overBudget(): Promise<boolean> {47  const [b] = await db.select().from(schema.settings).where(eq(schema.settings.key, "ai.daily_budget_usd"));48  const budget = Number((b?.value as any) ?? 20);49  const [{ spent }] = await db.select({ spent: dsql<number>`coalesce(sum(cost_usd),0)` }).from(schema.aiUsage).where(gte(schema.aiUsage.createdAt, new Date(Date.now() - 864e5)));50  return Number(spent) >= budget;51}5253export async function route(n: RouteNeeds): Promise<RouteDecision> {54  const models = await listModels();55  const routes = await getRoutes();56  let task = n.task;57  if ((task === "deep" || task === "reason") && (await overBudget())) task = task === "deep" ? "reason" : "chat";58  const r = routes[task];59  const avoid = new Set(r.avoid ?? []);60  const pick = (id: string | null | undefined) => (id ? models.find((m) => m.id === id && eligible(m, n) && !avoid.has(m.id)) ?? null : null);61  const pref = pick(n.preferred);62  if (pref) return { model: pref, provider: pref.provider as Provider, effort: r.effort, reason: "user selection", alternatives: [r.primary, r.fallback].filter((x) => x !== pref.id) };63  const prim = pick(r.primary); if (prim) return { model: prim, provider: prim.provider as Provider, effort: r.effort, reason: `primary route for ${task}${task !== n.task ? " (budget downgrade)" : ""}`, alternatives: [r.fallback] };64  const fb = pick(r.fallback); if (fb) return { model: fb, provider: fb.provider as Provider, effort: r.effort, reason: `fallback for ${task} (${r.primary} unavailable)`, alternatives: [] };65  const rest = models.filter((m) => eligible(m, n) && !avoid.has(m.id) && (m.benchmark as any)?.pass_rate != null)66    .sort((a, b) => measured(b, r.categories) - measured(a, r.categories) || ((a.benchmark as any).total_median_ms ?? 1e9) - ((b.benchmark as any).total_median_ms ?? 1e9) || ((a.benchmark as any).cost_mean ?? 1e9) - ((b.benchmark as any).cost_mean ?? 1e9));67  if (!rest.length) throw new Error("No eligible AI model (check provider keys and the model catalogue)");68  return { model: rest[0], provider: rest[0].provider as Provider, effort: r.effort, reason: `best measured model for ${task} (${rest[0].benchmark && (rest[0].benchmark as any).pass_rate != null ? Math.round((rest[0].benchmark as any).pass_rate * 100) + "% pass" : "untested"})`, alternatives: rest.slice(1, 3).map((m) => m.id) };69}7071/** Heuristic task classifier used when the caller does not state the class. */72export function classifyTask(prompt: string, ctx: { hasSelection?: boolean; contextTokens?: number; wantsCode?: boolean } = {}): TaskClass {73  const p = prompt.toLowerCase();74  if (ctx.contextTokens && ctx.contextTokens > 60_000) return "longctx";75  if (ctx.wantsCode || /\b(code|function|script|regex|sql|python|typescript|javascript|formula)\b/.test(p)) return "code";76  if (/\b(prove|derive|theorem|equation|integral|probability|why does|contradict|compare.*(method|approach)|step by step)\b/.test(p)) return "reason";77  if (/\b(literature|synthesi[sz]e|research (question|plan)|review the papers|state of the art|deep dive|think hard)\b/.test(p)) return "deep";78  if (/\b(rewrite|rephrase|translate|traduis|réécris|improve the (tone|style)|draft|write (a|an|the)|rédige|paragraph|email|abstract|summar)/.test(p)) return "write";79  if (/\b(extract|as json|table of|list the fields|structured)\b/.test(p)) return "extract";80  if (p.length < 60 && /^(title|tag|label|name)\b/.test(p)) return "quick";81  return "chat";82}83
Files · main
Menu

Theme