feat(router): make auto-router session affinity deployment-granular - #36045
feat(router): make auto-router session affinity deployment-granular#36045devin-ai-integration[bot] wants to merge 6 commits into
Conversation
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Greptile SummaryThe PR makes auto-router session affinity deployment-granular and scopes session pins by API key hash. The latest changes use a weak reference for the dynamic group-TTL provider so globally registered callbacks do not retain discarded Router instances
Confidence Score: 5/5The PR appears safe to merge No blocking failure remains
|
| Filename | Overview |
|---|---|
| litellm/router.py | Derives complexity-router affinity groups, registers the deployment-affinity callback, and exposes the TTL mapping through a weakref-backed provider |
| litellm/router_utils/pre_call_checks/deployment_affinity_check.py | Enables per-group auto-router affinity, scopes session keys by caller key hash, and applies group-specific TTLs |
| tests/test_litellm/router_strategy/test_complexity_router.py | Covers complexity-router group discovery and callback registration |
| tests/test_litellm/router_utils/pre_call_checks/test_session_id_affinity.py | Adds deployment pinning, isolation, fallback, expiration, configuration, and Router garbage-collection regression tests |
Reviews (2): Last reviewed commit: "fix(router): weaken optional affinity pr..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
@greptileai two commits landed after your review: the group-TTL provider handed to |
TLDR
Problem this solves:
How it solves it:
Relevant issues
Linear 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
Real Anthropic API calls against a local proxy, no mocks. Model is
anthropic/claude-haiku-4-5, the newest haiku-family model the Anthropic/v1/modelsendpoint lists. Each deployment in the fanned-out group has an explicitmodel_info.idso thex-litellm-model-idresponse header names the deployment that served the turn.Config (
/tmp/affinity_proof_config.yaml):df1b93ea3dcb9486e59d3db414e87bc03c288083x-litellm-session-idEvery turn of the session lands on the same deployment, so the provider prompt cache stays warm
Five hits against six sets: turn 1 has nothing cached so it picks freely, then every later turn reads the pin
Each session is internally consistent and three of the four went to the deployment session A did not use, so the pin is per session rather than a global lock. First-turn selection is still random, which is why the split is 2 vs 3 rather than anything designed
origin/litellm_internal_stagingat commitba917681461b1ad04d30f91da26e75b3521996f3, served on port 4001, one session id, eight turnsOne session, four turns on each deployment, so the prompt cache goes cold roughly every other turn. The pre-fix log shows why:
session_affinity_pinfires androuted_modelistarget-groupon all eight turns, whileset session affinity mappingappears zero times and noDeploymentAffinityCheckis registered. The tier's model name was pinned, the deployment was notDeployment selection on a session's first turn is random, so the specific ids above will differ run to run; the invariants (identical within a session after the fix, spread before it) are what reproduce. Runs were single worker with the default in-memory cache, over
/v1/chat/completionswith thex-litellm-session-idheaderType
🆕 New Feature
Changes
For an agent workload with a large stable prefix, cache affinity is worth more than model choice: a provider-side prompt-cache hit costs roughly a tenth of the normal input rate, so a router that moves a session to a different model, or merely to a different deployment of the same model, can be a net loss even when the new model is cheaper
The complexity router's
session_affinityonly ever pinned the routed model name. When the tier entry is a model group fanned across several providers or deployments, deployment selection still spreads later turns of the same session across those deployments and the prefix cache goes cold, which is the exact failure this fixesApproach
Two options were on the table: store
(model, deployment_id)in the complexity router's own pin, or make auto-routing delegate deployment pinning toDeploymentAffinityCheck, which already pins a concrete deployment id per (model group, session id) and exists for precisely this implicit-prompt-caching reasonThis PR takes the second one. The two components own disjoint questions: the complexity router decides which model group a session uses, and
DeploymentAffinityCheckdecides which deployment inside that group. Because the pre-routing hook rewrites the request's model before deployment resolution runs, the affinity check sees the routed group and pins within it with no coordination needed. There stays exactly one source of truth for the deployment id, and none of the affinity cache logic is duplicated. The first option would have meant re-implementing deployment filtering, cooldown awareness, and the post-call persist hook inside the complexity router, and would have left two mechanisms racing to answer the same questionConcretely,
Routerderives amodel_group -> ttlmapping from its registered complexity routers, covering every tier value (scalar and pool form) plusdefault_model, for routers wheresession_affinityis on and nopluginsare configured, which is the same gateasync_pre_routing_hookuses for its own pin. That mapping is injected intoDeploymentAffinityCheckas a callable rather than a snapshot, so deployments added at runtime are picked up, and it is OR'd into the effective flags so auto-routing can enable session pinning for a group without ever disabling something an operator configured. Duplicate groups take the minimum TTL, so a pin is never silently extendedThe deployment pin honors the complexity router's
session_affinity_ttl_secondsfor those groups instead of the Router-widedeployment_affinity_ttl_seconds, so one auto-router config knob controls both halves of the stickinessFallback behavior
DeploymentAffinityCheck.async_filter_deploymentsruns after health, cooldown, and blocked-deployment filtering, and already returns the full healthy list when the pinned deployment is not in it. So an unhealthy, cooling-down, rate-limited, or removed-from-config deployment falls back to normal selection instead of failing the request, and this PR adds no new failure mode. Note that TPM/RPM enforcement runs after the callback filter, so a pinned deployment can still be selected and then rejected by a later rate-limit check; that is pre-existing behavior for every affinity user and is untouched hereOne known limitation, pre-existing but now reachable by auto-router users: when
_get_stable_model_map_key_from_deploymentscannot derive a stable key for the group (for example Azure deployments withoutbase_modelset), session pinning silently degrades to normal selection. There is a test asserting it degrades rather than erroringBehavior changes worth calling out
session_affinitystill defaults toFalseand no default is flipped. For operators who already set it, the pin now also sticks to a deployment, which is the point of the changeDeploymentAffinityCheck's session cache key had no API-key scoping, so two virtual keys reusing the same client-suppliedsession_idshared a pin. It is now scoped by the hasheduser_api_key_hash, falling back tounscopedfor SDK usage with no authenticated caller, matching what the complexity router already does for its own pin. Existing proxy pins miss once after upgrade and re-pin, which is gracefulWhile gating the session-only write path, a latent issue surfaced:
async_pre_call_deployment_hookwrote an API-key affinity entry whenever a user key was resolvable, even for groups with only session affinity enabled. Those writes were never read. The write is now gated onenable_user_keyThe provider handed to
DeploymentAffinityCheckis built over aweakref.refto the Router rather than a bound method, and returns an empty mapping once the referent is gone. The callback is registered into the module-levellitellm.callbackslist, which is never pruned, so a bound method would have retained the Router and everything it owns for the process lifetime. That matters for the per-request routers built from a caller-supplieduser_configand for proxy config reloads, which is the same reason_live_routersis aWeakSet. There are GC regression tests covering every registration pathSync note for whoever documents this: there is no synchronous complexity pre-routing hook at all.
Router.get_available_deploymentnever invokesasync_pre_routing_hook, so auto-routing (and therefore this pin) is async-only. Nothing was added to the sync pathFor the docs page covering the affinity combo: enabling
session_affinityon an auto-router no longer requires separately addingsession_affinitytooptional_pre_call_checksto get deployment stickiness, and the TTL that governs the deployment pin for auto-routed groups is the auto-router'ssession_affinity_ttl_secondsFinal Attestation
Link to Devin session: https://app.devin.ai/sessions/86f1017360074499a6f46d06fa1bd8d9