Skip to content

fix(router): support tag-based routing for complexity routers sharing a model_name - #33660

Open
devin-ai-integration[bot] wants to merge 2 commits into
litellm_internal_stagingfrom
litellm_complexity_router_tag_routing
Open

fix(router): support tag-based routing for complexity routers sharing a model_name#33660
devin-ai-integration[bot] wants to merge 2 commits into
litellm_internal_stagingfrom
litellm_complexity_router_tag_routing

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #33655

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Type

🐛 Bug Fix

Changes

Two auto_router/complexity_router deployments that share a model_name but carry different tags used to collapse to a single config. Router.complexity_routers was a Dict[str, ComplexityRouter], so init_complexity_router_deployment either kept only the first-registered router or raised "... already exists ..." at startup. Every request then classified through the first deployment's tier config regardless of the key's tag, so a key carrying the second tag ended up on the wrong provider and got a 401 after model selection

The registry is now dict[str, list[ComplexityRouter]] and each ComplexityRouter remembers its own tags and model_id. Selection happens in a new Router._select_complexity_router, called from async_pre_routing_hook before classification:

routers = self.complexity_routers.get(model)
if len(routers) <= 1 or tag filtering is off:
    use routers[0]              # unchanged behaviour
else:
    idx = select_index_by_tags([r.tags for r in routers], request_tags, match_any)
    if idx is None: raise RouterErrors.no_deployments_with_tag_routing
    use routers[idx]

select_index_by_tags (new helper in tag_based_routing.py) reuses the existing exact-tag semantics: !tag excludes a candidate, a positive request tag picks the first candidate whose tags match under match_any/match-all, an untagged request prefers a default-tagged candidate, and no match with request tags present surfaces the standard tag-routing error instead of silently using the wrong config

Because the hook swaps model from the alias to the chosen tier model, alias litellm_params are now merged from the alias deployment that actually matches the selected router (_resolve_alias_index matches on model_info.id), not always the first alias, so the correct tags/params flow onto the request

Router-registry cleanup on deployment delete now evicts only the router backed by the deleted model_id (_evict_complexity_router) and keeps sibling routers registered under the same model_name, dropping the map entry only once the last sibling is gone. Single-router and non-tagged behaviour is unchanged

Tests: TestComplexityRouterTagBasedRouting registers two smart-router deployments tagged cn and row and asserts each request tag resolves to its own tier model, that an unmatched tag raises the tag-routing error, and that disabling tag filtering falls back to the first router; test_evict_complexity_router_keeps_sibling_tagged_routers covers per-model_id eviction. These fail against the old single-router registry (which raised at startup or routed both tags through the first config)

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Link to Devin session: https://app.devin.ai/sessions/23a95f953c3140c4a94b75a3313c563d

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where two complexity_router deployments sharing the same model_name but different tags would collapse to a single config — causing the wrong tier to be used (or a startup error). The registry is upgraded from Dict[str, ComplexityRouter] to dict[str, list[ComplexityRouter]], selection is tag-aware, and per-model_id eviction is added for the delete path.

  • ComplexityRouter now stores tags and model_id; Router._select_complexity_router picks the right router using the new select_index_by_tags helper that mirrors existing deployment tag-routing semantics (exclusion tags, default fallback, match-any/all).
  • _evict_complexity_router in the delete endpoint drops only the router backed by the deleted deployment's model_id, keeping siblings; _resolve_alias_index ensures the matched alias's litellm_params (not always the first alias) are applied to the request.

Confidence Score: 3/5

The core routing logic and tag-selection helper are correct, but the eviction helper has an over-eviction bug when model_id is None, and the adaptive-router init loop silently ignores the adaptive config of any tagged sibling beyond the first.

The null model_id eviction issue is a real defect on a code path exercised by the new feature; deleting any deployment whose model_info has no id would drop all complexity routers with model_id=None under that model_name, not just the intended one.

litellm/proxy/management_endpoints/model_management_endpoints.py (_evict_complexity_router's None-id branch) and litellm/router.py (adaptive-router init loop around line 7703)

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/model_management_endpoints.py Adds _evict_complexity_router helper and wires it into the delete endpoint; has a latent over-eviction bug when model_id is None
litellm/router.py Changes complexity_routers registry to list-per-key, adds _select_complexity_router and _resolve_alias_index; adaptive-init loop silently drops siblings' adaptive config
litellm/router_strategy/tag_based_routing.py Adds select_index_by_tags helper that mirrors existing tag-routing semantics for the single-pick case; logic is correct
litellm/router_strategy/complexity_router/complexity_router.py Adds tags and model_id fields to ComplexityRouter constructor; straightforward and correct
tests/test_litellm/router_strategy/test_complexity_router.py Adds TestComplexityRouterTagBasedRouting covering registration, tag selection, unmatched-tag error, and disabled-filtering fallback; all mock-only, no network calls
tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py Updates existing delete-model test for new list-based registry and adds test_evict_complexity_router_keeps_sibling_tagged_routers; coverage is thorough

Reviews (1): Last reviewed commit: "fix(router): support tag-based routing f..." | Re-trigger Greptile

Comment on lines +1074 to +1082
"""
existing = complexity_routers.get(model_name)
if not existing:
return
remaining = [router for router in existing if router.model_id != model_id]
if remaining:
complexity_routers[model_name] = remaining
else:
complexity_routers.pop(model_name, None)

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.

P1 Null model_id evicts all unidentified routers

When deployment.model_info is None in init_complexity_router_deployment, model_id is stored as None. If a deployment is later deleted with the same None id, the filter router.model_id != model_id evaluates as router.model_id != None, which is False for every router whose model_id is also None. All of those routers are dropped from remaining and evicted together — not just the one backing the deleted deployment.

In practice model_info.id is auto-generated, but any edge case where it is absent causes a broader-than-intended eviction.

Comment thread litellm/router.py
Comment on lines +7703 to +7709
for model_name, complexity_routers in self.complexity_routers.items():
for complexity_router in complexity_routers:
if not complexity_router.config.adaptive or model_name in self.adaptive_routers:
continue
adaptive_router = complexity_router._ensure_adaptive_router()
if adaptive_router is not None:
self.adaptive_routers[model_name] = adaptive_router

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.

P2 Only first tagged complexity router gets adaptive mode

When two complexity routers share a model_name (the new tag-based scenario) and both have config.adaptive = True, the inner-loop check model_name in self.adaptive_routers will be True after the first router creates its adaptive router. Every subsequent tagged sibling is skipped silently. Since adaptive_routers is a Dict[str, AdaptiveRouter] keyed by model_name (not by tag), only one adaptive router can exist per model_name — so adaptive mode will use the first registered tag's configuration regardless of which tag's complexity router handled the request.

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/router_strategy/tag_based_routing.py 69.23% 4 Missing ⚠️
...management_endpoints/model_management_endpoints.py 90.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_complexity_router_tag_routing (a8412e5) with litellm_internal_staging (4d33964)

Open in CodSpeed

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.

bug: complexity_router does not support tag-based routing — same model_name with different tags silently uses only the first registered config

1 participant