chore(proxy): drop client-supplied pricing fields from request bodies

The proxy currently forwards request-body pricing parameters (the fields
on `CustomPricingLiteLLMParams`, plus `metadata.model_info`) into the
core call path. Those fields belong to deployment configuration, not to
per-request input — sending them from a client mutates the request's
recorded cost and, via `litellm.completion` → `register_model`, the
process-wide `litellm.model_cost` map for every later caller in the
worker. Strip them at the boundary.

The strip set is built from `CustomPricingLiteLLMParams.model_fields` so
pricing fields added later are covered automatically. Operators who do
want clients to supply per-request pricing can opt back in per key or
team via `metadata.allow_client_pricing_override = true`, mirroring the
existing `allow_client_mock_response` and
`allow_client_message_redaction_opt_out` flags.

Tests cover the strip set's coverage, root and metadata strips, the
opt-in skip on both key and team metadata, and a regression check that
the global `litellm.model_cost` map is unmutated after a stripped
request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
user
2026-05-03 01:35:23 +00:00
co-authored by Claude Opus 4.7
parent c94a8d6514
commit e71aeadea0
2 changed files with 290 additions and 0 deletions
+42
View File
@@ -60,6 +60,7 @@ from litellm.secret_managers.main import get_secret_bool
from litellm.types.llms.anthropic import ANTHROPIC_API_HEADERS
from litellm.types.services import ServiceTypes
from litellm.types.utils import (
CustomPricingLiteLLMParams,
LlmProviders,
ProviderSpecificHeader,
StandardLoggingUserAPIKeyMetadata,
@@ -154,6 +155,20 @@ _ALLOW_CLIENT_MESSAGE_REDACTION_OPT_OUT_METADATA_KEY = (
"allow_client_message_redaction_opt_out"
)
# Per-request pricing parameters mutate cost-tracking output and (via
# ``litellm.completion`` → ``register_model``) the process-wide
# ``litellm.model_cost`` map. Both effects belong to deployment configuration,
# not to user-supplied request bodies, so the proxy strips them before they
# reach the call path. Built from the Pydantic model so newly-added pricing
# fields are covered automatically.
_CLIENT_PRICING_CONTROL_FIELDS = frozenset(
CustomPricingLiteLLMParams.model_fields.keys()
)
# ``model_info`` carries the same pricing fields when read by
# ``use_custom_pricing_for_model``; strip from metadata for the same reason.
_CLIENT_PRICING_METADATA_FIELDS = frozenset({"model_info"})
_ALLOW_CLIENT_PRICING_OVERRIDE_METADATA_KEY = "allow_client_pricing_override"
def _strip_untrusted_request_header_controls(
headers: Any,
@@ -212,6 +227,31 @@ def _key_or_team_allows_client_message_redaction_opt_out(
)
def _key_or_team_allows_client_pricing_override(
user_api_key_dict: UserAPIKeyAuth,
) -> bool:
return _key_or_team_metadata_flag_is_true(
user_api_key_dict=user_api_key_dict,
metadata_key=_ALLOW_CLIENT_PRICING_OVERRIDE_METADATA_KEY,
)
def _strip_client_pricing_overrides(data: Dict[str, Any]) -> None:
"""Drop pricing overrides from the request body and any metadata variant.
Skipped only when the calling key/team carries
``allow_client_pricing_override: True`` in its metadata.
"""
for field in _CLIENT_PRICING_CONTROL_FIELDS:
data.pop(field, None)
for metadata_key in ("metadata", "litellm_metadata"):
metadata = data.get(metadata_key)
if not isinstance(metadata, dict):
continue
for field in _CLIENT_PRICING_METADATA_FIELDS:
metadata.pop(field, None)
def _get_metadata_variable_name(request: Request) -> str:
"""
Helper to return what the "metadata" field should be called in the request data
@@ -1109,6 +1149,8 @@ async def add_litellm_data_to_request( # noqa: PLR0915
if _allow_client_mock_response and _internal_key in _CLIENT_MOCK_CONTROL_FIELDS:
continue
data.pop(_internal_key, None)
if not _key_or_team_allows_client_pricing_override(user_api_key_dict):
_strip_client_pricing_overrides(data)
# Strip spoofable auth metadata from user-supplied metadata dict
_user_metadata = data.get("metadata")
if isinstance(_user_metadata, dict):
@@ -0,0 +1,248 @@
"""Proxy strips client-supplied pricing parameters from request bodies.
`litellm.completion` accepts pricing fields (`input_cost_per_token`,
`output_cost_per_token`, the rest of `CustomPricingLiteLLMParams`,
`metadata.model_info`) as part of its kwarg surface. On direct SDK use that
is intentional. On the proxy, those same fields would let any caller rewrite
their own per-request cost and via `litellm.register_model` mutate
`litellm.model_cost` for every subsequent caller in the worker. The proxy
strips them at the boundary; an opt-in key/team flag preserves the override
for operators who actually want it.
"""
import os
import sys
from unittest.mock import MagicMock
import pytest
from fastapi import Request
import litellm
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.litellm_pre_call_utils import (
_CLIENT_PRICING_CONTROL_FIELDS,
_CLIENT_PRICING_METADATA_FIELDS,
_strip_client_pricing_overrides,
add_litellm_data_to_request,
)
from litellm.types.utils import CustomPricingLiteLLMParams
sys.path.insert(0, os.path.abspath("../../.."))
def _make_request_mock() -> Request:
request_mock = MagicMock(spec=Request)
request_mock.url.path = "/v1/chat/completions"
request_mock.url = MagicMock()
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
request_mock.method = "POST"
request_mock.query_params = {}
request_mock.headers = {"Content-Type": "application/json"}
request_mock.client = MagicMock()
request_mock.client.host = "127.0.0.1"
return request_mock
def _user_api_key_auth(metadata=None, team_metadata=None) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
api_key="hashed-key",
metadata=metadata or {},
team_metadata=team_metadata or {},
spend=0.0,
max_budget=100.0,
model_max_budget={},
team_spend=0.0,
team_max_budget=200.0,
)
class TestStripClientPricingOverrides:
def test_pricing_field_set_tracks_pydantic_model(self):
# The strip set is built from the model so additions are picked up
# automatically — this test guards against the model and the strip
# set drifting apart if someone replaces the auto-derivation later.
assert _CLIENT_PRICING_CONTROL_FIELDS == frozenset(
CustomPricingLiteLLMParams.model_fields.keys()
)
# Sanity: the obvious top-level pricing fields are in the set.
for field in (
"input_cost_per_token",
"output_cost_per_token",
"input_cost_per_second",
"cache_creation_input_token_cost",
):
assert field in _CLIENT_PRICING_CONTROL_FIELDS
def test_root_pricing_fields_dropped(self):
data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
"cache_creation_input_token_cost": 0.0,
}
_strip_client_pricing_overrides(data)
assert data == {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
}
def test_metadata_model_info_dropped(self):
data = {
"model": "gpt-4",
"metadata": {
"user_session": "keep-me",
"model_info": {"input_cost_per_token": 0.0},
},
"litellm_metadata": {
"model_info": {"output_cost_per_token": 0.0},
},
}
_strip_client_pricing_overrides(data)
assert data["metadata"] == {"user_session": "keep-me"}
assert data["litellm_metadata"] == {}
def test_non_pricing_fields_untouched(self):
data = {
"model": "gpt-4",
"temperature": 0.7,
"max_tokens": 100,
"tools": [{"type": "function"}],
"metadata": {"trace_id": "abc"},
}
snapshot = {
"model": "gpt-4",
"temperature": 0.7,
"max_tokens": 100,
"tools": [{"type": "function"}],
"metadata": {"trace_id": "abc"},
}
_strip_client_pricing_overrides(data)
assert data == snapshot
def test_metadata_strip_handles_non_dict_metadata(self):
# Defensive — Pydantic validation would normally reject non-dict
# metadata, but the strip mustn't crash if a malformed body sneaks in.
_strip_client_pricing_overrides({"metadata": "not-a-dict"})
_strip_client_pricing_overrides({"metadata": None})
_strip_client_pricing_overrides({"litellm_metadata": ["a", "b"]})
def test_metadata_field_set_contains_model_info(self):
assert "model_info" in _CLIENT_PRICING_METADATA_FIELDS
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_strips_root_pricing_fields():
data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
}
updated = await add_litellm_data_to_request(
data=data,
request=_make_request_mock(),
user_api_key_dict=_user_api_key_auth(),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert "input_cost_per_token" not in updated
assert "output_cost_per_token" not in updated
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_strips_metadata_model_info():
data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
"metadata": {"model_info": {"input_cost_per_token": 0.0}},
}
updated = await add_litellm_data_to_request(
data=data,
request=_make_request_mock(),
user_api_key_dict=_user_api_key_auth(),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert "model_info" not in updated.get("metadata", {})
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_skips_strip_with_key_opt_in():
data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
"input_cost_per_token": 0.0001,
"metadata": {"model_info": {"output_cost_per_token": 0.0002}},
}
user_auth = _user_api_key_auth(metadata={"allow_client_pricing_override": True})
updated = await add_litellm_data_to_request(
data=data,
request=_make_request_mock(),
user_api_key_dict=user_auth,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated["input_cost_per_token"] == 0.0001
assert updated["metadata"]["model_info"] == {"output_cost_per_token": 0.0002}
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_skips_strip_with_team_opt_in():
data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
"input_cost_per_token": 0.0001,
}
user_auth = _user_api_key_auth(
team_metadata={"allow_client_pricing_override": True}
)
updated = await add_litellm_data_to_request(
data=data,
request=_make_request_mock(),
user_api_key_dict=user_auth,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated["input_cost_per_token"] == 0.0001
@pytest.mark.asyncio
async def test_global_model_cost_unmutated_after_stripped_request(monkeypatch):
"""After a stripped request, ``litellm.model_cost`` must not carry the
caller's submitted pricing for the model. The mutation only happens when
the pricing fields reach ``litellm.completion``; the strip prevents that."""
snapshot = dict(litellm.model_cost)
data = {
"model": "test-pricing-canary-model",
"messages": [{"role": "user", "content": "hi"}],
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
}
await add_litellm_data_to_request(
data=data,
request=_make_request_mock(),
user_api_key_dict=_user_api_key_auth(),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
# The strip prevents the pricing fields from ever reaching the path that
# would mutate the global model_cost map.
assert "test-pricing-canary-model" not in litellm.model_cost
# And no other entries were mutated as a side effect.
assert litellm.model_cost == snapshot