mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-16 04:25:10 +00:00
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:
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
+40
@@ -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}'
|
||||
|
||||
+78
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user