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.
This commit is contained in:
Nate Armstrong 2026-11-12 11:39:53 -08:00 • committed by GitHub
parent 27f54ec379
commit 7553c4f501
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 50 additions and 1 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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}

View file

@ -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: