Skip to content

fix(logging): fall back to litellm_metadata when metadata is empty - #36105

Merged
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5137_bridge_metadata_fallback
Aug 7, 2026
Merged

fix(logging): fall back to litellm_metadata when metadata is empty#36105
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5137_bridge_metadata_fallback

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • get_litellm_params returned metadata=None whenever a caller supplied only litellm_metadata, overwriting a fallback function_setup had already applied correctly and leaving litellm_params["metadata"] empty
  • Every provider without a native Responses API config reaches /v1/responses through the chat-completions bridge, and on that path this discarded the caller's trace fields a second time, after the proxy had already promoted them in fix(proxy): promote caller metadata trace fields into litellm_metadata #35866
  • Logging.update_from_kwargs merges proxy-internal fields into whatever dict it is handed and was aliasing rather than copying it. On these routes that dict is the caller's provider-bound metadata, so once the merged value stopped being None it would write user_api_key_hash and user_api_key_auth into an outbound request body

How it solves it:

  • Resolve metadata to a copy of litellm_metadata when metadata is empty, which is the same fallback litellm/utils.py already applies two frames up
  • Guard on isinstance, since the proxy deliberately leaves an unparseable string litellm_metadata in place and str has no copy
  • Copy in update_from_kwargs instead of aliasing, so the merge cannot reach the caller's dict

Relevant issues

Second half of the fix for #34226. Stacked on #35866, which promotes the caller's trace fields into litellm_metadata for routes that track proxy state there. #35866 covers providers with a native Responses API config; this covers the rest, so both are needed to close the issue

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Live proxy, real Postgres, real Gemini traffic, Langfuse pointed at a local ingestion endpoint so the raw trace-create body is visible. /v1/responses against a provider with no native Responses API config, so the request takes the completion-transformation bridge:

TID=22662678-30c1-41a1-a24b-216d6e5fb83d; SID=218af06c-28a2-4705-8a0a-5f9970d39326
curl -sS localhost:4000/v1/responses -H 'Authorization: Bearer sk-1234' -H 'content-type: application/json' \
  -d "{\"model\":\"gemini-flash\",\"input\":\"say bridge\",\"metadata\":{\"trace_id\":\"$TID\",\"session_id\":\"$SID\",\"trace_user_id\":\"user-123\",\"trace_metadata\":{\"tenant_id\":\"tenant-1\"}}}"
# with #35866 alone, the bridge still loses the fields
{"name": "litellm-aresponses", "id": "bb6a5f83-862c-4907-989a-f77011b757fe", "sessionId": null, "userId": null, "metadata": null}

# with this PR on top
{"name": "litellm-aresponses", "id": "22662678-30c1-41a1-a24b-216d6e5fb83d", "sessionId": "218af06c-28a2-4705-8a0a-5f9970d39326", "userId": "user-123", "metadata": {"tenant_id": "tenant-1"}}

The aliasing half, reproduced directly against the resolved params rather than through a route, since _aresponses_websocket is the reachable entry point and takes metadata through **kwargs:

# before: the caller's own metadata dict is mutated by logging setup
caller metadata dict AFTER logging setup: ['litellm_api_version', 'requester_metadata', 'user_api_key_auth', 'user_api_key_hash']

# after
caller metadata dict AFTER logging setup: []

And the malformed-input guard, which is the regression the isinstance check prevents:

litellm_metadata='not-json-a-string'   -> metadata=None
litellm_metadata=12345                 -> metadata=None
litellm_metadata=['a']                 -> metadata=None
litellm_metadata={'trace_id': 't'}     -> metadata={'trace_id': 't'}

Without the guard the first three raise AttributeError: 'str' object has no attribute 'copy', which surfaces as a 500 on a request that previously succeeded

Type

🐛 Bug Fix

Changes

get_litellm_params resolves metadata to a copy of litellm_metadata when metadata is empty and litellm_metadata is a dict. The same value feeds the litellm_session_id and litellm_trace_id derivation directly above it, so call chaining now works on these routes too.

Logging.update_from_kwargs copies kwargs["metadata"] rather than aliasing it. The merge below it writes proxy-internal fields into that dict, and on litellm_metadata routes it is the caller's provider-bound object.

base_model still derives from the raw metadata rather than the resolved one. The final value is unchanged either way because _get_base_model_from_metadata in litellm/utils.py already falls back to litellm_metadata, so it is left alone to keep the change scoped.

Behavior changes

