Skip to content

feat(multi): manifest-aware tier routing — W1-W4 complete#2014

Open
oyi77 wants to merge 2 commits intodiegosouzapw:mainfrom
oyi77:feat/manifest-routing
Open

feat(multi): manifest-aware tier routing — W1-W4 complete#2014
oyi77 wants to merge 2 commits intodiegosouzapw:mainfrom
oyi77:feat/manifest-routing

Conversation

@oyi77
Copy link
Copy Markdown
Contributor

@oyi77 oyi77 commented May 6, 2026

Summary

Implements manifest-aware tier routing for OmniRoute — issue #1957.

What was built

Wave 1 — Types:

  • tierTypes.ts — Provider tier enums, TierAssignment, TierConfig, ProviderTierAssignment
  • specificityTypes.tsRuleInput, SpecificityBreakdown, SpecificityResult
  • tierConfig.ts (services) — mergeTierConfig(), getTierConfig(), default constants
  • providerCostData.tsgetModelPricing(), isExplicitlyFree(), cost lookup for all providers

Wave 2 — Detection Engine:

  • tierResolver.tsclassifyTier(), classifyTiers(), setTierConfig(), clearTierCache(), getTierStats()
  • specificityDetector.tsanalyzeSpecificity(), getSpecificityLevel(), getRecommendedMinTier(), isHighSpecificity(), isLowSpecificity()
  • specificityRules.ts — 5 heuristic rule detectors: conversation depth, file references, error context, enhanced context size, tool call patterns

Wave 3 — Integration:

  • manifestAdapter.tsgenerateRoutingHints(), getTargetTier(), compareByCostEffectiveness(), estimateRequestCost()
  • comboManifestMetrics.tsrecordComboIntentWithSpecificity() (log-only analytics)
  • comboConfig.tsmanifestRouting: false default (opt-in)
  • combo.ts — manifest routing integrated in strategy block
  • autoCombo/scoring.tstierAffinity + specificityMatch scoring factors with ?? 0 backward compat
  • DB migration 051_manifest_routing.sql + src/lib/db/tierConfig.ts

Wave 4 — Tests (73 passing):

  • tierResolver.test.ts — 25 tests
  • specificityDetector.test.ts — 28 tests
  • manifestAdapter.test.ts — 12 tests
  • tests/integration/manifest-routing.test.ts — 8 tests

Key constraints enforced

  • manifestRouting: false default (opt-in, not opt-out)
  • No LLM calls in detection code — purely heuristic
  • Weights sum to exactly 1.0
  • No as any, @ts-ignore, empty catch blocks, or console.log in new code
  • DB writes are log-only (no dead-end analytics)
  • npm run typecheck:core — zero errors
  • npm run check:cycles — no circular dependencies

Post-review fixes

  • tierResolver.ts: setTierConfig() now lazily loads persisted tier config from SQLite via dynamic require() inside the function body, avoiding circular dependency at module load time

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
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +51 to +63
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
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment on lines +163 to +218
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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Comment on lines +220 to +257
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),
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The detectEnhancedContextSize and getEnhancedSpecificityBreakdown functions appear to be dead code as they are not imported or used in specificityDetector.ts or any other part of the PR. The detector currently relies on the standard getSpecificityBreakdown.

"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 },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
"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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid duplicating the tier order array in multiple files (manifestAdapter.ts, scoring.ts), consider exporting a constant TIER_ORDER from this file.

Suggested change
} as const;
} as const;
export const TIER_ORDER: ProviderTier[] = ["free", "cheap", "premium"];

@@ -0,0 +1,136 @@
import { describe, it } from "node:test";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant