fix(proxy): always strip client guardrail info and mirror failure attribution into litellm_metadata

Backport of #59 to rc/1.105.0.
Cherry-picked from 79e48b9b19 (main). This line has no client pricing strip on pass-through, so only the guardrail strip is ported there.
This commit is contained in:
mateo-berri 2026-10-08 07:34:00 -07:00 • committed by yucheng
parent 0517d47e8c
commit 13eb926676
6 changed files with 83 additions and 6 deletions

View file

@ -367,10 +367,7 @@ _ALLOW_CLIENT_MESSAGE_REDACTION_OPT_OUT_METADATA_KEY: Final = "allow_client_mess
_CLIENT_PRICING_CONTROL_FIELDS: Final = frozenset(CustomPricingLiteLLMParams.model_fields.keys()) | {"base_model"}
# ``model_info`` carries the same pricing fields when read by
# ``use_custom_pricing_for_model``; strip from metadata for the same reason.
# ``standard_logging_guardrail_information`` is proxy-written telemetry summed
# into response_cost and spend; a client seeding it forges (even negative)
# guardrail cost.
_CLIENT_PRICING_METADATA_FIELDS: Final = frozenset({"model_info", "standard_logging_guardrail_information"})
_CLIENT_PRICING_METADATA_FIELDS: Final = frozenset({"model_info"})
# ``attempted_fallbacks`` and ``original_model_group`` are written by the router
# and read by spend logs as fact; a client value has no legitimate meaning and no
# key or team setting keeps it, so the strip is never gated.
@ -626,6 +623,19 @@ def _strip_client_pricing_overrides(data: dict[str, object]) -> None:
)
def strip_client_guardrail_information(data: dict[str, object]) -> None:
"""Guardrail hooks write this field and its cost is billed, so no key or team setting keeps a caller's value."""
for metadata_key in ("metadata", "litellm_metadata"):
if (
isinstance(metadata := data.get(metadata_key), dict)
and "standard_logging_guardrail_information" in metadata
):
metadata.pop("standard_logging_guardrail_information")
verbose_proxy_logger.debug(
"Stripped client-supplied guardrail information from request body: %s", metadata_key
)
def _strip_router_reserved_metadata(
data: dict[str, Any], # mutable-ok: strips in place on the request body the pre-call pipeline threads through
) -> None:
@ -2239,6 +2249,7 @@ async def add_litellm_data_to_request(
# would silently skip the field.
if not _key_or_team_allows_client_pricing_override(user_api_key_dict):
_strip_client_pricing_overrides(data)
strip_client_guardrail_information(data)
_strip_router_reserved_metadata(data)
# Same reason as the strips above: runs after the metadata string-to-dict parse

View file

@ -102,6 +102,7 @@ from litellm.proxy.common_utils.sse_keepalive import (
from litellm.proxy.litellm_pre_call_utils import (
LiteLLMProxyRequestSetup,
_get_dynamic_logging_metadata, # pyright: ignore[reportPrivateUsage] # shared proxy helper, same import style as _read_request_body above
strip_client_guardrail_information,
)
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.utils import normalize_route_for_root_path
@ -1133,6 +1134,9 @@ async def pass_through_request(
_parsed_body,
)
if _parsed_body is not None:
strip_client_guardrail_information(_parsed_body)
### COLLECT GUARDRAILS FOR PASSTHROUGH ENDPOINT ###
# Passthrough endpoints are opt-in only for guardrails
# When enabled, collect guardrails from org/team/key levels + passthrough-specific

View file

@ -3738,6 +3738,7 @@ class ProxyLogging:
key: {
**_as_str_mapping(existing.get(key)),
**_as_str_mapping(value),
**user_api_key_logged_metadata,
}
for key, value in _litellm_params.items()
if key == "litellm_metadata"

View file

@ -1343,6 +1343,39 @@ async def test_pass_through_request_uses_resolved_timeout():
assert mock_get_client.call_args[1]["params"]["timeout"] == 1500
@pytest.mark.asyncio
@pytest.mark.parametrize("allow_client_pricing_override", [True, False])
async def test_pass_through_request_strips_client_guardrail_information(allow_client_pricing_override: bool):
mock_request = MagicMock(spec=Request)
mock_request.method = "POST"
mock_request.headers = Headers({})
mock_request.query_params = QueryParams({})
with patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_proxy_logging:
mock_proxy_logging.pre_call_hook = AsyncMock(side_effect=RuntimeError("stop before upstream"))
mock_proxy_logging.post_call_failure_hook = AsyncMock()
with pytest.raises(ProxyException):
await pass_through_request(
request=mock_request,
target="http://test.com",
custom_headers={},
user_api_key_dict=UserAPIKeyAuth(
metadata={"allow_client_pricing_override": allow_client_pricing_override}
),
custom_body={
"model": "gpt-4o",
"input_cost_per_token": 0.5,
"metadata": {
"trace_id": "keep-me",
"standard_logging_guardrail_information": [{"guardrail_name": "forged", "guardrail_cost": 5.0}],
},
},
)
hooked_body: Final = mock_proxy_logging.pre_call_hook.call_args.kwargs["data"]
assert hooked_body["metadata"] == {"trace_id": "keep-me"}
@pytest.mark.asyncio
async def test_create_pass_through_route_forwards_timeout():
unique_path = "/test/path/unique/timeout"

View file

@ -22,6 +22,7 @@ from litellm.proxy.litellm_pre_call_utils import (
_CLIENT_PRICING_METADATA_FIELDS,
_strip_client_pricing_overrides,
add_litellm_data_to_request,
strip_client_guardrail_information,
)
from litellm.types.utils import CustomPricingLiteLLMParams
@ -136,7 +137,7 @@ class TestStripClientPricingOverrides:
"standard_logging_guardrail_information": [{"guardrail_cost": 5.0}],
},
}
_strip_client_pricing_overrides(data)
strip_client_guardrail_information(data)
assert data["metadata"] == {"user_session": "keep-me"}
assert data["litellm_metadata"] == {}
@ -167,7 +168,6 @@ class TestStripClientPricingOverrides:
def test_metadata_field_set_contains_model_info(self):
assert "model_info" in _CLIENT_PRICING_METADATA_FIELDS
assert "standard_logging_guardrail_information" 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
@ -300,6 +300,33 @@ async def test_add_litellm_data_to_request_skips_strip_with_key_opt_in():
assert updated["base_model"] == "gpt-4o-mini"
@pytest.mark.asyncio
@pytest.mark.parametrize("opt_in_field", ["metadata", "team_metadata"])
async def test_add_litellm_data_to_request_strips_guardrail_information_despite_pricing_opt_in(opt_in_field):
data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
"input_cost_per_token": 0.0001,
"metadata": {
"user_session": "keep-me",
"standard_logging_guardrail_information": [{"guardrail_name": "forged", "guardrail_cost": 5.0}],
},
}
updated = await add_litellm_data_to_request(
data=data,
request=_make_request_mock(),
user_api_key_dict=_user_api_key_auth(**{opt_in_field: {"allow_client_pricing_override": True}}),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated["input_cost_per_token"] == 0.0001
assert updated["metadata"]["user_session"] == "keep-me"
assert "standard_logging_guardrail_information" not in updated["metadata"]
@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/

View file

@ -186,6 +186,7 @@ async def test_proxy_only_error_log_keeps_tags_and_drops_caller_logging_fields(
assert "caller" not in str(details["litellm_params"])
assert details["litellm_params"][metadata_field]["tags"] == ["t"]
assert details["litellm_params"]["metadata"]["user_api_key_team_id"] == "team-a"
assert details["litellm_params"][metadata_field]["user_api_key_team_id"] == "team-a"
@pytest.mark.asyncio