All three below follow from one thing: on the routes that pass litellm_metadata with no metadata, meaning the /v1/responses bridge, /files, metadata-less /batches and bedrock passthrough, litellm_params["metadata"] changes from None to a copy of the litellm_metadata dict. Code that reads that dict had been failing into a swallowing try and now runs. Each was confirmed on a base-vs-head proxy A/B rather than reasoned about.

Worth a release note. max_budget_per_session starts being enforced on these routes. Its pre-call gate already read both metadata dicts, but the spend increment reads litellm_params["metadata"]["session_id"], which never existed there, so the counter never incremented and the limit was inert. With an agent-scoped request the handler sees session_id=None agent_id=None on base and returns early; on head it sees both and increments. An operator who configured this months ago has been running without it on these routes, and after this change requests that used to succeed can start returning 429 with no config change on their side. Correct behavior, but it arrives without warning.

Data change, no action needed. The SpendLogs session_id column changes from a per-request UUID to the caller's session id when one is supplied, because litellm_session_id and litellm_trace_id can now be derived on these routes. The column is named for what it now holds, and a random per-request value made session grouping useless there, so this is the intended repair. The x-litellm-session-id header already produced this same value on every route. Dashboards that group by that column will see the grouping start working.

Strict repair. Callbacks on these routes now receive the same proxy metadata that /chat/completions and the native /v1/responses path have always sent, including the user_api_key_* fields. A Lago-style bare index on litellm_params["metadata"]["user_api_key_user_id"] raises TypeError on base and returns the value on head, which is the shape PromptLayer and Slack alerting use too. These integrations were simply broken on these routes before.

Correction to an earlier revision of this description, recorded rather than silently removed: it also listed the v1 parallel-request limiter's success handler as newly running. That was wrong. Only _PROXY_MaxParallelRequestsHandler_v3 is registered in a default deployment, parallel_request_limiter.py never appears in the proxy log, and the v3 handler does not read litellm_params["metadata"].

QA runbook

Run the curl above against a model whose provider has no native Responses API config, with the langfuse success callback enabled and LANGFUSE_HOST pointed at any server that accepts POST /api/public/ingestion and returns 207. The captured trace-create body should carry the caller's trace_id with sessionId, userId and metadata populated. Then repeat with "litellm_metadata": "not-json" in the body and confirm the request still succeeds rather than returning a 500

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Open in Devin Review

Note

Medium Risk
Behavior change on litellm_metadata-only routes: spend/session limits, SpendLogs session_id, and callbacks may start seeing metadata that was previously null; logging no longer mutates caller dicts but downstream code that relied on that side effect could differ.

Overview
Fixes trace/metadata loss and accidental mutation on routes that only pass litellm_metadata (e.g. /v1/responses chat bridge).

get_litellm_params now sets metadata to a copy of litellm_metadata when metadata is missing or empty and litellm_metadata is a dict (non-dicts are ignored so unparseable proxy strings do not 500). That resolved dict drives litellm_params["metadata"] and session/trace ID derivation, aligning with the fallback already applied higher in litellm/utils.py.

Logging.update_from_kwargs only treats kwargs["metadata"] as metadata when it is a dict and copies it before merging proxy fields, so logging setup no longer writes user_api_key_* into the caller’s outbound provider metadata object.

New unit tests cover fallback precedence, malformed litellm_metadata, copy-vs-alias behavior, and logging merge edge cases.

Reviewed by Cursor Bugbot for commit 749b823. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR restores metadata propagation when only litellm_metadata is populated and prevents logging setup from mutating the caller’s top-level metadata dictionary

  • Resolves metadata from a copied litellm_metadata dictionary when regular metadata is empty
  • Derives session and trace identifiers from the resolved metadata
  • Copies dictionary metadata before logging merges proxy fields
  • Adds regression coverage for fallback, malformed values, precedence, identifier derivation, and aliasing

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up scope

No blocking failure remains

Important Files Changed

Filename Overview
litellm/litellm_core_utils/get_litellm_params.py Resolves metadata from a guarded copy of litellm_metadata and uses it for identifier derivation and returned parameters
litellm/litellm_core_utils/litellm_logging.py Copies dictionary metadata before merging logging fields, preventing top-level mutation of caller-owned request data
tests/test_litellm/litellm_core_utils/test_get_litellm_params.py Adds focused regression coverage for fallback precedence, invalid types, identifier derivation, and copy isolation
tests/test_litellm/litellm_core_utils/test_litellm_logging.py Verifies malformed metadata handling and confirms that logging merges do not mutate the caller’s dictionary

Reviews (4): Last reviewed commit: "fix(logging): fall back to litellm_metad..." | Re-trigger Greptile

