From c9e4949485291f06b769da78d648876bcf390fcf Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Sat, 11 Apr 2026 18:29:34 +0200 Subject: [PATCH] fix(logging): preserve proxy key-auth metadata on /v1/messages Langfuse traces (#25448) * fix(logging): preserve proxy key-auth metadata on /v1/messages Langfuse traces update_from_kwargs() overwrites proxy metadata (user_api_key_hash, etc.) with Anthropic's native metadata when both exist. Merge instead of replace. * fix(test): update stale assertion for new metadata merge semantics * test: add explicit conflict-resolution test for metadata merge --- litellm/litellm_core_utils/litellm_logging.py | 10 +++ .../test_litellm_logging.py | 74 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 7a3547bca2e..e84c1e13a8b 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -613,7 +613,17 @@ class Logging(LiteLLMLoggingBaseClass): base_litellm_params["metadata"] = kwargs["litellm_metadata"].copy() if litellm_params: + # Merge metadata carefully — don't overwrite the merged metadata + # from kwargs/litellm_metadata with the caller's litellm_params metadata. + # e.g. anthropic_messages passes Anthropic's native metadata ({user_id: ...}) + # in litellm_params, which would overwrite proxy key-auth fields. + lp_metadata = litellm_params.pop("metadata", None) base_litellm_params.update(litellm_params) + if lp_metadata and isinstance(lp_metadata, dict): + base_litellm_params.setdefault("metadata", {}) + for k, v in lp_metadata.items(): + if k not in base_litellm_params["metadata"]: + base_litellm_params["metadata"][k] = v self.update_environment_variables( litellm_params=base_litellm_params, diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 9d584446eb5..ddc44cb5059 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -429,7 +429,7 @@ class TestUpdateFromKwargs: assert logging_obj.litellm_params["litellm_metadata"] == lm_meta def test_caller_litellm_params_win_over_kwargs(self, logging_obj): - """Explicit litellm_params from the caller should override auto-extracted values.""" + """Explicit litellm_params metadata merges into kwargs metadata without overwriting.""" kwargs = {"metadata": {"from_kwargs": True}} logging_obj.update_from_kwargs( @@ -437,7 +437,24 @@ class TestUpdateFromKwargs: litellm_params={"metadata": {"from_caller": True}, "litellm_call_id": "x"}, ) - assert logging_obj.litellm_params["metadata"] == {"from_caller": True} + # kwargs metadata is preserved, caller metadata is merged in + assert logging_obj.litellm_params["metadata"] == {"from_kwargs": True, "from_caller": True} + + def test_kwargs_metadata_wins_over_caller_metadata_in_conflict(self, logging_obj): + """kwargs metadata takes precedence; caller litellm_params metadata is merged without overwriting.""" + kwargs = {"metadata": {"from_kwargs": True, "shared_key": "kwargs_value"}} + + logging_obj.update_from_kwargs( + kwargs=kwargs, + litellm_params={"metadata": {"from_caller": True, "shared_key": "caller_value"}, "litellm_call_id": "x"}, + ) + + # kwargs metadata is preserved (shared_key keeps the kwargs value), caller-only keys are added + assert logging_obj.litellm_params["metadata"] == { + "from_kwargs": True, + "from_caller": True, + "shared_key": "kwargs_value", # kwargs wins on conflict + } def test_custom_pricing_detected_via_litellm_metadata(self, logging_obj): """Custom pricing in litellm_metadata.model_info should set custom_pricing flag.""" @@ -2153,6 +2170,59 @@ def test_function_setup_metadata_takes_precedence_over_litellm_metadata(): assert litellm_metadata.get("user_api_key_hash") == "sk-hashed-xyz" +def test_update_from_kwargs_litellm_params_metadata_does_not_overwrite_proxy_fields(): + """ + Test the exact bug: when update_from_kwargs is called with litellm_params + containing a 'metadata' key (e.g. Anthropic's native metadata with user_id), + it must NOT overwrite proxy key-auth fields already merged from litellm_metadata. + + This is the anthropic_messages code path where async_anthropic_messages_handler + passes anthropic_messages_optional_request_params (which includes metadata) + as litellm_params to update_from_kwargs. + """ + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj = Logging( + model="claude-3-5-sonnet", + messages=[{"role": "user", "content": "test"}], + stream=False, + call_type="anthropic_messages", + start_time=time.time(), + litellm_call_id="test-overwrite-bug", + function_id="test-function-id", + ) + + kwargs = { + "litellm_metadata": { + "user_api_key_hash": "sk-hashed-proxy", + "user_api_key_alias": "claude-api", + "user_api_key_team_id": "team-zurich", + }, + } + + # Simulate what async_anthropic_messages_handler does: + # passes Anthropic's native metadata in litellm_params + logging_obj.update_from_kwargs( + kwargs=kwargs, + litellm_params={ + "preset_cache_key": None, + "stream_response": {}, + "metadata": {"user_id": "anthropic-device-id"}, # Anthropic native metadata + }, + ) + + litellm_params = logging_obj.model_call_details.get("litellm_params", {}) + metadata = litellm_params.get("metadata") + + assert metadata is not None + # Proxy key-auth fields must survive the litellm_params.update() + assert metadata.get("user_api_key_hash") == "sk-hashed-proxy" + assert metadata.get("user_api_key_alias") == "claude-api" + assert metadata.get("user_api_key_team_id") == "team-zurich" + # Anthropic native metadata must also be present + assert metadata.get("user_id") == "anthropic-device-id" + + def test_function_setup_empty_metadata_falls_back_to_litellm_metadata(): """ Test that when metadata is explicitly set to {} (empty dict), litellm_metadata