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%
14.7 KB · 341 lines typescript
Raw Blame History
1import type { ModelCapabilities, PolyModel, ProviderId } from "@/lib/ai/core/types";2import { blendedPrice, buildBadgeContext, isCodingModel, isFastModel, isFrontierModel, isNewModel, isOpenWeightsModel, releaseDateMs, firstSeenMs, sortWeightOf, type BadgeContext } from "./badges";34/**5 * Model search with intent parsing — pure, no React.6 *7 *   "cheap vision model"      → capability vision, sort cheapest8 *   "1M context"              → minContext 1 000 0009 *   "under $1/M"              → maxInputPrice 110 *   "fastest gemini"          → brand gemini, sort fastest11 *   "json schema" / "200k" / "open source" / "reasoning" / "OpenAI"12 *13 * Free-text tokens must all match (name, id, family, provider, user label); intents filter or boost.14 */15export type CapabilityKey = keyof ModelCapabilities;16export type SearchSort = "cheapest" | "fastest" | "context" | "newest" | "quality";1718export interface SearchIntent {19  /** Remaining free-text tokens (already lower-cased). */20  text: string[];21  brands: Brand[];22  capabilities: CapabilityKey[];23  openWeights: boolean;24  coding: boolean;25  newest: boolean;26  quality: boolean;27  minContext?: number;28  maxInputPrice?: number;29  maxOutputPrice?: number;30  sort?: SearchSort;31  /** Human-readable chips describing what was understood. */32  chips: string[];33}3435export interface Brand {36  key: string;37  label: string;38  provider?: ProviderId;39  match: RegExp;40}4142const BRANDS: (Brand & { words: RegExp })[] = [43  { key: "openai", label: "OpenAI", provider: "openai", words: /^(openai|gpt|chatgpt|o[1-4])$/, match: /\bgpt|openai|\bo[1-4]\b/i },44  { key: "anthropic", label: "Anthropic", provider: "anthropic", words: /^(anthropic|claude)$/, match: /claude|anthropic/i },45  { key: "gemini", label: "Gemini", provider: "gemini", words: /^(gemini|google)$/, match: /gemini|google/i },46  { key: "xai", label: "xAI", provider: "xai", words: /^(xai|grok)$/, match: /grok|x-ai|\bxai\b/i },47  { key: "mistral", label: "Mistral", provider: "mistral", words: /^(mistral|mistralai)$/, match: /mistral|codestral|magistral|ministral|devstral|mixtral/i },48  { key: "deepseek", label: "DeepSeek", provider: "deepseek", words: /^deepseek$/, match: /deepseek/i },49  { key: "kimi", label: "Kimi", provider: "kimi", words: /^(kimi|moonshot|moonshotai)$/, match: /kimi|moonshot/i },50  { key: "openrouter", label: "OpenRouter", provider: "openrouter", words: /^openrouter$/, match: /^$/ },51  { key: "cerebras", label: "Cerebras", provider: "cerebras", words: /^cerebras$/, match: /^$/ },52  { key: "llama", label: "Llama", words: /^(llama|meta)$/, match: /llama|meta-/i },53  { key: "qwen", label: "Qwen", words: /^qwen$/, match: /qwen/i },54];5556const CAP_WORDS: { re: RegExp; cap: CapabilityKey; chip: string }[] = [57  { re: /^(vision|image|images|multimodal|photo|photos|ocr|see|picture|pictures)$/, cap: "vision", chip: "Vision" },58  { re: /^(reasoning|reason|reasoner|thinking|think|thinks|cot|deliberate)$/, cap: "reasoning", chip: "Reasoning" },59  { re: /^(tool|tools|function|functions|agentic|agent|agents)$/, cap: "tools", chip: "Tools" },60  { re: /^(web|search|browse|browsing|internet|grounding|grounded)$/, cap: "webSearch", chip: "Web search" },61  { re: /^(json|schema|structured)$/, cap: "structuredOutput", chip: "JSON schema" },62  { re: /^(pdf|pdfs|file|files|document|documents)$/, cap: "files", chip: "PDF / files" },63  { re: /^(audio|voice|speech)$/, cap: "audioInput", chip: "Audio input" },64  { re: /^(video)$/, cap: "video", chip: "Video" },65];6667const PHRASES: [RegExp, string][] = [68  [/open[\s-]?(source|weights?)/g, " openweights "],69  [/\boss\b/g, " openweights "],70  [/json[\s-]?schema/g, " json "],71  [/structured[\s-]?output(s)?/g, " json "],72  [/web[\s-]?search/g, " web "],73  [/(tool|function)[\s-]?calling/g, " tools "],74  [/(long|large|big|huge)[\s-]?(context|ctx|window)/g, " longcontext "],75  [/context[\s-]?window/g, " context "],76  [/low[\s-]?cost/g, " cheap "],77  [/low[\s-]?latency/g, " fast "],78  [/per[\s-]?million/g, "/m"],79  [/\bmtok\b/g, "/m"],80];8182const CHEAP_RE = /^(cheap|cheapest|cheaper|budget|inexpensive|affordable|economical|free)$/;83const FAST_WORD_RE = /^(fast|fastest|faster|quick|quickest|speedy|speed|snappy|instant|latency)$/;84const OPEN_RE = /^openweights$/;85const CODING_WORD_RE = /^(code|coding|coder|programming|program|developer|dev|debug|refactor)$/;86const NEW_RE = /^(new|newest|latest|recent|recently|released|fresh)$/;87const QUALITY_RE = /^(best|smart|smartest|strongest|frontier|quality|powerful|top|flagship|premium)$/;88const LONG_RE = /^(longcontext|long)$/;89const STOP_RE = /^(a|an|the|model|models|llm|llms|with|for|and|or|that|which|to|in|of|is|are|can|good|great|at|my|me|i|want|need|please|show|find)$/;9091function ctxTokens(n: number, unit: string): number {92  return unit === "m" ? Math.round(n * 1_000_000) : Math.round(n * 1_000);93}9495function fmtCtx(n: number): string {96  return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M` : `${Math.round(n / 1000)}K`;97}9899export function parseSearchQuery(raw: string): SearchIntent {100  let q = ` ${raw.toLowerCase().trim()} `;101  for (const [re, rep] of PHRASES) q = q.replace(re, rep);102  const intent: SearchIntent = { text: [], brands: [], capabilities: [], openWeights: false, coding: false, newest: false, quality: false, chips: [] };103104  // Price constraints: "under $1/m", "< $2", "$0.5 per million", "2$/m". Output when the query says so.105  const wantsOutput = /\b(output|out|completion)\b/.test(q);106  const priceRe = /(?:(under|below|<|less than|max|up to|at most|cheaper than)\s*)?\$\s?(\d+(?:\.\d+)?)(?:\s*\/\s*1?m)?|(?:(under|below|<|less than|max|up to|at most|cheaper than)\s*)?(\d+(?:\.\d+)?)\s?\$(?:\s*\/\s*1?m)?|(\d+(?:\.\d+)?)\s*\/\s*1?m\b/g;107  q = q.replace(priceRe, (_m, _p1, a, _p3, b, c) => {108    const v = Number(a ?? b ?? c);109    if (Number.isFinite(v)) {110      if (wantsOutput) intent.maxOutputPrice = v;111      else intent.maxInputPrice = v;112    }113    return " ";114  });115  q = q.replace(/\b(output|out|completion|input|in)\b/g, " ");116117  // Context: "1m context", "200k", "128k ctx", "1000000 tokens"118  const ctxRe = /(\d+(?:\.\d+)?)\s*([mk])\b(?:\s*(?:context|ctx|tokens?|window))?/g;119  q = q.replace(ctxRe, (_m, n, unit) => {120    const v = ctxTokens(Number(n), unit);121    intent.minContext = Math.max(intent.minContext ?? 0, v);122    return " ";123  });124  q = q.replace(/(\d{5,8})\s*(?:context|ctx|tokens?|window)/g, (_m, n) => {125    intent.minContext = Math.max(intent.minContext ?? 0, Number(n));126    return " ";127  });128  q = q.replace(/\b(context|ctx|tokens?|window)\b/g, " ");129130  for (const tok of q.split(/\s+/).filter(Boolean)) {131    const brand = BRANDS.find((b) => b.words.test(tok));132    if (brand) {133      if (!intent.brands.some((b) => b.key === brand.key)) intent.brands.push(brand);134      continue;135    }136    const cap = CAP_WORDS.find((c) => c.re.test(tok));137    if (cap) {138      if (!intent.capabilities.includes(cap.cap)) intent.capabilities.push(cap.cap);139      continue;140    }141    if (CHEAP_RE.test(tok)) {142      intent.sort = intent.sort ?? "cheapest";143      continue;144    }145    if (FAST_WORD_RE.test(tok)) {146      intent.sort = intent.sort ?? "fastest";147      continue;148    }149    if (OPEN_RE.test(tok)) {150      intent.openWeights = true;151      continue;152    }153    if (CODING_WORD_RE.test(tok)) {154      intent.coding = true;155      continue;156    }157    if (NEW_RE.test(tok)) {158      intent.newest = true;159      intent.sort = intent.sort ?? "newest";160      continue;161    }162    if (QUALITY_RE.test(tok)) {163      intent.quality = true;164      intent.sort = intent.sort ?? "quality";165      continue;166    }167    if (LONG_RE.test(tok)) {168      intent.minContext = Math.max(intent.minContext ?? 0, 400_000);169      intent.sort = intent.sort ?? "context";170      continue;171    }172    if (STOP_RE.test(tok)) continue;173    intent.text.push(tok);174  }175176  for (const b of intent.brands) intent.chips.push(b.label);177  for (const c of intent.capabilities) intent.chips.push(CAP_WORDS.find((w) => w.cap === c)?.chip ?? c);178  if (intent.minContext) intent.chips.push(`≥ ${fmtCtx(intent.minContext)} context`);179  if (intent.maxInputPrice !== undefined) intent.chips.push(`≤ $${intent.maxInputPrice}/M input`);180  if (intent.maxOutputPrice !== undefined) intent.chips.push(`≤ $${intent.maxOutputPrice}/M output`);181  if (intent.openWeights) intent.chips.push("Open weights");182  if (intent.coding) intent.chips.push("Coding");183  if (intent.newest) intent.chips.push("New");184  if (intent.sort === "cheapest") intent.chips.push("Cheapest first");185  if (intent.sort === "fastest") intent.chips.push("Fastest first");186  if (intent.sort === "quality") intent.chips.push("Strongest first");187  return intent;188}189190export function isEmptyIntent(i: SearchIntent): boolean {191  return !i.text.length && !i.brands.length && !i.capabilities.length && !i.openWeights && !i.coding && !i.newest && !i.quality && i.minContext === undefined && i.maxInputPrice === undefined && i.maxOutputPrice === undefined && !i.sort;192}193194export interface SearchContext {195  ctx?: BadgeContext;196  favorites?: Set<string>;197  recents?: Set<string> | string[];198  labels?: Record<string, string>;199  providerNames?: Partial<Record<ProviderId, string>>;200  now?: number;201}202203export interface SearchResult {204  model: PolyModel;205  score: number;206  /** Why it matched (for the UI). */207  matches: string[];208}209210/** Subsequence match ("sonet" ⊂ "sonnet") for light typo tolerance. */211function fuzzy(needle: string, hay: string): boolean {212  let i = 0;213  for (const ch of hay) {214    if (ch === needle[i]) i++;215    if (i === needle.length) return true;216  }217  return i === needle.length;218}219220function textScore(m: PolyModel, tokens: string[], whole: string, label: string | undefined, providerName: string): number {221  if (!tokens.length) return 0;222  const name = m.displayName.toLowerCase();223  const id = m.id.toLowerCase();224  const family = (m.family ?? "").toLowerCase();225  const vendor = String(m.metadata?.vendor ?? "").toLowerCase();226  const lbl = (label ?? "").toLowerCase();227  const prov = providerName.toLowerCase();228  let score = 0;229  if (id === whole || name === whole) score += 100;230  else if (name.startsWith(whole) || id.startsWith(whole)) score += 60;231  const nameWords = name.split(/[\s\-_./:]+/);232  for (const t of tokens) {233    let s = 0;234    if (nameWords.some((w) => w.startsWith(t))) s = 30;235    else if (name.includes(t)) s = 18;236    else if (id.includes(t)) s = 15;237    else if (lbl.includes(t)) s = 25;238    else if (family.includes(t)) s = 10;239    else if (vendor.includes(t)) s = 8;240    else if (prov.includes(t)) s = 8;241    else if (t.length >= 4 && (fuzzy(t, name.replace(/\s+/g, "")) || fuzzy(t, id))) s = 4;242    if (s === 0) return -1;243    score += s;244  }245  return score;246}247248export function searchModels(models: PolyModel[], query: string | SearchIntent, sctx: SearchContext = {}): SearchResult[] {249  const intent = typeof query === "string" ? parseSearchQuery(query) : query;250  const now = sctx.now ?? sctx.ctx?.now ?? Date.now();251  const ctx = sctx.ctx ?? buildBadgeContext(models, now);252  const recents = sctx.recents instanceof Set ? sctx.recents : new Set(sctx.recents ?? []);253  const whole = intent.text.join(" ");254  const blendedMax = Math.max(1, ...models.map((m) => blendedPrice(m) ?? 0));255  const ctxMax = Math.max(1, ...models.map((m) => m.limits?.contextTokens ?? 0));256257  const out: SearchResult[] = [];258  for (const m of models) {259    const matches: string[] = [];260    // Hard filters ----------------------------------------------------------261    if (intent.brands.length) {262      const hay = `${m.id} ${m.displayName} ${m.family ?? ""} ${m.metadata?.vendor ?? ""}`;263      const ok = intent.brands.some((b) => (b.provider && m.provider === b.provider) || b.match.test(hay));264      if (!ok) continue;265      matches.push(intent.brands.map((b) => b.label).join("/"));266    }267    if (intent.capabilities.some((c) => !m.capabilities[c])) continue;268    for (const c of intent.capabilities) matches.push(CAP_WORDS.find((w) => w.cap === c)?.chip ?? c);269    if (intent.minContext !== undefined) {270      if ((m.limits?.contextTokens ?? 0) < intent.minContext) continue;271      matches.push(`${fmtCtx(m.limits!.contextTokens!)} context`);272    }273    if (intent.maxInputPrice !== undefined) {274      const p = m.pricing?.inputPerMillion;275      if (typeof p !== "number" || p > intent.maxInputPrice) continue;276      matches.push(`$${p}/M in`);277    }278    if (intent.maxOutputPrice !== undefined) {279      const p = m.pricing?.outputPerMillion;280      if (typeof p !== "number" || p > intent.maxOutputPrice) continue;281      matches.push(`$${p}/M out`);282    }283    if (intent.openWeights) {284      if (!isOpenWeightsModel(m)) continue;285      matches.push("open weights");286    }287    if (intent.newest && !isNewModel(m, ctx) && intent.text.length === 0 && !intent.brands.length) continue;288289    // Text --------------------------------------------------------------------290    const ts = textScore(m, intent.text, whole, sctx.labels?.[m.key], sctx.providerNames?.[m.provider] ?? m.provider);291    if (ts < 0) continue;292    let score = ts;293294    // Soft boosts ------------------------------------------------------------295    const blended = blendedPrice(m);296    const fast = isFastModel(m, ctx);297    const frontier = isFrontierModel(m);298    if (intent.sort === "cheapest") {299      score += blended === null ? -10 : (1 - blended / blendedMax) * 30;300      if (blended !== null && blended <= ctx.cheapThreshold) matches.push("cheap");301    }302    if (intent.sort === "fastest") {303      score += fast ? 25 : 0;304      if (m.provider === "cerebras") score += 8;305      if (fast) matches.push("fast tier");306    }307    if (intent.sort === "context") score += ((m.limits?.contextTokens ?? 0) / ctxMax) * 15;308    if (intent.sort === "newest") {309      const t = firstSeenMs(m) ?? releaseDateMs(m);310      if (t !== null) score += Math.max(0, 20 - (now - t) / 86_400_000 / 10);311    }312    if (intent.quality) {313      if (frontier) {314        score += 25;315        matches.push("frontier");316      }317      if (m.capabilities.reasoning) score += 5;318      if (fast) score -= 8;319    }320    if (intent.coding) {321      if (isCodingModel(m)) {322        score += 30;323        matches.push("coding");324      } else if (frontier && m.capabilities.tools) score += 10;325      else if (!m.capabilities.tools) score -= 10;326    }327    // Base hygiene328    score += Math.min(10, Math.max(0, sortWeightOf(m) / 10));329    if (sctx.favorites?.has(m.key)) score += 6;330    if (recents.has(m.key)) score += 3;331    if (m.status === "deprecated") score -= 50;332    else if (m.status === "preview") score -= 2;333    else if (m.status === "unknown") score -= 4;334    if (m.provider === "openrouter" && intent.text.length) score -= 5; // prefer native providers for the same model335336    out.push({ model: m, score, matches: [...new Set(matches)] });337  }338  out.sort((a, b) => b.score - a.score || sortWeightOf(b.model) - sortWeightOf(a.model) || a.model.displayName.localeCompare(b.model.displayName));339  return out;340}341