fix(router): scan all deployments for weight/rpm/tpm in simple-shuffle - #27916
fix(router): scan all deployments for weight/rpm/tpm in simple-shuffle#27916mateo-berri wants to merge 1 commit into
Conversation
simple_shuffle only consulted healthy_deployments[0] when deciding whether to do a weighted random pick. A config like
- model_name: gpt-4o-mini
litellm_params: { model: openai/gpt-4o-mini } # no weight
- model_name: gpt-4o-mini
litellm_params: { model: openai/gpt-4o-mini, rpm: 1000 } # weighted
silently fell back to uniform random because the first entry had no rpm. Users perceived this as deployment-level rpm: (or tpm: or weight:) being ignored under simple-shuffle. The actual behavior was ordering-sensitive: if the first deployment happened to declare the weight field everything worked; if not, the rest were silently disregarded.
Fix: extract _pick_weight_field which scans every deployment for the precedence-ordered weight fields (weight > rpm > tpm) and returns the first one declared by any of them. Deployments that don't declare the chosen field get weight 0 (the existing semantics for the in-list-but-unweighted case).
Also: defensive divide-by-zero guard when total weight is 0 (every deployment declared the field as 0 -- pathological config). Falls through to uniform random instead of crashing.
Docstring updated to be explicit that rpm and tpm here are static relative weights, not cap-enforced limits, and to point users at enable_pre_call_checks or the proxy v3 limiter for cap enforcement under simple-shuffle.
Tests: 10 new unit tests covering the silent-fallthrough regression, the new precedence helper, divide-by-zero defense, single-deployment passthrough, and the 'weight wins over rpm' precedence guarantee.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR fixes a long-standing ordering-sensitive bug in
Confidence Score: 3/5The core logic change is correct and well-tested, but a defect in the weights-list construction can cause a TypeError crash when any deployment has a weight field explicitly set to null in YAML alongside a sibling with a numeric value. The weights list uses .get(weight_field, 0), which returns None when the key is present with a null value rather than returning the 0 default. Because _pick_weight_field may return the field name based on a sibling deployment's non-None value, the weights list for the null-valued deployment becomes None, causing sum() to raise a TypeError. The new tests do not cover this case. litellm/router_strategy/simple_shuffle.py lines 81-84 need null-value coercion; tests/test_litellm/router_strategy/test_simple_shuffle.py is missing a case for field: null (key present, value None).
|
| Filename | Overview |
|---|---|
| litellm/router_strategy/simple_shuffle.py | Core logic fix is correct (scan all deployments for weight fields, add divide-by-zero guard). One defect: when a weight key is present with value None in litellm_params, sum() raises TypeError. Edge case with field: 0 triggering weighted mode but resolving to uniform random may be surprising. |
| tests/test_litellm/router_strategy/test_simple_shuffle.py | 10 new unit tests with good coverage of the regression case, precedence rules, zero-weight fallback, and single-deployment path. No real-network calls. Missing coverage for field: null (value present, set to None), which would have caught the sum() TypeError. |
Reviews (1): Last reviewed commit: "fix(router): scan all deployments for we..." | Re-trigger Greptile
| weights = [ | ||
| (m.get("litellm_params") or {}).get(weight_field, 0) | ||
| for m in healthy_deployments | ||
| ] |
There was a problem hiding this comment.
If a deployment's YAML sets a weight field to
null (i.e. the key is present in litellm_params but its Python value is None), .get(weight_field, 0) returns None rather than 0 — the default is only used when the key is absent. Meanwhile _pick_weight_field returns the field name because the other deployment has a non-None value for it. The resulting weights list then contains None, causing sum() to raise TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'. The fix is to coerce None to 0 explicitly.
| weights = [ | |
| (m.get("litellm_params") or {}).get(weight_field, 0) | |
| for m in healthy_deployments | |
| ] | |
| weights = [ | |
| (m.get("litellm_params") or {}).get(weight_field) or 0 | |
| for m in healthy_deployments | |
| ] |
| for field in _WEIGHT_FIELDS_PRECEDENCE: | ||
| for deployment in healthy_deployments: | ||
| litellm_params = deployment.get("litellm_params") or {} | ||
| if litellm_params.get(field) is not None: | ||
| return field |
There was a problem hiding this comment.
field: 0 triggers weighted mode but effective uniform random
_pick_weight_field returns a field when any deployment sets it to 0 (since 0 is not None). When this happens and no other deployment declares a positive value for that field, every entry defaults to weight 0, total_weight == 0, and the code falls through to uniform random — including the deployment with the explicit 0. A user who writes weight: 0 expecting to exclude one endpoint while letting the rest participate normally will instead get equal traffic to all deployments. The check could be tightened to litellm_params.get(field, 0) > 0 so that a field is only "detected" when at least one deployment declares a positive value for it.
|
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
|
🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it? I read the description against our contribution rubric. Here's how it lined up: What you got right:
What's still missing:
If the description isn't updated in the next 2 hours, I'll auto-close this PR. That's not us saying we don't care about the change; we want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later," not a rejection. Take your time; everything below still works after the close. During the grace period: just update the PR description with the missing pieces. No need to ping me; I'll re-check on the next sweep and skip the auto-close if it now passes. See what counts as QA proof for the full rubric (a linked issue alone isn't enough; it covers context, not proof). If the PR does get auto-closed in 2 hours, you still have easy recovery paths:
Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer. (I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a maintainer; they'll override me.) |
|
🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it? I read the description against our contribution rubric. Here's how it lined up: What you got right:
What's still missing:
Closing this PR isn't a rejection of the change. We want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later"; your work is still here, the diff is still here, and getting it reopened is one comment away. Take your time. To bring this PR back:
What "end-to-end QA proof" means, since it's the most common gap: at least one of a short before/after screen recording / video (the bug reproducing, then the fix working; for a brand-new feature, a recording of it working end-to-end), a screenshot (or before/after screenshots) of it working, or the exact commands you ran paired with their real output against the real system. Running Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer. (I'm an LLM, so I'm not infallible. If you think I got this wrong, comment |
Problem
simple_shuffleonly consultshealthy_deployments[0]when deciding whether to do a weighted random pick. A config like:silently falls back to uniform random across all three because the first entry has no
rpm. Users perceive this as "deployment-levelrpm:(ortpm:/weight:) is silently ignored undersimple-shuffle" — the actual behavior is ordering-sensitive: if the first deployment happens to declare the weight field, everything works; if not, the rest are silently disregarded.This was reported as part of a broader audit of v3 rate limit semantics (sibling to #27913, #27914, #27915). It is the only one of those audit findings that's a pre-existing router bug rather than a v3-limiter or exception-mapper issue.
Fix
Extract
_pick_weight_field(healthy_deployments)which scans every deployment for the precedence-ordered weight fields (weight>rpm>tpm) and returns the first one declared by any of them. Deployments that don't declare the chosen field continue to get weight0(preserves the existing semantics for in-list-but-unweighted entries).Also: defensive divide-by-zero guard when
total_weight == 0(every deployment declared the field as 0 — pathological config). Falls through to uniform random instead of crashing.Docstring updated to be explicit that
rpmandtpmhere are static relative weights, not cap-enforced limits, and to point users atrouter_settings.enable_pre_call_checks(router-level RPM cap filter, gated onmessages is not None) or the proxy's key-level v3 TPM/RPM limiter for actual cap enforcement under simple-shuffle.Scope notes
This PR does not change the documented semantics of
rpm/tpmunder simple-shuffle (still relative weights). Adding actual cap enforcement to simple-shuffle would change load-balancing behavior for every user on that strategy and is out of scope here — leaving it for a follow-up.Tests
tests/test_litellm/router_strategy/test_simple_shuffle.py— 10 new unit tests:TestPickWeightField:test_returns_none_when_no_deployment_declares_anythingtest_returns_weight_when_declared_on_firsttest_returns_weight_when_declared_only_on_later_deployments— the headline regression testtest_precedence_weight_over_rpm_over_tpm— covers two precedence pairstest_handles_missing_litellm_params— defensiveTestSimpleShuffle(statistical assertions over 6,000 picks):test_uniform_random_when_no_weightstest_rpm_on_later_deployment_is_respected— end-to-end regression for silent-fallthroughtest_zero_total_weight_falls_through_to_uniform_random— divide-by-zero defensetest_single_deployment_always_pickedtest_weight_field_wins_over_rpm— precedence under simple_shuffle (not just helper)Related
Companion PRs from the same audit:
Slack Thread