From e71aeadea02d90b0c5bbb50bba69024bd4efe88c Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sun, 3 May 2026 01:35:23 +0000 Subject: [PATCH] chore(proxy): drop client-supplied pricing fields from request bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- litellm/proxy/litellm_pre_call_utils.py | 42 +++ .../proxy/test_pricing_field_strip.py | 248 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 tests/test_litellm/proxy/test_pricing_field_strip.py diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 853c56856f..c09cce6476 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -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): diff --git a/tests/test_litellm/proxy/test_pricing_field_strip.py b/tests/test_litellm/proxy/test_pricing_field_strip.py new file mode 100644 index 0000000000..63dde7a56b --- /dev/null +++ b/tests/test_litellm/proxy/test_pricing_field_strip.py @@ -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