fix(logging): redact tool call arguments to valid JSON and preserve null content - #38182
Conversation
…ull content Resolves LIT-6102
Greptile SummaryThis PR preserves null assistant content during redaction and makes redacted tool calls replayable without changing the stored sentinel
Confidence Score: 5/5The PR appears safe to merge No blocking failure remains
|
| Filename | Overview |
|---|---|
| litellm/constants.py | Defines the shared in-memory placeholder alongside the existing redaction sentinel, completing the requested centralization |
| litellm/litellm_core_utils/redact_messages.py | Preserves null content while consistently writing the centralized redaction sentinel to tool-call arguments |
| litellm/responses/litellm_completion_transformation/session_handler.py | Replaces stored redaction sentinels with valid empty JSON only in replayed in-memory messages |
| litellm/responses/litellm_completion_transformation/transformation.py | Normalizes redacted Responses API function-call arguments before conversion to chat-completion messages |
| litellm/integrations/langfuse/langfuse_otel.py | Uses safe JSON parsing so redacted arguments do not prevent Langfuse observation output |
| tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py | Adds replay regression coverage using the previously accepted test seam |
| tests/test_litellm/litellm_core_utils/test_redact_messages.py | Covers multi-tool redaction and preservation of null content across object and dictionary response forms |
Reviews (6): Last reviewed commit: "fix(logging): keep the redaction sentine..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…nd satisfy test-quality gate
…d by basedpyright
|
bugbot run |
There was a problem hiding this comment.
✅ 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 bba6e8d. Configure here.
…ts and preserve null output text
|
bugbot run |
There was a problem hiding this comment.
✅ 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 2e986da. Configure here.
|
bugbot run |
There was a problem hiding this comment.
✅ 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 2e986da. Configure here.
ba8d8b6
into
litellm_internal_staging
…erriAI#38265, BerriAI#37962, and BerriAI#37969 - test_custom_callback_input: audio redaction assertion expects None content (redaction leaves None untouched, gpt-audio-1.5 returns content=None) - local_testing conftest: drain GLOBAL_LOGGING_WORKER in isolate_litellm_state teardown so mocked-router tests stop leaking pending logging tasks into test_gcs_pub_sub - test_together_ai: tools is always a supported param now; only response_format is gated by function-calling support - test_keys: /team/new omits models instead of sending null (422), so the key's team really exists and auth no longer raises TeamNotFoundError - test_team_delete_member_add_race: per-test unique team and user ids so xdist workers sharing one Postgres stop deleting each other's team mid-race
TLDR
Problem this solves:
turn_off_message_loggingstores tool call arguments as invalid JSONredacted-by-litellmcontent for tool-only turnsprevious_response_idfails with 500How it solves it:
redacted-by-litellmmarker inarguments, so a redacted call stays distinguishable in the logs{}in memory while rebuilding the provider request during session replay, so converters can parse itnullassistant contentnullinstead of inventing text, on chat choices and on Responses API output items alikeUser Flow
Before: a developer whose proxy has
turn_off_message_logging: truecannot continue any Responses API session that contains a tool calltoolsand get back afunction_callwith real argumentsprevious_response_idand a new questionUnable to convert openai tool calls ... Expecting value: line 1 column 1, because the stored turn now holds"arguments": "redacted-by-litellm"redacted-by-litellmcontent where the model actually sent none, and the stored tool call arguments are unparseable for anything reading the logAfter: the same follow-up works and the stored log stays redacted
toolsand get back the samefunction_callwith real argumentsprevious_response_idreturns HTTP 200 with the model's answer: the marker is swapped for{}only in memory while building the provider requestRelevant issues
Linear ticket
Resolves LIT-6102
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Shared setup: proxy with
turn_off_message_logging: true,store_prompts_in_spend_logs: true, alangfuse_otelcallback, Postgres attached, real Gemini and Anthropic upstreams.TOOLSis oneget_weatherfunction tool in the endpoint's native shapeBefore (a9c7b84)
case 1: /v1/chat/completions tool call, live response untouched
curl -s $PROXY/v1/chat/completions -d '{"model":"gemini-flash","messages":[{"role":"user","content":"Weather in Berlin? You must use the get_weather tool."}],"tools":TOOLS}'"content": null, "tool_calls": [{"function": {"arguments": "{\"city\": \"Berlin\"}", "name": "get_weather"}}]case 2: /v1/chat/completions stream=true tool call, live stream untouched
"stream": true"function":{"arguments":"{\"city\": \"Tokyo\"}","name":"get_weather"}case 3: /v1/responses tool call, live response untouched
curl -s $PROXY/v1/responses -d '{"model":"gemini-flash","input":"Weather in Berlin? You must use the get_weather tool.","tools":TOOLS}'"function_call": {"name": "get_weather", "arguments": "{\"city\": \"Berlin\"}"}case 4: what the spend log stored
SELECT response FROM "LiteLLM_SpendLogs" ORDER BY "startTime" DESC LIMIT 3;then count the redacted fields3 "arguments": "redacted-by-litellm"and3 "content": "redacted-by-litellm"; the arguments are invalid JSON and the content is invented (the model sent none)case 5: replay the session to the same model
curl -s $PROXY/v1/responses -d '{"model":"gemini-flash","previous_response_id":"<id from case 3>","input":"and tomorrow?"}'litellm.APIConnectionError: Unable to convert openai tool calls={'content': 'redacted-by-litellm', ... 'arguments': 'redacted-by-litellm' ...}case 6: replay the session cross-provider
"model": "claude-haiku"AnthropicException - Failed to parse tool call arguments for tool 'get_weather' ... Expecting value: line 1 column 1 (char 0). Arguments: redacted-by-litellmcase 7: replay a session stored by the old release
After (92ee7e3b57)
case 1: /v1/chat/completions tool call, live response untouched
"content": null, "tool_calls": [{"function": {"arguments": "{\"city\": \"Berlin\"}", "name": "get_weather"}}]case 2: /v1/chat/completions stream=true tool call, live stream untouched
"function":{"arguments":"{\"city\": \"Tokyo\"}","name":"get_weather"}case 3: /v1/responses tool call, live response untouched
"function_call": {"name": "get_weather", "arguments": "{\"city\": \"Berlin\"}"}case 4: what the spend log stored
3 "arguments": "redacted-by-litellm"and3 "content": null; the stored row keeps the explicit redaction marker and no content is inventedcase 5: replay the session to the same model
{}in memory while building the provider request, so the converter parses itcase 6: replay the session cross-provider
case 7: replay a session stored by the old release
previous_response_idof a session written by the unfixed buildLangfuse destination A/B (real cloud.langfuse.com, base a9c7b84 vs head 2e986da)
Same two proxies with a
langfuse_otelcallback exporting tohttps://cloud.langfuse.com; every value below was read back through the Langfuse public API, no local collectorcase 8: /v1/chat/completions tool call reaches Langfuse on both sides
get_weathercall on each proxycase 9: native /v1/responses tool call, the branch this PR guards
curl -s $PROXY/v1/responses -d '{"model":"gpt-4o-mini","input":"Weather in Berlin? You must use the get_weather tool.","tools":TOOLS,"tool_choice":"required"}'on each proxy89bd360f...): the observation has"output": nulland the proxy log shows the crash that ate it:2b0f5d14...): the observation output arrives intact, and itsidmatches thefunction_callid the client received, proving it is the same call:"arguments": "redacted-by-litellm", no invented text), confirming the stored format is untouched and this run also exercised the Responses-shape redaction writer liveType
🐛 Bug Fix
Caveats (if any)
argumentskeep theredacted-by-litellmmarker, which is not valid JSON; readers thatjson.loadsstored arguments must keep handling that, as they did before this PRfunctionfield, so the replay normalizer skips them via agetattrguardBehavior changes
redacted-by-litellmmarker, so log readers can still tell a redacted call from a genuinely empty onecontent: nullinstead of the sentinel string, and Responses API output items withtext: nullkeep it too; Langfuse and OTEL payloads omit those fields for such turnsredacted-by-litellmarguments for{}in memory while building the provider request; the stored row is untouched{}instead of the marker; that never shipped, and external log readers keep seeing the marker exactly as before