diff --git a/litellm/google_genai/adapters/transformation.py b/litellm/google_genai/adapters/transformation.py index c1895376675..bacb007171e 100644 --- a/litellm/google_genai/adapters/transformation.py +++ b/litellm/google_genai/adapters/transformation.py @@ -255,6 +255,7 @@ def _genai_content_messages(content: object) -> tuple[AllMessageValues, ...]: return () parts: Final = _genai_content_parts(fields.get("parts", ())) converted: Final = _genai_message_parts(parts) + responses: Final = tuple(response for part in parts if (response := _genai_tool_response(part)) is not None) role: Final = fields.get("role", "user") if role == "user": user_message: Final = ( @@ -269,18 +270,20 @@ def _genai_content_messages(content: object) -> tuple[AllMessageValues, ...]: if converted else () ) - responses: Final = tuple(response for part in parts if (response := _genai_tool_response(part)) is not None) return (*user_message, *responses) if role != "model": return () text: Final = "".join(part["text"] for part in converted if part["type"] == "text") calls: Final = [call for part in parts if (call := _genai_tool_call(part)) is not None] assistant_content: Final = list(converted) if any(part["type"] != "text" for part in converted) else text or None - return ( + if responses and not calls and assistant_content is None: + return responses + assistant_message: Final = ( ChatCompletionAssistantMessage(role="assistant", content=assistant_content, tool_calls=calls) if calls - else ChatCompletionAssistantMessage(role="assistant", content=assistant_content), + else ChatCompletionAssistantMessage(role="assistant", content=assistant_content) ) + return (assistant_message, *responses) class GoogleGenAIStreamWrapper(AdapterCompletionStreamWrapper): diff --git a/tests/unit/google_genai/test_google_genai_adapter.py b/tests/unit/google_genai/test_google_genai_adapter.py index 4ab642dc99d..2a79ce05efc 100644 --- a/tests/unit/google_genai/test_google_genai_adapter.py +++ b/tests/unit/google_genai/test_google_genai_adapter.py @@ -194,6 +194,80 @@ def test_native_function_call_preserves_arguments_and_matching_response(args: ob ] +@pytest.mark.parametrize("response_key", ["functionResponse", "function_response"]) +def test_model_turn_function_response_answers_the_preceding_call(response_key: str) -> None: + result: Final = {"output": "return 1"} + messages: Final = GoogleGenAIAdapter()._transform_contents_to_messages( + [ + {"role": "user", "parts": [{"text": "Read /tmp/a.py"}]}, + {"role": "model", "parts": [{"functionCall": {"name": "view_file", "args": {"path": "/tmp/a.py"}}}]}, + {"role": "model", "parts": [{response_key: {"name": "view_file", "response": result}}]}, + ] + ) + assert [message["role"] for message in messages] == ["user", "assistant", "tool"] + assert messages[2]["tool_call_id"] == messages[1]["tool_calls"][0]["id"] + assert json.loads(messages[2]["content"]) == result + + +def test_model_turn_with_call_and_response_puts_the_result_after_the_call() -> None: + text: Final = "Checking the weather" + result: Final = {"temp": "4C"} + messages: Final = GoogleGenAIAdapter()._transform_contents_to_messages( + [ + { + "role": "model", + "parts": [ + {"text": text}, + {"functionCall": {"name": "get_weather", "args": {"city": "Oslo"}}}, + {"functionResponse": {"name": "get_weather", "response": result}}, + ], + } + ] + ) + assert [message["role"] for message in messages] == ["assistant", "tool"] + assert messages[0]["content"] == text + assert messages[1]["tool_call_id"] == messages[0]["tool_calls"][0]["id"] + assert json.loads(messages[1]["content"]) == result + + +def test_model_turn_text_next_to_a_function_response_is_not_dropped() -> None: + text: Final = "Here is what the file contains" + result: Final = {"output": "return 1"} + messages: Final = GoogleGenAIAdapter()._transform_contents_to_messages( + [{"role": "model", "parts": [{"text": text}, {"functionResponse": {"name": "view_file", "response": result}}]}] + ) + assert [message["role"] for message in messages] == ["assistant", "tool"] + assert messages[0]["content"] == text + assert json.loads(messages[1]["content"]) == result + + +def test_model_turn_function_response_keeps_its_media() -> None: + result: Final = {"ok": True} + mime: Final = "image/png" + data: Final = "bWVkaWE=" + messages: Final = GoogleGenAIAdapter()._transform_contents_to_messages( + [ + {"role": "model", "parts": [{"functionCall": {"name": "screenshot", "args": {}}}]}, + { + "role": "model", + "parts": [ + { + "functionResponse": { + "name": "screenshot", + "response": result, + "parts": [{"inlineData": {"mimeType": mime, "data": data}}], + } + } + ], + }, + ] + ) + assert [message["role"] for message in messages] == ["assistant", "tool"] + text_part, media_part = messages[1]["content"] + assert json.loads(text_part["text"]) == result + assert media_part["image_url"]["url"] == f"data:{mime};base64,{data}" + + def test_adapter_import(): """Test that the adapter can be imported successfully""" from litellm.google_genai.adapters.handler import GenerateContentToCompletionHandler