fix(main): stop per-request custom pricing from clobbering shared model_cost pricing - #32163
Conversation
…el_cost pricing
A request routed through a wildcard deployment with explicit zero pricing
(e.g. openai/* with input_cost_per_token: 0) registered that pricing on the
shared {provider}/{model} key in litellm.model_cost, so sibling deployments
relying on built-in pricing logged $0 until process restart (LIT-3991).
Request-time registration in completion()/embedding() now mirrors the
router-startup isolation: router-originated requests register full pricing
under the deployment's unique model id only, while the shared backend key
receives the entry with custom pricing fields stripped. Direct SDK calls
without a router deployment id keep the legacy shared-key registration.
The stripping logic is shared via
CustomPricingLiteLLMParams.strip_custom_pricing_fields and reused by
Router._create_deployment and Router.add_deployment.
Greptile SummaryThis PR fixes a pricing isolation bug where a router deployment with explicit zero-cost pricing (e.g. an
Confidence Score: 5/5Safe to merge — the fix is narrowly scoped, direct SDK callers are unaffected, and the behaviour is exercised by both unit and end-to-end router tests that fail on the base commit and pass with the fix. The core change routes custom pricing registration through the deployment id rather than the shared key, which is well-contained in two call sites and follows the same pattern already applied at router startup in _create_deployment. The strip_custom_pricing_fields extraction removes duplication without changing logic. The two updated legacy tests were encoding the bug as expected behaviour; the corrected assertions still verify that the custom pricing is applied to the request, so no coverage is lost. No regressions found in the direct-SDK path or the router path. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/main.py | Adds _get_router_deployment_id and _register_custom_pricing_for_request; both completion() and embedding() now use the latter, correctly isolating per-deployment pricing from the shared model_cost key for router requests while preserving legacy SDK behavior. |
| litellm/types/utils.py | Adds strip_custom_pricing_fields classmethod to CustomPricingLiteLLMParams; clean extraction of the inline dict-comprehension used in router.py, no logic changes. |
| litellm/router.py | Refactors two identical inline stripping dict-comprehensions in _create_deployment and add_deployment to use CustomPricingLiteLLMParams.strip_custom_pricing_fields; behavior is identical. |
| tests/local_testing/test_router_fallbacks.py | Updates test_router_fallbacks_with_custom_model_costs to assert shared model key retains built-in pricing instead of the custom pricing — correctly encodes the intended contract stated in the test docstring, and still checks the request itself is costed at the custom rate via response_cost > 10. |
| tests/local_testing/test_cost_calc.py | Updates streaming cost assertion to pass custom_cost_per_token explicitly rather than relying on the shared gpt-3.5-turbo key having been poisoned with custom pricing; correct fix for the changed isolation behavior. |
| tests/test_litellm/test_register_model_custom_pricing.py | Adds four new regression tests covering the embedding zero-pricing isolation, deployment-id-based cost resolution, completion path isolation, and direct SDK legacy behavior; all use mock_response (no real network calls). |
| tests/test_litellm/test_router_model_cost_isolation.py | Adds end-to-end regression test through a real Router with named + wildcard zero-cost deployments; verifies the wildcard request doesn't poison the named deployment's built-in pricing. |
Reviews (3): Last reviewed commit: "test: update legacy tests that asserted ..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR fixes a production bug where per-request custom pricing on router deployments clobbered the shared
Confidence Score: 5/5Safe to merge — the change is tightly scoped to the pricing-registration path, direct SDK behaviour is explicitly preserved, and the isolation logic mirrors what the Router already does at startup. The fix correctly identifies router-originated requests via the deployment id already placed in metadata by _update_kwargs_with_deployment, isolates custom pricing to the deployment-id key (matching startup-time behaviour), and leaves the shared provider/model key unmodified. The helper that strips pricing fields is a clean extraction of existing logic in two router.py sites. Tests cover zero-pricing non-clobber, correct cost attribution via deployment id, and direct SDK guard — all using mocked responses. No regressions are introduced for existing callers. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/main.py | Adds _get_router_deployment_id and _register_custom_pricing_for_request helpers; replaces inline register_model calls in completion() and embedding() with the new isolation-aware function. Logic is correct — router calls get full pricing under deployment id only, direct SDK calls keep legacy behavior. |
| litellm/types/utils.py | Adds strip_custom_pricing_fields classmethod to CustomPricingLiteLLMParams — pure refactoring of duplicate dict-comprehension logic already in router.py, behaviour is identical. |
| litellm/router.py | Two call sites in _create_deployment and add_deployment that hand-rolled the custom-pricing-field stripping now delegate to strip_custom_pricing_fields — equivalent transformation, no behaviour change. |
| tests/test_litellm/test_register_model_custom_pricing.py | Four new tests added: zero-pricing non-clobber for embedding and completion, cost-via-deployment-id, and direct SDK guard; all use mock_response and fake keys — no network calls. |
| tests/test_litellm/test_router_model_cost_isolation.py | End-to-end Router test covering the full LIT-3991 scenario: named deployment + zero-cost wildcard; verifies shared key pricing survives a wildcard call and named deployment still accrues non-zero cost. |
Reviews (2): Last reviewed commit: "fix(main): stop per-request custom prici..." | Re-trigger Greptile
…nto shared model_cost test_router_fallbacks_with_custom_model_costs asserted the shared claude-sonnet-4-5-20250929 entry ends up with the deployment's 30/60 pricing, which is exactly the cross-deployment leak this PR removes; it now asserts the shared key keeps the built-in pricing, matching the test's stated goal. test_cost_calc.py::test_run computed streaming cost via completion_cost(response), which only matched the non-stream cost while the shared gpt-3.5-turbo entry was poisoned with the per-request 2/token pricing; it now passes the request's custom pricing explicitly via custom_cost_per_token.
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 58df116. Configure here.
d0c82c3
into
litellm_internal_staging
Relevant issues
Linear ticket
Resolves LIT-3991
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays 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
End-to-end demonstration against a live proxy hitting the real OpenAI API; no mocks, no database, no caching. Two deployments share the
openai/text-embedding-3-smallbackend: the namedtext-embedding-3-smallwith no explicit pricing (built-in pricing applies) and anopenai/*wildcard with explicit zero pricing. Cost is read from thex-litellm-response-cost*response headers; note the proxy omits the plainx-litellm-response-costheader when the computed cost is 0, in which casex-litellm-response-cost-original: 0.0is the signal. Every request uses a distinct input string to rule out any cachingqa_config.yaml:
Setup: a fresh checkout with its own venv (
uv venv .venv && uv pip install -e ".[proxy]"),OPENAI_API_KEYexported by sourcing a local.env, and a random free portBefore (base 26c0c93)
Step 1, named deployment. Built-in pricing applies, cost is 2.8e-07
Step 2, wildcard route. Cost is 0.0 as configured on the wildcard
Step 3, named deployment again. This is the bug: one wildcard request poisoned the shared pricing entry and the named deployment now reports 0.0
After (this PR)
Same worktree after
git checkout origin/litellm_lit3991_wildcard_zero_cost_poisoning, proxy restarted with the same command (logged to proxy_after.log)Step 1, named deployment. Cost is 2.6e-07, built-in pricing as before
Step 2, wildcard route. Still 0.0 as configured
Step 3, named deployment again. Fixed: cost stays at built-in pricing (3e-07, same magnitude as step 1) instead of dropping to 0
Type
🐛 Bug Fix
Changes
When a proxy has a named deployment that relies on built-in pricing (e.g. model_name
text-embedding-3-smallbacked byopenai/text-embedding-3-small) plus anopenai/*wildcard deployment with explicitinput_cost_per_token: 0andoutput_cost_per_token: 0, a single call routed through the wildcard rewrote the sharedopenai/text-embedding-3-smallentry inlitellm.model_costwith the zero pricing. Every subsequent call to the named deployment was then logged at $0 cost until process restartRouter startup already isolates per-deployment custom pricing:
_create_deploymentregisters the full pricing under the deployment's unique model id and strips custom pricing fields from the shared{provider}/{model}key. The request-time registration incompletion()andembedding()did not follow that isolation; it wrote the full custom pricing entry onto the shared key on every request carrying input/output cost overridesThis PR aligns the request-time registration with the startup isolation. Requests that carry a router deployment id (in metadata
model_info, the same signal the cost calculator uses forrouter_model_idresolution) now register the full pricing under that unique id only, while the shared key receives the entry with custom pricing fields stripped. The request itself is still costed with its own pricing because the cost calculator resolves custom pricing through the deployment id entry. Direct SDK calls without a router deployment id keep the existing behavior of registering the shared key with the given pricing, solitellm.completion/litellm.embeddingusers passinginput_cost_per_token/output_cost_per_tokenare unaffected, and_is_model_cost_zero(which reads router deployment info) is untouchedThe stripping logic is shared through
CustomPricingLiteLLMParams.strip_custom_pricing_fieldsand reused byRouter._create_deploymentandRouter.add_deploymentinstead of being duplicated inlineNew regression tests in
tests/test_litellm/test_register_model_custom_pricing.pycover the embedding and completion paths (zero pricing must not clobber the built-in entry, the custom-priced request is still costed via its deployment id, direct SDK behavior preserved), andtests/test_litellm/test_router_model_cost_isolation.pyadds the full scenario through a Router with a named deployment plus a zero-costopenai/*wildcard. They fail on the base commit (except the direct SDK guard, which protects existing behavior) and pass with the fixTwo legacy tests encoded the leak as expected behavior and are updated to the corrected semantics.
tests/local_testing/test_router_fallbacks.py::test_router_fallbacks_with_custom_model_costsasserted that calling a deployment with 30/60 per-token pricing rewrote the sharedclaude-sonnet-4-5-20250929entry, contradicting its own stated goal ("make sure custom model doesn't override default model costs"); it now asserts the shared entry keeps the built-in pricing while the request itself is still costed at the deployment's rate.tests/local_testing/test_cost_calc.py::test_runrecomputed the streaming cost with a barecompletion_cost(response), which only matched the non-stream cost while the sharedgpt-3.5-turboentry was poisoned by the per-request pricing; it now passes that pricing explicitly viacustom_cost_per_tokenNote
Medium Risk
Changes global
litellm.model_costregistration and billing-related paths for router-originated requests; behavior is intentionally different from the buggy legacy path but direct SDK pricing registration is preserved.Overview
Fixes LIT-3991: router/proxy requests that register custom per-token pricing during
completion()/embedding()no longer overwrite the shared{provider}/{model}entry inlitellm.model_cost, so a zero-cost wildcard deployment cannot zero out built-in pricing for sibling deployments on the same backend.Request-time registration now mirrors router startup:
_register_custom_pricing_for_requestdetects a router deployment id in metadata, registers full pricing under that id, and updates the shared key withCustomPricingLiteLLMParams.strip_custom_pricing_fields. Direct SDK calls without a deployment id still register custom pricing on the shared key unchanged. Router_create_deployment/add_deploymentuse the same strip helper instead of inline field filtering.Tests add LIT-3991 regression coverage (embedding/completion/router wildcard scenarios) and adjust legacy tests that previously treated shared-key pollution as expected behavior.
Reviewed by Cursor Bugbot for commit 58df116. Bugbot is set up for automated code reviews on this repo. Configure here.