fix: preserve tool output ordering for gemini in responses bridge (#19360)

* fix: preserve tool output ordering for gemini in responses bridge

- Keep function_call_output adjacent to its function_call when building chat messages
- Normalize function_call_output.output lists (input_* parts) into tool message content

* fix test

* small improvements
This commit is contained in:
victorigualada
2026-01-19 20:37:59 -08:00
committed by GitHub
parent 581d086c20
commit 7d6d419a67
3 changed files with 242 additions and 27 deletions
@@ -367,14 +367,6 @@ class LiteLLMCompletionResponsesConfig:
ChatCompletionResponseMessage,
]
] = []
tool_call_output_messages: List[
Union[
AllMessageValues,
GenericChatCompletionMessage,
ChatCompletionMessageToolCall,
ChatCompletionResponseMessage,
]
] = []
if isinstance(input, str):
messages.append(ChatCompletionUserMessage(role="user", content=input))
@@ -385,15 +377,6 @@ class LiteLLMCompletionResponsesConfig:
input_item=_input
)
#########################################################
# If Input Item is a Tool Call Output, add it to the tool_call_output_messages list
#########################################################
if LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(
input_item=_input
):
tool_call_output_messages.extend(chat_completion_messages)
continue
if LiteLLMCompletionResponsesConfig._is_input_item_function_call(
input_item=_input
):
@@ -401,15 +384,57 @@ class LiteLLMCompletionResponsesConfig:
if call_id_raw:
existing_tool_call_ids.add(str(call_id_raw))
messages.extend(chat_completion_messages)
#########################################################
# If Input Item is a Tool Call Output, add it to the tool_call_output_messages list
# preserving the ordering of tool call outputs. Some models require the tool
# result to immediately follow the assistant tool call.
#########################################################
if LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(
input_item=_input
):
if not chat_completion_messages:
continue
deduped_tool_call_messages = (
LiteLLMCompletionResponsesConfig._deduplicate_tool_call_output_messages(
tool_call_output_messages=tool_call_output_messages,
existing_tool_call_ids=existing_tool_call_ids,
)
)
messages.extend(deduped_tool_call_messages)
deduped_in_place: List[Any] = []
for m in chat_completion_messages:
role = ""
if isinstance(m, dict):
role = str(m.get("role") or "")
else:
role = str(getattr(m, "role", "") or "")
# Drop assistant tool_calls wrappers if we already have this call_id
if role == "assistant":
tool_calls: Any = (
m.get("tool_calls")
if isinstance(m, dict)
else getattr(m, "tool_calls", None)
)
call_id = ""
if (
isinstance(tool_calls, Sequence)
and not isinstance(tool_calls, (str, bytes))
and len(tool_calls) > 0
):
first_call = tool_calls[0]
call_id_raw = (
first_call.get("id")
if isinstance(first_call, dict)
else getattr(first_call, "id", None)
)
if call_id_raw:
call_id = str(call_id_raw)
if call_id and call_id in existing_tool_call_ids:
continue
if call_id:
existing_tool_call_ids.add(call_id)
deduped_in_place.append(m)
messages.extend(deduped_in_place)
continue
messages.extend(chat_completion_messages)
return messages
@staticmethod
@@ -821,10 +846,82 @@ class LiteLLMCompletionResponsesConfig:
# Empty call_id means we can't create a valid tool message
if not call_id:
return []
def _normalize_function_call_output_to_tool_content(
output: Any,
) -> Any:
"""
Normalize Responses API function_call_output.output into a shape that downstream
chat adapters (esp. Gemini) can reliably consume.
OpenAI Responses API typically uses:
- output: string
Some clients/adapters send:
- output: [{"type": "input_text", "text": "..."}, {"type": "input_image", ...}]
For chat tool messages we normalize to either:
- string (preferred)
- list of {"type": "text"|"image_url", ...} blocks (for multimodal tool outputs)
"""
if output is None:
return ""
if isinstance(output, str):
return output
# Some adapters represent tool output as a list of "input_*" parts
if isinstance(output, list):
normalized_blocks: List[Dict[str, Any]] = []
text_acc: List[str] = []
for part in output:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type in ("input_text", "output_text", "text"):
txt = part.get("text")
if isinstance(txt, str) and txt:
text_acc.append(txt)
normalized_blocks.append({"type": "text", "text": txt})
elif part_type in ("input_image", "image_url"):
image_url_val = part.get("image_url") or part.get("url")
if isinstance(image_url_val, dict):
url = image_url_val.get("url")
if isinstance(url, str) and url:
normalized_blocks.append(
{"type": "image_url", "image_url": {"url": url}}
)
elif isinstance(image_url_val, str) and image_url_val:
normalized_blocks.append(
{"type": "image_url", "image_url": {"url": image_url_val}}
)
# Prefer structured blocks if we have images; otherwise return a string.
if any(b.get("type") == "image_url" for b in normalized_blocks):
# Ensure we include any accumulated text as text blocks too
return normalized_blocks
if text_acc:
return "".join(text_acc)
try:
# last resort: keep something meaningful for providers that require a string
import json as _json
return _json.dumps(output)
except Exception:
return str(output)
# Fallback for dict/number/etc.
try:
import json as _json
return _json.dumps(output)
except Exception:
return str(output)
tool_output_message = ChatCompletionToolMessage(
role="tool",
content=tool_call_output.get("output") or "",
content=_normalize_function_call_output_to_tool_content(
tool_call_output.get("output")
),
tool_call_id=str(call_id),
)
@@ -0,0 +1,40 @@
"""
Tests for normalizing Responses API function_call_output into chat tool messages.
This is important for Gemini/Vertex, which expects tool results to be represented
as tool/function response parts; if the tool output is passed as a list of input_* parts,
we normalize it to text/image blocks or a string.
"""
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
def test_function_call_output_list_input_text_is_converted_to_tool_string_content():
out = LiteLLMCompletionResponsesConfig._transform_responses_api_tool_call_output_to_chat_completion_message(
tool_call_output={
"type": "function_call_output",
"call_id": "call_1",
"output": [{"type": "input_text", "text": "hello"}, {"type": "input_text", "text": " world"}],
}
)
assert len(out) == 1
msg = out[0]
assert msg["role"] == "tool"
assert msg["tool_call_id"] == "call_1"
assert msg["content"] == "hello world"
def test_function_call_output_string_passthrough():
out = LiteLLMCompletionResponsesConfig._transform_responses_api_tool_call_output_to_chat_completion_message(
tool_call_output={
"type": "function_call_output",
"call_id": "call_1",
"output": '{"ok":true}',
}
)
assert len(out) == 1
assert out[0]["content"] == '{"ok":true}'
@@ -0,0 +1,78 @@
"""
Regression: preserve function_call_output ordering.
Gemini/Vertex requires tool outputs to immediately follow the assistant tool call.
The ResponsesAPI->Chat conversion must not move tool outputs to the end.
"""
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
def test_function_call_output_stays_adjacent_to_tool_call():
msgs = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=[
{
"role": "user",
"type": "message",
"content": [{"type": "input_text", "text": "Call echo with 'hello'."}],
},
{
"type": "function_call",
"name": "echo",
"call_id": "call_123",
"arguments": '{"text":"hello"}',
},
{
"type": "function_call_output",
"call_id": "call_123",
"output": '{"text":"hello"}',
},
{
"role": "assistant",
"type": "message",
"content": [{"type": "output_text", "text": "Done."}],
},
{
"role": "user",
"type": "message",
"content": [{"type": "input_text", "text": "Now say hi."}],
},
]
)
# Find the assistant message that contains tool_calls
tool_call_idx = None
tool_msg_idx = None
assistant_ok_idx = None
for i, m in enumerate(msgs):
if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls"):
tool_call_idx = i
if isinstance(m, dict) and m.get("role") == "tool":
tool_msg_idx = i
# Assistant "Done." can be either a plain string or a structured content list
if isinstance(m, dict) and m.get("role") == "assistant":
content = m.get("content")
if content == "Done.":
assistant_ok_idx = i
elif isinstance(content, list):
for block in content:
if (
isinstance(block, dict)
and block.get("type") == "text"
and block.get("text") == "Done."
):
assistant_ok_idx = i
break
assert tool_call_idx is not None
assert tool_msg_idx is not None
assert assistant_ok_idx is not None
# Tool output must be right after tool call, and before the assistant "Done." message.
assert tool_msg_idx == tool_call_idx + 1
assert assistant_ok_idx > tool_msg_idx