Skip to content

fix(logging): bind litellm_metadata by reference in function_setup so guardrail info reaches spend logs - #35292

Merged
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4512_messages_guardrail_info
Jul 31, 2026
Merged

fix(logging): bind litellm_metadata by reference in function_setup so guardrail info reaches spend logs#35292
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4512_messages_guardrail_info

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • /v1/messages spend logs show guardrail_information null and applied_guardrails empty
  • Guardrails UI panels never update for those requests
  • /v1/chat/completions with the same key and guardrail works fine

How it solves it:

  • function_setup now binds litellm_metadata by reference, not a copy
  • guardrail writes after logging-object creation become visible to spend logs
  • matches what update_from_kwargs already does on the same dict

Relevant issues

Linear ticket

Resolves LIT-4512

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)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

All evidence is from a live proxy (Postgres backed) with a litellm_content_filter pre-call guardrail attached to a freshly generated virtual key, hitting real provider APIs (no mocks), then reading GET /spend/logs. Before runs are at base commit 8e287652c6, after runs are at the fix commit (same tree as head 899ed67860, code-identical rebase of 62411488cf)

One nuance the evidence has to work around: some provider handlers rebind litellm_metadata into the logging object later in the call (Logging.update_from_kwargs in the shared HTTP handler), which masks this bug on those paths even without the fix. The paths with no later rebind are the OpenAI-SDK ones; in particular, /v1/messages with an OpenAI model routes through the Responses API bridge by default (_should_route_to_responses_api), so the /v1/responses run below exercises the same broken path the reported /v1/messages traffic takes. The OpenAI account ran out of credits mid-capture (the after run below consumed the last of them; a direct probe now returns "You have no credits remaining"), so a same-provider /v1/messages differential could not be replayed; happy to re-capture one on a funded OpenAI key

1) Differential on the broken path, real OpenAI gpt-4o-mini via /v1/responses

Before, base 8e287652c6; the request succeeds and costs real money, the spend log has nothing for the guardrail:

$ curl -sS -X POST http://127.0.0.1:20512/v1/responses -H "Authorization: Bearer $KEY" \
    -d '{"model":"gpt-4o-mini","input":"hello pass responses"}'
{"id":"resp_Mc-syf-...","status":"completed"}

$ curl -sS "http://127.0.0.1:20512/spend/logs?limit=15" -H "Authorization: Bearer sk-1234..." | jq ...
{"call_type":"aresponses","applied":[],"gi_status":null}

After, at the fix:

{"call_type":"aresponses","spend":0.000768,"applied":["pam-ethical-request"],"gi_status":["success"]}

2) /v1/messages and /v1/chat/completions with the fix, real Gemini through an OpenAI-SDK path

Model openai/gemini-2.5-flash against Google's OpenAI-compatible endpoint with use_chat_completions_url_for_anthropic_messages: true, so /v1/messages goes through the completion adapter on the OpenAI SDK client:

== /v1/messages pass ==
{"id":"se1rav2jBsna_uMPzdGnm...","content":["hello"]}
== /v1/chat/completions pass ==
{"content":"hello"}

== spend logs ==
{"call_type":"anthropic_messages","applied":["pam-ethical-request"],"gi":[{"guardrail_name":"pam-ethical-request","guardrail_status":"success","guardrail_mode":"pre_call"}]}
{"call_type":"acompletion","applied":["pam-ethical-request"],"gi":[{"guardrail_name":"pam-ethical-request","guardrail_status":"success","guardrail_mode":"pre_call"}]}

(The same two legs also pass on base code because the chat adapter is one of the rebind-masked paths; the regression test in this PR plus run 1 above pin the unmasked behavior)

3) Real Bedrock (claude-haiku-4-5), all three endpoints at the fix

$ ALIAS=after-bedrock bash evidence.sh
== /v1/chat/completions ==  {"id":"chatcmpl-57498699-...","content":"hello"}
== /v1/messages ==          {"id":"msg_bdrk_01EdVj6K2BPJj9W4ftbY2p7Z","content":["hello"]}
== /v1/responses ==         {"status":"completed","text":["hello"]}
== spend logs ==
{"call_type":"aresponses","spend":0.0000352,"applied":["pam-ethical-request"],"gi":[{"guardrail_status":"success",...}]}
{"call_type":"anthropic_messages","spend":0.0000352,"applied":["pam-ethical-request"],"gi":[{"guardrail_status":"success",...}]}
{"call_type":"acompletion","spend":0.0000352,"applied":["pam-ethical-request"],"gi":[{"guardrail_status":"success",...}]}

A streaming /v1/messages request on the same rig also persists both fields

4) Admin UI at the fix: /v1/messages request now shows the Guardrails panel

The /v1/messages (anthropic_messages) success row on the bundled dashboard Logs page, with the Guardrails and Policy Compliance panel, request lifecycle showing the passed pre-call guardrail, and evaluation details:

messages row guardrail panel

Same key on /v1/chat/completions for comparison:

chat row guardrail panel

Type

🐛 Bug Fix

Changes

function_setup (litellm/utils.py) stored kwargs["litellm_metadata"] into the logging object's litellm_params via .copy() while storing kwargs["metadata"] by reference. The proxy creates the logging object before pre-call guardrails run (common_request_processing.py, deliberately, so rejected requests still get logged), so on routes that carry proxy state in litellm_metadata (/v1/messages, /v1/responses, batches, files) every guardrail write that happens after setup (standard_logging_guardrail_information, applied_guardrails) landed in the request dict but never in the snapshot the spend-log payload is built from. /v1/chat/completions only worked because its metadata dict is aliased

