Merge pull request #48605 from BerriAI/litellm_backport_48599_stable_1_106_x
Some checks are pending
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run

fix(proxy)!: backport #48599 to stable/1.106.x
This commit is contained in:
yuneng-jiang 2026-10-27 11:02:20 -07:00 • committed by GitHub
commit 2569eb8869
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 485 additions and 203 deletions

View file

@ -18,7 +18,7 @@ from litellm.llms.custom_httpx.http_handler import (
httpxSpecialProvider,
)
from litellm.types.integrations.gcs_bucket import *
from litellm.types.utils import StandardCallbackDynamicParams, StandardLoggingPayload
from litellm.types.utils import StandardLoggingPayload
if TYPE_CHECKING:
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
@ -137,49 +137,16 @@ class GCSBucketBase(CustomBatchLogger):
async def get_gcs_logging_config(self, kwargs: dict[str, Any] | None = {}) -> GCSLoggingConfig:
"""
This function is used to get the GCS logging config for the GCS Bucket Logger.
It checks if the dynamic parameters are provided in the kwargs and uses them to get the GCS logging config.
If no dynamic parameters are provided, it uses the default values.
Returns the bucket and service account this logger was configured with.
"""
if kwargs is None:
kwargs = {}
standard_callback_dynamic_params: Final[StandardCallbackDynamicParams | None] = kwargs.get(
"standard_callback_dynamic_params", None
)
bucket_name: str
path_service_account: str | None
if standard_callback_dynamic_params is not None:
verbose_logger.debug("Using dynamic GCS logging")
verbose_logger.debug("standard_callback_dynamic_params: %s", standard_callback_dynamic_params)
_bucket_name: str | None = standard_callback_dynamic_params.get("gcs_bucket_name", None) or self.BUCKET_NAME
_path_service_account: Final[str | None] = (
standard_callback_dynamic_params.get("gcs_path_service_account", None) or self.path_service_account_json
if self.BUCKET_NAME is None:
raise ValueError(
"GCS_BUCKET_NAME is not set in the environment, but GCS Bucket is being used as a logging callback. Please set 'GCS_BUCKET_NAME' in the environment."
)
if _bucket_name is None:
raise ValueError(
"GCS_BUCKET_NAME is not set in the environment, but GCS Bucket is being used as a logging callback. Please set 'GCS_BUCKET_NAME' in the environment."
)
bucket_name = _bucket_name
path_service_account = _path_service_account
vertex_instance = await self.get_or_create_vertex_instance(credentials=path_service_account)
else:
# If no dynamic parameters, use the default instance
if self.BUCKET_NAME is None:
raise ValueError(
"GCS_BUCKET_NAME is not set in the environment, but GCS Bucket is being used as a logging callback. Please set 'GCS_BUCKET_NAME' in the environment."
)
bucket_name = self.BUCKET_NAME
path_service_account = self.path_service_account_json
vertex_instance = await self.get_or_create_vertex_instance(credentials=path_service_account)
return GCSLoggingConfig(
bucket_name=bucket_name,
vertex_instance=vertex_instance,
path_service_account=path_service_account,
bucket_name=self.BUCKET_NAME,
vertex_instance=await self.get_or_create_vertex_instance(credentials=self.path_service_account_json),
path_service_account=self.path_service_account_json,
)
async def get_or_create_vertex_instance(self, credentials: str | None) -> VertexBase:

View file

@ -6478,19 +6478,17 @@ class StandardLoggingPayloadSetup:
litellm_metadata: Final = cast( # cast-ok: request metadata is caller-provided
Mapping[str, object], litellm_params.get("litellm_metadata") or {}
)
if metadata.get("tags", []):
request_tags = cast(list[str], metadata.get("tags", [])).copy() # cast-ok: tags are caller-provided
elif litellm_metadata.get("tags", []):
request_tags = cast(list[str], litellm_metadata.get("tags", [])).copy() # cast-ok: tags are caller-provided
else:
request_tags = []
request_tags: Final = next(
(
cast(list[str], tags) # cast-ok: tags are caller-provided
for tags in (metadata.get("tags"), litellm_metadata.get("tags"))
if isinstance(tags, list) and tags
),
[],
)
user_agent_tags: Final = StandardLoggingPayloadSetup._get_user_agent_tags(proxy_server_request)
additional_header_tags: Final = StandardLoggingPayloadSetup._get_extra_header_tags(proxy_server_request)
if user_agent_tags is not None:
request_tags.extend(user_agent_tags)
if additional_header_tags is not None:
request_tags.extend(additional_header_tags)
return request_tags
return [*request_tags, *(user_agent_tags or ()), *(additional_header_tags or ())]
_get_request_tags = get_request_tags

View file

@ -402,10 +402,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.
@ -661,6 +658,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, object], # mutable-ok: strips in place on the request body the pre-call pipeline threads through
) -> None:
@ -2364,6 +2374,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

