From d82aa1e4940e12dd85cfab4bcf4d689b3e66061d Mon Sep 17 00:00:00 2001 From: Meenal Gupta Date: Thu, 30 Apr 2026 17:16:39 +0530 Subject: [PATCH] fix(vertex): repair tool-call history for Gemini --- .../llms/vertex_ai/gemini/transformation.py | 19 +++++- .../test_vertex_ai_gemini_transformation.py | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 533bd06d2d8..0017f458f17 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -305,6 +305,16 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 user_message_types = {"user", "system"} contents: List[ContentType] = [] + # Pre-build tool_call_id → assistant message so we can recover + # last_message_with_tool_calls at the tool-response side when a provider + # (e.g. Codex CLI) sends assistant messages with an empty tool_calls list. + tool_call_map: dict = {} + for msg in messages: + if msg["role"] == "assistant": + for tc in msg.get("tool_calls") or []: + if "id" in tc: + tool_call_map[tc["id"]] = msg + last_message_with_tool_calls = None msg_i = 0 @@ -527,7 +537,10 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 ## HANDLE ASSISTANT FUNCTION CALL if ( - assistant_msg.get("tool_calls", []) is not None + ( + assistant_msg.get("tool_calls", []) is not None + and len(assistant_msg.get("tool_calls", [])) > 0 + ) or assistant_msg.get("function_call") is not None ): # support assistant tool invoke conversion gemini_tool_call_parts = convert_to_gemini_tool_call_invoke( @@ -589,6 +602,10 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 msg_i < len(messages) and messages[msg_i]["role"] in tool_call_message_roles ): + _tid = messages[msg_i].get("tool_call_id") + if _tid and _tid in tool_call_map: + last_message_with_tool_calls = tool_call_map[_tid] + _part = convert_to_gemini_tool_call_result( messages[msg_i], last_message_with_tool_calls # type: ignore ) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 831d1ef464b..8877ea6a98f 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -1913,3 +1913,61 @@ def test_multi_turn_function_calling_roles(): assert ( content["role"] == "user" ), f"Content block {i} with function_response has role='{content['role']}', expected 'user'" + + +def test_tool_call_history_repair_empty_tool_calls(): + """ + Regression test for: Codex CLI sends assistant messages with empty tool_calls + list, causing 'Missing corresponding tool call for tool response message'. + + Codex CLI sometimes emits: + {"role": "assistant", "content": "...", "tool_calls": []} + followed by a separate assistant message that has the actual tool_calls, + then a tool response referencing one of those calls. + + Before fix: last_message_with_tool_calls pointed to the empty-tool_calls + message, name recovery failed, Vertex AI returned 400. + + After fix: a pre-pass builds tool_call_id → assistant message so the + correct originating message is always found. + """ + messages = [ + {"role": "user", "content": "Run a shell command"}, + { + "role": "assistant", + "content": "I'll run that for you.", + "tool_calls": [], # empty — Codex CLI quirk + }, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "shell", + "arguments": '{"cmd": "echo hello"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_abc123", + "content": "hello", + }, + ] + + contents = _gemini_convert_messages_with_history(messages=messages) + + # The function_response part must have the correct function name + found = False + for content in contents: + for part in content.get("parts", []): + if "function_response" in part: + assert part["function_response"]["name"] == "shell", ( + f"Expected function name 'shell', got {part['function_response']['name']}" + ) + found = True + assert found, "No function_response part found in converted history"