mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge pull request #27071 from stuxf/fix/strip-pricing-fields
chore(proxy): drop client-supplied pricing fields from request bodies
This commit is contained in:
commit
42cd9493e9
4 changed files with 394 additions and 9 deletions
|
|
@ -216,22 +216,26 @@ _EXTRA_BANNED_OBSERVABILITY_PARAMS: FrozenSet[str] = frozenset(
|
|||
def _build_banned_observability_params() -> FrozenSet[str]:
|
||||
"""Derive the observability ban list from the canonical allowlist.
|
||||
|
||||
``_supported_callback_params`` in
|
||||
``_supported_callback_params`` and ``_request_blocked_callback_params`` in
|
||||
``litellm/litellm_core_utils/initialize_dynamic_callback_params.py`` is
|
||||
the single place that enumerates every observability field
|
||||
integrations resolve from kwargs/metadata. Subtract the small set of
|
||||
informational fields (``_SAFE_CLIENT_CALLBACK_PARAMS``) and union with
|
||||
the extras the canonical allowlist hasn't caught up to yet. New
|
||||
integrations added to the canonical allowlist are banned by default,
|
||||
which is the safe failure mode.
|
||||
the single place that enumerates every observability field integrations
|
||||
resolve from kwargs/metadata, plus fields that integration code explicitly
|
||||
blocks from request-supplied callback params. Subtract the small set of
|
||||
informational fields (``_SAFE_CLIENT_CALLBACK_PARAMS``) and union with the
|
||||
extras the canonical allowlist hasn't caught up to yet. New integrations
|
||||
added to the canonical allowlist are banned by default, which is the safe
|
||||
failure mode.
|
||||
"""
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
_request_blocked_callback_params,
|
||||
_supported_callback_params,
|
||||
)
|
||||
|
||||
return (
|
||||
frozenset(_supported_callback_params) - _SAFE_CLIENT_CALLBACK_PARAMS
|
||||
) | _EXTRA_BANNED_OBSERVABILITY_PARAMS
|
||||
(frozenset(_supported_callback_params) - _SAFE_CLIENT_CALLBACK_PARAMS)
|
||||
| frozenset(_request_blocked_callback_params)
|
||||
| _EXTRA_BANNED_OBSERVABILITY_PARAMS
|
||||
)
|
||||
|
||||
|
||||
_BANNED_REQUEST_BODY_PARAMS: Tuple[str, ...] = (
|
||||
|
|
|
|||
|
|
@ -61,6 +61,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,
|
||||
|
|
@ -168,6 +169,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"
|
||||
|
||||
# Request fields whose value, when URL-valued, becomes the outbound destination
|
||||
# for a provider call. Letting a proxy caller pin the destination is an SSRF
|
||||
# primitive (HuggingFace/Oobabooga `model`, Gemini files `file_id`); guard
|
||||
|
|
@ -265,6 +280,46 @@ 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. Emits a
|
||||
``debug``-level log line naming the dropped fields so operators can
|
||||
trace why a client-supplied pricing override stopped being applied
|
||||
(otherwise the strip is invisible from the caller's perspective).
|
||||
"""
|
||||
stripped: List[str] = []
|
||||
for field in _CLIENT_PRICING_CONTROL_FIELDS:
|
||||
if field in data:
|
||||
stripped.append(field)
|
||||
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:
|
||||
if field in metadata:
|
||||
stripped.append(f"{metadata_key}.{field}")
|
||||
metadata.pop(field, None)
|
||||
if stripped:
|
||||
verbose_proxy_logger.debug(
|
||||
"Stripped client-supplied pricing fields from request body: %s. "
|
||||
"Set `allow_client_pricing_override: true` on the key or team "
|
||||
"metadata to keep these values.",
|
||||
", ".join(stripped),
|
||||
)
|
||||
|
||||
|
||||
def _get_metadata_variable_name(request: Request) -> str:
|
||||
"""
|
||||
Helper to return what the "metadata" field should be called in the request data
|
||||
|
|
@ -1364,6 +1419,14 @@ async def add_litellm_data_to_request( # noqa: PLR0915
|
|||
]:
|
||||
_user_meta.pop(_k, None)
|
||||
|
||||
# Strip pricing overrides AFTER the litellm_metadata string-to-dict parse
|
||||
# above, for the same reason as the user_api_key_* strip — JSON-string
|
||||
# metadata (sent via multipart/form-data or extra_body) wouldn't be a
|
||||
# dict yet at the earlier strip point and the isinstance(dict) guard
|
||||
# would silently skip the field.
|
||||
if not _key_or_team_allows_client_pricing_override(user_api_key_dict):
|
||||
_strip_client_pricing_overrides(data)
|
||||
|
||||
# Strip caller-supplied routing/budget tags unless the admin has opted
|
||||
# this key or team in via metadata.allow_client_tags=True. Tags drive
|
||||
# tag-based routing and tag budget attribution — accepting them from
|
||||
|
|
|
|||
|
|
@ -1493,6 +1493,7 @@ def test_observability_ban_covers_canonical_supported_callback_params():
|
|||
safe is an explicit decision recorded in
|
||||
``_SAFE_CLIENT_CALLBACK_PARAMS``."""
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
_request_blocked_callback_params,
|
||||
_supported_callback_params,
|
||||
)
|
||||
from litellm.proxy.auth.auth_utils import (
|
||||
|
|
@ -1508,3 +1509,8 @@ def test_observability_ban_covers_canonical_supported_callback_params():
|
|||
f"informational per-request field; otherwise the derivation will "
|
||||
f"ban it automatically."
|
||||
)
|
||||
for param in _request_blocked_callback_params:
|
||||
assert param in banned, (
|
||||
f"{param} is in _request_blocked_callback_params but is not banned "
|
||||
"at the proxy request-body boundary."
|
||||
)
|
||||
|
|
|
|||
312
tests/test_litellm/proxy/test_pricing_field_strip.py
Normal file
312
tests/test_litellm/proxy/test_pricing_field_strip.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
"""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
|
||||
|
||||
def test_strip_emits_debug_log_listing_dropped_fields(self, caplog):
|
||||
# Operators need a paper trail so they can diagnose why a previously
|
||||
# working override stopped applying after the strip landed.
|
||||
import logging
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
verbose_proxy_logger.setLevel(logging.DEBUG)
|
||||
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
|
||||
_strip_client_pricing_overrides(
|
||||
{
|
||||
"model": "gpt-4",
|
||||
"input_cost_per_token": 0.0,
|
||||
"metadata": {"model_info": {"output_cost_per_token": 0.0}},
|
||||
}
|
||||
)
|
||||
log_text = " ".join(record.getMessage() for record in caplog.records)
|
||||
assert "input_cost_per_token" in log_text
|
||||
assert "metadata.model_info" in log_text
|
||||
assert "allow_client_pricing_override" in log_text
|
||||
|
||||
def test_strip_does_not_log_when_no_fields_present(self, caplog):
|
||||
# No-op strips must stay silent so the log isn't filled with noise on
|
||||
# every legitimate request.
|
||||
import logging
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
verbose_proxy_logger.setLevel(logging.DEBUG)
|
||||
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
|
||||
_strip_client_pricing_overrides({"model": "gpt-4", "temperature": 0.7})
|
||||
assert not any(
|
||||
"pricing" in record.getMessage().lower() for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@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_strips_json_string_litellm_metadata():
|
||||
"""``litellm_metadata`` may arrive as a JSON-encoded string (multipart/
|
||||
form-data or ``extra_body``). The strip has to run after the proxy parses
|
||||
it into a dict; otherwise the ``isinstance(dict)`` guard skips the field
|
||||
and ``model_info`` survives the strip via the string path.
|
||||
"""
|
||||
import json
|
||||
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"litellm_metadata": json.dumps({"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",
|
||||
)
|
||||
|
||||
parsed_metadata = updated.get("litellm_metadata")
|
||||
assert isinstance(parsed_metadata, dict)
|
||||
assert "model_info" not in parsed_metadata
|
||||
|
||||
|
||||
@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
|
||||
Loading…
Add table
Reference in a new issue