From 6fb312fb1d76dcd357c659d1cf92685adf9d98f3 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 27 Apr 2026 21:46:18 +0530 Subject: [PATCH] fix(realtime): include function name in toolResponse (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store call_id → name mapping when receiving toolCall from Gemini - Look up and include name in functionResponses when sending tool results - Required by Gemini Live API spec for proper tool call routing - Add test to verify name field is included in round-trip Made-with: Cursor --- .../llms/gemini/realtime/transformation.py | 51 ++++++++++---- .../test_gemini_realtime_transformation.py | 67 +++++++++++++++++++ 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/litellm/llms/gemini/realtime/transformation.py b/litellm/llms/gemini/realtime/transformation.py index c3432032fc8..0c6b04c41d4 100644 --- a/litellm/llms/gemini/realtime/transformation.py +++ b/litellm/llms/gemini/realtime/transformation.py @@ -68,6 +68,11 @@ MAP_GEMINI_FIELD_TO_OPENAI_EVENT: Dict[str, Union[OpenAIRealtimeEventTypes, Resp class GeminiRealtimeConfig(BaseRealtimeConfig): + def __init__(self): + super().__init__() + # Store call_id → function_name mapping for tool call round-trip + self._tool_call_id_to_name: Dict[str, str] = {} + def validate_environment( self, headers: dict, model: str, api_key: Optional[str] = None ) -> dict: @@ -283,15 +288,25 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): except json.JSONDecodeError: output_dict = {"result": output} + # Look up the function name from stored mapping + function_name = self._tool_call_id_to_name.get(call_id) + if not function_name: + verbose_logger.warning( + f"Gemini Realtime: Function name not found for call_id={call_id}. " + "This may cause Gemini to reject the response." + ) + # Build Gemini toolResponse format + function_response = { + "id": call_id, + "response": output_dict, + } + if function_name: + function_response["name"] = function_name + tool_response_message = { "toolResponse": { - "functionResponses": [ - { - "id": call_id, - "response": output_dict, - } - ] + "functionResponses": [function_response] } } @@ -679,6 +694,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): Transform Gemini toolCall message to OpenAI function call events. Converts Gemini's functionCalls format to OpenAI's response.function_call_arguments.done events. + Also stores call_id → name mapping for later use in function_call_output responses. """ function_calls = tool_call_message.get("functionCalls", []) resolved_response_id = response_id or f"resp_{uuid.uuid4()}" @@ -687,19 +703,28 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): verbose_logger.debug( f"Gemini Realtime: Transforming {len(function_calls)} tool call(s) to OpenAI format" ) - return [ - { + + events = [] + for idx, fc in enumerate(function_calls): + call_id = fc.get("id", "") + name = fc.get("name", "") + + # Store call_id → name mapping for round-trip + if call_id and name: + self._tool_call_id_to_name[call_id] = name + + events.append({ "type": "response.function_call_arguments.done", "event_id": f"event_{uuid.uuid4()}", "response_id": resolved_response_id, "item_id": f"{resolved_output_item_id}_tool_{idx}", "output_index": idx, - "call_id": fc.get("id", ""), - "name": fc.get("name", ""), + "call_id": call_id, + "name": name, "arguments": json.dumps(fc.get("args", {})), - } - for idx, fc in enumerate(function_calls) - ] + }) + + return events @staticmethod def get_nested_value(obj: dict, path: str) -> Any: diff --git a/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py b/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py index c531f949232..3aefc4d149f 100644 --- a/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py +++ b/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py @@ -674,3 +674,70 @@ def test_gemini_tool_call_emits_response_created_preamble(): assert responses[5]["response"]["status"] == "completed" assert len(responses[5]["response"]["output"]) == 1 assert responses[5]["response"]["output"][0]["type"] == "function_call" + + +def test_gemini_function_call_output_includes_name(): + """Verify function_call_output includes name field from stored mapping.""" + config = GeminiRealtimeConfig() + + # First, receive a toolCall from Gemini (this stores the call_id → name mapping) + gemini_tool_call = { + "toolCall": { + "functionCalls": [ + { + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"} + } + ] + } + } + + logging_obj = MagicMock() + logging_obj.litellm_trace_id = "trace_123" + + config.transform_realtime_response( + json.dumps(gemini_tool_call), + "gemini-2.5-flash", + logging_obj, + realtime_response_transform_input={ + "session_configuration_request": None, + "current_output_item_id": None, + "current_response_id": None, + "current_conversation_id": None, + "current_delta_chunks": [], + "current_item_chunks": [], + "current_delta_type": None, + }, + ) + + # Verify mapping was stored + assert "call_123" in config._tool_call_id_to_name + assert config._tool_call_id_to_name["call_123"] == "get_weather" + + # Now send a function_call_output back (this should include the name) + function_output = { + "type": "conversation.item.create", + "item": { + "type": "function_call_output", + "call_id": "call_123", + "output": json.dumps({"result": "72 degrees"}) + } + } + + result = config.transform_realtime_request( + json.dumps(function_output), + "gemini-2.5-flash", + session_configuration_request="{}", + ) + + assert len(result) == 1 + tool_response = json.loads(result[0]) + assert "toolResponse" in tool_response + assert "functionResponses" in tool_response["toolResponse"] + assert len(tool_response["toolResponse"]["functionResponses"]) == 1 + + function_response = tool_response["toolResponse"]["functionResponses"][0] + assert function_response["id"] == "call_123" + assert function_response["name"] == "get_weather" # ✅ Name is included + assert "response" in function_response