greptile-apps[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 2 additional findings.

Open in Devin Review

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 78aaa52. Configure here.

@yucheng-berri
yucheng-berri force-pushed the litellm_lit5137_langfuse_responses_trace_metadata branch from a33983d to 4a87735 Compare August 6, 2026 18:31
@yucheng-berri
yucheng-berri force-pushed the litellm_lit5137_bridge_metadata_fallback branch from 78aaa52 to 070877c Compare August 6, 2026 18:39
devin-ai-integration[bot]

This comment was marked as resolved.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 070877c. Since your last review the test docstrings were dropped and the branch was rebased onto the updated #35866

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 070877c. Configure here.

@yucheng-berri
yucheng-berri force-pushed the litellm_lit5137_bridge_metadata_fallback branch from 070877c to c884213 Compare August 6, 2026 18:53
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head c884213. Fixes a TypeError when a caller sends metadata null alongside litellm_metadata

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please re-check head c884213. The docstring finding looks stale, the diff has no added docstring lines

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c884213. Configure here.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +588 to 592
if isinstance(kwargs.get("metadata"), dict):
base_litellm_params["metadata"] = kwargs["metadata"].copy()
if "litellm_metadata" in kwargs and isinstance(kwargs["litellm_metadata"], dict):
base_litellm_params["litellm_metadata"] = kwargs["litellm_metadata"]
if "metadata" not in base_litellm_params:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Requests that send an empty metadata object still lose their tracking information in logs

An empty metadata object sent by the caller is treated as real metadata (isinstance(kwargs.get("metadata"), dict) at litellm/litellm_core_utils/litellm_logging.py:588-593) instead of falling back to the tracking data the proxy stored separately, so callbacks and spend logs for those requests still see nothing.
Impact: On routes like batches, videos, and vector stores, a client that sends "metadata": {} gets logs and spend records without the proxy key/session information, while the same request with no metadata field at all is logged correctly.

Inconsistent emptiness check between the two fallback sites

This PR changed get_litellm_params to fall back when metadata is falsy (if not metadata and _litellm_metadata_dict at litellm/litellm_core_utils/get_litellm_params.py:119), but update_from_kwargs still keys the backfill off key presence: {} is a dict, so base_litellm_params["metadata"] = {} is set and the if "metadata" not in base_litellm_params guard at litellm/litellm_core_utils/litellm_logging.py:592 skips the litellm_metadata copy. Callers that also pass metadata inside litellm_params (e.g. litellm/responses/main.py:1092) recover via the merge below, but callers that don't (litellm/batches/main.py:193, litellm/videos/main.py:229, litellm/vector_stores/main.py:230) end up with an empty metadata dict in logging_obj.litellm_params.

Using truthiness in both places (if not base_litellm_params.get("metadata")) makes the two fallback sites agree.

(Refers to lines 588-593)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yucheng-berri
yucheng-berri force-pushed the litellm_lit5137_bridge_metadata_fallback branch from c884213 to 2146318 Compare August 6, 2026 23:53
Base automatically changed from litellm_lit5137_langfuse_responses_trace_metadata to litellm_internal_staging August 7, 2026 00:07
get_litellm_params returned metadata=None whenever only litellm_metadata was
supplied, which overwrote the fallback function_setup had already applied and
left litellm_params["metadata"] empty. On the /v1/responses
completion-transformation bridge, used by every provider without a native
Responses API config, and on /v1/messages, that discarded the caller's trace
fields a second time after the proxy had promoted them.

Resolve metadata to a copy of litellm_metadata when metadata is empty, guarding
on isinstance because the proxy leaves an unparseable litellm_metadata string in
place and a null metadata would otherwise suppress the backfill and break the
merge. update_from_kwargs copies rather than aliases for the same reason: on
these routes it is handed the caller's provider-bound dict and would otherwise
write user_api_key_auth into it.
@yucheng-berri
yucheng-berri force-pushed the litellm_lit5137_bridge_metadata_fallback branch from 2146318 to 749b823 Compare August 7, 2026 00:16
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 749b823. #35866 merged, so this was rebased onto staging and is now just the get_litellm_params and update_from_kwargs changes

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 749b823. Configure here.

@yucheng-berri
yucheng-berri merged commit f3f72c4 into litellm_internal_staging Aug 7, 2026
79 of 80 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit5137_bridge_metadata_fallback branch August 7, 2026 00:32
@codspeed-hq

codspeed-hq Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5137_bridge_metadata_fallback (749b823) with litellm_internal_staging (b66d4e6)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (988ee8b) during the generation of this report, so 60bde31 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants