diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index eaa80c6cfe..3badbc5057 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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), ) diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_function_call_output_normalization.py b/tests/test_litellm/responses/litellm_completion_transformation/test_function_call_output_normalization.py new file mode 100644 index 0000000000..19aeba7f9c --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_function_call_output_normalization.py @@ -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}' + diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py b/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py new file mode 100644 index 0000000000..5cb01fbae6 --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py @@ -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 +