From 1b766e1c344485f2941e0d4a639be470bfef6932 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Tue, 9 Jun 2026 02:39:34 +0200 Subject: [PATCH] fix(logging): wholesale-clear response provider_specific_fields when message logging is off --- litellm/litellm_core_utils/redact_messages.py | 45 +++++++--------- .../test_redact_messages.py | 53 ++++++++++++------- 2 files changed, 54 insertions(+), 44 deletions(-) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 52a9fc1e978..89212f7e7be 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -63,33 +63,26 @@ def _redact_choice_content(choice): ) -# Keys inside Message.provider_specific_fields that duplicate reasoning -# content already covered by the flat-field scrub. Anthropic populates -# "thinking_blocks" / "reasoning_content" (the latter also carries the -# raw "signature" blob); Bedrock converse populates "reasoningContentBlocks". -# -# This is an intentional allowlist: we redact known reasoning-bearing keys -# rather than wiping all of provider_specific_fields (which also carries -# benign provider metadata). Any future provider field that embeds raw -# reasoning or prompt content must be added here to be redacted. -_PROVIDER_SPECIFIC_REASONING_KEYS = ( - "reasoning_content", - "thinking_blocks", - "reasoningContentBlocks", -) +def _redact_provider_specific_fields(psf): + """Wholesale-clear a response Message/Delta ``provider_specific_fields`` dict. - -def _redact_provider_specific_fields(psf, redacted_str: str = "redacted-by-litellm"): - """Scrub reasoning-content duplicates inside Message.provider_specific_fields.""" + This is a provider-NATIVE grab-bag. Beyond the reasoning duplicates + (``reasoning_content``/``thinking_blocks``/``reasoningContentBlocks``), + providers stash output-bearing content under ~20 other keys an allowlist can + never enumerate: Anthropic ``citations``/``web_search_results``/ + ``tool_results``/``code_interpreter_results``/``compaction_blocks``, Bedrock + ``citationsContent``, Gemini ``thought_signatures``/ + ``server_side_tool_invocations``, Cohere ``tool_plan``, MCP + ``mcp_call_results``, RAG ``search_results``, and so on — the same + unenumerable shape as the provider-native request body. Redaction only ever + runs on the logging copy, and no consumer reads a named key off that copy + (streaming reassembly and multi-turn replay read the untouched live response + / request history instead), so we clear the whole dict to close the class + instead of chasing keys one provider at a time. + """ if not isinstance(psf, dict): return - for key in _PROVIDER_SPECIFIC_REASONING_KEYS: - if key not in psf: - continue - if key == "reasoning_content": - psf[key] = redacted_str - else: - psf[key] = None + psf.clear() def _redact_responses_api_output(output_items): @@ -276,7 +269,7 @@ def _redact_model_response_dict_choices(choices, redacted_str: str): if "audio" in choice["message"]: choice["message"]["audio"] = None _redact_provider_specific_fields( - choice["message"].get("provider_specific_fields"), redacted_str + choice["message"].get("provider_specific_fields") ) elif "delta" in choice and isinstance(choice["delta"], dict): choice["delta"]["content"] = redacted_str @@ -287,7 +280,7 @@ def _redact_model_response_dict_choices(choices, redacted_str: str): if "audio" in choice["delta"]: choice["delta"]["audio"] = None _redact_provider_specific_fields( - choice["delta"].get("provider_specific_fields"), redacted_str + choice["delta"].get("provider_specific_fields") ) else: _redact_choice_content(choice) diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py index a4e20493b39..e9f8eb874de 100644 --- a/tests/test_litellm/litellm_core_utils/test_redact_messages.py +++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py @@ -398,9 +398,10 @@ class TestPerformRedaction: ) def test_redacts_provider_specific_fields_on_object_choices(self): - """Anthropic reasoning content lives in both message.thinking_blocks - AND message.provider_specific_fields.thinking_blocks. The flat field - is scrubbed; ensure the duplicate is too (plus the signature blob).""" + """provider_specific_fields is a provider-native grab-bag carrying + output content (reasoning, citations, web-search/tool/code-interpreter + results, compaction). Redaction wholesale-clears it — every key gone, + including otherwise-benign metadata — so no content can slip through.""" result = litellm.ModelResponse( choices=[ litellm.Choices( @@ -420,6 +421,12 @@ class TestPerformRedaction: "signature": "RAW_SIGNATURE_BLOB", } ], + "citations": ["CANARY_CITATION"], + "web_search_results": ["CANARY_SEARCH"], + "tool_results": ["CANARY_TOOL"], + "code_interpreter_results": ["CANARY_CODE"], + "compaction_blocks": ["CANARY_COMPACTION"], + "container": {"id": "benign-container-id"}, }, ) ) @@ -429,12 +436,15 @@ class TestPerformRedaction: redacted = perform_redaction({}, result) psf = redacted.choices[0].message.provider_specific_fields - assert psf["reasoning_content"] == "redacted-by-litellm" - assert psf["thinking_blocks"] is None + assert psf == {} + assert "CANARY" not in str(psf) + assert "RAW_SIGNATURE_BLOB" not in str(psf) - def test_redacts_provider_specific_fields_bedrock_reasoning_content_blocks(self): - """Bedrock converse populates provider_specific_fields.reasoningContentBlocks. - Covered by code symmetry — assert it via the dict path.""" + def test_redacts_provider_specific_fields_multi_provider_dict_path(self): + """The dict path (standard_logging_object) wholesale-clears the same + grab-bag across providers — Bedrock reasoning/citations, Gemini thought + signatures, MCP tool results, RAG search results — not just the old + reasoning keys.""" details = { "standard_logging_object": { "response": { @@ -445,13 +455,13 @@ class TestPerformRedaction: "reasoning_content": "flat reasoning", "provider_specific_fields": { "reasoningContentBlocks": [ - { - "reasoningText": { - "text": "BEDROCK_THOUGHT", - "signature": "sig", - } - } + {"reasoningText": {"text": "BEDROCK_THOUGHT"}} ], + "citationsContent": ["CANARY_BEDROCK_CITE"], + "thought_signatures": ["CANARY_SIGNATURE"], + "server_side_tool_invocations": ["CANARY_SSTI"], + "mcp_call_results": ["CANARY_MCP"], + "search_results": ["CANARY_RAG"], }, } } @@ -464,10 +474,14 @@ class TestPerformRedaction: choice = details["standard_logging_object"]["response"]["choices"][0] psf = choice["message"]["provider_specific_fields"] - assert psf["reasoningContentBlocks"] is None + assert psf == {} + assert "CANARY" not in str(psf) + assert "BEDROCK_THOUGHT" not in str(psf) def test_redacts_provider_specific_fields_on_dict_delta(self): - """Streaming-style dict path: choice['delta']['provider_specific_fields'].""" + """Streaming-style dict path: choice['delta']['provider_specific_fields'] + is wholesale-cleared too (streaming-only keys like compaction_delta + included).""" result = { "choices": [ { @@ -480,6 +494,8 @@ class TestPerformRedaction: {"type": "thinking", "thinking": "STREAMED_THOUGHT"} ], "reasoning_content": "psf streamed reasoning", + "compaction_delta": {"content": "CANARY_COMPACTION_DELTA"}, + "citation": {"text": "CANARY_CITATION_DELTA"}, }, } } @@ -489,8 +505,9 @@ class TestPerformRedaction: redacted = perform_redaction({}, result) psf = redacted["choices"][0]["delta"]["provider_specific_fields"] - assert psf["thinking_blocks"] is None - assert psf["reasoning_content"] == "redacted-by-litellm" + assert psf == {} + assert "CANARY" not in str(psf) + assert "STREAMED_THOUGHT" not in str(psf) def test_redact_provider_specific_fields_is_safe_when_absent(self): """Messages without provider_specific_fields (the common case) must