@ -117,6 +117,7 @@ from litellm.proxy.litellm_pre_call_utils import ( # noqa: F401 # legacy modul
_key_or_team_allows_client_pricing_override, # pyright: ignore[reportPrivateUsage] # reuse the proxy's pricing trust policy
_strip_client_pricing_overrides, # pyright: ignore[reportPrivateUsage] # sanitize before trusted hooks add guardrail costs
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
@ -1186,10 +1187,11 @@ async def pass_through_request(
_parsed_body,
)
client_body: Final = TypeAdapter(dict[str, object]).validate_python(_parsed_body)
strip_client_guardrail_information(client_body)
if not _key_or_team_allows_client_pricing_override(user_api_key_dict):
pricing_body: Final = TypeAdapter(dict[str, object]).validate_python(_parsed_body)
_strip_client_pricing_overrides(pricing_body)
_parsed_body = pricing_body
_strip_client_pricing_overrides(client_body)
_parsed_body = client_body
if custom_llm_provider in ("laya", "bespoke"):
decision_request: Final = TypeAdapter(Mapping[str, object]).validate_python(_parsed_body)
checkpoint: Final = validate_oss_request(custom_llm_provider, decision_request)

View file

@ -1237,7 +1237,8 @@ def _stamp_deployment_attribution(
callbacks key off it and must not count a proxy-side reject against the deployment. A failure
after the provider handoff keeps the metadata the router stamped; a request that never reached a
provider is flagged ``PROXY_REJECTED_BEFORE_ROUTING_KEY`` (deployment metrics key off it) whatever
its metadata says, since ``metadata.model_info`` can be caller supplied."""
its metadata says, since ``metadata.model_info`` can be caller supplied. Its ``deployment`` and
``model_group`` always come from the router config, never from the caller's metadata."""
attribution: Final = _deployment_attribution_for_model_group(model_group, team_id)
if "custom_llm_provider" in attribution:
litellm_params["custom_llm_provider"] = attribution["custom_llm_provider"]
@ -1252,9 +1253,13 @@ def _stamp_deployment_attribution(
if not isinstance(metadata, dict):
return attribution
metadata.setdefault("model_info", attribution["model_info"])
metadata.setdefault("deployment", attribution["deployment"])
if isinstance(model_group, str):
metadata.setdefault("model_group", model_group)
routing: Final = MappingProxyType(
{"deployment": attribution["deployment"], **({"model_group": model_group} if model_group else {})}
)
metadata.update(routing)
litellm_metadata: Final = litellm_params.get("litellm_metadata")
if isinstance(litellm_metadata, dict):
litellm_metadata.update(routing)
return attribution
@ -1328,6 +1333,55 @@ def _call_type_for_route(route: str | None) -> str | None:
_PROXY_ONLY_LLM_API_ERRORS: Final = (HTTPException, ProxyException, GuardrailRaisedException)
_LOG_DB_METRICS_CALL_TYPES: Final = frozenset(("get_data", "insert_data", "update_data", "delete_data"))
_PROXY_ONLY_ERROR_LOGGED_FIELDS: Final = frozenset(
{"model", "messages", "input", "prompt", "user", "stream", "litellm_call_id"}
)
def _as_str_mapping(value: object) -> Mapping[str, object]:
if not isinstance(value, Mapping):
return MappingProxyType({})
return cast( # cast-ok: the runtime Mapping check validates the container shape
Mapping[str, object], value
)
def _proxy_only_error_logged_metadata_keys(*, authenticated: bool, past_pre_call: bool) -> frozenset[str]:
return frozenset(
key
for key, logged in (("tags", authenticated), ("standard_logging_guardrail_information", past_pre_call))
if logged
)
def _is_logged_metadata_value(key: str, value: object) -> bool:
return key != "tags" or (isinstance(value, list) and all(isinstance(item, str) for item in value))
def _proxy_only_error_log_metadata(value: object, logged_keys: frozenset[str]) -> dict[str, object] | None:
if not isinstance(value, Mapping):
return None
metadata: Final = _as_str_mapping(value)
return {
key: value for key, value in metadata.items() if key in logged_keys and _is_logged_metadata_value(key, value)
}
def _proxy_only_error_log_fields(
request_data: Mapping[str, object], logged_metadata_keys: frozenset[str]
) -> Mapping[str, object]:
metadata_fields: Final = {
key: filtered_metadata
for key in ("metadata", "litellm_metadata")
if (filtered_metadata := _proxy_only_error_log_metadata(request_data.get(key), logged_metadata_keys))
is not None
}
return MappingProxyType(
{
**{key: value for key, value in request_data.items() if key in _PROXY_ONLY_ERROR_LOGGED_FIELDS},
**metadata_fields,
}
)
_EXCEPTION_CHAIN_MAX_DEPTH: Final = 16
@ -3607,6 +3661,7 @@ class ProxyLogging:
user_api_key_dict=user_api_key_dict,
route=route,
original_exception=original_exception,
error_type=error_type,
)
request_data.update(
@ -3695,6 +3750,7 @@ class ProxyLogging:
user_api_key_dict: UserAPIKeyAuth,
route: str | None = None,
original_exception: Exception | None = None,
error_type: ProxyErrorTypes | None = None,
):
"""
Handle logging for proxy only errors by calling `litellm_logging_obj.async_failure_handler`
@ -3702,19 +3758,22 @@ class ProxyLogging:
Is triggered when self._is_proxy_only_error() returns True
"""
litellm_logging_obj: Logging | None = request_data.get("litellm_logging_obj", None)
logged_metadata_keys: Final = _proxy_only_error_logged_metadata_keys(
authenticated=error_type != ProxyErrorTypes.auth_error, past_pre_call=litellm_logging_obj is not None
)
user_api_key_logged_metadata: Final = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(
user_api_key_dict=user_api_key_dict
)
if litellm_logging_obj is None:
from litellm._uuid import uuid
request_data.setdefault("litellm_call_id", str(uuid.uuid4()))
user_api_key_logged_metadata: Final = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(
user_api_key_dict=user_api_key_dict
)
litellm_logging_obj, data = litellm.utils.function_setup(
original_function=route or "IGNORE_THIS",
rules_obj=litellm.utils.Rules(),
start_time=datetime.now(),
**request_data,
**_proxy_only_error_log_fields(request_data, logged_metadata_keys),
)
request_data["litellm_logging_obj"] = litellm_logging_obj # rebind-ok: lifted then popped by the caller
if "metadata" not in request_data:
@ -3727,14 +3786,33 @@ class ProxyLogging:
_litellm_params: Final = {}
litellm_param_keys: Final = LoggedLiteLLMParams.__annotations__.keys()
for k, v in request_data.items():
for k, v in _proxy_only_error_log_fields(request_data, logged_metadata_keys).items():
if k in litellm_param_keys:
_litellm_params[k] = v
elif k not in ("model", "user", "litellm_logging_obj"):
_optional_params[k] = v
existing: Final = _as_str_mapping(litellm_logging_obj.litellm_params)
litellm_params: Final = {
**_litellm_params,
"metadata": {
**_as_str_mapping(existing.get("metadata")),
**_as_str_mapping(_litellm_params.get("metadata")),
**user_api_key_logged_metadata,
},
**{
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"
},
}
attribution: Final = _stamp_deployment_attribution(
_litellm_params,
litellm_params,
request_data.get("model"),
user_api_key_dict.team_id,
dispatched=_reached_deployment(litellm_logging_obj),
@ -3744,7 +3822,7 @@ class ProxyLogging:
model=request_data.get("model", ""),
user=request_data.get("user", ""),
optional_params=_optional_params,
litellm_params=_litellm_params,
litellm_params=litellm_params,
**(
{"custom_llm_provider": attribution["custom_llm_provider"]}
if "custom_llm_provider" in attribution

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.106.4"
version = "1.106.5"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.15"
@ -379,7 +379,7 @@ litellm-enterprise = { workspace = true }
members = ["enterprise", "litellm-proxy-extras"]
[tool.commitizen]
version = "1.106.4"
version = "1.106.5"
version_files = [
"pyproject.toml:^version",
]

View file

@ -91,6 +91,24 @@ class TestGCSBucketBase:
custom_llm_provider="vertex_ai",
)
@pytest.mark.asyncio
async def test_gcs_logging_config_ignores_request_time_bucket_and_credentials(self, monkeypatch):
monkeypatch.setenv("GCS_PATH_SERVICE_ACCOUNT", "/admin/sa.json")
handler = GCSBucketBase(bucket_name="admin-bucket")
with patch.object(handler, "get_or_create_vertex_instance", new_callable=AsyncMock) as mock_vertex:
config = await handler.get_gcs_logging_config(
{
"standard_callback_dynamic_params": {
"gcs_bucket_name": "caller-bucket",
"gcs_path_service_account": "/caller/sa.json",
}
}
)
assert (config["bucket_name"], config["path_service_account"]) == ("admin-bucket", "/admin/sa.json")
mock_vertex.assert_awaited_once_with(credentials="/admin/sa.json")
@pytest.mark.asyncio
async def test_log_json_data_on_gcs_url_encodes_object_name(self):
handler = GCSBucketBase(bucket_name="test-bucket")

View file

@ -3140,6 +3140,21 @@ def test_get_request_tags_from_metadata_and_litellm_metadata():
assert "User-Agent: litellm/1.0.0" in tags
@pytest.mark.parametrize("unusable_metadata_tags", [123, "tag", {"tag": True}, []])
def test_get_request_tags_falls_back_to_litellm_metadata_when_metadata_tags_are_unusable(unusable_metadata_tags):
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
tags = StandardLoggingPayloadSetup._get_request_tags(
litellm_params={
"metadata": {"tags": unusable_metadata_tags},
"litellm_metadata": {"tags": ["litellm-metadata-tag"]},
},
proxy_server_request={},
)
assert tags == ["litellm-metadata-tag"]
def test_get_request_tags_does_not_mutate_original_tags():
"""
Test that _get_request_tags does not mutate the original tags list in metadata.

View file

@ -260,87 +260,3 @@ class TestVertexAIFilesHandler:
)
assert service_account == '{"type": "service_account", "project_id": "p"}'
@pytest.mark.asyncio
async def test_afile_content_honors_per_model_bucket_over_env(self, monkeypatch):
"""
Regression for #32640: a batch output written to a per-model gcs_bucket_name must be
readable even when the global GCS_BUCKET_NAME points at a different bucket. Before the
fix the read path resolved the bucket from env only and raised
"file_id bucket does not match the configured storage bucket".
"""
monkeypatch.setenv("GCS_BUCKET_NAME", "env-default-bucket")
monkeypatch.delenv("GCS_PATH_SERVICE_ACCOUNT", raising=False)
file_id = "gs%3A%2F%2Fmy-model-bucket%2Flitellm-vertex-files%2Fuploads%2Fabc-batch-output.jsonl"
file_content_request = FileContentRequest(file_id=file_id, extra_headers=None, extra_body=None)
with (
patch.object(self.handler, "download_gcs_object", new_callable=AsyncMock) as mock_download,
patch.object(
self.handler,
"get_or_create_vertex_instance",
new_callable=AsyncMock,
return_value=object(),
),
):
mock_download.return_value = b"batch output"
result = await self.handler.afile_content(
file_content_request=file_content_request,
vertex_credentials="/model/sa.json",
vertex_project="test-project",
vertex_location="us-central1",
timeout=60.0,
max_retries=0,
litellm_params={
"gcs_bucket_name": "my-model-bucket",
"vertex_credentials": "/model/sa.json",
},
)
assert isinstance(result, HttpxBinaryResponseContent)
assert result.response.content == b"batch output"
dynamic_params = mock_download.call_args.kwargs["standard_callback_dynamic_params"]
assert dynamic_params["gcs_bucket_name"] == "my-model-bucket"
assert dynamic_params["gcs_path_service_account"] == "/model/sa.json"
assert mock_download.call_args.kwargs["object_name"] == "litellm-vertex-files/uploads/abc-batch-output.jsonl"
@pytest.mark.asyncio
async def test_afile_content_reads_without_global_env_bucket(self, monkeypatch):
"""
Regression for #32640: with no global GCS_BUCKET_NAME set, a model-group-level
deployment (per-model gcs_bucket_name) must still be readable. Before the fix the read
path raised "GCS_BUCKET_NAME is not set in the environment".
"""
monkeypatch.delenv("GCS_BUCKET_NAME", raising=False)
monkeypatch.delenv("GCS_PATH_SERVICE_ACCOUNT", raising=False)
file_id = "gs%3A%2F%2Fmy-model-bucket%2Flitellm-vertex-files%2Fuploads%2Fabc-batch-output.jsonl"
file_content_request = FileContentRequest(file_id=file_id, extra_headers=None, extra_body=None)
with (
patch.object(self.handler, "download_gcs_object", new_callable=AsyncMock) as mock_download,
patch.object(
self.handler,
"get_or_create_vertex_instance",
new_callable=AsyncMock,
return_value=object(),
),
):
mock_download.return_value = b"batch output"
result = await self.handler.afile_content(
file_content_request=file_content_request,
vertex_credentials="/model/sa.json",
vertex_project="test-project",
vertex_location="us-central1",
timeout=60.0,
max_retries=0,
litellm_params={"gcs_bucket_name": "my-model-bucket"},
)
assert isinstance(result, HttpxBinaryResponseContent)
dynamic_params = mock_download.call_args.kwargs["standard_callback_dynamic_params"]
assert dynamic_params["gcs_bucket_name"] == "my-model-bucket"

View file

@ -1383,6 +1383,65 @@ 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.parametrize("metadata_key", ["litellm_metadata", "metadata"])
def test_pass_through_logging_ignores_client_guardrail_information_sent_as_pairs(metadata_key: str):
mock_request = MagicMock(spec=Request)
mock_request.method = "POST"
mock_request.url = httpx.URL("http://0.0.0.0:4000/openai/v1/chat/completions")
mock_request.headers = Headers({})
mock_request.scope = {}
kwargs: Final = HttpPassThroughEndpointHelpers._init_kwargs_for_pass_through_endpoint(
request=mock_request,
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
passthrough_logging_payload=MagicMock(),
logging_obj=MagicMock(),
_parsed_body={
metadata_key: [
["trace_id", "from-pairs"],
["standard_logging_guardrail_information", [{"guardrail_name": "forged", "guardrail_cost": 5.0}]],
],
},
)
logged_metadata: Final = kwargs["litellm_params"]["metadata"]
assert "standard_logging_guardrail_information" not in logged_metadata
assert "trace_id" not in logged_metadata
@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
@ -133,7 +134,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"] == {}
@ -164,7 +165,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
@ -315,6 +315,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

@ -116,51 +116,132 @@ async def test_proxy_only_error_log_marks_no_upstream_llm_call():
@pytest.mark.asyncio
async def test_proxy_only_error_log_keeps_litellm_metadata_in_litellm_params():
"""Responses API requests carry guardrail info under ``litellm_metadata``
(not ``metadata``). It must land in litellm_params so
``merge_litellm_metadata`` can surface ``guardrail_information`` in the
spend-log failure row, matching the chat completions path."""
from litellm.proxy._types import UserAPIKeyAuth
@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"])
async def test_proxy_only_error_log_drops_caller_guardrail_information_without_logging_object(
metadata_field: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "input_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
guardrail_info: Final = [
{
"guardrail_name": "caller-guardrail",
"guardrail_status": "guardrail_intervened",
"guardrail_cost": 123.45,
}
]
request_data: Final[dict[str, object]] = {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
metadata_field: {"tags": ["t"], "standard_logging_guardrail_information": guardrail_info},
}
proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
captured = {}
guardrail_info = [{"guardrail_name": "test-guard", "guardrail_status": "blocked"}]
await ProxyLogging(user_api_key_cache=DualCache())._handle_logging_proxy_only_error(
request_data=request_data,
user_api_key_dict=UserAPIKeyAuth(api_key="sk-rejected", team_id="team-a", request_route="/v1/chat/completions"),
route="/v1/chat/completions",
original_exception=HTTPException(status_code=400, detail="blocked"),
)
def fake_update_environment_variables(self, *args, **kwargs):
captured["litellm_params"] = kwargs.get("litellm_params")
captured["optional_params"] = kwargs.get("optional_params")
details: Final = request_data["litellm_logging_obj"].model_call_details
assert details["standard_logging_object"] is not None
assert details["litellm_params"][metadata_field]["tags"] == ["t"]
assert "standard_logging_guardrail_information" not in details["litellm_params"][metadata_field]
assert "guardrail_cost" not in str(details["standard_logging_object"])
assert "litellm_metadata" not in details["optional_params"]
from litellm.litellm_core_utils.litellm_logging import Logging
orig_update_env = Logging.update_environment_variables
orig_pre_call = Logging.pre_call
orig_async_failure = Logging.async_failure_handler
@pytest.mark.asyncio
@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"])
async def test_proxy_only_error_log_keeps_tags_and_drops_caller_logging_fields(
metadata_field: str,
) -> None:
request_data: Final[dict[str, object]] = {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
metadata_field: {
"langsmith_base_url": "https://caller.invalid",
"langsmith_api_key": "caller-key",
"tags": ["t"],
"user_api_key_team_id": "caller-team",
"user_api_key_hash": "caller-hash",
"user_api_key_auth_metadata": {"caller": "metadata"},
},
"langsmith_base_url": "https://caller.invalid",
"standard_callback_dynamic_params": {"gcs_path_service_account": "/caller/sa.json"},
}
async def _noop_async_failure(self, *args, **kwargs):
return None
await ProxyLogging(user_api_key_cache=DualCache())._handle_logging_proxy_only_error(
request_data=request_data,
user_api_key_dict=UserAPIKeyAuth(api_key="sk-rejected", team_id="team-a", request_route="/v1/chat/completions"),
route="/v1/chat/completions",
original_exception=Exception("bad key"),
)
Logging.update_environment_variables = fake_update_environment_variables
Logging.pre_call = lambda self, *args, **kwargs: None
Logging.async_failure_handler = _noop_async_failure
try:
await proxy_logging_obj._handle_logging_proxy_only_error(
request_data={
"model": "gpt-4o",
"input": "blocked prompt",
"litellm_metadata": {"standard_logging_guardrail_information": guardrail_info},
},
user_api_key_dict=UserAPIKeyAuth(api_key="sk-9876", request_route="/v1/responses"),
route="/v1/responses",
original_exception=HTTPException(status_code=400, detail="blocked"),
)
finally:
Logging.update_environment_variables = orig_update_env
Logging.pre_call = orig_pre_call
Logging.async_failure_handler = orig_async_failure
details: Final = request_data["litellm_logging_obj"].model_call_details
assert "caller" not in str(details["standard_callback_dynamic_params"])
assert "langsmith_base_url" not in details
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"
assert captured["litellm_params"]["litellm_metadata"]["standard_logging_guardrail_information"] == guardrail_info
assert "litellm_metadata" not in captured["optional_params"]
@pytest.mark.asyncio
@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"])
async def test_proxy_only_error_log_drops_malformed_metadata_tags(metadata_field: str) -> None:
guardrail_info: Final = [{"guardrail_name": "g", "guardrail_status": "guardrail_intervened"}]
request_data: Final[dict[str, object]] = {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
metadata_field: {"tags": 123, "standard_logging_guardrail_information": guardrail_info},
}
await ProxyLogging(user_api_key_cache=DualCache())._handle_logging_proxy_only_error(
request_data=request_data,
user_api_key_dict=UserAPIKeyAuth(api_key="sk-rejected", team_id="team-a", request_route="/v1/chat/completions"),
route="/v1/chat/completions",
original_exception=Exception("bad key"),
)
details: Final = request_data["litellm_logging_obj"].model_call_details
assert details["standard_logging_object"] is not None
assert "tags" not in details["litellm_params"][metadata_field]
assert "standard_logging_guardrail_information" not in details["litellm_params"][metadata_field]
@pytest.mark.asyncio
@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"])
async def test_proxy_only_error_log_builds_payload_when_logging_object_has_malformed_tags(
metadata_field: str,
) -> None:
request_data: Final[dict[str, object]] = {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"litellm_call_id": "malformed-tags-call",
metadata_field: {"tags": 123},
}
logging_obj, _ = litellm.utils.function_setup(
original_function="acompletion",
rules_obj=litellm.utils.Rules(),
start_time=real_datetime.datetime.now(),
**request_data,
)
await ProxyLogging(user_api_key_cache=DualCache())._handle_logging_proxy_only_error(
request_data={**request_data, "litellm_logging_obj": logging_obj},
user_api_key_dict=UserAPIKeyAuth(api_key="sk-blocked", team_id="team-a", request_route="/v1/chat/completions"),
route="/v1/chat/completions",
original_exception=HTTPException(status_code=400, detail="Content blocked"),
)
standard_logging_object: Final = logging_obj.model_call_details["standard_logging_object"]
assert standard_logging_object is not None
assert standard_logging_object["model"] == request_data["model"]
assert standard_logging_object["metadata"]["user_api_key_team_id"] == "team-a"
assert 123 not in standard_logging_object["request_tags"]
@pytest.mark.asyncio

View file

@ -357,6 +357,103 @@ async def test_post_call_failure_hook_flags_pre_routing_reject_despite_caller_mo
assert kwargs["litellm_params"]["custom_llm_provider"] == "openai"
@pytest.mark.asyncio
@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"])
async def test_pre_routing_reject_logs_the_configured_deployment_over_caller_metadata(
proxy_logging, make_user_api_key_auth, monkeypatch, metadata_field: str
):
from litellm.proxy import proxy_server
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
recorded: list[dict] = []
class _RecordingLogger(CustomLogger):
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
recorded.append(kwargs)
monkeypatch.setattr(
proxy_server,
"llm_router",
litellm.Router(
model_list=[
{
"model_name": "internal-model",
"litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"},
}
]
),
)
monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()])
proxy_logging.alert_types = []
request_data: Final[dict[str, object]] = {
"model": "internal-model",
"messages": [{"role": "user", "content": "hi"}],
"litellm_call_id": "caller-routing-metadata",
metadata_field: {"deployment": "openai/caller-chosen", "model_group": "caller-group"},
}
logging_obj, _ = litellm.utils.function_setup(
original_function="acompletion", rules_obj=litellm.utils.Rules(), start_time=datetime.now(), **request_data
)
await proxy_logging.post_call_failure_hook(
request_data={**request_data, "litellm_logging_obj": logging_obj},
original_exception=HTTPException(status_code=400, detail="Content blocked"),
user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"),
route="/chat/completions",
)
assert len(recorded) == 1
now: Final = datetime.now()
payload: Final = get_logging_payload(
kwargs={**recorded[0], "completion_start_time": now}, response_obj=None, start_time=now, end_time=now
)
standard_logging_object: Final = recorded[0]["standard_logging_object"]
assert {
"logged_model": standard_logging_object["model"],
"logged_model_group": standard_logging_object["model_group"],
"spend_model": payload["model"],
"spend_model_group": payload["model_group"],
} == {
"logged_model": "openai/gpt-4.1",
"logged_model_group": "internal-model",
"spend_model": "openai/gpt-4.1",
"spend_model_group": "internal-model",
}
@pytest.mark.asyncio
@pytest.mark.parametrize(("error_type", "logged_tags"), [(ProxyErrorTypes.auth_error, []), (None, ["checkout"])])
async def test_proxy_only_failure_log_keeps_caller_tags_only_after_auth(
proxy_logging, monkeypatch, error_type: ProxyErrorTypes | None, logged_tags: list[str]
):
from litellm.proxy import proxy_server
recorded: list[dict] = []
class _RecordingLogger(CustomLogger):
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
recorded.append(kwargs)
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()])
proxy_logging.alert_types = []
await proxy_logging.post_call_failure_hook(
request_data={
"model": "internal-model",
"messages": [{"role": "user", "content": "hi"}],
"metadata": {"tags": ["checkout"]},
},
original_exception=HTTPException(status_code=401, detail="Invalid proxy server token passed"),
user_api_key_dict=UserAPIKeyAuth(request_route="/chat/completions"),
error_type=error_type,
route="/chat/completions",
)
assert len(recorded) == 1
assert recorded[0]["standard_logging_object"]["request_tags"] == logged_tags
@pytest.mark.asyncio
async def test_post_call_failure_hook_attribution_does_not_count_against_the_deployment(
proxy_logging, make_user_api_key_auth, monkeypatch
@ -529,9 +626,8 @@ async def test_post_call_failure_hook_omits_provider_when_a_deployment_does_not_
async def test_handle_logging_proxy_only_path_attributes_with_read_only_metadata(
proxy_logging, make_user_api_key_auth, monkeypatch
):
"""With a logging object already on the request, its metadata is taken as given;
a read-only mapping there must not crash the stamp, and the failure handler
still receives the provider attribution."""
"""With a logging object already on the request, allowlisted metadata is kept,
a read-only mapping must not crash the stamp, and the failure handler receives provider attribution."""
from litellm.proxy import proxy_server
monkeypatch.setattr(
@ -550,16 +646,26 @@ async def test_handle_logging_proxy_only_path_attributes_with_read_only_metadata
logging_obj = MagicMock()
logging_obj.call_type = "acompletion"
logging_obj.model_call_details = {}
logging_obj.litellm_params = {"metadata": {"preexisting": "logged"}}
logging_obj.async_failure_handler = AsyncMock()
guardrail_info: Final = [
{
"guardrail_name": "local-block",
"guardrail_status": "guardrail_intervened",
"guardrail_cost": 0.25,
}
]
await proxy_logging._handle_logging_proxy_only_error(
request_data={
"litellm_logging_obj": logging_obj,
"model": "internal-model",
"messages": [{"role": "user", "content": "hi"}],
"metadata": MappingProxyType({"user_api_key_alias": "frozen"}),
"metadata": MappingProxyType(
{"tags": ["checkout"], "standard_logging_guardrail_information": guardrail_info}
),
},
user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"),
user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions", key_alias="frozen"),
route="/chat/completions",
original_exception=HTTPException(status_code=403, detail="blocked"),
)
@ -568,7 +674,11 @@ async def test_handle_logging_proxy_only_path_attributes_with_read_only_metadata
update_kwargs = logging_obj.update_environment_variables.call_args.kwargs
assert update_kwargs["custom_llm_provider"] == "openai"
assert update_kwargs["litellm_params"]["custom_llm_provider"] == "openai"
assert update_kwargs["litellm_params"]["metadata"] == {"user_api_key_alias": "frozen"}
assert {
key: update_kwargs["litellm_params"]["metadata"][key] for key in ("preexisting", "user_api_key_alias")
} == {"preexisting": "logged", "user_api_key_alias": "frozen"}
assert update_kwargs["litellm_params"]["metadata"]["tags"] == ["checkout"]
assert update_kwargs["litellm_params"]["metadata"]["standard_logging_guardrail_information"] == guardrail_info
@pytest.mark.asyncio

2
uv.lock generated
View file

@ -4540,7 +4540,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.106.4"
version = "1.106.5"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },