TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import type { PolyModel, ProviderId } from "@/lib/client/types";2import { estimateTextTokens, estimateAttachmentTokens, estimateCost, type AttachmentLike } from "./tokens";34/**5 * PolyLLM Smart Router — transparent, client-side model recommendation.6 *7 * Pure function over the user's usable models: no network, no hidden state. The UI always shows8 * the recommendation, the reasons and the estimated cost before anything is sent, and never9 * silently upgrades to an expensive model (see `budgetCap`).10 */11export type RouterMode = "balanced" | "quality" | "fastest" | "cheapest" | "coding" | "research";1213export const ROUTER_MODES: { value: RouterMode; label: string; description: string }[] = [14 { value: "balanced", label: "Balanced", description: "Good quality at a sensible price" },15 { value: "quality", label: "Best quality", description: "Strongest reasoning, cost secondary" },16 { value: "fastest", label: "Fastest", description: "Lowest latency tiers" },17 { value: "cheapest", label: "Lowest cost", description: "Cheapest model that fits the task" },18 { value: "coding", label: "Coding", description: "Code generation, review and debugging" },19 { value: "research", label: "Research", description: "Long context, reasoning, web search" },20];2122export type TaskType = "coding" | "research" | "writing" | "reasoning" | "analysis" | "vision" | "translation" | "chat";2324export interface PromptAnalysis {25 task: TaskType;26 inputTokens: number;27 needsVision: boolean;28 needsFiles: boolean;29 needsLongContext: boolean;30 needsReasoning: boolean;31 needsWeb: boolean;32 needsJson: boolean;33 signals: string[];34}3536const CODE_RE = /```|\bfunction\b|\bconst\b|\bimport\b|\bdef\b|\bclass\b|\breturn\b|=>|\bSELECT\b|\bnpm\b|\bpip\b|stack ?trace|exception|bug|refactor|typescript|python|rust|\bsql\b|regex|compile|debug/i;37const RESEARCH_RE = /research|sources?|cite|citation|literature|paper|study|studies|latest|news|today|current|2026|compare .* (and|vs)|pros and cons|state of the art|survey/i;38const REASON_RE = /prove|proof|derive|step[- ]by[- ]step|theorem|puzzle|riddle|optimi[sz]e|strategy|plan (a|the)|trade-?offs?|why does|analy[sz]e deeply|chain of thought|logic/i;39const WRITING_RE = /write|draft|essay|blog|email|letter|story|poem|rewrite|paraphrase|tone|copy(writing)?|headline|slogan|summar/i;40const ANALYSIS_RE = /csv|spreadsheet|dataset|data\b|table|statistics|trend|chart|forecast|financial|revenue|kpi|metrics/i;41const TRANSLATE_RE = /translate|traduis|traduction|in (french|spanish|german|italian|japanese|chinese|english)/i;42const JSON_RE = /\bjson\b|schema|structured output|as a list of objects|key[- ]value/i;43const WEB_RE = /search the web|look up|latest|news|today|this week|current price|weather|stock|who won|release date/i;4445export function analyzePrompt(text: string, attachments: AttachmentLike[] = [], opts: { historyTokens?: number; systemPrompt?: string | null; webSearch?: boolean; responseFormat?: boolean } = {}): PromptAnalysis {46 const signals: string[] = [];47 const t = text ?? "";48 const attTokens = attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0);49 const inputTokens = estimateTextTokens(t) + estimateTextTokens(opts.systemPrompt ?? "") + attTokens + (opts.historyTokens ?? 0);50 const needsVision = attachments.some((a) => a.kind === "image");51 const needsFiles = attachments.some((a) => a.kind === "pdf");52 const needsLongContext = inputTokens > 60_000;53 const needsWeb = Boolean(opts.webSearch) || WEB_RE.test(t);54 const needsJson = Boolean(opts.responseFormat) || JSON_RE.test(t);55 if (needsVision) signals.push("image attached");56 if (needsFiles) signals.push("PDF attached");57 if (needsLongContext) signals.push(`${Math.round(inputTokens / 1000)}K tokens of context`);58 if (needsWeb) signals.push("fresh information");59 if (needsJson) signals.push("structured output");6061 let task: TaskType = "chat";62 const codeScore = (t.match(CODE_RE) ?? []).length + (t.includes("```") ? 3 : 0);63 if (attachments.some((a) => a.kind === "code")) signals.push("code file");64 if (codeScore >= 2 || attachments.some((a) => a.kind === "code")) task = "coding";65 else if (TRANSLATE_RE.test(t)) task = "translation";66 else if (REASON_RE.test(t)) task = "reasoning";67 else if (RESEARCH_RE.test(t)) task = "research";68 else if (ANALYSIS_RE.test(t) || attachments.some((a) => a.kind === "csv" || a.kind === "json")) task = "analysis";69 else if (WRITING_RE.test(t)) task = "writing";70 else if (needsVision) task = "vision";71 const needsReasoning = task === "reasoning" || task === "coding" || task === "analysis" || (task === "research" && t.length > 400) || t.length > 1500;72 if (task !== "chat") signals.unshift(`${task} task`);73 if (needsReasoning && task !== "reasoning") signals.push("benefits from reasoning");74 return { task, inputTokens, needsVision, needsFiles, needsLongContext, needsReasoning, needsWeb, needsJson, signals };75}7677export interface RouteCandidate {78 model: PolyModel;79 score: number;80 estimatedUsd: number | null;81 reasons: string[];82}8384export interface RouteResult {85 analysis: PromptAnalysis;86 recommended: RouteCandidate | null;87 alternatives: RouteCandidate[];88 mode: RouterMode;89 /** True when the recommendation exceeds the cap and needs explicit confirmation. */90 needsConfirmation: boolean;91}9293const FAST_RE = /flash|mini|nano|haiku|fast|lite|non-reasoning|instant|turbo|small|ministral|8b|9b|gemma|gpt-oss-20b|llama-3\.1-8b/i;94const FRONTIER_RE = /opus|fable|gpt-5\.5|gpt-6|pro\b|grok-4(\.\d+)?(?!-fast)|large|k3|deepseek-v4-pro|magistral-medium/i;95const CODING_RE = /codestral|code|coder|devstral|gpt-5\.5|sonnet|opus|fable|k2\.7|deepseek/i;96const OPEN_RE = /llama|qwen|gemma|gpt-oss|mistral-small|mixtral|deepseek|kimi|k2|k3|glm|command/i;9798const PROVIDER_LATENCY_BONUS: Partial<Record<ProviderId, number>> = { cerebras: 3, xai: 0.5, gemini: 0.5, openrouter: -0.3 };99100/** Median USD per 1M output tokens across usable models — used as the "expensive" reference. */101function medianOut(models: PolyModel[]): number {102 const v = models.map((m) => m.pricing?.outputPerMillion).filter((x): x is number => typeof x === "number").sort((a, b) => a - b);103 return v.length ? v[Math.floor(v.length / 2)] : 5;104}105106export function routeModels(models: PolyModel[], analysis: PromptAnalysis, mode: RouterMode = "balanced", opts: { budgetCapUsd?: number; favorites?: Set<string>; expectedOutputTokens?: number } = {}): RouteResult {107 const expectedOut = opts.expectedOutputTokens ?? (analysis.task === "writing" || analysis.task === "coding" ? 1200 : 600);108 const usable = models.filter((m) => m.status !== "deprecated" && m.capabilities.text !== false);109 const median = medianOut(usable);110 const cap = opts.budgetCapUsd ?? 0.5;111112 const scored: RouteCandidate[] = usable113 .filter((m) => {114 if (analysis.needsVision && !m.capabilities.vision) return false;115 if (analysis.needsFiles && !m.capabilities.files) return false;116 const ctx = m.limits?.contextTokens ?? 128_000;117 if (analysis.inputTokens + expectedOut > ctx * 0.92) return false;118 return true;119 })120 .map((m) => {121 const reasons: string[] = [];122 let score = 0;123 const id = `${m.id} ${m.displayName}`;124 const fast = FAST_RE.test(id);125 const frontier = FRONTIER_RE.test(id);126 const outPrice = m.pricing?.outputPerMillion ?? median;127 const inPrice = m.pricing?.inputPerMillion ?? median / 4;128 const cost = estimateCost(m, analysis.inputTokens, expectedOut).usd;129 const cheapness = Math.max(0, 1 - outPrice / (median * 2)); // 0..1130 const ctx = m.limits?.contextTokens ?? 0;131132 // Capability fit133 if (analysis.needsReasoning && m.capabilities.reasoning) {134 score += 3;135 reasons.push("reasoning");136 } else if (analysis.needsReasoning) score -= 2;137 if (analysis.needsWeb && m.capabilities.webSearch) {138 score += 2.5;139 reasons.push("web search");140 } else if (analysis.needsWeb) score -= 1;141 if (analysis.needsJson && m.capabilities.structuredOutput) {142 score += 1.5;143 reasons.push("JSON schema");144 }145 if (analysis.needsVision) reasons.push("vision");146 if (analysis.needsLongContext && ctx >= 400_000) {147 score += 2.5;148 reasons.push(`${Math.round(ctx / 1000)}K context`);149 } else if (ctx >= 1_000_000 && (mode === "research" || analysis.task === "research")) {150 score += 1;151 reasons.push("1M context");152 }153 if ((analysis.task === "coding" || mode === "coding") && CODING_RE.test(id)) {154 score += 2;155 reasons.push("strong at code");156 }157 if (analysis.task === "translation" && fast) score += 1;158 if (analysis.task === "chat" && fast) score += 1.5;159160 // Tier161 if (frontier) {162 if (mode === "quality" || analysis.task === "reasoning" || analysis.task === "research") {163 score += 3;164 reasons.push("frontier quality");165 } else if (mode === "balanced" && analysis.needsReasoning) score += 1;166 else if (mode === "cheapest" || mode === "fastest") score -= 3;167 }168 if (fast) {169 if (mode === "fastest") {170 score += 3.5;171 reasons.push("fast tier");172 } else if (mode === "cheapest") score += 1.5;173 else if (mode === "quality") score -= 2;174 else if (mode === "balanced" && !analysis.needsReasoning) {175 score += 1;176 reasons.push("fast");177 }178 }179 score += (PROVIDER_LATENCY_BONUS[m.provider] ?? 0) * (mode === "fastest" ? 1 : 0.2);180181 // Cost182 const costWeight = mode === "cheapest" ? 5 : mode === "balanced" ? 2.2 : mode === "quality" ? 0.4 : mode === "fastest" ? 1 : 1.5;183 score += cheapness * costWeight;184 if (cheapness > 0.7) reasons.push(`cheap (${inPrice.toFixed(2)}/${outPrice.toFixed(2)} per 1M)`);185 if (cost !== null && cost > cap && mode !== "quality") score -= 2;186187 // Preference & hygiene188 if (opts.favorites?.has(m.key)) {189 score += 0.8;190 reasons.push("favorite");191 }192 if (m.status === "preview") score -= 0.3;193 if (m.provider === "openrouter") score -= 0.6; // prefer native connections when the same model exists194 if (OPEN_RE.test(id) && mode === "quality") score -= 0.5;195196 return { model: m, score, estimatedUsd: cost, reasons: dedupe(reasons).slice(0, 3) };197 })198 .sort((a, b) => b.score - a.score);199200 const recommended = scored[0] ?? null;201 const alternatives = scored.slice(1, 4);202 const needsConfirmation = Boolean(recommended?.estimatedUsd && recommended.estimatedUsd > cap);203 return { analysis, recommended, alternatives, mode, needsConfirmation };204}205206function dedupe<T>(arr: T[]): T[] {207 return [...new Set(arr)];208}209210/** Human explanation: "Strong reasoning + 1M context". */211export function explainRoute(c: RouteCandidate | null): string {212 if (!c) return "No compatible model is connected.";213 if (!c.reasons.length) return "Best overall fit for this prompt.";214 return c.reasons.map((r, i) => (i === 0 ? r.charAt(0).toUpperCase() + r.slice(1) : r)).join(" + ");215}216