From c60b2dd9877cec21e0afd447ed471b63ea147069 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 18 Dec 2025 21:59:30 -0300 Subject: [PATCH] fix(responses-api): fix tool calls transformation in completion bridge Fixes two bugs in the openai/responses/... completion bridge: 1. function_call_output.output must be a string, not a list - When sending tool results back to the model, the content was being transformed to [{type: "output_text", text: "..."}] instead of a plain string - This caused OpenAI to reject with "Invalid value: 'output_text'" 2. Multiple tool calls must be in a single choice, not separate choices - When the model returned multiple tool calls, each was put in its own Choice with index 0, 1, 2... instead of all together in one Choice - This broke the standard Chat Completions API format where all tool_calls belong in a single message Fixes #18201 --- .../transformation.py | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 612bec239ba..27232d616f2 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -167,24 +167,28 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ) elif role == "tool": # Convert tool message to function call output format - # Transform content to responses format (handles str, list, and other types) - # _convert_content_to_responses_format always returns List[Dict[str, Any]] + # The Responses API expects 'output' to be a string, not a list if content is None: - transformed_output: list[dict[str, Any]] = [] - elif isinstance(content, (str, list)): - transformed_output = self._convert_content_to_responses_format( - content, "tool" - ) + output_str = "" + elif isinstance(content, str): + output_str = content + elif isinstance(content, list): + # If content is a list, extract text parts and join them + text_parts = [] + for item in content: + if isinstance(item, str): + text_parts.append(item) + elif isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + output_str = " ".join(text_parts) if text_parts else str(content) else: - # Fallback: convert unexpected types to string first - transformed_output = self._convert_content_to_responses_format( - str(content), "tool" - ) + # Fallback: convert unexpected types to string + output_str = str(content) input_items.append( { "type": "function_call_output", "call_id": tool_call_id, - "output": transformed_output, + "output": output_str, } ) elif role == "assistant" and tool_calls and isinstance(tool_calls, list): @@ -345,6 +349,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): index = 0 reasoning_content: Optional[str] = None + # Collect all tool calls to put them in a single choice + # (Chat Completions API expects all tool calls in one message) + accumulated_tool_calls: List[Dict[str, Any]] = [] + tool_call_index = 0 + for item in output_items: if isinstance(item, ResponseReasoningItem): for summary_item in item.summary: @@ -378,20 +387,10 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): tool_call_dict = LiteLLMCompletionResponsesConfig.convert_response_function_tool_call_to_chat_completion_tool_call( tool_call_item=item, - index=index, + index=tool_call_index, ) - - msg = Message( - content=None, - tool_calls=[tool_call_dict], - reasoning_content=reasoning_content, - ) - - choices.append( - Choices(message=msg, finish_reason="tool_calls", index=index) - ) - reasoning_content = None # flush reasoning content - index += 1 + accumulated_tool_calls.append(tool_call_dict) + tool_call_index += 1 elif isinstance(item, dict) and handle_raw_dict_callback is not None: # Handle raw dict responses (e.g., from GPT-5 Codex) @@ -401,6 +400,18 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): else: pass # don't fail request if item in list is not supported + # If we accumulated tool calls, create a single choice with all of them + if accumulated_tool_calls: + msg = Message( + content=None, + tool_calls=accumulated_tool_calls, + reasoning_content=reasoning_content, + ) + choices.append( + Choices(message=msg, finish_reason="tool_calls", index=index) + ) + reasoning_content = None + return choices def transform_response( # noqa: PLR0915