fix(logging): preserve proxy key-auth metadata on /v1/messages Langfuse traces#25448
Conversation
…se traces update_from_kwargs() overwrites proxy metadata (user_api_key_hash, etc.) with Anthropic's native metadata when both exist. Merge instead of replace.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR fixes a metadata overwrite bug in Confidence Score: 5/5Safe to merge — fix correctly addresses the metadata overwrite bug with no new P1/P0 issues introduced. The merge logic is correct: kwargs-sourced proxy metadata is built first, then litellm_params metadata is merged in with setdefault semantics so existing keys are never overwritten. The new conflict-resolution test (test_kwargs_metadata_wins_over_caller_metadata_in_conflict) directly validates the fix. All current call sites pass freshly constructed dicts, so the previously flagged pop() mutation concern is not a live defect. All remaining findings are P2 or lower. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/litellm_core_utils/litellm_logging.py | update_from_kwargs() now pops metadata from litellm_params before bulk update and re-merges carefully; fix is logically correct but pop() still mutates the caller's dict (flagged previously, unaddressed) |
| tests/test_litellm/litellm_core_utils/test_litellm_logging.py | Adds explicit conflict-resolution test (test_kwargs_metadata_wins_over_caller_metadata_in_conflict) alongside prior merge test; no real network calls, stays in unit-test scope |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["update_from_kwargs(kwargs, litellm_params)"] --> B["Build base_litellm_params from kwargs['metadata'] and kwargs['litellm_metadata']"]
B --> C{litellm_params provided?}
C -- No --> G
C -- Yes --> D["lp_metadata = litellm_params.pop('metadata', None)"]
D --> E["base_litellm_params.update(litellm_params)"]
E --> F{lp_metadata is non-empty dict?}
F -- No --> G["update_environment_variables(base_litellm_params)"]
F -- Yes --> H["setdefault('metadata', {})"]
H --> I["Merge lp_metadata keys NOT already in base metadata\n(kwargs metadata wins on conflicts)"]
I --> G
style D fill:#ffd700,color:#000
style I fill:#90ee90,color:#000
Reviews (2): Last reviewed commit: "test: add explicit conflict-resolution t..." | Re-trigger Greptile
| # 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) |
There was a problem hiding this comment.
pop() mutates the caller's dict
litellm_params.pop("metadata", None) modifies the dict passed in. All current call sites pass freshly constructed dicts, so this is safe today. However, any future caller that reuses the dict after this call would silently lose its metadata key. Using get plus a filtered update is safer:
| lp_metadata = litellm_params.pop("metadata", None) | |
| lp_metadata = litellm_params.get("metadata", None) | |
| litellm_params_without_meta = {k: v for k, v in litellm_params.items() if k != "metadata"} | |
| base_litellm_params.update(litellm_params_without_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( | ||
| kwargs=kwargs, | ||
| 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} |
There was a problem hiding this comment.
Misleading test name and missing conflict-resolution scenario
The name test_caller_litellm_params_win_over_kwargs says the caller wins, but the fix intentionally makes kwargs (proxy metadata) win when the same key appears in both sources. The test doesn't actually exercise that conflict — "from_kwargs" and "from_caller" are different keys, so both always end up present regardless of which side "wins."
Consider renaming and adding an explicit conflict assertion:
| 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( | |
| kwargs=kwargs, | |
| 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 keys take precedence; caller litellm_params metadata is merged in 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 | |
| } |
c9e4949
into
BerriAI:litellm_internal_staging_04_11_2026
update_from_kwargs() overwrites proxy metadata (user_api_key_hash, etc.) with Anthropic's native metadata when both exist. Merge instead of replace.
Relevant issues
Fixes missing
user_api_key_hash,user_api_key_alias,user_api_key_team_idin Langfuse traces for/v1/messages(Anthropic Messages API) requests sent by Claude Code through the LiteLLM proxy.Related PRs: #23259, #24003
Pre-Submission checklist
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
CI (LiteLLM team)
Branch creation CI run
Link:
CI run for the last commit
Link:
Merge / cherry-pick CI run
Links:
Type
🐛 Bug Fix
Changes
Root cause:
async_anthropic_messages_handlerinllm_http_handler.pycallslogging_obj.update_from_kwargs()withlitellm_paramscontaining**anthropic_messages_optional_request_params. SinceAnthropicMessagesRequestOptionalParamsincludes ametadatafield (Anthropic's native metadata, e.g.{user_id: "..."}), thebase_litellm_params.update(litellm_params)call inupdate_from_kwargs()overwrites the proxy key-auth fields (user_api_key_hash,user_api_key_alias,user_api_key_team_id, etc.) that were already merged fromlitellm_metadata.Fix: In
update_from_kwargs(), popmetadatafromlitellm_paramsbefore the.update(), then merge it back without overwriting existing keys. This preserves both proxy key-auth fields and Anthropic's native metadata.Files changed:
litellm/litellm_core_utils/litellm_logging.py— merge metadata inupdate_from_kwargs()instead of overwritingtests/test_litellm/litellm_core_utils/test_litellm_logging.py— test reproducing the exact bug scenario