The fix binds litellm_metadata by reference, the same shape Logging.update_from_kwargs (litellm_core_utils/litellm_logging.py line 600) has always used on this exact dict, and the same aliasing metadata has always had on the chat path. The .copy() being removed was introduced by a review-bot suggested edit (17804edc78) with no test or issue behind it. Line 1057's fallback copy into litellm_params["metadata"] is intentionally kept: it preserves metadata is not litellm_metadata, which an existing test asserts, and merge_litellm_metadata fills the guardrail keys from the live litellm_metadata since they never exist in the stale fallback

The regression test extends the existing function_setup litellm_metadata suite in tests/test_litellm/litellm_core_utils/test_litellm_logging.py (the mapped suite for this behavior; litellm/utils.py has no dedicated mirror file and the sibling tests live here). It reproduces the production sequence (setup, then a bucket write via get_or_create_metadata_bucket) and fails if the .copy() is reintroduced

Behavior changes

Spend logs and logging callbacks on litellm_metadata routes now receive guardrail_information and applied_guardrails that were previously dropped; that is the fix itself. The logging object's litellm_params["litellm_metadata"] is now the same dict as the request data's bucket, identical to the long-standing chat-path semantics; a two-pass regression sweep found no consumer that relied on the snapshot (the one snapshot-dependent site, the router's mid-stream fallback, already deep-copies both metadata dicts). Pre-existing gaps deliberately not covered here are tracked in LIT-5022 (failure-path spend logs drop guardrail_information for blocked requests on every endpoint, including chat) and LIT-5023 (litellm_metadata bucket hardening follow-ups)

Why the aliasing is a pre-existing pattern, not a new one

Four pieces of merged code establish that binding these metadata dicts by reference is the codebase's long-standing convention, and that the .copy() this PR removes was the outlier

  1. The chat path has bound metadata by reference since January 2024 (a299ac2328), on the line directly above the one changed here, untouched by this PR. Every /v1/chat/completions request that ever logged guardrail info depended on that aliasing
# litellm/utils.py:1048-1051
if "metadata" in kwargs:
    litellm_params["metadata"] = kwargs["metadata"]                     # reference since 2024-01
if "litellm_metadata" in kwargs and isinstance(kwargs["litellm_metadata"], dict):
    litellm_params["litellm_metadata"] = kwargs["litellm_metadata"]     # this PR makes it match
  1. Logging.update_from_kwargs already uses the identical shape on the identical dict, reference for litellm_metadata with a .copy() only for the metadata fallback, and it runs on the live /v1/messages success path (llm_http_handler.py:2031). Production traffic has been flowing through this exact aliasing all along, which is why many routes behave identically before and after this PR; the fix extends the aliasing to the window between function_setup and the provider handler, where guardrails run
# litellm/litellm_core_utils/litellm_logging.py:597-602
if "litellm_metadata" in kwargs and isinstance(kwargs["litellm_metadata"], dict):
    base_litellm_params["litellm_metadata"] = kwargs["litellm_metadata"]      # reference
    if "metadata" not in base_litellm_params:
        base_litellm_params["metadata"] = kwargs["litellm_metadata"].copy()   # copy fallback
  1. Merged code documents the alias as a contract it relies on (feat(spend): track prompt compression saved tokens in daily spend aggregates #33810, merged 2026-07-18): litellm/integrations/compression_interception/handler.py updates the existing dict in place "because the proxy and the logging object hold references to the same object; replacing it would orphan writes made through those references"

  2. The exact line changed here was introduced as a reference in 8c3d6db482 and flipped to .copy() hours later in 17804edc78, an applied review-bot suggestion with no test or issue behind it. This PR restores the line's original form, which is also the form of its three siblings above

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

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR keeps litellm_metadata live after logging setup so subsequent guardrail updates reach spend logs and callbacks.

  • Replaces the copied metadata snapshot with a shared dictionary reference in function_setup.
  • Adds regression coverage for post-setup guardrail information and applied-guardrail updates.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/utils.py Binds the logging object's litellm_metadata to the request dictionary while preserving the separate fallback metadata copy.
tests/test_litellm/litellm_core_utils/test_litellm_logging.py Adds focused regression coverage proving that post-setup guardrail mutations remain visible when constructing standard logging metadata.

Reviews (2): Last reviewed commit: "fix(logging): bind litellm_metadata by r..." | Re-trigger Greptile

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@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 bugs or issues to report.

Open in Devin Review

@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 6241148. Configure here.

@codecov

codecov Bot commented Jul 31, 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_lit4512_messages_guardrail_info branch from 6241148 to 899ed67 Compare July 31, 2026 00:39
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 899ed67

@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 899ed67. Configure here.

@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit4512_messages_guardrail_info (899ed67) with litellm_internal_staging (6e26087)1

Open in CodSpeed

Footnotes

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

@mateo-berri mateo-berri 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.

LGTM. Thanks!

@mateo-berri
mateo-berri merged commit 81ff7cb into litellm_internal_staging Jul 31, 2026
83 checks passed
@mateo-berri
mateo-berri deleted the litellm_lit4512_messages_guardrail_info branch July 31, 2026 02:53
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