From 2b6471550591ee7fc5f70f79e66a6e4080df442b Mon Sep 17 00:00:00 2001 From: George Weale Date: Wed, 3 Dec 2025 19:16:43 -0800 Subject: [PATCH] fix: Handle string function responses in LiteLLM conversion When converting `types.Content` with a `function_response` to LiteLLM's `ChatCompletionToolMessage`, if the response is already a string, use it directly. Otherwise, serialize the response to JSON. This prevents double-serialization of string payloads Close #3676 Co-authored-by: George Weale PiperOrigin-RevId: 840013822 --- src/google/adk/models/lite_llm.py | 8 +++++- tests/unittests/models/test_litellm.py | 37 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index ba1f767d..5098425e 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -410,11 +410,17 @@ async def _content_to_message_param( tool_messages = [] for part in content.parts: if part.function_response: + response = part.function_response.response + response_content = ( + response + if isinstance(response, str) + else _safe_json_serialize(response) + ) tool_messages.append( ChatCompletionToolMessage( role="tool", tool_call_id=part.function_response.id, - content=_safe_json_serialize(part.function_response.response), + content=response_content, ) ) if tool_messages: diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 53208f05..094fde77 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -1382,6 +1382,43 @@ async def test_content_to_message_param_multi_part_function_response(): assert messages[1]["content"] == '{"value": 123}' +@pytest.mark.asyncio +async def test_content_to_message_param_function_response_preserves_string(): + """Tests that string responses are used directly without double-serialization. + + The google.genai FunctionResponse.response field is typed as dict, but + _content_to_message_param defensively handles string responses to avoid + double-serialization. This test verifies that behavior by mocking a + function_response with a string response attribute. + """ + response_payload = '{"type": "files", "count": 2}' + + # Create a Part with a dict response, then mock the response to be a string + # to simulate edge cases where response might be set directly as a string + part = types.Part.from_function_response( + name="list_files", + response={"placeholder": "will be mocked"}, + ) + + # Mock the response attribute to return a string + # Using Mock without spec_set to allow setting response to a string, + # which simulates the edge case we're testing + mock_function_response = Mock(spec=types.FunctionResponse) + mock_function_response.response = response_payload + mock_function_response.id = "tool_call_1" + part.function_response = mock_function_response + + content = types.Content( + role="tool", + parts=[part], + ) + message = await _content_to_message_param(content) + + assert message["role"] == "tool" + assert message["tool_call_id"] == "tool_call_1" + assert message["content"] == response_payload + + @pytest.mark.asyncio async def test_content_to_message_param_assistant_message(): content = types.Content(