fix(google_genai): map functionResponse parts in model turns to tool messages

Google's Antigravity CLI sends tool results as role "model" turns
containing functionResponse parts. The generateContent->completion
adapter only mapped functionResponse inside user turns, so these were
dropped, the request ended on an assistant turn, and Gemini rejected it
with "Requests ending with a model turn are not supported."
This commit is contained in:
Luca (Sanity Horizon) 2026-10-13 22:35:37 +00:00
parent 7bd108139f
commit edb8d8df36
2 changed files with 90 additions and 0 deletions

View file

@ -547,6 +547,9 @@ class GoogleGenAIAdapter:
# Handle assistant messages with potential function calls
combined_text = ""
tool_calls: list[ChatCompletionAssistantToolCall] = []
# Some clients (e.g. Google Antigravity CLI) send functionResponse
# parts in a "model" turn rather than a "user" turn.
model_tool_messages: list[ChatCompletionToolMessage] = []
for part in parts:
if isinstance(part, dict):
@ -564,9 +567,23 @@ class GoogleGenAIAdapter:
),
)
tool_calls.append(tool_call)
elif "functionResponse" in part:
func_response = part["functionResponse"]
model_tool_messages.append(
ChatCompletionToolMessage(
role="tool",
tool_call_id=f"call_{func_response.get('name', 'unknown')}",
content=json.dumps(func_response.get("response", {})),
)
)
elif isinstance(part, str):
combined_text += part
# A model turn holding only function responses becomes tool messages
if model_tool_messages and not combined_text and not tool_calls:
messages.extend(model_tool_messages)
continue
# Create assistant message
if tool_calls:
assistant_message = ChatCompletionAssistantMessage(
@ -581,6 +598,7 @@ class GoogleGenAIAdapter:
)
messages.append(assistant_message)
messages.extend(model_tool_messages)
return messages

View file

@ -1641,3 +1641,75 @@ async def test_generate_content_sends_response_schema_and_tool_parameters_to_the
}
assert response["candidates"][0]["content"]["parts"] == [{"text": '{"park_name": "EPCOT"}'}]
assert "text" not in response
def test_function_response_in_model_turn_becomes_tool_message():
"""Function responses sent in a "model" turn must not be dropped.
Google's Antigravity CLI sends tool results as
{"role": "model", "parts": [{"functionResponse": ...}]}. Dropping them left
the request ending on an assistant turn, which Gemini rejects with
"Requests ending with a model turn are not supported."
"""
from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter
adapter = GoogleGenAIAdapter()
contents = [
{"role": "user", "parts": [{"text": "Run the tests"}]},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "run_command",
"args": {"command": "python3 test_calc.py"},
}
}
],
},
{
"role": "model",
"parts": [
{
"functionResponse": {
"name": "run_command",
"response": {"output": "AssertionError"},
}
}
],
},
]
completion_request = adapter.translate_generate_content_to_completion(
model="gemini-pro", contents=contents
)
messages = completion_request["messages"]
assert [m["role"] for m in messages] == ["user", "assistant", "tool"]
assert messages[1]["tool_calls"][0]["id"] == "call_run_command"
assert messages[2]["tool_call_id"] == "call_run_command"
assert json.loads(messages[2]["content"]) == {"output": "AssertionError"}
def test_model_turn_with_call_and_response_keeps_order():
"""A model turn holding both a call and its response yields assistant then tool."""
from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter
adapter = GoogleGenAIAdapter()
contents = [
{"role": "user", "parts": [{"text": "Weather?"}]},
{
"role": "model",
"parts": [
{"functionCall": {"name": "get_weather", "args": {"city": "Oslo"}}},
{"functionResponse": {"name": "get_weather", "response": {"temp": "4C"}}},
],
},
]
messages = adapter.translate_generate_content_to_completion(
model="gemini-pro", contents=contents
)["messages"]
assert [m["role"] for m in messages] == ["user", "assistant", "tool"]
assert messages[2]["tool_call_id"] == messages[1]["tool_calls"][0]["id"]