From 3ab30b011e0cae6d5e67b47cd2b200c1e1efc8db Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Mon, 6 Jul 2026 10:18:41 +0530 Subject: [PATCH 01/12] fix(a2a): add kind and blocking configuration to A2A request/message templates, serialize data parts, and add regression tests --- litellm/llms/a2a/chat/transformation.py | 1 + litellm/llms/a2a/common_utils.py | 11 ++- .../test_regression_issue_28577.py | 71 +++++++++++++++++++ .../responses/test_regression_issue_28553.py | 52 ++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/unit/a2a_protocol/test_regression_issue_28577.py create mode 100644 tests/unit/responses/test_regression_issue_28553.py diff --git a/litellm/llms/a2a/chat/transformation.py b/litellm/llms/a2a/chat/transformation.py index 6171b341ca4..9a030c10933 100644 --- a/litellm/llms/a2a/chat/transformation.py +++ b/litellm/llms/a2a/chat/transformation.py @@ -411,6 +411,7 @@ class A2AConfig(BaseConfig): role: Final = message.get("role", "user") return { + "kind": "message", "role": role, "parts": [{"kind": "text", "text": str(content)}], "messageId": str(uuid.uuid4()), diff --git a/litellm/llms/a2a/common_utils.py b/litellm/llms/a2a/common_utils.py index 030c5bc222e..d0ef8e417e1 100644 --- a/litellm/llms/a2a/common_utils.py +++ b/litellm/llms/a2a/common_utils.py @@ -2,6 +2,7 @@ Common utilities for A2A (Agent-to-Agent) Protocol """ +import json from collections.abc import Awaitable, Callable, Mapping from typing import Any, Final @@ -82,8 +83,16 @@ def extract_text_from_a2a_message(message: dict[str, Any], depth: int = 0, max_d text_parts: Final[list[str]] = [] for part in parts: - if part.get("kind") == "text": + kind = part.get("kind") + if kind == "text": text_parts.append(part.get("text", "")) + elif kind == "data": + data = part.get("data") + if data is not None: + try: + text_parts.append(json.dumps(data, ensure_ascii=False)) + except (TypeError, ValueError): + text_parts.append(str(data)) # Handle nested parts if they exist elif "parts" in part: nested_text = extract_text_from_a2a_message(part, depth + 1, max_depth) diff --git a/tests/unit/a2a_protocol/test_regression_issue_28577.py b/tests/unit/a2a_protocol/test_regression_issue_28577.py new file mode 100644 index 00000000000..88292f907cf --- /dev/null +++ b/tests/unit/a2a_protocol/test_regression_issue_28577.py @@ -0,0 +1,71 @@ +import sys +import os + +# Add litellm to sys.path +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +) + +from litellm.llms.a2a.chat.transformation import A2AConfig +from litellm.llms.a2a.common_utils import extract_text_from_a2a_message + + +def test_regression_issue_28577_a2a_discriminator(): + """ + Test that A2A transformation adds the mandatory 'kind': 'message' discriminator. + Fixes Bug 1 in #28577. + """ + config = A2AConfig() + messages = [{"role": "user", "content": "ping"}] + + # transform_request creates the A2A JSON-RPC payload + request_data = config.transform_request( + model="a2a/demo", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + # Check Bug 1: message.kind missing + a2a_message = request_data["params"]["message"] + assert a2a_message["kind"] == "message" + assert a2a_message["role"] == "user" + assert "parts" in a2a_message + + +def test_regression_issue_28577_a2a_data_serialization(): + """ + Test that A2A common_utils handle kind: 'data' parts by serializing them. + Fixes Bug 2 in #28577. + """ + message_with_data = { + "kind": "message", + "role": "assistant", + "parts": [{"kind": "data", "data": {"result": {"msg": "pong"}}}], + "messageId": "msg-123", + } + + text = extract_text_from_a2a_message(message_with_data) + assert '"result": {"msg": "pong"}' in text + + +def test_regression_issue_28577_a2a_blocking_param(): + """ + Test that A2A requests include configuration.blocking: True. + Fixes Bug 3 in #28577 (async task unblocking). + """ + config = A2AConfig() + messages = [{"role": "user", "content": "ping"}] + + request_data = config.transform_request( + model="a2a/demo", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + # Check Bug 3 fix: configuration.blocking = True + assert "configuration" in request_data["params"] + assert request_data["params"]["configuration"]["blocking"] is True diff --git a/tests/unit/responses/test_regression_issue_28553.py b/tests/unit/responses/test_regression_issue_28553.py new file mode 100644 index 00000000000..f07b1523d97 --- /dev/null +++ b/tests/unit/responses/test_regression_issue_28553.py @@ -0,0 +1,52 @@ +import sys +import os + +# Add litellm to sys.path +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +) + +from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing + + +async def test_regression_issue_28553_stream_usage_whitelist(): + """ + Test that stream_options.include_usage is only injected for chat/text completions, + and explicitly NOT for the Responses API (aresponses). + Fixes #28553. + """ + # Initialize processor with mock data + processor = ProxyBaseLLMRequestProcessing(data={"stream": True, "model": "gpt-4o"}) + assert processor.data["stream"] is True + + # 1. Test case: acompletion (Chat Completions) - SHOULD inject + + # We call common_processing_pre_call_logic + # It takes many args, but we only care about usage tracking injection + # For simplicity, we can mock the rest of the method or just isolate the block + + # Actually, the block uses: + # general_settings.get("always_include_stream_usage", False) + # self.data.get("stream", False) + # route_type in ["acompletion", "atext_completion"] + + # Since we can't easily call the async method without full setup, + # let's verify the logic by running the isolated block if possible, + # or just trust the A2A test for now. + + # Wait, I can try to call it by mocking everything it needs. + pass + + +def test_logic_verification(): + # Manual verification of the whitelist logic + route_types = ["acompletion", "atext_completion", "aresponses", "arealtime", "auth"] + whitelist = ["acompletion", "atext_completion"] + + results = {rt: (rt in whitelist) for rt in route_types} + + assert results["acompletion"] is True + assert results["atext_completion"] is True + assert results["aresponses"] is False + assert results["arealtime"] is False + assert results["auth"] is False From 50a70a93ba34eddb55b085ddaefc9f3b6b103bc2 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Thu, 30 Jul 2026 11:00:35 +0530 Subject: [PATCH 02/12] fix(a2a): close guardrail bypass for kind:data parts in A2A protocol extract_text_from_a2a_message folds kind:data parts into the completion text callers see, but A2AGuardrailHandler's input/output extraction only scanned kind:text parts, letting structured data content reach callers without ever being scanned or redacted by output/input guardrails. Extend both process_input_messages and process_output_response (plus the streaming path) to serialize and scan data parts the same way, using a shared serialize_a2a_data_part helper so the completion-text builder and the guardrail extractor can't diverge again. Guardrailed values are written back into the correct field (text or data) per part. Fixes the security finding flagged on this PR: A2A data parts bypass output guardrails (litellm/llms/a2a/common_utils.py:89). --- .../a2a/chat/guardrail_translation/handler.py | 66 ++++++--- litellm/llms/a2a/common_utils.py | 18 ++- .../guardrail_translation/test_handler.py | 133 ++++++++++++++++++ 3 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py diff --git a/litellm/llms/a2a/chat/guardrail_translation/handler.py b/litellm/llms/a2a/chat/guardrail_translation/handler.py index afd9ba84c96..d15a6d17253 100644 --- a/litellm/llms/a2a/chat/guardrail_translation/handler.py +++ b/litellm/llms/a2a/chat/guardrail_translation/handler.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, Final, Optional from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger +from litellm.llms.a2a.common_utils import serialize_a2a_data_part from litellm.llms.base_llm.guardrail_translation.base_translation import ( BaseTranslation, StreamingScanKey, @@ -34,6 +35,7 @@ class _A2ATextPart(TypedDict, total=False): kind: ReadOnly[str] text: ReadOnly[str] + data: ReadOnly[object] class A2AGuardrailHandler(BaseTranslation): @@ -45,8 +47,10 @@ class A2AGuardrailHandler(BaseTranslation): 2. Process output responses (post-call hook) - extracts text from A2A response parts A2A Message Format: - - Input: params.message.parts[].text (where kind == "text") - - Output: result.message.parts[].text or result.artifacts[].parts[].text + - Input: params.message.parts[].text (where kind == "text") or + params.message.parts[].data (where kind == "data") + - Output: result.message.parts[].text or result.artifacts[].parts[].text, + and the "data" equivalents of both """ async def process_input_messages( @@ -78,15 +82,23 @@ class A2AGuardrailHandler(BaseTranslation): return data texts_to_check: Final[list[str]] = [] - text_part_indices: Final[list[int]] = [] # Track which parts contain text + # Track which parts contain scannable content, and which field to write + # the guardrailed value back to ("text" or "data") + part_mappings: Final[list[tuple[int, str]]] = [] - # Step 1: Extract text from all text parts + # Step 1: Extract text from all text parts, and serialized data from all data parts for part_idx, part in enumerate(parts): - if part.get("kind") == "text": + kind = part.get("kind") + if kind == "text": text = part.get("text", "") if text: texts_to_check.append(text) - text_part_indices.append(part_idx) + part_mappings.append((part_idx, "text")) + elif kind == "data": + part_data = part.get("data") + if part_data is not None: + texts_to_check.append(serialize_a2a_data_part(part_data)) + part_mappings.append((part_idx, "data")) # Step 2: Apply guardrail to all texts in batch if texts_to_check: @@ -110,9 +122,9 @@ class A2AGuardrailHandler(BaseTranslation): guardrailed_texts: Final = guardrailed_inputs.get("texts", []) # Step 3: Apply guardrailed text back to original parts - if guardrailed_texts and len(guardrailed_texts) == len(text_part_indices): - for task_idx, part_idx in enumerate(text_part_indices): - parts[part_idx]["text"] = guardrailed_texts[task_idx] + if guardrailed_texts and len(guardrailed_texts) == len(part_mappings): + for task_idx, (part_idx, field) in enumerate(part_mappings): + parts[part_idx][field] = guardrailed_texts[task_idx] verbose_proxy_logger.debug("A2A: Processed input message: %s", message) @@ -164,7 +176,7 @@ class A2AGuardrailHandler(BaseTranslation): texts_to_check: Final[list[str]] = [] # Each mapping is (path_to_parts_list, part_index) # path_to_parts_list is a tuple of keys to navigate to the parts list - task_mappings: Final[list[tuple[tuple[str, ...], int]]] = [] + task_mappings: Final[list[tuple[tuple[str, ...], int, str]]] = [] # Extract texts from all possible locations self._extract_texts_from_result( @@ -205,11 +217,12 @@ class A2AGuardrailHandler(BaseTranslation): # Step 3: Apply guardrailed text back to original response if guardrailed_texts and len(guardrailed_texts) == len(task_mappings): - for task_idx, (path, part_idx) in enumerate(task_mappings): + for task_idx, (path, part_idx, field) in enumerate(task_mappings): self._apply_text_to_path( result=result, path=path, part_idx=part_idx, + field=field, text=guardrailed_texts[task_idx], ) @@ -281,8 +294,8 @@ class A2AGuardrailHandler(BaseTranslation): result = obj.get("result", {}) if not isinstance(result, dict): continue - texts_in_chunk: list[str] = [] - mappings: list[tuple[tuple[str, ...], int]] = [] + texts_in_chunk: Final[list[str]] = [] + mappings: Final[list[tuple[tuple[str, ...], int, str]]] = [] self._extract_texts_from_result( result=result, texts_to_check=texts_in_chunk, @@ -292,20 +305,22 @@ class A2AGuardrailHandler(BaseTranslation): continue if orig_i == first_chunk_with_text: # Put full guardrailed text in first text part; clear others - for task_idx, (path, part_idx) in enumerate(mappings): + for task_idx, (path, part_idx, field) in enumerate(mappings): text = guardrailed_text if task_idx == 0 else "" self._apply_text_to_path( result=result, path=path, part_idx=part_idx, + field=field, text=text, ) else: - for path, part_idx in mappings: + for path, part_idx, field in mappings: self._apply_text_to_path( result=result, path=path, part_idx=part_idx, + field=field, text="", ) @@ -363,7 +378,7 @@ class A2AGuardrailHandler(BaseTranslation): self, result: dict[str, Any], texts_to_check: list[str], - task_mappings: list[tuple[tuple[str, ...], int]], + task_mappings: list[tuple[tuple[str, ...], int, str]], ) -> None: """ Extract text from all possible locations in an A2A result. @@ -433,21 +448,28 @@ class A2AGuardrailHandler(BaseTranslation): parts: Sequence[_A2ATextPart], path: tuple[str, ...], texts_to_check: list[str], - task_mappings: list[tuple[tuple[str, ...], int]], + task_mappings: list[tuple[tuple[str, ...], int, str]], ) -> None: - """Extract text from message parts.""" + """Extract text from message parts, and serialized data from data parts.""" for part_idx, part in enumerate(parts): - if part.get("kind") == "text": + kind = part.get("kind") + if kind == "text": text = part.get("text", "") if text: texts_to_check.append(text) - task_mappings.append((path, part_idx)) + task_mappings.append((path, part_idx, "text")) + elif kind == "data": + data = part.get("data") + if data is not None: + texts_to_check.append(serialize_a2a_data_part(data)) + task_mappings.append((path, part_idx, "data")) def _apply_text_to_path( self, result: dict[str | int, Any], path: tuple[str, ...], part_idx: int, + field: str, text: str, ) -> None: """Apply guardrailed text back to the specified path in the result.""" @@ -460,5 +482,5 @@ class A2AGuardrailHandler(BaseTranslation): else: current = current[key] - # Update the text in the part - current[part_idx]["text"] = text + # Update the guardrailed value in the part + current[part_idx][field] = text diff --git a/litellm/llms/a2a/common_utils.py b/litellm/llms/a2a/common_utils.py index d0ef8e417e1..89b3428852f 100644 --- a/litellm/llms/a2a/common_utils.py +++ b/litellm/llms/a2a/common_utils.py @@ -64,6 +64,19 @@ def convert_messages_to_prompt(messages: list[AllMessageValues]) -> str: return "\n".join(conversation_parts) +def serialize_a2a_data_part(data: Any) -> str: + """ + Serialize an A2A ``data``-kind part's payload to text. + + Used both to build the flattened completion text shown to callers and to + extract guardrail-scannable text, so the two stay in sync. + """ + try: + return json.dumps(data, ensure_ascii=False) + except (TypeError, ValueError): + return str(data) + + def extract_text_from_a2a_message(message: dict[str, Any], depth: int = 0, max_depth: int = 10) -> str: """ Extract text content from A2A message parts. @@ -89,10 +102,7 @@ def extract_text_from_a2a_message(message: dict[str, Any], depth: int = 0, max_d elif kind == "data": data = part.get("data") if data is not None: - try: - text_parts.append(json.dumps(data, ensure_ascii=False)) - except (TypeError, ValueError): - text_parts.append(str(data)) + text_parts.append(serialize_a2a_data_part(data)) # Handle nested parts if they exist elif "parts" in part: nested_text = extract_text_from_a2a_message(part, depth + 1, max_depth) diff --git a/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py b/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py new file mode 100644 index 00000000000..4f958ba15fd --- /dev/null +++ b/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py @@ -0,0 +1,133 @@ +""" +Unit tests for A2A Protocol Guardrail Translation Handler + +Regression coverage for the "data"-kind part guardrail bypass: A2A responses +can carry structured content in `kind: "data"` parts, which +`extract_text_from_a2a_message` (used to build the completion text callers +see) folds into the final text, but the guardrail handler previously only +inspected `kind: "text"` parts, so guarded output checks were skipped for +that content path. +""" + +import os +import sys +from typing import Any, Literal, Optional + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../.."))) + +from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.llms.a2a.chat.guardrail_translation.handler import A2AGuardrailHandler +from litellm.types.utils import GenericGuardrailAPIInputs + + +class MockGuardrail(CustomGuardrail): + """Mock guardrail that uppercases text so we can assert exactly what was scanned and where the result landed.""" + + def __init__(self, guardrail_name: str = "test"): + super().__init__(guardrail_name=guardrail_name) + self.last_inputs: Optional[GenericGuardrailAPIInputs] = None + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + self.last_inputs = inputs + texts = inputs.get("texts", []) + return {"texts": [text.upper() for text in texts]} + + +@pytest.mark.asyncio +async def test_process_output_response_scans_data_parts(): + """A `kind: data` part in the output must be sent to the guardrail and the + guardrailed value written back into `data`, not silently skipped.""" + handler = A2AGuardrailHandler() + guardrail = MockGuardrail() + + response = { + "result": { + "kind": "message", + "parts": [ + {"kind": "text", "text": "hello"}, + {"kind": "data", "data": {"secret": "leak-me"}}, + ], + } + } + + result = await handler.process_output_response( + response=response, + guardrail_to_apply=guardrail, + ) + + # The data part's serialized content must have reached the guardrail. + assert guardrail.last_inputs is not None + scanned_texts = guardrail.last_inputs["texts"] + assert any("leak-me" in t for t in scanned_texts) + + # The guardrailed (uppercased) value must be written back into "data", + # and the part must remain a "data" part, not be silently dropped or + # converted into an unguarded pass-through. + data_part = result["result"]["parts"][1] + assert data_part["kind"] == "data" + assert "LEAK-ME" in data_part["data"] + + # The text part must still be guardrailed as before (no regression). + text_part = result["result"]["parts"][0] + assert text_part["text"] == "HELLO" + + +@pytest.mark.asyncio +async def test_process_output_response_data_only_still_scanned(): + """A response with ONLY a data part (no text parts at all) must not be + skipped as "no text content in response".""" + handler = A2AGuardrailHandler() + guardrail = MockGuardrail() + + response = { + "result": { + "kind": "message", + "parts": [{"kind": "data", "data": {"result": {"msg": "pong"}}}], + } + } + + result = await handler.process_output_response( + response=response, + guardrail_to_apply=guardrail, + ) + + assert guardrail.last_inputs is not None + assert guardrail.last_inputs["texts"] + assert "PONG" in result["result"]["parts"][0]["data"] + + +@pytest.mark.asyncio +async def test_process_input_messages_scans_data_parts(): + """The same bypass existed on the request/input side of the handler.""" + handler = A2AGuardrailHandler() + guardrail = MockGuardrail() + + data = { + "params": { + "message": { + "kind": "message", + "role": "user", + "parts": [{"kind": "data", "data": {"secret": "leak-me"}}], + } + } + } + + result = await handler.process_input_messages( + data=data, + guardrail_to_apply=guardrail, + ) + + assert guardrail.last_inputs is not None + assert any("leak-me" in t for t in guardrail.last_inputs["texts"]) + + data_part = result["params"]["message"]["parts"][0] + assert data_part["kind"] == "data" + assert "LEAK-ME" in data_part["data"] From ff065dbba961f44035bbdb0368236bfb9a2e13ac Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Thu, 30 Jul 2026 11:11:28 +0530 Subject: [PATCH 03/12] fix(a2a): recurse into nested parts for guardrail scanning extract_text_from_a2a_message recurses into a part that itself carries a nested 'parts' list, but _extract_texts_from_parts did not, so text/data content inside nested parts could reach the completion text without ever passing through a guardrail. Mirror the recursion (including the same depth guard) so the two extraction paths can't diverge on nested structures either. _apply_text_to_path needed no changes since its path-navigation is already depth-agnostic. Addresses the follow-up finding on this PR: nested data bypasses output guardrails (litellm/llms/a2a/common_utils.py:100). --- .../a2a/chat/guardrail_translation/handler.py | 28 ++++++++++++-- .../guardrail_translation/test_handler.py | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/litellm/llms/a2a/chat/guardrail_translation/handler.py b/litellm/llms/a2a/chat/guardrail_translation/handler.py index d15a6d17253..ce2a082b70f 100644 --- a/litellm/llms/a2a/chat/guardrail_translation/handler.py +++ b/litellm/llms/a2a/chat/guardrail_translation/handler.py @@ -36,6 +36,7 @@ class _A2ATextPart(TypedDict, total=False): kind: ReadOnly[str] text: ReadOnly[str] data: ReadOnly[object] + parts: ReadOnly[Sequence["_A2ATextPart"]] class A2AGuardrailHandler(BaseTranslation): @@ -449,8 +450,18 @@ class A2AGuardrailHandler(BaseTranslation): path: tuple[str, ...], texts_to_check: list[str], task_mappings: list[tuple[tuple[str, ...], int, str]], + depth: int = 0, + max_depth: int = 10, ) -> None: - """Extract text from message parts, and serialized data from data parts.""" + """ + Extract text from message parts, serialized data from data parts, and + recurse into any part that itself carries a nested "parts" list. + + Mirrors `extract_text_from_a2a_message`'s handling (including the + recursion depth guard) so the two stay in sync. + """ + if depth >= max_depth: + return for part_idx, part in enumerate(parts): kind = part.get("kind") if kind == "text": @@ -459,10 +470,19 @@ class A2AGuardrailHandler(BaseTranslation): texts_to_check.append(text) task_mappings.append((path, part_idx, "text")) elif kind == "data": - data = part.get("data") - if data is not None: - texts_to_check.append(serialize_a2a_data_part(data)) + part_data = part.get("data") + if part_data is not None: + texts_to_check.append(serialize_a2a_data_part(part_data)) task_mappings.append((path, part_idx, "data")) + elif "parts" in part: + self._extract_texts_from_parts( + parts=part["parts"], + path=path + (str(part_idx), "parts"), + texts_to_check=texts_to_check, + task_mappings=task_mappings, + depth=depth + 1, + max_depth=max_depth, + ) def _apply_text_to_path( self, diff --git a/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py b/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py index 4f958ba15fd..9af0bae00c1 100644 --- a/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py +++ b/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py @@ -104,6 +104,44 @@ async def test_process_output_response_data_only_still_scanned(): assert "PONG" in result["result"]["parts"][0]["data"] +@pytest.mark.asyncio +async def test_process_output_response_scans_nested_parts(): + """A part that itself carries a nested "parts" list (grouping sub-parts) + must be recursed into, matching extract_text_from_a2a_message's own + recursion, instead of being silently skipped as neither text nor data.""" + handler = A2AGuardrailHandler() + guardrail = MockGuardrail() + + response = { + "result": { + "kind": "message", + "parts": [ + { + "kind": "group", + "parts": [ + {"kind": "text", "text": "hello"}, + {"kind": "data", "data": {"secret": "leak-me"}}, + ], + }, + ], + } + } + + result = await handler.process_output_response( + response=response, + guardrail_to_apply=guardrail, + ) + + assert guardrail.last_inputs is not None + scanned_texts = guardrail.last_inputs["texts"] + assert "hello" in scanned_texts + assert any("leak-me" in t for t in scanned_texts) + + nested_parts = result["result"]["parts"][0]["parts"] + assert nested_parts[0]["text"] == "HELLO" + assert "LEAK-ME" in nested_parts[1]["data"] + + @pytest.mark.asyncio async def test_process_input_messages_scans_data_parts(): """The same bypass existed on the request/input side of the handler.""" From ba766a1272a7b55a3189bd05aa1560bef18c3385 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Tue, 4 Aug 2026 10:21:36 +0530 Subject: [PATCH 04/12] fix(a2a): rewrite guardrail text extraction functionally to satisfy LIT002 budget _extract_texts_from_result/_extract_texts_from_parts now return tuples of scanned entries instead of mutating shared out-parameter lists, matching the repo's no-mutation convention and staying under the type-discipline gate's mutable-collection-construction budget. Two GenericGuardrailAPIInputs call sites convert the resulting tuple to a list at that external boundary (marked mutable-ok, since the TypedDict itself is typed List[str]). Also allowlists the now-legitimately-recursive _extract_texts_from_parts in the CI's recursive-function detector, matching the pattern already used for its sibling extract_text_from_a2a_message (bounded max_depth=10 guard). --- .../a2a/chat/guardrail_translation/handler.py | 146 +++++++----------- .../code_coverage_tests/recursive_detector.py | 1 + 2 files changed, 61 insertions(+), 86 deletions(-) diff --git a/litellm/llms/a2a/chat/guardrail_translation/handler.py b/litellm/llms/a2a/chat/guardrail_translation/handler.py index ce2a082b70f..dee9bdf6bbe 100644 --- a/litellm/llms/a2a/chat/guardrail_translation/handler.py +++ b/litellm/llms/a2a/chat/guardrail_translation/handler.py @@ -82,28 +82,30 @@ class A2AGuardrailHandler(BaseTranslation): verbose_proxy_logger.debug("A2A: No parts in message, skipping guardrail") return data - texts_to_check: Final[list[str]] = [] - # Track which parts contain scannable content, and which field to write - # the guardrailed value back to ("text" or "data") - part_mappings: Final[list[tuple[int, str]]] = [] - - # Step 1: Extract text from all text parts, and serialized data from all data parts - for part_idx, part in enumerate(parts): + def _scan_input_part(part_idx: int, part: dict[str, Any]) -> tuple[str, int, str] | None: kind = part.get("kind") if kind == "text": text = part.get("text", "") - if text: - texts_to_check.append(text) - part_mappings.append((part_idx, "text")) - elif kind == "data": + return (text, part_idx, "text") if text else None + if kind == "data": part_data = part.get("data") - if part_data is not None: - texts_to_check.append(serialize_a2a_data_part(part_data)) - part_mappings.append((part_idx, "data")) + return (serialize_a2a_data_part(part_data), part_idx, "data") if part_data is not None else None + return None + + # Extract text from all text parts, and serialized data from all data parts + scanned: Final = tuple( + entry for part_idx, part in enumerate(parts) if (entry := _scan_input_part(part_idx, part)) is not None + ) + texts_to_check: Final = tuple(text for text, _, _ in scanned) + # Track which parts contain scannable content, and which field to write + # the guardrailed value back to ("text" or "data") + part_mappings: Final = tuple((part_idx, field) for _, part_idx, field in scanned) # Step 2: Apply guardrail to all texts in batch if texts_to_check: - inputs: Final = GenericGuardrailAPIInputs(texts=texts_to_check) + inputs: Final = GenericGuardrailAPIInputs( + texts=list(texts_to_check) # mutable-ok: GenericGuardrailAPIInputs.texts is typed List[str] + ) # Pass the structured A2A message to guardrails inputs["structured_messages"] = [message] @@ -173,18 +175,12 @@ class A2AGuardrailHandler(BaseTranslation): verbose_proxy_logger.debug("A2A: No result in response, skipping guardrail") return response - # Find all text-containing parts in the response - texts_to_check: Final[list[str]] = [] - # Each mapping is (path_to_parts_list, part_index) - # path_to_parts_list is a tuple of keys to navigate to the parts list - task_mappings: Final[list[tuple[tuple[str, ...], int, str]]] = [] - - # Extract texts from all possible locations - self._extract_texts_from_result( - result=result, - texts_to_check=texts_to_check, - task_mappings=task_mappings, - ) + # Find all text-containing parts in the response. Each scanned entry is + # (text, path_to_parts_list, part_index, field); path_to_parts_list is a + # tuple of keys to navigate to the parts list. + scanned: Final = self._extract_texts_from_result(result=result) + texts_to_check: Final = tuple(text for text, _, _, _ in scanned) + task_mappings: Final = tuple((path, part_idx, field) for _, path, part_idx, field in scanned) if not texts_to_check: verbose_proxy_logger.debug("A2A: No text content in response") @@ -205,7 +201,9 @@ class A2AGuardrailHandler(BaseTranslation): if user_metadata: request_data["litellm_metadata"] = user_metadata - inputs: Final = GenericGuardrailAPIInputs(texts=texts_to_check) + inputs: Final = GenericGuardrailAPIInputs( + texts=list(texts_to_check) # mutable-ok: GenericGuardrailAPIInputs.texts is typed List[str] + ) guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail( inputs=inputs, @@ -295,18 +293,12 @@ class A2AGuardrailHandler(BaseTranslation): result = obj.get("result", {}) if not isinstance(result, dict): continue - texts_in_chunk: Final[list[str]] = [] - mappings: Final[list[tuple[tuple[str, ...], int, str]]] = [] - self._extract_texts_from_result( - result=result, - texts_to_check=texts_in_chunk, - task_mappings=mappings, - ) - if not mappings: + scanned: Final = self._extract_texts_from_result(result=result) + if not scanned: continue if orig_i == first_chunk_with_text: # Put full guardrailed text in first text part; clear others - for task_idx, (path, part_idx, field) in enumerate(mappings): + for task_idx, (_, path, part_idx, field) in enumerate(scanned): text = guardrailed_text if task_idx == 0 else "" self._apply_text_to_path( result=result, @@ -316,7 +308,7 @@ class A2AGuardrailHandler(BaseTranslation): text=text, ) else: - for path, part_idx, field in mappings: + for _, path, part_idx, field in scanned: self._apply_text_to_path( result=result, path=path, @@ -378,9 +370,7 @@ class A2AGuardrailHandler(BaseTranslation): def _extract_texts_from_result( self, result: dict[str, Any], - texts_to_check: list[str], - task_mappings: list[tuple[tuple[str, ...], int, str]], - ) -> None: + ) -> tuple[tuple[str, tuple[str, ...], int, str], ...]: """ Extract text from all possible locations in an A2A result. @@ -390,46 +380,32 @@ class A2AGuardrailHandler(BaseTranslation): 3. Task with artifacts: {"artifacts": [{"parts": [...]}]} 4. Task with status message: {"status": {"message": {"parts": [...]}}} 5. Streaming artifact-update: {"artifact": {"parts": [...]}} + + Returns a tuple of (text, path_to_parts_list, part_index, field) entries. """ + entries: tuple[tuple[str, tuple[str, ...], int, str], ...] = () + # Case 1: Direct parts in result (direct message) if "parts" in result: - self._extract_texts_from_parts( - parts=result["parts"], - path=("parts",), - texts_to_check=texts_to_check, - task_mappings=task_mappings, - ) + entries += self._extract_texts_from_parts(parts=result["parts"], path=("parts",)) # Case 2: Nested message message: Final = result.get("message") if message and isinstance(message, dict) and "parts" in message: - self._extract_texts_from_parts( - parts=message["parts"], - path=("message", "parts"), - texts_to_check=texts_to_check, - task_mappings=task_mappings, - ) + entries += self._extract_texts_from_parts(parts=message["parts"], path=("message", "parts")) # Case 3: Streaming artifact-update (singular artifact) artifact: Final = result.get("artifact") if artifact and isinstance(artifact, dict) and "parts" in artifact: - self._extract_texts_from_parts( - parts=artifact["parts"], - path=("artifact", "parts"), - texts_to_check=texts_to_check, - task_mappings=task_mappings, - ) + entries += self._extract_texts_from_parts(parts=artifact["parts"], path=("artifact", "parts")) # Case 4: Task with status message status: Final = result.get("status", {}) if isinstance(status, dict): status_message: Final = status.get("message") if status_message and isinstance(status_message, dict) and "parts" in status_message: - self._extract_texts_from_parts( - parts=status_message["parts"], - path=("status", "message", "parts"), - texts_to_check=texts_to_check, - task_mappings=task_mappings, + entries += self._extract_texts_from_parts( + parts=status_message["parts"], path=("status", "message", "parts") ) # Case 5: Task with artifacts (plural, array) @@ -437,52 +413,50 @@ class A2AGuardrailHandler(BaseTranslation): if artifacts and isinstance(artifacts, list): for artifact_idx, art in enumerate(artifacts): if isinstance(art, dict) and "parts" in art: - self._extract_texts_from_parts( - parts=art["parts"], - path=("artifacts", str(artifact_idx), "parts"), - texts_to_check=texts_to_check, - task_mappings=task_mappings, + entries += self._extract_texts_from_parts( + parts=art["parts"], path=("artifacts", str(artifact_idx), "parts") ) + return entries + def _extract_texts_from_parts( self, parts: Sequence[_A2ATextPart], path: tuple[str, ...], - texts_to_check: list[str], - task_mappings: list[tuple[tuple[str, ...], int, str]], depth: int = 0, max_depth: int = 10, - ) -> None: + ) -> tuple[tuple[str, tuple[str, ...], int, str], ...]: """ Extract text from message parts, serialized data from data parts, and recurse into any part that itself carries a nested "parts" list. Mirrors `extract_text_from_a2a_message`'s handling (including the - recursion depth guard) so the two stay in sync. + recursion depth guard) so the two stay in sync. Returns a tuple of + (text, path_to_parts_list, part_index, field) entries. """ if depth >= max_depth: - return - for part_idx, part in enumerate(parts): + return () + + def _scan(part_idx: int, part: dict[str, Any]) -> tuple[tuple[str, tuple[str, ...], int, str], ...]: kind = part.get("kind") if kind == "text": text = part.get("text", "") - if text: - texts_to_check.append(text) - task_mappings.append((path, part_idx, "text")) - elif kind == "data": + return ((text, path, part_idx, "text"),) if text else () + if kind == "data": part_data = part.get("data") - if part_data is not None: - texts_to_check.append(serialize_a2a_data_part(part_data)) - task_mappings.append((path, part_idx, "data")) - elif "parts" in part: - self._extract_texts_from_parts( + if part_data is None: + return () + return ((serialize_a2a_data_part(part_data), path, part_idx, "data"),) + if "parts" in part: + return self._extract_texts_from_parts( parts=part["parts"], path=path + (str(part_idx), "parts"), - texts_to_check=texts_to_check, - task_mappings=task_mappings, depth=depth + 1, max_depth=max_depth, ) + return () + + return tuple(entry for part_idx, part in enumerate(parts) for entry in _scan(part_idx, part)) def _apply_text_to_path( self, diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index e1d58ee73d2..421f5be26dd 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -47,6 +47,7 @@ IGNORE_FUNCTIONS = [ "_validate_inheritance_chain", # max depth set (default 100) to prevent infinite recursion in policy inheritance validation. "_basic_json_schema_validate", # max depth set. "extract_text_from_a2a_message", # max depth set (default 10) to prevent infinite recursion in A2A message parsing. + "_extract_texts_from_parts", # max depth set (default 10), mirrors extract_text_from_a2a_message's recursion guard. "_convert_to_json_serializable_dict", # max depth set (default 20) and circular reference protection to prevent infinite recursion. "dict", # max depth set. _LiteLLMParamsDictView.dict() calls builtin dict(), not itself. "_read_image_bytes", # max depth set. From 84f92247f146e29ea919986febd674ee29576228 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Thu, 10 Sep 2026 08:49:38 +0530 Subject: [PATCH 05/12] fix(a2a): add ANN401 per-file ignore for common_utils.py serialize_a2a_data_part's data param is an A2A data-kind part's payload, which the protocol defines as arbitrary JSON-serializable content with no fixed shape. object was not viable since json.dumps requires the narrower JSON-value types. Resolves the ruff-strict ANN401 budget breach from PR #28593's new Any-typed parameter. --- ruff-strict.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ruff-strict.toml b/ruff-strict.toml index 9e0b11a1af4..f5f28217bd1 100644 --- a/ruff-strict.toml +++ b/ruff-strict.toml @@ -34,6 +34,11 @@ external = [ # hook signatures inherit `Any` for `response` from CustomLogger, so narrowing them here # would break the override rather than describe it. "litellm/proxy/guardrails/guardrail_hooks/llm_shield_proxy/llm_shield_proxy.py" = ["ANN401"] +# `serialize_a2a_data_part`'s `data` param is the payload of an A2A `data`-kind part, +# which the A2A protocol defines as arbitrary JSON-serializable content with no fixed +# shape. `object` was not viable: json.dumps requires the narrower JSON-value types, +# so typing it `object` just moves the Any into a cast at every call site instead. +"litellm/llms/a2a/common_utils.py" = ["ANN401"] [lint.mccabe] max-complexity = 15 From a12ba03f382fad8c23b7c576ffa817a2153480f3 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Fri, 11 Sep 2026 10:15:26 +0530 Subject: [PATCH 06/12] test(a2a): drop no-op sys.path.insert from new regression tests pytest's rootdir handling and the installed package already make litellm importable; these were copy-paste no-ops that also made the guardrail_translation test's imports depend on the working directory it happens to run from. Resolves the TQ003 test-quality budget breach flagged in the lint job. --- tests/unit/a2a_protocol/test_regression_issue_28577.py | 8 -------- .../llms/a2a/chat/guardrail_translation/test_handler.py | 4 ---- tests/unit/responses/test_regression_issue_28553.py | 8 -------- 3 files changed, 20 deletions(-) diff --git a/tests/unit/a2a_protocol/test_regression_issue_28577.py b/tests/unit/a2a_protocol/test_regression_issue_28577.py index 88292f907cf..ae8b670083d 100644 --- a/tests/unit/a2a_protocol/test_regression_issue_28577.py +++ b/tests/unit/a2a_protocol/test_regression_issue_28577.py @@ -1,11 +1,3 @@ -import sys -import os - -# Add litellm to sys.path -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) -) - from litellm.llms.a2a.chat.transformation import A2AConfig from litellm.llms.a2a.common_utils import extract_text_from_a2a_message diff --git a/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py b/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py index 9af0bae00c1..150538f1d2c 100644 --- a/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py +++ b/tests/unit/llms/a2a/chat/guardrail_translation/test_handler.py @@ -9,14 +9,10 @@ inspected `kind: "text"` parts, so guarded output checks were skipped for that content path. """ -import os -import sys from typing import Any, Literal, Optional import pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../.."))) - from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.llms.a2a.chat.guardrail_translation.handler import A2AGuardrailHandler from litellm.types.utils import GenericGuardrailAPIInputs diff --git a/tests/unit/responses/test_regression_issue_28553.py b/tests/unit/responses/test_regression_issue_28553.py index f07b1523d97..a621154c5f9 100644 --- a/tests/unit/responses/test_regression_issue_28553.py +++ b/tests/unit/responses/test_regression_issue_28553.py @@ -1,11 +1,3 @@ -import sys -import os - -# Add litellm to sys.path -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) -) - from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing From 39032e105aa8a279cb4c2f8e085b5e3a5f966470 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Wed, 23 Sep 2026 09:41:15 +0530 Subject: [PATCH 07/12] fix(a2a): drop invalid Final annotation on loop-local scanned var basedpyright's reportGeneralTypeIssues rejects a Final-annotated variable assigned inside a loop body, breaching the repo's per-rule budget by 1. The binding is fresh each iteration, not mutated, so dropping Final here is safe. Co-Authored-By: Claude Sonnet 5 --- litellm/llms/a2a/chat/guardrail_translation/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/a2a/chat/guardrail_translation/handler.py b/litellm/llms/a2a/chat/guardrail_translation/handler.py index dee9bdf6bbe..0e859f8de01 100644 --- a/litellm/llms/a2a/chat/guardrail_translation/handler.py +++ b/litellm/llms/a2a/chat/guardrail_translation/handler.py @@ -293,7 +293,7 @@ class A2AGuardrailHandler(BaseTranslation): result = obj.get("result", {}) if not isinstance(result, dict): continue - scanned: Final = self._extract_texts_from_result(result=result) + scanned = self._extract_texts_from_result(result=result) if not scanned: continue if orig_i == first_chunk_with_text: From 6e41f7b39319915fccf2311384b391bb31d472c5 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Wed, 23 Sep 2026 10:15:58 +0530 Subject: [PATCH 08/12] test(responses): replace no-op regression test with real assertions against _stream_usage_tracking_updates Greptile flagged that the async test body was a bare pass and the only running assertion checked Python's `in` operator on a hardcoded list, never touching production code. Parametrized over route_type against the actual function, verified red-before-green by disabling the gate. --- .../responses/test_regression_issue_28553.py | 63 +++++++------------ 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/tests/unit/responses/test_regression_issue_28553.py b/tests/unit/responses/test_regression_issue_28553.py index a621154c5f9..5ff7f9aeadc 100644 --- a/tests/unit/responses/test_regression_issue_28553.py +++ b/tests/unit/responses/test_regression_issue_28553.py @@ -1,44 +1,23 @@ -from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing +import pytest + +from litellm.proxy.common_request_processing import _stream_usage_tracking_updates -async def test_regression_issue_28553_stream_usage_whitelist(): - """ - Test that stream_options.include_usage is only injected for chat/text completions, - and explicitly NOT for the Responses API (aresponses). - Fixes #28553. - """ - # Initialize processor with mock data - processor = ProxyBaseLLMRequestProcessing(data={"stream": True, "model": "gpt-4o"}) - assert processor.data["stream"] is True - - # 1. Test case: acompletion (Chat Completions) - SHOULD inject - - # We call common_processing_pre_call_logic - # It takes many args, but we only care about usage tracking injection - # For simplicity, we can mock the rest of the method or just isolate the block - - # Actually, the block uses: - # general_settings.get("always_include_stream_usage", False) - # self.data.get("stream", False) - # route_type in ["acompletion", "atext_completion"] - - # Since we can't easily call the async method without full setup, - # let's verify the logic by running the isolated block if possible, - # or just trust the A2A test for now. - - # Wait, I can try to call it by mocking everything it needs. - pass - - -def test_logic_verification(): - # Manual verification of the whitelist logic - route_types = ["acompletion", "atext_completion", "aresponses", "arealtime", "auth"] - whitelist = ["acompletion", "atext_completion"] - - results = {rt: (rt in whitelist) for rt in route_types} - - assert results["acompletion"] is True - assert results["atext_completion"] is True - assert results["aresponses"] is False - assert results["arealtime"] is False - assert results["auth"] is False +@pytest.mark.parametrize( + "route_type,expect_injected", + [ + ("acompletion", True), + ("atext_completion", False), + ("aresponses", False), + ("aget_responses", False), + ("arealtime", False), + ], +) +def test_stream_usage_only_injected_for_whitelisted_route_types(route_type, expect_injected): + updates = _stream_usage_tracking_updates( + data={"stream": True, "model": "gpt-4o"}, + general_settings={}, + route_type=route_type, + supports_stream_options=lambda: True, + ) + assert ("stream_options" in updates) is expect_injected From 37ec41ec6c5f1b7b526ef198dc86526c14508151 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Sat, 3 Oct 2026 23:35:52 +0530 Subject: [PATCH 09/12] fix(a2a): remove dead mutable-ok suppressions, flatten double-for comprehension Post-rebase type-discipline gate flagged two mutable-ok suppressions that suppress nothing (LIT013) and a two-clause comprehension (LIT014). Dropped the dead comments and replaced the manual flatten with chain.from_iterable. --- .../llms/a2a/chat/guardrail_translation/handler.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/litellm/llms/a2a/chat/guardrail_translation/handler.py b/litellm/llms/a2a/chat/guardrail_translation/handler.py index 0e859f8de01..bd97b5c2058 100644 --- a/litellm/llms/a2a/chat/guardrail_translation/handler.py +++ b/litellm/llms/a2a/chat/guardrail_translation/handler.py @@ -12,6 +12,7 @@ A2A Protocol Format: import json from collections.abc import Sequence +from itertools import chain from typing import TYPE_CHECKING, Any, Final, Optional from typing_extensions import ReadOnly, TypedDict @@ -103,9 +104,7 @@ class A2AGuardrailHandler(BaseTranslation): # Step 2: Apply guardrail to all texts in batch if texts_to_check: - inputs: Final = GenericGuardrailAPIInputs( - texts=list(texts_to_check) # mutable-ok: GenericGuardrailAPIInputs.texts is typed List[str] - ) + inputs: Final = GenericGuardrailAPIInputs(texts=list(texts_to_check)) # Pass the structured A2A message to guardrails inputs["structured_messages"] = [message] @@ -201,9 +200,7 @@ class A2AGuardrailHandler(BaseTranslation): if user_metadata: request_data["litellm_metadata"] = user_metadata - inputs: Final = GenericGuardrailAPIInputs( - texts=list(texts_to_check) # mutable-ok: GenericGuardrailAPIInputs.texts is typed List[str] - ) + inputs: Final = GenericGuardrailAPIInputs(texts=list(texts_to_check)) guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail( inputs=inputs, @@ -456,7 +453,7 @@ class A2AGuardrailHandler(BaseTranslation): ) return () - return tuple(entry for part_idx, part in enumerate(parts) for entry in _scan(part_idx, part)) + return tuple(chain.from_iterable(_scan(part_idx, part) for part_idx, part in enumerate(parts))) def _apply_text_to_path( self, From 4d5c1d4a8ba4ceebb5e02da225562335cc436c2b Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Tue, 13 Oct 2026 11:44:06 +0530 Subject: [PATCH 10/12] fix(a2a): declare Final on local bindings, fold accumulator into chain.from_iterable LIT010 flagged new unannotated local bindings (kind, text, part_data in two nested scan functions) and the entries accumulator in _extract_texts_from_result. Annotated the locals Final and replaced the sequential-rebind accumulator with five case tuples combined via +, using chain.from_iterable for the artifacts loop case to stay within LIT014's one-for/one-if comprehension limit. --- .../a2a/chat/guardrail_translation/handler.py | 64 +++++++++++-------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/litellm/llms/a2a/chat/guardrail_translation/handler.py b/litellm/llms/a2a/chat/guardrail_translation/handler.py index bd97b5c2058..8728b9db7a1 100644 --- a/litellm/llms/a2a/chat/guardrail_translation/handler.py +++ b/litellm/llms/a2a/chat/guardrail_translation/handler.py @@ -84,12 +84,12 @@ class A2AGuardrailHandler(BaseTranslation): return data def _scan_input_part(part_idx: int, part: dict[str, Any]) -> tuple[str, int, str] | None: - kind = part.get("kind") + kind: Final = part.get("kind") if kind == "text": - text = part.get("text", "") + text: Final = part.get("text", "") return (text, part_idx, "text") if text else None if kind == "data": - part_data = part.get("data") + part_data: Final = part.get("data") return (serialize_a2a_data_part(part_data), part_idx, "data") if part_data is not None else None return None @@ -380,41 +380,51 @@ class A2AGuardrailHandler(BaseTranslation): Returns a tuple of (text, path_to_parts_list, part_index, field) entries. """ - entries: tuple[tuple[str, tuple[str, ...], int, str], ...] = () - # Case 1: Direct parts in result (direct message) - if "parts" in result: - entries += self._extract_texts_from_parts(parts=result["parts"], path=("parts",)) + case1: Final = ( + self._extract_texts_from_parts(parts=result["parts"], path=("parts",)) if "parts" in result else () + ) # Case 2: Nested message message: Final = result.get("message") - if message and isinstance(message, dict) and "parts" in message: - entries += self._extract_texts_from_parts(parts=message["parts"], path=("message", "parts")) + case2: Final = ( + self._extract_texts_from_parts(parts=message["parts"], path=("message", "parts")) + if message and isinstance(message, dict) and "parts" in message + else () + ) # Case 3: Streaming artifact-update (singular artifact) artifact: Final = result.get("artifact") - if artifact and isinstance(artifact, dict) and "parts" in artifact: - entries += self._extract_texts_from_parts(parts=artifact["parts"], path=("artifact", "parts")) + case3: Final = ( + self._extract_texts_from_parts(parts=artifact["parts"], path=("artifact", "parts")) + if artifact and isinstance(artifact, dict) and "parts" in artifact + else () + ) # Case 4: Task with status message status: Final = result.get("status", {}) - if isinstance(status, dict): - status_message: Final = status.get("message") - if status_message and isinstance(status_message, dict) and "parts" in status_message: - entries += self._extract_texts_from_parts( - parts=status_message["parts"], path=("status", "message", "parts") - ) + status_message: Final = status.get("message") if isinstance(status, dict) else None + case4: Final = ( + self._extract_texts_from_parts(parts=status_message["parts"], path=("status", "message", "parts")) + if isinstance(status_message, dict) and "parts" in status_message + else () + ) # Case 5: Task with artifacts (plural, array) artifacts: Final = result.get("artifacts", []) - if artifacts and isinstance(artifacts, list): - for artifact_idx, art in enumerate(artifacts): - if isinstance(art, dict) and "parts" in art: - entries += self._extract_texts_from_parts( - parts=art["parts"], path=("artifacts", str(artifact_idx), "parts") - ) + case5: Final = ( + tuple( + chain.from_iterable( + self._extract_texts_from_parts(parts=art["parts"], path=("artifacts", str(artifact_idx), "parts")) + for artifact_idx, art in enumerate(artifacts) + if isinstance(art, dict) and "parts" in art + ) + ) + if artifacts and isinstance(artifacts, list) + else () + ) - return entries + return case1 + case2 + case3 + case4 + case5 def _extract_texts_from_parts( self, @@ -435,12 +445,12 @@ class A2AGuardrailHandler(BaseTranslation): return () def _scan(part_idx: int, part: dict[str, Any]) -> tuple[tuple[str, tuple[str, ...], int, str], ...]: - kind = part.get("kind") + kind: Final = part.get("kind") if kind == "text": - text = part.get("text", "") + text: Final = part.get("text", "") return ((text, path, part_idx, "text"),) if text else () if kind == "data": - part_data = part.get("data") + part_data: Final = part.get("data") if part_data is None: return () return ((serialize_a2a_data_part(part_data), path, part_idx, "data"),) From 459efd00b95280f052deae4eebfd8ccadf64ee03 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Tue, 13 Oct 2026 12:07:36 +0530 Subject: [PATCH 11/12] ci: assign test_regression_issue_28553.py to the responses-caching-types shard Added by this branch but never registered in any shard's test-path list, so assert_ci_coverage.py --shards (and the no-flag ownership check) both flagged it as invoked by no job after the upstream rebase. --- .github/workflows/test-unit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index e9236ee7787..b26d953494d 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -694,6 +694,7 @@ jobs: tests/unit/responses/test_metadata_codex_callback.py tests/unit/responses/test_no_duplicate_spend_logs.py tests/unit/responses/test_null_test_fix.py + tests/unit/responses/test_regression_issue_28553.py tests/unit/responses/test_responses_api_bridge_flag.py tests/unit/responses/test_responses_api_lifecycle.py tests/unit/responses/test_responses_api_request_body.py From e4cd222fd9e5418d139746fbfa7f54b1c179d5ab Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Tue, 13 Oct 2026 12:41:16 +0530 Subject: [PATCH 12/12] fix(a2a): type _scan's part parameter as _A2ATextPart, not dict[str, Any] basedpyright's reportArgumentType flagged _scan (called with elements of Sequence[_A2ATextPart]) still typed to accept dict[str, Any], a stricter mismatch than the sibling _scan_input_part, which is called with real dict[str, Any] parts and had no such mismatch. Matching the real element type also clears 3 cascading reportUnknownArgumentType/ reportUnknownVariableType errors downstream of the call. --- litellm/llms/a2a/chat/guardrail_translation/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/a2a/chat/guardrail_translation/handler.py b/litellm/llms/a2a/chat/guardrail_translation/handler.py index 8728b9db7a1..7b132c30e00 100644 --- a/litellm/llms/a2a/chat/guardrail_translation/handler.py +++ b/litellm/llms/a2a/chat/guardrail_translation/handler.py @@ -444,7 +444,7 @@ class A2AGuardrailHandler(BaseTranslation): if depth >= max_depth: return () - def _scan(part_idx: int, part: dict[str, Any]) -> tuple[tuple[str, tuple[str, ...], int, str], ...]: + def _scan(part_idx: int, part: _A2ATextPart) -> tuple[tuple[str, tuple[str, ...], int, str], ...]: kind: Final = part.get("kind") if kind == "text": text: Final = part.get("text", "")