fix(responses): restore isinstance guards dropped in guardrail masking loops

The Any-cleanup pass on streaming_iterator.py collapsed the two-step
"read into a variable, then isinstance-guard before iterating" pattern
for output_item["content"]/["summary"] into direct one-liner for-loops,
dropping the isinstance(..., list) checks in _unmask_response_event and
_mask_response_completed. A response.completed event whose content or
summary field is a truthy non-iterable value (malformed upstream
payload) then raised TypeError instead of being skipped. Restores the
original guards and adds a regression test.

Also fixes two follow-on regressions surfaced by this and the earlier
typing changes: _get_default_team_param in team_endpoints.py was
returning a tuple instead of a list for list-shaped defaults, breaking
callers that compare against list literals; and typing
key_management_endpoints.py's prisma_client parameter as Optional
required a None-guard in _validate_update_key_data that pushed its
McCabe complexity 2 over the ratcheted budget, suppressed with a
reasoned noqa since the guard itself is a correctness fix, not
something to remove.

Re-ran make lint-budget-update to reflect the net state after these
fixes.
This commit is contained in:
mateo-berri 2026-07-31 18:10:32 +00:00
parent 375d7199bf
commit f4b77e0458
No known key found for this signature in database
7 changed files with 90 additions and 42 deletions

View file

@ -1,9 +1,9 @@
{
"reportAny": {
"limit": 31306
"limit": 30112
},
"reportArgumentType": {
"limit": 2641
"limit": 2633
},
"reportAssignmentType": {
"limit": 329
@ -24,7 +24,7 @@
"limit": 42
},
"reportExplicitAny": {
"limit": 10089
"limit": 9839
},
"reportFunctionMemberAccess": {
"limit": 11
@ -54,10 +54,10 @@
"limit": 0
},
"reportMissingParameterType": {
"limit": 5858
"limit": 5836
},
"reportMissingTypeArgument": {
"limit": 15833
"limit": 15777
},
"reportMissingTypeStubs": {
"limit": 41
@ -99,19 +99,19 @@
"limit": 0
},
"reportUnknownArgumentType": {
"limit": 45345
"limit": 45301
},
"reportUnknownLambdaType": {
"limit": 113
},
"reportUnknownMemberType": {
"limit": 40390
"limit": 40216
},
"reportUnknownParameterType": {
"limit": 20299
"limit": 20221
},
"reportUnknownVariableType": {
"limit": 31974
"limit": 31834
},
"reportUnnecessaryCast": {
"limit": 177
@ -123,7 +123,7 @@
"limit": 7
},
"reportUnnecessaryIsInstance": {
"limit": 1204
"limit": 1202
},
"reportUntypedBaseClass": {
"limit": 165

View file

@ -2311,7 +2311,7 @@ async def _validate_mcp_servers_for_key_update(
return normalized_object_permission
async def _validate_update_key_data(
async def _validate_update_key_data( # noqa: C901 # prisma_client None-guard needed now that it's typed Optional
data: UpdateKeyRequest,
existing_key_row: LiteLLM_VerificationToken,
user_api_key_dict: UserAPIKeyAuth,

View file

@ -498,7 +498,9 @@ def _get_default_team_param(field: str) -> _DefaultTeamParamValue | None:
if raw_value is None:
return None
if isinstance(raw_value, list):
return tuple(v.value if hasattr(v, "value") else v for v in raw_value)
# mutable-ok: callers compare this against list literals (e.g. team_member_permissions
# equality checks), so it must stay a real list, not a tuple, to match prior behavior.
return [v.value if hasattr(v, "value") else v for v in raw_value]
return _DEFAULT_TEAM_PARAM_ADAPTER.validate_python(raw_value)

View file

@ -1687,7 +1687,10 @@ class ResponsesWebSocketStreaming:
for output_item in response_obj.get("output") or []:
if not isinstance(output_item, dict):
continue
for content_block in output_item.get("content") or []:
content = output_item.get("content") or []
if not isinstance(content, list):
continue
for content_block in content:
if not isinstance(content_block, dict):
continue
text = content_block.get("text")
@ -1750,21 +1753,26 @@ class ResponsesWebSocketStreaming:
if masked_args != arguments:
output_item["arguments"] = masked_args
modified = True
for summary_block in output_item.get("summary") or []:
if not isinstance(summary_block, dict):
continue
summary_text = summary_block.get("text")
if isinstance(summary_text, str):
masked_summary = await cb.check_pii(
text=summary_text,
output_parse_pii=False,
presidio_config=presidio_config,
request_data=self.request_data,
)
if masked_summary != summary_text:
summary_block["text"] = masked_summary
modified = True
for content_block in output_item.get("content") or []:
summary = output_item.get("summary") or []
if isinstance(summary, list):
for summary_block in summary:
if not isinstance(summary_block, dict):
continue
summary_text = summary_block.get("text")
if isinstance(summary_text, str):
masked_summary = await cb.check_pii(
text=summary_text,
output_parse_pii=False,
presidio_config=presidio_config,
request_data=self.request_data,
)
if masked_summary != summary_text:
summary_block["text"] = masked_summary
modified = True
content = output_item.get("content") or []
if not isinstance(content, list):
continue
for content_block in content:
if not isinstance(content_block, dict):
continue
text = content_block.get("text")

View file

@ -1,6 +1,6 @@
{
"ANN001": {
"limit": 3107
"limit": 3085
},
"ANN002": {
"limit": 69
@ -9,13 +9,13 @@
"limit": 831
},
"ANN201": {
"limit": 2135
"limit": 2129
},
"ANN202": {
"limit": 943
"limit": 941
},
"ANN204": {
"limit": 723
"limit": 721
},
"ANN205": {
"limit": 127
@ -24,7 +24,7 @@
"limit": 130
},
"ANN401": {
"limit": 1931
"limit": 1773
},
"ASYNC230": {
"limit": 14
@ -135,7 +135,7 @@
"limit": 30
},
"PERF401": {
"limit": 140
"limit": 136
},
"PERF402": {
"limit": 9
@ -222,7 +222,7 @@
"limit": 38
},
"RET504": {
"limit": 712
"limit": 704
},
"RUF010": {
"limit": 874
@ -273,7 +273,7 @@
"limit": 6
},
"SIM114": {
"limit": 110
"limit": 108
},
"SIM115": {
"limit": 5
@ -324,7 +324,7 @@
"limit": 879
},
"UP006": {
"limit": 12075
"limit": 11949
},
"UP007": {
"limit": 2526
@ -360,9 +360,9 @@
"limit": 4
},
"UP037": {
"limit": 104
"limit": 102
},
"UP045": {
"limit": 17797
"limit": 17781
}
}

View file

@ -1084,6 +1084,44 @@ class TestNativeWebSocketGuardrails:
assert handler._unmask_response_event(event) == event
assert await handler._mask_response_completed(event) == event
@pytest.mark.asyncio
async def test_completed_event_with_non_list_content_and_summary_passes_through(
self,
):
"""Regression test: an output item whose "content"/"summary" is a truthy
non-iterable value (malformed upstream payload) must be skipped, not raise."""
from unittest.mock import MagicMock
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
class Guardrail:
def get_presidio_settings_from_request_data(self, request_data):
return None
def _unmask_pii_text(self, text, pii_tokens):
return text
event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [{"type": "message", "content": 42, "summary": True}]
},
}
)
guardrail = Guardrail()
handler = ResponsesWebSocketStreaming(
websocket=MagicMock(),
backend_ws=MagicMock(),
logging_obj=MagicMock(),
request_data={"metadata": {"pii_tokens": {"<TOKEN_1>": "secret"}}},
guardrail_callbacks=[guardrail],
output_guardrail_callbacks=[guardrail],
)
assert handler._unmask_response_event(event) == event
assert await handler._mask_response_completed(event) == event
@pytest.mark.asyncio
async def test_output_masking_suppresses_delta_without_calling_presidio(self):
import json

View file

@ -1,9 +1,9 @@
{
"LIT001": {
"limit": 23151
"limit": 22947
},
"LIT002": {
"limit": 27409
"limit": 27375
},
"LIT003": {
"limit": 292
@ -24,6 +24,6 @@
"limit": 1004
},
"LIT009": {
"limit": 2466
"limit": 2450
}
}