From 8606550e3e396266b2d81b733ba2a4168f2f7b94 Mon Sep 17 00:00:00 2001 From: Mrunmayee Daware <165297479+Mrun25@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:45:36 +0530 Subject: [PATCH 1/5] fix(a2a): stop silently dropping unsupported sampling params --- litellm/llms/a2a/chat/transformation.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/litellm/llms/a2a/chat/transformation.py b/litellm/llms/a2a/chat/transformation.py index 6171b341ca4..df72f3663f1 100644 --- a/litellm/llms/a2a/chat/transformation.py +++ b/litellm/llms/a2a/chat/transformation.py @@ -131,13 +131,13 @@ class A2AConfig(BaseConfig): return api_base, api_key, headers def get_supported_openai_params(self, model: str) -> list[str]: - """Return list of supported OpenAI parameters""" - return [ - "stream", - "temperature", - "max_tokens", - "top_p", - ] + """Return list of supported OpenAI parameters. + + Only ``stream`` is forwarded (map_openai_params maps nothing else onto the + A2A JSON-RPC payload). Declaring sampling params as supported used to drop + them silently; leaving them unsupported makes callers fail loudly instead. + """ + return ["stream"] def map_openai_params( self, From 33a3ba3aeea6868d7435cb3b91cdfad5ec33eee7 Mon Sep 17 00:00:00 2001 From: Mrunmayee Daware <165297479+Mrun25@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:46:51 +0530 Subject: [PATCH 2/5] test(a2a): cover loud failure on unsupported sampling params --- .../a2a/chat/test_a2a_chat_transformation.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py b/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py index a419e73a2e1..584ef4e1e80 100644 --- a/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py +++ b/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py @@ -121,3 +121,36 @@ def test_resolve_agent_config_from_registry_returns_the_explicit_headers_object_ assert resolved[2] is headers assert resolved[:2] == ("http://explicit.example", "explicit-key") assert optional_params == {"stream": True} + + +def test_get_supported_openai_params_declares_only_stream(): + """Sampling params are not mapped onto the A2A JSON-RPC payload, so the config + must not declare them as supported - declaring them used to drop them silently.""" + assert A2AConfig().get_supported_openai_params(model="test-agent") == ["stream"] + + +def test_sampling_params_fail_loudly_instead_of_dropping_silently(): + """temperature/max_tokens/top_p are not forwarded to the agent; callers must get + a named error rather than a call that silently ignores their settings.""" + from litellm.exceptions import UnsupportedParamsError + from litellm.utils import get_optional_params + + for param in ("temperature", "max_tokens", "top_p"): + with pytest.raises(UnsupportedParamsError): + get_optional_params(model="test-agent", custom_llm_provider="a2a", **{param: 0.7}) + + +def test_drop_params_still_drops_unsupported_sampling_params(): + """With drop_params set, unsupported sampling params are stripped and only the + supported stream flag survives.""" + from litellm.utils import get_optional_params + + result = get_optional_params( + model="test-agent", + custom_llm_provider="a2a", + temperature=0.7, + stream=True, + drop_params=True, + ) + assert "temperature" not in result + assert result.get("stream") is True From 350fea6705535338bb09da7d8a5eb5fc721045c5 Mon Sep 17 00:00:00 2001 From: Mrunmayee Daware <165297479+Mrun25@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:10:06 +0530 Subject: [PATCH 3/5] Integrate Pydantic TypeAdapter for JSON validation --- litellm/llms/a2a/chat/transformation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/llms/a2a/chat/transformation.py b/litellm/llms/a2a/chat/transformation.py index df72f3663f1..79208416f52 100644 --- a/litellm/llms/a2a/chat/transformation.py +++ b/litellm/llms/a2a/chat/transformation.py @@ -7,6 +7,7 @@ from collections.abc import AsyncIterator, Iterator, Mapping from typing import TYPE_CHECKING, Final import httpx +from pydantic import ConfigDict, TypeAdapter from litellm.llms.azure_ai.common_utils import AZURE_ENTRA_LITELLM_PARAM_KEYS, get_azure_ai_agent_entra_token from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator @@ -30,6 +31,7 @@ if TYPE_CHECKING: _REGISTRY_PARAMS_KEPT_OUT_OF_OPTIONAL_PARAMS: Final = ( frozenset({"api_key", "api_base", "headers", "model"}) | AZURE_ENTRA_LITELLM_PARAM_KEYS ) +_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True)) def _card_declares_no_streaming(agent_card_params: Mapping[str, object]) -> bool: @@ -325,7 +327,7 @@ class A2AConfig(BaseConfig): # Check for JSON-RPC error if "error" in response_json: - error: Final = response_json["error"] + error: Final = _JSON_OBJECT.validate_python(response_json["error"]) raise A2AError( status_code=raw_response.status_code, message=f"A2A error: {error.get('message', 'Unknown error')}", @@ -333,7 +335,7 @@ class A2AConfig(BaseConfig): ) # Extract text from A2A response - text: Final = extract_text_from_a2a_response(response_json) + text: Final = extract_text_from_a2a_response(_JSON_OBJECT.validate_python(response_json)) # Populate model response model_response.choices = [ From eef899d7532db59916a078a5d5ad654c01cbf779 Mon Sep 17 00:00:00 2001 From: Mrunmayee Daware <165297479+Mrun25@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:11:40 +0530 Subject: [PATCH 4/5] test(a2a): resolve conflict with main, keep sampling-param loud-failure tests --- .../a2a/chat/test_a2a_chat_transformation.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py b/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py index 584ef4e1e80..8c64bacd6bb 100644 --- a/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py +++ b/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py @@ -1,11 +1,15 @@ """Tests for litellm/llms/a2a/chat/transformation.py response transform.""" +import uuid from unittest.mock import MagicMock +import httpx import pytest +from pydantic import ValidationError from litellm.llms.a2a.chat.streaming_iterator import A2AModelResponseIterator from litellm.llms.a2a.chat.transformation import A2AConfig +from litellm.llms.a2a.common_utils import A2AError from litellm.types.utils import ModelResponse @@ -123,6 +127,107 @@ def test_resolve_agent_config_from_registry_returns_the_explicit_headers_object_ assert optional_params == {"stream": True} +def _transform(payload: object, headers: dict[str, str] | None = None) -> ModelResponse: + return A2AConfig().transform_response( + model="a2a/test-agent", + raw_response=httpx.Response(200, json=payload, headers=headers), + model_response=ModelResponse(), + logging_obj=None, + request_data={}, + messages=[{"role": "user", "content": "hi there agent"}], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + +@pytest.mark.parametrize( + ("result", "expected_text"), + [ + ({"kind": "message", "parts": [{"kind": "text", "text": "direct"}]}, "direct"), + ({"message": {"parts": [{"kind": "text", "text": "nested"}]}}, "nested"), + ({"kind": "task", "status": {"message": {"parts": [{"kind": "text", "text": "status"}]}}}, "status"), + ({"kind": "task", "artifacts": [{"parts": [{"kind": "text", "text": "artifact"}]}]}, "artifact"), + ({"kind": "task", "status": {"state": "submitted"}}, ""), + (None, ""), + ("plain text", ""), + ([{"kind": "text", "text": "not a result object"}], ""), + ], +) +def test_transform_response_extracts_the_text_of_each_result_shape(result: object, expected_text: str): + response = _transform({"jsonrpc": "2.0", "id": "resp-1", "result": result, "extra": None}) + + assert response.choices[0].message.content == expected_text + assert response.choices[0].finish_reason == "stop" + assert response.id == "resp-1" + assert response.model == "a2a/test-agent" + + +@pytest.mark.parametrize("payload", [{}, {"jsonrpc": "2.0", "result": {"parts": [{"kind": "text", "text": "4"}]}}]) +def test_transform_response_generates_an_id_when_the_agent_sends_none(payload: dict[str, object]): + response = _transform(payload) + + assert str(uuid.UUID(response.id)) == response.id + + +@pytest.mark.parametrize( + ("error", "expected_message"), + [ + ({"code": -32600, "message": "Invalid Request"}, "A2A error: Invalid Request"), + ({"code": -32600}, "A2A error: Unknown error"), + ({}, "A2A error: Unknown error"), + ({"message": None}, "A2A error: None"), + ({"message": 429}, "A2A error: 429"), + ({"message": {"detail": "nested"}}, "A2A error: {'detail': 'nested'}"), + ], +) +def test_transform_response_raises_the_agent_error_with_its_message(error: dict[str, object], expected_message: str): + payload = {"jsonrpc": "2.0", "id": "1", "error": error, "result": {"parts": [{"kind": "text", "text": "4"}]}} + + with pytest.raises(A2AError) as exc_info: + _transform(payload, headers={"x-agent": "billing"}) + + assert exc_info.value.message == expected_message + assert exc_info.value.status_code == 200 + assert exc_info.value.headers["x-agent"] == "billing" + + +@pytest.mark.parametrize("error", [None, "429 Too Many Requests", 429, False, ["Request timed out"]]) +def test_transform_response_rejects_an_error_member_that_is_not_an_object(error: object): + with pytest.raises(ValidationError) as exc_info: + _transform({"jsonrpc": "2.0", "id": "1", "error": error, "result": {"parts": []}}) + + assert [(detail["type"], detail["loc"]) for detail in exc_info.value.errors()] == [("dict_type", ())] + assert "input_value" not in str(exc_info.value) + + +@pytest.mark.parametrize("payload", [[], [{"result": {"parts": []}}], ["rate limit"], "upstream unavailable"]) +def test_transform_response_rejects_a_body_that_is_not_an_object(payload: object): + with pytest.raises(ValidationError) as exc_info: + _transform(payload) + + assert [(detail["type"], detail["loc"]) for detail in exc_info.value.errors()] == [("dict_type", ())] + assert "input_value" not in str(exc_info.value) + + +def test_transform_response_reports_an_undecodable_body_as_an_agent_error(): + with pytest.raises(A2AError) as exc_info: + A2AConfig().transform_response( + model="a2a/test-agent", + raw_response=httpx.Response(502, content=b"Bad Gateway"), + model_response=ModelResponse(), + logging_obj=None, + request_data={}, + messages=[{"role": "user", "content": "hi there agent"}], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert exc_info.value.status_code == 502 + assert exc_info.value.message.startswith("Failed to parse A2A response: ") + + def test_get_supported_openai_params_declares_only_stream(): """Sampling params are not mapped onto the A2A JSON-RPC payload, so the config must not declare them as supported - declaring them used to drop them silently.""" From 391e20bf4233a8a8fb9f9df1e6f7cbb95d157bf9 Mon Sep 17 00:00:00 2001 From: Mrunmayee Daware <165297479+Mrun25@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:12:46 +0530 Subject: [PATCH 5/5] test(a2a): restore resolved test file after bad empty commit