From 7553c4f5015cbd49c8a288e299ca2d74e8d06a07 Mon Sep 17 00:00:00 2001 From: Nate Armstrong Date: Thu, 12 Nov 2026 11:39:53 -0800 Subject: [PATCH] fix(messages): hand rust only the anthropic metadata user_id (#51355) A guardrail such as Pillar aliases the request's metadata to litellm_metadata, which holds the caller's UserAPIKeyAuth. Since #51119 the Rust host converts the whole metadata field to JSON before its own user_id filter runs, so the call failed with "unsupported type UserAPIKeyAuth". The host now passes only user_id, the one key the Rust validator and Python's AnthropicMetadata keep. --- litellm/litellm_core_utils/core_helpers.py | 7 +++++++ litellm/rust_bridge/host/routes/messages.py | 12 ++++++++++- .../litellm_core_utils/test_core_helpers.py | 12 +++++++++++ .../rust_bridge/host/routes/test_messages.py | 20 +++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 70cb517168c..1a34b8f01ed 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -141,6 +141,13 @@ def without( return {key: value for key, value in mapping.items() if key not in keys} +def only( + mapping: Mapping[_Key, _Value], *keys: object +) -> dict[_Key, _Value]: # mutable-ok: a fresh copy the caller owns + """``mapping`` with only ``keys``, as a new dict in the same order; ``mapping`` is unchanged.""" + return {key: value for key, value in mapping.items() if key in keys} + + def decoded_json_object(value: object) -> Mapping[str, object]: mapping: Final = as_str_mapping(value) if mapping is not None: diff --git a/litellm/rust_bridge/host/routes/messages.py b/litellm/rust_bridge/host/routes/messages.py index 36cad1cf93e..b4113bf7efa 100644 --- a/litellm/rust_bridge/host/routes/messages.py +++ b/litellm/rust_bridge/host/routes/messages.py @@ -25,6 +25,7 @@ from litellm.litellm_core_utils.core_helpers import ( as_str_mapping, drops_unsupported_params, normalize_drop_params, + only, proxy_request_headers, stream_cost_injection_requested, ) @@ -174,8 +175,17 @@ def modify_max_tokens( cache.remember_unclamped(kwargs.get("litellm_logging_obj"), unclamped) +def _anthropic_metadata(params: Mapping[str, object]) -> Mapping[str, object]: + metadata: Final = as_str_mapping(params.get("metadata")) + if metadata is None: + return params + return {**params, "metadata": only(metadata, "user_id")} + + def _body_fields(request: Mapping[str, object]) -> Mapping[str, object]: - params: Final = known_params(_BODY_FIELDS.difference(handler.additional_drop_paths(request)), request) + params: Final = _anthropic_metadata( + known_params(_BODY_FIELDS.difference(handler.additional_drop_paths(request)), request) + ) if deployment_reasoning_effort_levels(request.get("model_info")) is None: return params return _with_deployment_effort(params, request) diff --git a/tests/unit/litellm_core_utils/test_core_helpers.py b/tests/unit/litellm_core_utils/test_core_helpers.py index ef2048998b1..fa75e7f9a11 100644 --- a/tests/unit/litellm_core_utils/test_core_helpers.py +++ b/tests/unit/litellm_core_utils/test_core_helpers.py @@ -26,6 +26,7 @@ from litellm.litellm_core_utils.core_helpers import ( json_contains, map_finish_reason, normalize_drop_params, + only, preserve_upstream_non_openai_attributes, process_response_headers, provider_reported_usage_cost, @@ -934,3 +935,14 @@ def test_without_returns_a_new_dict_and_leaves_the_source_unchanged() -> None: assert source == {"a": 1, "b": 2} assert result == {"b": 2, "c": 3} + + +def test_only_keeps_the_named_keys_in_order_and_leaves_the_source_unchanged() -> None: + source: Final = {"a": 1, "b": 2, "c": 3, "d": 4} + + result: Final = only(source, "d", "b", "missing") + result["e"] = 5 + + assert list(result.items()) == [("b", 2), ("d", 4), ("e", 5)] + assert only(source) == {} + assert source == {"a": 1, "b": 2, "c": 3, "d": 4} diff --git a/tests/unit/rust_bridge/host/routes/test_messages.py b/tests/unit/rust_bridge/host/routes/test_messages.py index bf11aa158b9..ec1b1282a7e 100644 --- a/tests/unit/rust_bridge/host/routes/test_messages.py +++ b/tests/unit/rust_bridge/host/routes/test_messages.py @@ -296,6 +296,26 @@ def test_body_params_forward_the_thread_like_the_python_handler( assert messages_host.body_params(request, asynchronous=True).get("thread") == expected +@pytest.mark.parametrize( + ("metadata", "expected"), + ( + ({"user_id": "u-1", "user_api_key_auth": object(), "pillar_flagged": False}, {"user_id": "u-1"}), + ({"user_api_key_auth": object()}, {}), + (["u-1"], ["u-1"]), + ), + ids=("litellm-keys-dropped", "only-litellm-keys", "not-an-object-left-for-rust-to-reject"), +) +def test_body_params_forward_only_the_metadata_anthropic_accepts(metadata: object, expected: object) -> None: + request: Final[dict[str, object]] = { + "model": "claude-sonnet-4-5", + "messages": [], + "max_tokens": 8, + "metadata": metadata, + } + + assert messages_host.body_params(request, asynchronous=True)["metadata"] == expected + + def _marked(phase: str | None = None, *args: object) -> ValueError: error: Final = ValueError(*args) if phase is not None: