TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
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