fix(complexity_router): resolve a classified tier up a ladder instead of to whatever is configured - #35331
Conversation
… of to whatever is configured A tier resolved to whatever the config named, whether or not the router could serve it. A model group that was never registered, or whose every deployment was in cooldown, still won the pick and failed the request. A tier with no entry at all jumped to default_model and then to MEDIUM, which could answer a COMPLEX request with the model the classifier had just ruled out Resolution now walks a ladder: live models in the classified tier, then each tier above it, then default_model, then the classified tier as a best effort. A pool member counts as live when the router knows that model group and it has at least one deployment out of cooldown, so a dead model is passed over for its peers before the tier is given up on. Health probes for a pool run concurrently and the ladder stops at the first tier that can serve, so the common case is one tier's worth of cooldown reads It never falls to a cheaper tier. That is the model the classifier already ruled out, so a COMPLEX request is not answered by the SIMPLE model just because SIMPLE is healthy. The last step is best effort rather than an error because cooldowns expire and the health view is a snapshot; a request that might succeed is sent rather than failed Climbing is recorded as tier_fallback_from on the routing decision and in the log line, so a cheap request sitting on an expensive model is explainable afterwards. `tier` is the tier that served, matching how the escalation path already reports the final tier Two behaviors are deliberately kept. A tier configured as an empty pool still raises, being a config error rather than a gap, and climbing past it would hide the misconfiguration behind a pricier model. A routing plugin narrowing a tier to zero still raises, because refusing every candidate is a policy decision; plugins now run against whichever tier the ladder settles on, so they vet what is actually served rather than being handed a tier that cannot answer. An unreadable health view treats the pool as live, degrading to the previous behavior instead of declaring every tier dead
Greptile SummaryThe PR changes complexity routing to select a healthy model from the classified tier or progressively higher tiers.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| litellm/router_strategy/complexity_router/complexity_router.py | Adds health-aware upward tier resolution, plugin filtering, and routing-decision provenance. |
| litellm/types/utils.py | Adds tier_fallback_from to the typed routing-decision schema and derived-field allowlist. |
| tests/test_litellm/router_strategy/test_complexity_router.py | Adds coverage for live peers, upward fallback, default fallback, health failures, and plugin behavior. |
| litellm/router_strategy/complexity_router/README.md | Describes the tier fallback ladder and its handling of plugins, empty pools, and unavailable health data. |
Reviews (2): Last reviewed commit: "test(complexity_router): pin that defaul..." | Re-trigger Greptile
| ### Tier Fallback Ladder | ||
|
|
||
| Classifying a request is only half the job; the tier still has to have something that can answer. Resolution runs in this order: | ||
|
|
||
| 1. A live model in the classified tier. A pool member counts as live when the router knows that model group and it has at least one deployment out of cooldown, so a dead model in a pool is passed over for its peers. | ||
| 2. The next tier up, then the one above that. Resolution never falls to a cheaper tier, because that is the model the classifier already ruled out; a request classified COMPLEX is not answered by the SIMPLE model just because SIMPLE is healthy. | ||
| 3. `default_model`, if you set one. | ||
| 4. The classified tier anyway, as a best effort. Cooldowns expire and the health view is a snapshot, so a request that might succeed is sent rather than failed. | ||
|
|
||
| When the ladder climbs, the decision records `tier_fallback_from` with the tier the request was classified into, and the log line carries the same fact: | ||
|
|
||
| ``` | ||
| ComplexityRouter: routing decision cause=heuristic_scorer, tier=MEDIUM, score=-0.150, signals=[...], routed_model=gpt-4o, tier_fallback_from=SIMPLE | ||
| ``` | ||
|
|
||
| Two things are deliberately not routed around. A tier configured as an empty pool (`SIMPLE: []`) raises, because it is a config error rather than a gap. And a routing plugin that narrows a tier to zero candidates raises, because refusing every candidate is a policy decision; climbing past it would serve exactly what the plugin just denied. Plugins run against whichever tier the ladder settles on, so they still vet what is served. | ||
|
|
||
| If the health view itself cannot be read, the whole pool is treated as live and resolution behaves as it did before, instead of declaring every tier dead and pushing traffic to the top. | ||
|
|
||
| ### Reasoning Override |
There was a problem hiding this comment.
Product documentation added in-tree
This adds customer-facing configuration and fallback behavior documentation to the implementation repository, splitting it from the canonical litellm-docs repository and increasing the risk of documentation drift
Rule Used: Prevent documentation from being added - needs to ... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…usted ladder under plugins
|
Added a test in the latest commit for the other side of a policy #35050 just tightened: with routing plugins configured and nothing live anywhere on the ladder, That mirrors the config-time check in #35050, which no longer accepts |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…s no models for it With routing plugins configured, a tier absent from `tiers` produced "No candidate models left for tier MEDIUM after routing-plugin filtering". Nothing was filtered: the pool was empty before any plugin ran, so the message sent an operator to read plugin code for what is a gap in `tiers` The two cases now raise separately. An empty pool names the tier and says why default_model is not consulted; a plugin narrowing a real pool to zero keeps its own message, since that one really is a policy decision This is the path no-signal traffic now takes, because the implicit MEDIUM default is deliberately not validated at load: doing so would turn every partial tiers map into a startup failure. The tier ladder in #35331 resolves the same shape at runtime by climbing to the next configured tier, so this is the standalone behavior
|
@greptile can you reply here with a score and an explanation why |
|
bugbot run |
|
Score: 4/5 The core change is well-reasoned and correctly implemented. Here's the breakdown: What's solid:
Issues: 1. Stale comment in # Plugins configured: default_model must never bypass them, so it's not
# checked here at all -- _pick_model_for_tier -> get_model_for_tier still
# falls back to it (after the MEDIUM tier) once the plugin pipeline runs.This comment refers to the old MEDIUM-tier fallback that no longer exists. 2. In return self.config.default_model, tier
3. README removal (minor): The These are all minor. The logic is correct, the edge cases are covered by tests, and the observability improvement ( |
…not just to what Resolution returned (model, served_tier), a shape that cannot express two of its own outcomes. When default_model answered, served_tier was still the classified tier, so it was indistinguishable from an ordinary pick out of that tier; likewise when nothing reported a live deployment and the tier was served on a best effort. Callers inferred "no fallback happened" from served_tier == classified_tier, which was true in both of those cases and wrong _pick_model_for_tier now returns a TierResolution naming the model, the classified tier, the tier that served, and how the model was obtained. `resolved_by` is `default_model` when the ladder was exhausted and `best_effort` when nothing confirmed a live deployment, and it is omitted for the ordinary path. The climb keeps its own field. The two are independent facts, since an exhausted ladder can both climb to a configured tier and be serving something unconfirmed, so neither is derived from the other _build_routing_decision takes the resolution and derives both facts itself, instead of each of the three call sites recomputing the comparison and only one of them being able to spot the fallback at all Also drops a comment that described the pre-ladder fallback (default_model reached "after the MEDIUM tier"), which stopped being true when the ladder replaced the fixed MEDIUM jump
|
Both fixed in d076a7d, the second one at the primitive rather than at the call sites Stale comment. Gone. It described default_model not setting tier_fallback_from. The root cause was the return shape, not the missing assignment.
Four tests added: |
|
The fix is well done. The four new tests ( LGTM. |
|
@greptile can you reply here with a score and an explanation why |
|
bugbot run |
|
Score: 4/5 The logic is sound and the test suite is thorough. Three issues worth flagging: 1. Health probes on every request (performance — critical path)
The existing router already consults the cooldown cache during deployment selection — this is a second pass before the tier is even chosen. Consider whether the same information can be pulled from an already-hot in-process cache view rather than re-querying. 2. Silent removal of the MEDIUM fallback (backwards-incompatible, rule b48b7341) The old code had: medium_key = ComplexityTier.MEDIUM.value
if medium_key in self.config.tiers:
return self._pick_from_tier_value(self.config.tiers[medium_key], medium_key)Any unresolvable tier previously fell to MEDIUM. Under the new ladder, a COMPLEX tier with no models now climbs to REASONING (if configured), not MEDIUM. Users who intentionally left COMPLEX unconfigured to get MEDIUM as a catch-all will silently start getting REASONING instead — the more expensive model. This is a meaningful behavior shift. Per the project's backwards-compat rule, it should either be guarded by a flag or at least called out explicitly in the PR description as a deliberate breaking change. 3. README removal leaves the component undocumented in-tree The README was the only discoverable explanation of scoring dimensions, tier boundaries, and config options. The external docs repo presumably has none of this yet. Until it does, new contributors have nowhere to look. The ladder semantics, |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
1 issue from previous review remains unresolved.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit d076a7d. Configure here.
… once per model _live_models asked the router for healthy deployments per pool member, and each of those calls re-read the entire cooldown set: _async_get_healthy_deployments goes through _async_get_cooldown_deployments, which batches over every deployment id the router has. So a tier pool of N models meant N full cooldown reads, and that cache is Redis-backed in production, putting N round trips in front of every completion. asyncio.gather made them concurrent, which hid the count without removing it The set is now read once per request, before the ladder walks, and reused for every tier it visits. What remains per model is a lookup in the router's in-process deployment list and a frozenset membership test, so resolution does one piece of I/O regardless of pool size or how far the ladder climbs Behavior is unchanged, including the degradation: an unreadable cooldown view still means "assume the pool is live" rather than emptying every tier. The test double now models the mechanism it is standing in for, a set of registered model groups whose deployments may be cooling, so a test can assert the read happens exactly once across a four-tier climb, and a group the router does not know is passed over the same way one that is entirely cooling is
|
Fixed in 364be93. The finding was right, and worse than it read: The set is now read once per request, before the ladder walks, and reused for every tier it visits. What is left per model is a lookup in the router's in-process deployment list plus a frozenset membership test, so resolution does one piece of I/O regardless of pool size or how far it climbs. Measured on a 6-model pool: 6 reads before, 1 after I did not reach into an existing hot view instead, deliberately. Degradation is unchanged: an unreadable cooldown view still means "assume the pool is live" instead of emptying every tier. The test double now models the mechanism it stands in for, registered model groups whose deployments may be cooling, so @greptileai can you reply here with a score and why that score? |
|
5/5 The fix is correct and the explanation is accurate. What the diff shows:
cooled_down = await self._cooled_down_deployment_ids()
for candidate_tier in self._tier_ladder_from(tier):
...
live = self._live_models(pool, cooled_down)Everything after that one read is in-process: The degradation contract is preserved: a The test is meaningful: One minor observation: Everything else — the single I/O point, the in-process per-model lookups, the degraded-mode behavior, and the test asserting the one-read invariant — is solid. |
The membership test read `deployment_id not in cooled_down`, which is True for None, so a deployment missing model_info.id counted as live by falling out of a frozenset[str] rather than by saying so. The behavior is right: cooldowns are keyed by id, so a deployment that cannot be identified can never be known to be cooling, and the rule everywhere else in the ladder is that an absent health signal means live. Excluding it instead would let missing metadata empty a tier and push traffic to a pricier one Now written as `deployment_id is None or deployment_id not in cooled_down`, so the rule is on the page instead of being derived, with a test that pins it. Behavior is unchanged Reachable only via a raw deployment dict: ModelInfo mints a uuid whenever id is None, so anything registered through the typed path already has one
|
Not a defect, so no behavior change; addressed as a legibility fix in 1716d82 The reasoning: cooldowns are keyed by deployment id, so a deployment that cannot be identified can never appear in the cooldown set and can never be known to be cooling. Counting it as live is the same rule the rest of the ladder follows, that an absent health signal means live. The inverse would be actively worse: missing metadata would silently exclude a deployment, potentially emptying a tier and escalating traffic to a pricier one on the strength of a metadata gap rather than anything about health, which is exactly what the fail-open design exists to prevent Reachability is narrower than "pre-existing edge case" suggests. What was fair is that the rule was implicit, recoverable only by noticing @greptileai can you reply here with a score and why that score? |
|
5/5 The legibility fix in 1716d82 is exactly right, and the reasoning holds up. The original implicit behavior ( The rest of the PR holds up equally well across all the dimensions I'd check:
The one deletion worth noting is the module README, but it documented behavior that is now expressed directly in code and test names, so removing it avoids the documentation drift that would have followed as the ladder behavior matured. |
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1716d82. Configure here.
| return any( | ||
| deployment_id is None or deployment_id not in cooled_down | ||
| for deployment_id in map(self._deployment_id, deployments) | ||
| ) |
There was a problem hiding this comment.
Paused deployments skip ladder climb
Medium Severity
The updated _has_live_deployment function only checks for cooldowns, unlike the previous _async_get_healthy_deployments which also filtered admin-paused deployments. This change means a tier with only blocked deployments is now incorrectly considered live, preventing the router from climbing the ladder and causing requests to fail instead of routing to an available tier.
Reviewed by Cursor Bugbot for commit 1716d82. Configure here.


TLDR
Problem this solves:
default_modeland then to MEDIUM, so a COMPLEX request could be answered by the model the classifier had just ruled outHow it solves it:
default_model, then the classified tier as a best efforttier_fallback_fromon the routing decision and in the log lineRelevant issues
tier_fallback_fromwhenever the tier that served is not the tier that was classified, so a cheap request on an expensive model is explainableLinear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Live proxy on this branch. Two routers, identical except that
ladder-routerpoints its SIMPLE tier at a model group that does not exist, standing in for a tier that cannot serve:Healthy config, unchanged behavior:
Same prompt, same classification, but the tier cannot serve. Before this change the router returned
model-that-does-not-existand the request failed with Invalid model name; now it climbs one tier and says so:The upstream completions returned 429 (the OpenAI key on this box is out of quota, and the other provider keys available locally are unfunded), so the transcript proves the routing decision and the deployment handoff, not the provider response. The runbook below reruns it against a funded key for the 200s
Type
🐛 Bug Fix
Changes
get_model_for_tierkeeps its signature and delegates to_resolve_configured_tier, the structural half of the ladder: the first tier at or above the classified one that has models configured, thendefault_model. The async path adds the health half, filtering each pool to model groups the router knows and that have at least one deployment out of cooldown, via the router's existing_async_get_healthy_deployments. Probes for a pool run underasyncio.gather, and the ladder returns at the first tier that can serve_pick_model_for_tiernow returns(model, served_tier). Every call site passes the served tier to the decision record and, when it differs from the classified tier,tier_fallback_from. The field is registered inDERIVED_ROUTING_DECISION_FIELDS, so it survives a row with message logging turned off; it names a tier, never caller textPlugin handling moved into
_run_routing_pluginsand now runs against whichever tier the ladder settles on, rather than always the classified tier. A plugin narrowing to zero still raises rather than climbing, because refusing every candidate is a policy decision and the next tier up is exactly what was refused.default_modelis still skipped entirely when plugins are configured, so their policy cannot be bypassedTests cover peer fallback inside a tier (repeated, so a lucky random pick cannot pass it), a single-tier climb, a climb past several dead tiers, the no-downward-fallback rule,
default_modelas last resort rather than first and as the ladder's terminus, probe scoping, health-lookup failure degrading to the old behavior, the empty-pool error, and both plugin paths. They inject a router stub rather than patching internalsThe adaptive path keeps its own selection, which already scores every pool model with a tier-distance penalty
QA runbook
make bootstrap, and write the config above tolit4898_ladder.yamlwith a fundedOPENAI_API_KEYin the environmentLITELLM_LOG=INFO python litellm/proxy/proxy_cli.py --config lit4898_ladder.yaml --port 4899return_raw_model_name: trueto a router's config puts the deployment that served into the responsemodelfield, so the fallback is visible without reading logssmart-routerserves gpt-5.4-nano,ladder-routerserves gpt-5.4-mini and logstier_fallback_from=SIMPLE["model-that-does-not-exist", "gpt-5.4-nano"]and confirm every request lands on gpt-5.4-nano with notier_fallback_frompython -m pytest tests/test_litellm/router_strategy/test_complexity_router.py -qfor the unit coverageFinal Attestation
Note
Medium Risk
Changes sit on the pre-routing model-selection path for every complexity-router request, including cost and failure behavior when deployments are missing or in cooldown; coverage is strong but mis-routing would affect production traffic.
Overview
Complexity router no longer picks whatever name is configured for a tier. It walks an upward ladder: live pool members in the classified tier (skipping cooldown/unregistered deployments), then higher tiers, then
default_model, then a best-effort pick on the classified tier when nothing looks healthy.Routing decisions and logs can now record
tier_fallback_fromwhen the served tier differs from the classified one, andresolved_by(default_model/best_effort) when the model did not come from a normal tier pool._pick_model_for_tierreturns aTierResolutioninstead of a bare model string; routing plugins run against the tier that actually serves and still fail closed (no climbing past a plugin denial, nodefault_modelwhen plugins are configured).Cooldown state is read once per request; if that lookup fails, behavior degrades to treating pools as servable. The complexity router README is removed. Broad unit tests cover peer fallback, multi-tier climbs, no downward fallback, plugin paths, and cooldown edge cases.
Reviewed by Cursor Bugbot for commit 1716d82. Bugbot is set up for automated code reviews on this repo. Configure here.