fix(logging): wholesale-clear response provider_specific_fields when message logging is off
Some checks failed
Unit Tests: Security / security (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / schema-migration (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled

This commit is contained in:
michelligabriele 2026-06-09 02:39:34 +02:00
parent e223a02c8e
commit 1b766e1c34
No known key found for this signature in database
2 changed files with 54 additions and 44 deletions

View file

@ -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)

View file

@ -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