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
This commit is contained in:
Chesars 2025-12-18 21:59:30 -03:00
parent 9faee8bba6
commit c60b2dd987

View file

@ -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