feat(multi): manifest-aware tier routing — W1-W4 complete#2014
feat(multi): manifest-aware tier routing — W1-W4 complete#2014oyi77 wants to merge 2 commits intodiegosouzapw:mainfrom
Conversation
Wave 1 (Types): tierTypes, specificityTypes, tierConfig, providerCostData Wave 2 (Detection): tierResolver, specificityDetector, specificityRules Wave 3 (Integration): manifestAdapter, comboManifestMetrics, combo integration Wave 4 (Tests): tierResolver (25), specificityDetector (28), manifestAdapter (12), integration (8) = 73 passing Key features: - Heuristic query complexity detection (no LLM calls) via conversation depth, file references, error context, enhanced context size, tool call patterns - 5-tier provider classification (free/cheap/budget/pro/premium) with DB persistence - Routing hints bridge between specificity analysis and combo scoring - scoring.ts: tierAffinity + specificityMatch factors with ?? 0 backward compat - manifestRouting flag defaults to false (opt-in) Fixes: - scoring.ts: removed duplicate ScoringWeights/calculateScore/DEFAULT_WEIGHTS - scoring.ts: calculateScore handles missing tierAffinity/specificityMatch gracefully
There was a problem hiding this comment.
Code Review
This pull request introduces a manifest-based routing system that dynamically selects model providers based on query specificity and cost-effectiveness. It includes a new specificity detector, a tier resolver for classifying providers into cost tiers, and updates to the scoring logic to incorporate tier affinity and specificity matching. However, several critical issues were identified: the calculateScore function is redundantly defined in scoring.ts, which will cause build errors; there is significant dead code in specificityRules.ts; and a naming mismatch for Claude models in providerCostData.ts will lead to incorrect pricing. Additionally, the placement of new test files in __tests__ directories violates the repository's style guide, which requires all tests to be located within the tests/ directory.
| export function calculateScore(factors: ScoringFactors, weights: ScoringWeights): number { | ||
| return ( | ||
| weights.quota * factors.quota + | ||
| weights.health * factors.health + | ||
| weights.costInv * factors.costInv + | ||
| weights.latencyInv * factors.latencyInv + | ||
| weights.taskFit * factors.taskFit + | ||
| weights.stability * factors.stability + | ||
| weights.tierPriority * factors.tierPriority + | ||
| weights.tierAffinity * factors.tierAffinity + | ||
| weights.specificityMatch * factors.specificityMatch | ||
| ); | ||
| } |
There was a problem hiding this comment.
The calculateScore function is defined twice in this file. This first definition (lines 51-63) is redundant and lacks the null-coalescing safety checks for tierAffinity and specificityMatch that are present in the second definition (lines 92-104). This will likely cause a duplicate export error or unexpected behavior depending on the build setup.
| export function detectConversationDepth(input: RuleInput): number { | ||
| const userMessages = input.messages.filter( | ||
| (m) => (m as { role?: string }).role === "user" | ||
| ).length; | ||
| const assistantMessages = input.messages.filter( | ||
| (m) => (m as { role?: string }).role === "assistant" | ||
| ).length; | ||
|
|
||
| const totalTurns = userMessages + assistantMessages; | ||
| if (totalTurns > 30) return 8; | ||
| if (totalTurns > 20) return 6; | ||
| if (totalTurns > 10) return 4; | ||
| if (totalTurns > 5) return 2; | ||
| return 0; | ||
| } | ||
|
|
||
| export function detectFileReferences(input: RuleInput): number { | ||
| const allText = input.messages | ||
| .map((m) => (typeof m.content === "string" ? m.content : "")) | ||
| .join("\n"); | ||
|
|
||
| const filePatterns = [ | ||
| /(?:\/[\w.-]+){2,}/g, | ||
| /\b\w+:\d+:\d+\b/g, | ||
| /\b(?:diff|patch|merge)\b/gi, | ||
| /\b(?:README|CHANGELOG|TODO)\b/gi, | ||
| /@@[\s+-]+\d+,\d+\s+@@/g, | ||
| ]; | ||
|
|
||
| const matches = filePatterns.reduce((sum, re) => { | ||
| return sum + (allText.match(re)?.length || 0); | ||
| }, 0); | ||
|
|
||
| return Math.min(5, matches * 1); | ||
| } | ||
|
|
||
| export function detectErrorContext(input: RuleInput): number { | ||
| const allText = input.messages | ||
| .map((m) => (typeof m.content === "string" ? m.content : "")) | ||
| .join("\n"); | ||
|
|
||
| const errorPatterns = [ | ||
| /\b(?:Error|Exception|TypeError|ReferenceError|SyntaxError)\b/g, | ||
| /\bat\s+[\w.]+\s+\([\w./]+:\d+:\d+\)/g, | ||
| /\b(?:throw|catch|finally)\b/g, | ||
| /\b(?:ERRO|FATAL|WARN)\b/g, | ||
| /\b(?:failed|crashed|unexpected)\b/gi, | ||
| /\bExit code \d+\b/g, | ||
| ]; | ||
|
|
||
| const matches = errorPatterns.reduce((sum, re) => { | ||
| return sum + (allText.match(re)?.length || 0); | ||
| }, 0); | ||
|
|
||
| return Math.min(5, matches * 0.5); | ||
| } |
There was a problem hiding this comment.
The functions detectConversationDepth, detectFileReferences, and detectErrorContext are defined but not utilized in any exported breakdown function or elsewhere in the current PR. If these are intended for future use, consider adding a TODO or integrating them; otherwise, they should be removed to keep the codebase clean.
| export function detectEnhancedContextSize(input: RuleInput): number { | ||
| const msgTokens = estimateMessageTokens(input.messages); | ||
| const sysTokens = input.systemPrompt ? estimateTokens(input.systemPrompt) : 0; | ||
| const toolTokens = input.tools | ||
| ? input.tools.reduce( | ||
| (sum, t) => | ||
| sum + | ||
| estimateTokens( | ||
| JSON.stringify( | ||
| (t as { function?: { description?: string; parameters?: unknown } })?.function || t | ||
| ) | ||
| ), | ||
| 0 | ||
| ) | ||
| : 0; | ||
|
|
||
| const total = msgTokens + sysTokens + toolTokens; | ||
|
|
||
| if (total > 100000) return 15; | ||
| if (total > 64000) return 13; | ||
| if (total > 32000) return 10; | ||
| if (total > 16000) return 7; | ||
| if (total > 8000) return 5; | ||
| if (total > 4000) return 3; | ||
| if (total > 1000) return 1; | ||
| return 0; | ||
| } | ||
|
|
||
| export function getEnhancedSpecificityBreakdown(input: RuleInput): SpecificityBreakdown { | ||
| return { | ||
| codeComplexity: detectCodeComplexity(input), | ||
| mathComplexity: detectMathComplexity(input), | ||
| reasoningDepth: detectReasoningDepth(input), | ||
| contextSize: detectEnhancedContextSize(input), | ||
| toolCalling: detectToolCalling(input), | ||
| domainSpecificity: detectDomainSpecificity(input), | ||
| }; | ||
| } |
| "gpt-4o": { inputCostPer1M: 2.5, outputCostPer1M: 10.0, isFree: false }, | ||
| "gpt-4o-mini": { inputCostPer1M: 0.15, outputCostPer1M: 0.6, isFree: false }, | ||
| "claude-opus-4-7": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, | ||
| "claude-sonnet-4-6": { inputCostPer1M: 3.0, outputCostPer1M: 15.0, isFree: false }, |
There was a problem hiding this comment.
There is a potential mismatch in model naming. KNOWN_MODEL_PRICING uses claude-sonnet-4-6, but the unit tests (e.g., tierResolver.test.ts) and integration tests use claude-sonnet-4.5. This will cause getModelPricing to return default premium pricing for the version used in tests unless it is explicitly caught by the freeProviders list.
| "claude-sonnet-4-6": { inputCostPer1M: 3.0, outputCostPer1M: 15.0, isFree: false }, | |
| "claude-sonnet-4.5": { inputCostPer1M: 3.0, outputCostPer1M: 15.0, isFree: false }, |
| FREE: "free", | ||
| CHEAP: "cheap", | ||
| PREMIUM: "premium", | ||
| } as const; |
There was a problem hiding this comment.
| @@ -0,0 +1,136 @@ | |||
| import { describe, it } from "node:test"; | |||
There was a problem hiding this comment.
According to the Repository Style Guide (Rule 5), all unit tests must strictly be placed within the tests/ directory (e.g., tests/unit/). Co-locating tests in __tests__ folders within the service directory is a violation of this project's organization rules.
References
- Test Files: ALL unit tests, integration tests, ecosystem tests, or Vitest files MUST strictly be placed within the tests/ directory. NEVER create test files in the project root. (link)
setTierConfig() now lazily loads persisted tier config from SQLite when called with no args (setTierConfig() or setTierConfig(null)), enabling DB-backed config without creating a circular dependency at module load time. The require() is inside the function so it only resolves when called and is wrapped in try/catch for graceful fallback to defaults.
Summary
Implements manifest-aware tier routing for OmniRoute — issue #1957.
What was built
Wave 1 — Types:
tierTypes.ts— Provider tier enums,TierAssignment,TierConfig,ProviderTierAssignmentspecificityTypes.ts—RuleInput,SpecificityBreakdown,SpecificityResulttierConfig.ts(services) —mergeTierConfig(),getTierConfig(), default constantsproviderCostData.ts—getModelPricing(),isExplicitlyFree(), cost lookup for all providersWave 2 — Detection Engine:
tierResolver.ts—classifyTier(),classifyTiers(),setTierConfig(),clearTierCache(),getTierStats()specificityDetector.ts—analyzeSpecificity(),getSpecificityLevel(),getRecommendedMinTier(),isHighSpecificity(),isLowSpecificity()specificityRules.ts— 5 heuristic rule detectors: conversation depth, file references, error context, enhanced context size, tool call patternsWave 3 — Integration:
manifestAdapter.ts—generateRoutingHints(),getTargetTier(),compareByCostEffectiveness(),estimateRequestCost()comboManifestMetrics.ts—recordComboIntentWithSpecificity()(log-only analytics)comboConfig.ts—manifestRouting: falsedefault (opt-in)combo.ts— manifest routing integrated in strategy blockautoCombo/scoring.ts—tierAffinity+specificityMatchscoring factors with?? 0backward compat051_manifest_routing.sql+src/lib/db/tierConfig.tsWave 4 — Tests (73 passing):
tierResolver.test.ts— 25 testsspecificityDetector.test.ts— 28 testsmanifestAdapter.test.ts— 12 teststests/integration/manifest-routing.test.ts— 8 testsKey constraints enforced
manifestRouting: falsedefault (opt-in, not opt-out)as any,@ts-ignore, empty catch blocks, orconsole.login new codenpm run typecheck:core— zero errorsnpm run check:cycles— no circular dependenciesPost-review fixes
tierResolver.ts:setTierConfig()now lazily loads persisted tier config from SQLite via dynamicrequire()inside the function body, avoiding circular dependency at module load time