mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-14 02:22:54 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user