/** * Task-aware routing layer for combo routing. * * Derives request difficulty (light / standard / heavy / critical) from cheap, * local structural signals — no LLM call. Routes heavier tasks toward higher-power * models using a continuous `modelPowerScore`. Also provides conversation-affinity * sticky round-robin for prompt-cache efficiency. * * Ported from upstream PR #2045 (decolua/9router) by @nguyenxvotanminh3. * Adaptation: operates on ResolvedComboTarget[] (TS target objects) rather than * plain string arrays, uses getResolvedModelCapabilities (OmniRoute TS API) instead * of getCapabilitiesForModel, and is wired additively — only applies when * isTaskRoutingStrategy() returns true. All other strategies are unaffected. * * ReDoS safety: all regexes use word-boundary anchors with alternation of fixed * literals, no variable-length quantifiers on overlapping groups. Safe per * CLAUDE.md PII/Regex learnings. */ import { createHash } from "node:crypto"; import { getResolvedModelCapabilities } from "./modelCapabilities.ts"; import type { ResolvedComboTarget } from "./combo/types.ts"; // ── Task level constants ────────────────────────────────────────────────────── export const TASK_LEVEL_WEIGHT = { light: 1, standard: 2, heavy: 3, critical: 4, } as const; export type TaskLevel = keyof typeof TASK_LEVEL_WEIGHT; export const TASK_TARGET_POWER: Record = { light: 35, standard: 65, heavy: 95, critical: 120, }; // ReDoS-safe: all alternations are fixed-length word literals under \b anchors. // No overlapping or nested quantifiers. export const LIGHT_TASK_RE = /\b(hi|hello|thanks|thank you|ping|format|rewrite|grammar|translate|summari[sz]e|short|quick|one[- ]?liner|explain briefly)\b/i; export const HEAVY_TASK_RE = /\b(debug|root cause|architecture|architectural|refactor|migrate|implementation|implement|design|analy[sz]e|investigate|compare|benchmark|whitebox|codebase|end[- ]?to[- ]?end|e2e)\b/i; export const CRITICAL_TASK_RE = /\b(critical|security|vulnerability|exploit|rce|remote code execution|supply chain|account takeover|auth bypass|privilege escalation|tenant|cross[- ]tenant|sandbox escape|ssrf|deserialization|prod incident|data exfiltration|bug bounty)\b/i; // ── Conversation affinity state ─────────────────────────────────────────────── /** @internal exported only for testing */ export const comboConversationAffinity = new Map(); const CONVERSATION_AFFINITY_TTL_MS = 60 * 60 * 1000; // 1 hour const MAX_CONVERSATION_AFFINITY_ENTRIES = 1000; // ── Strategy gate ───────────────────────────────────────────────────────────── /** * Returns true when the combo strategy is one of the task-aware strategies. * Task routing is additive: other strategies are wholly unaffected. */ export function isTaskRoutingStrategy(strategy: unknown): boolean { return ["smart", "task", "task-aware", "task_aware", "auto"].includes( String(strategy ?? "").toLowerCase() ); } // ── Internal helpers ────────────────────────────────────────────────────────── function taskWeight(level: TaskLevel): number { return TASK_LEVEL_WEIGHT[level]; } function collectText(value: unknown, out: string[] = []): string[] { if (value == null) return out; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { out.push(String(value)); return out; } if (Array.isArray(value)) { for (const item of value) collectText(item, out); return out; } if (typeof value !== "object") return out; const rec = value as Record; if (typeof rec["text"] === "string") out.push(rec["text"]); if (typeof rec["input_text"] === "string") out.push(rec["input_text"]); if (typeof rec["output_text"] === "string") out.push(rec["output_text"]); if (typeof rec["content"] === "string") out.push(rec["content"]); else if (Array.isArray(rec["content"])) collectText(rec["content"], out); if (Array.isArray(rec["parts"])) collectText(rec["parts"], out); if (typeof rec["query"] === "string") out.push(rec["query"]); if (typeof rec["url"] === "string") out.push(rec["url"]); return out; } function estimatePromptChars(body: Record): number { const contents = (body["contents"] as unknown) ?? ((body["request"] as Record | undefined)?.["contents"] as unknown); const parts = [ body["system"], body["instructions"], body["messages"], body["input"], contents, body["query"], body["url"], ]; return collectText(parts).join("\n").length; } function countMessages(body: Record): number { const contents = (body["contents"] as unknown[]) ?? ((body["request"] as Record | undefined)?.["contents"] as unknown[]); return ( (Array.isArray(body["messages"]) ? body["messages"].length : 0) + (Array.isArray(body["input"]) ? body["input"].length : 0) + (Array.isArray(body["contents"]) ? (body["contents"] as unknown[]).length : 0) + (Array.isArray(contents) ? contents.length : 0) ); } function maxRequestedOutput(body: Record): number { const genConf = body["generationConfig"] as Record | undefined; const candidates = [ body["max_tokens"], body["max_output_tokens"], body["max_completion_tokens"], genConf?.["maxOutputTokens"], ] .map((v) => Number.parseInt(String(v ?? ""), 10)) .filter((v) => Number.isFinite(v)); return candidates.length > 0 ? Math.max(...candidates) : 0; } function getTaskText(body: Record): string { const contents = (body?.["contents"] as unknown) ?? ((body?.["request"] as Record | undefined)?.["contents"] as unknown); return collectText([ body?.["system"], body?.["instructions"], body?.["messages"], body?.["input"], contents, body?.["query"], body?.["url"], ]).join("\n"); } function normalizeEffort(body: Record): string { const reasoning = body?.["reasoning"] as Record | undefined; return String(body?.["reasoning_effort"] ?? reasoning?.["effort"] ?? "").toLowerCase(); } // ── Task signals ────────────────────────────────────────────────────────────── export interface TaskSignals { promptChars: number; messageCount: number; toolCount: number; outputTokens: number; effort: string; hasExplicitReasoning: boolean; lightKeyword: boolean; heavyKeyword: boolean; criticalKeyword: boolean; } export function getTaskSignals(body: Record): TaskSignals { const promptChars = estimatePromptChars(body); const messageCount = countMessages(body); const toolCount = Array.isArray(body?.["tools"]) ? (body["tools"] as unknown[]).length : 0; const outputTokens = maxRequestedOutput(body); const effort = normalizeEffort(body); const text = getTaskText(body); return { promptChars, messageCount, toolCount, outputTokens, effort, hasExplicitReasoning: Boolean( effort && effort !== "none" && effort !== "off" && effort !== "disabled" ), lightKeyword: LIGHT_TASK_RE.test(text), heavyKeyword: HEAVY_TASK_RE.test(text), criticalKeyword: CRITICAL_TASK_RE.test(text), }; } // ── Task classification ─────────────────────────────────────────────────────── export interface TaskClassification extends TaskSignals { level: TaskLevel; weight: number; reasons: string[]; } /** * Classify request difficulty for smart combo routing. * * Deliberately uses cheap, local signals only — no LLM call. It is a routing * hint: light requests stay on fast/cheap models while large, tool-heavy, * security-sensitive, or reasoning-heavy requests try stronger models first. * Fallback still tries every model. */ export function classifyTask(body: Record): TaskClassification { const s = getTaskSignals(body ?? {}); const reasons: string[] = []; const add = (condition: boolean, reason: string): boolean => { if (condition) reasons.push(reason); return condition; }; const effortIsHigh = /^(high|xhigh|max|maximum|hard|deep)$/.test(s.effort); const effortIsLight = !s.hasExplicitReasoning || /^(low|minimal|none|off|disabled)$/.test(s.effort); const critical = add(s.promptChars >= 100000, "huge-context") || add(s.outputTokens >= 32768, "huge-output") || add(s.toolCount >= 8 && s.promptChars >= 16000, "many-tools-large-context") || add( s.criticalKeyword && (effortIsHigh || s.toolCount >= 3 || s.promptChars >= 8000), "critical-domain" ); if (critical) { return { level: "critical", weight: taskWeight("critical"), ...s, reasons }; } const heavySignalCount = [ add(s.promptChars >= 50000, "large-context"), add(s.promptChars >= 24000, "medium-large-context"), add(s.messageCount >= 16, "long-conversation"), add(s.toolCount >= 4, "many-tools"), add(s.outputTokens >= 8192, "large-output"), add(effortIsHigh, "high-reasoning-effort"), add(s.criticalKeyword, "security-sensitive"), add(s.heavyKeyword && s.promptChars >= 4000, "complex-task"), ].filter(Boolean).length; if (heavySignalCount >= 2 || s.promptChars >= 50000 || effortIsHigh) { return { level: "heavy", weight: taskWeight("heavy"), ...s, reasons }; } const light = s.promptChars <= 2000 && s.messageCount <= 3 && s.toolCount === 0 && s.outputTokens <= 1500 && effortIsLight && !s.criticalKeyword && !s.heavyKeyword; if ( light || (s.lightKeyword && s.promptChars <= 4000 && s.toolCount === 0 && effortIsLight && !s.criticalKeyword) ) { return { level: "light", weight: taskWeight("light"), ...s, reasons: reasons.length > 0 ? reasons : ["small-simple-request"], }; } return { level: "standard", weight: taskWeight("standard"), ...s, reasons: reasons.length > 0 ? reasons : ["default"], }; } // ── Model power scoring ─────────────────────────────────────────────────────── /** * Estimate how capable a model is on a continuous 0–150 scale, using both * registry-derived capabilities and heuristic name matching. */ export function modelPowerScore(modelStr: string): number { const id = `${modelStr ?? ""}`.toLowerCase(); const caps = getResolvedModelCapabilities(modelStr); let score = 35; if (caps.reasoning) score += 18; if (caps.supportsVision === true) score += 3; if (caps.toolCalling) score += 3; const ctx = caps.contextWindow ?? 0; if (ctx >= 1_000_000) score += 22; else if (ctx >= 400_000) score += 15; else if (ctx >= 200_000) score += 9; else if (ctx > 0 && ctx <= 32_000) score -= 10; const maxOut = caps.maxOutputTokens ?? 0; if (maxOut >= 128_000) score += 12; else if (maxOut >= 64_000) score += 8; else if (maxOut > 0 && maxOut <= 8_192) score -= 8; if ( /\b(opus|mythos|gpt-5|o3|o4|pro|max|ultra|deepseek-v4-pro|sonnet-4|glm-5|kimi-k2\.7|minimax-m3|reasoner)\b/i.test( id ) ) score += 28; if (/\b(coder|code|coding)\b/i.test(id)) score += 8; if (/\b(haiku|flash|mini|lite|small|nano|instant|fast|turbo|3\.5|8b|7b)\b/i.test(id)) score -= 24; return Math.max(0, Math.min(150, score)); } // Hard capabilities: missing one drops request data (images, PDFs). Maps TS // ResolvedModelCapabilities fields that correspond to hard-cap modalities. const HARD_CAP_CHECKS = new Set(["vision"]); /** * Score a single model for a given task + capability requirements. * Higher score = better fit. Negative score = capability hard-miss. */ export function scoreModelForTask( modelStr: string, task: TaskClassification = classifyTask({}), required: Set = new Set() ): number { const caps = getResolvedModelCapabilities(modelStr); const target = TASK_TARGET_POWER[task.level]; const power = modelPowerScore(modelStr); let score = 100 - Math.abs(power - target); // Hard capability misses: drop score heavily so the model sorts to the back // but stays in the list (fallback still tries every model). for (const cap of required) { if (!HARD_CAP_CHECKS.has(cap)) continue; if (cap === "vision" && caps.supportsVision !== true) score -= 10000; } if ((required.has("reasoning") || task.weight >= TASK_LEVEL_WEIGHT.heavy) && !caps.reasoning) score -= 120; if (required.has("search") && !caps.toolCalling) score -= 30; const estimatedPromptTokens = Math.ceil((task.promptChars ?? 0) / 4); const ctxLimit = caps.contextWindow ?? 0; if (ctxLimit > 0 && estimatedPromptTokens > ctxLimit * 0.85) score -= 200; const maxOut = caps.maxOutputTokens ?? 0; if (maxOut > 0 && task.outputTokens > 0 && task.outputTokens > maxOut) score -= 80; if (task.level === "light" && power > 95) score -= 35; if (task.level === "standard" && power > 125) score -= 10; if (task.level === "heavy" && power < 65) score -= 60; if (task.level === "critical" && power < 85) score -= 100; return score; } /** * Reorder ResolvedComboTarget[] so the best-fit model for the task comes first. * Stable: ties keep original order. Identity-returns when no reordering needed * (avoids allocations on the common path). Never removes targets. */ export function reorderByTaskWeight( targets: ResolvedComboTarget[], task: TaskClassification = classifyTask({}), required: Set = new Set() ): ResolvedComboTarget[] { if (!Array.isArray(targets) || targets.length <= 1) return targets; const reordered = targets .map((t, i) => ({ t, i, score: scoreModelForTask(t.modelStr, task, required) })) .sort((a, b) => b.score - a.score || a.i - b.i) .map((x) => x.t); return reordered.every((t, i) => t === targets[i]) ? targets : reordered; } // ── Conversation affinity (cache-key derivation) ────────────────────────────── function normalizeFingerprintText(value: unknown): string { return String(value ?? "") .replace(/\s+/g, " ") .trim() .slice(0, 12000); } function firstRoleText( items: unknown[], roles: Set, contentKey: "content" | "parts" = "content" ): string { if (!Array.isArray(items)) return ""; for (const item of items) { if (!item || typeof item !== "object") continue; const rec = item as Record; if (!roles.has(String(rec["role"] ?? ""))) continue; const raw = contentKey === "parts" ? rec["parts"] : rec["content"]; const text = normalizeFingerprintText(collectText(raw).join("\n")); if (text) return text; } return ""; } function allRoleText( items: unknown[], roles: Set, contentKey: "content" | "parts" = "content" ): string { if (!Array.isArray(items)) return ""; return normalizeFingerprintText( items .filter( (item): item is Record => !!item && typeof item === "object" && roles.has(String((item as Record)["role"] ?? "")) ) .map((item) => collectText(contentKey === "parts" ? item["parts"] : item["content"]).join("\n") ) .filter(Boolean) .join("\n") ); } function hashConversationSeed(seed: string): string | null { const normalized = normalizeFingerprintText(seed); if (!normalized) return null; return createHash("sha1").update(normalized).digest("hex").slice(0, 24); } /** * Derive a stable cache-affinity key from explicit thread metadata when present, * otherwise from the immutable start of the prompt (system + first user turn). * Appended turns should not move an existing conversation to another model. */ export function getConversationCacheKey(body: Record): string | null { if (!body || typeof body !== "object") return null; const meta = body["metadata"] as Record | undefined; const explicitCandidates = [ body["conversation_id"], body["conversationId"], body["thread_id"], body["threadId"], body["session_id"], body["sessionId"], meta?.["conversation_id"], meta?.["conversationId"], meta?.["thread_id"], meta?.["threadId"], meta?.["session_id"], meta?.["sessionId"], ]; const explicit = explicitCandidates.find((v) => v != null && String(v).trim()); if (explicit != null) return hashConversationSeed(`explicit:${String(explicit).trim()}`); const systemRoles = new Set(["system", "developer"]); const userRoles = new Set(["user"]); const contents = (body["contents"] as unknown[]) ?? ((body["request"] as Record | undefined)?.["contents"] as unknown[]); const seedParts = [ collectText(body["system"]).join("\n"), collectText(body["instructions"]).join("\n"), allRoleText((body["messages"] as unknown[]) ?? [], systemRoles), allRoleText((body["input"] as unknown[]) ?? [], systemRoles), allRoleText(contents ?? [], systemRoles, "parts"), firstRoleText((body["messages"] as unknown[]) ?? [], userRoles), typeof body["input"] === "string" ? body["input"] : firstRoleText((body["input"] as unknown[]) ?? [], userRoles), firstRoleText(contents ?? [], userRoles, "parts"), body["query"], body["url"], ].filter(Boolean); return hashConversationSeed(seedParts.join("\n")); } // ── Affinity management (for round-robin integration) ──────────────────────── /** @internal exported for testing */ export function pruneConversationAffinity(now = Date.now()): void { for (const [key, value] of comboConversationAffinity) { if (!value || now - value.lastUsed > CONVERSATION_AFFINITY_TTL_MS) { comboConversationAffinity.delete(key); } } while (comboConversationAffinity.size > MAX_CONVERSATION_AFFINITY_ENTRIES) { const oldestKey = comboConversationAffinity.keys().next().value; if (oldestKey === undefined) break; comboConversationAffinity.delete(oldestKey); } } /** * Returns the pinned target index for a conversation, or null if none. * Creates and stores an affinity entry on first call for a new conversation. * * Used by getRotatedModels (round-robin) when stickyLimit > 1. */ export function getOrSetConversationAffinityIndex( rotationKey: string, conversationCacheKey: string, currentIndex: number ): number { const now = Date.now(); pruneConversationAffinity(now); const affinityKey = `${rotationKey}:${conversationCacheKey}`; const existing = comboConversationAffinity.get(affinityKey); if (existing) { const pinnedIndex = existing.index; // Refresh TTL (move to end of Map iteration order) comboConversationAffinity.delete(affinityKey); comboConversationAffinity.set(affinityKey, { index: pinnedIndex, lastUsed: now }); return pinnedIndex; } comboConversationAffinity.set(affinityKey, { index: currentIndex, lastUsed: now }); return currentIndex; } /** * Clear affinity entries for a specific combo (or all if no name given). * Called by resetComboRotation. */ export function clearConversationAffinity(comboName?: string): void { if (comboName) { const prefix = `${comboName}:`; for (const key of comboConversationAffinity.keys()) { if (key.startsWith(prefix)) comboConversationAffinity.delete(key); } } else { comboConversationAffinity.clear(); } }