SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
10.8 KB · 216 lines typescript
Raw Blame History
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