From 67019f669d0d7bbf17ab33e0b562e3a00f9691d1 Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Tue, 25 Aug 2026 14:05:32 +0800 Subject: [PATCH] fix(responses): lift function_call from mixed assistant content to tool_calls A Responses API assistant message whose content list mixes text/output_text parts with function_call parts was dropped on the floor when transformed to a Chat Completion message. Lift the function_call parts into tool_calls (indexed by tool-call position) while keeping the text content, so the round-trip preserves both. Adds a focused transform + regression tests. --- .../transformation.py | 70 +++++++++++ ...t_responses_mixed_content_function_call.py | 119 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_responses_mixed_content_function_call.py diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 1381ad36d12..0f4f3c0d6cb 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1278,6 +1278,23 @@ class LiteLLMCompletionResponsesConfig: # Since guardrails skip None content anyway, we return empty list to exclude it from structured messages if content is None: return [] + + # An assistant message can carry both text and function_call items mixed + # inside `content`. The top-level `type` is not "function_call" in that + # case (it's a regular message item with a `role`), so the function_call + # parts would otherwise be silently dropped here — leaving the downstream + # `function_call_output` orphaned and causing "Missing corresponding tool + # call for tool response message". Split them out into a proper assistant + # message with both `content` and `tool_calls`. See issue #28978. + if ( + _input_item_role(input_item) == "assistant" + and isinstance(content, list) + and any(isinstance(part, dict) and part.get("type") == "function_call" for part in content) + ): + return LiteLLMCompletionResponsesConfig._transform_responses_api_mixed_assistant_content_to_chat_completion_message( + content_list=content + ) + return [ GenericChatCompletionMessage( role=_input_item_role(input_item), @@ -1603,6 +1620,59 @@ class LiteLLMCompletionResponsesConfig: return [chat_completion_response_message] + @staticmethod + def _is_function_call_part(part: object) -> bool: + return isinstance(part, dict) and part.get("type") == "function_call" + + @staticmethod + def _transform_responses_api_mixed_assistant_content_to_chat_completion_message( + content_list: Sequence[object], + ) -> list[ChatCompletionResponseMessage]: # mutable-ok: caller returns this message list verbatim + """ + Transform a Responses API assistant message whose `content` list mixes + text/output_text parts with `function_call` parts into a single Chat + Completion assistant message carrying both `content` and `tool_calls`. + + The non-mixed cases are already handled by: + - `_transform_responses_api_function_call_to_chat_completion_message` + (top-level item with `type == "function_call"`) + - `_transform_responses_api_content_to_chat_completion_content` + (top-level item with text-only content) + """ + is_call: Final = LiteLLMCompletionResponsesConfig._is_function_call_part + + # index = position among tool calls, not position in the raw content list + # (a leading text part must not shift the first tool call off 0). + tool_calls: Final = [ # mutable-ok: built once, handed to the message verbatim + ChatCompletionToolCallChunk( + id=part.get("call_id") or part.get("id") or "", + type="function", + function=ChatCompletionToolCallFunctionChunk( + name=part.get("name") or "", + arguments=str(part.get("arguments") or ""), + ), + index=idx, + ) + for idx, part in enumerate(p for p in content_list if is_call(p)) + ] + non_call_parts: Final = [ # mutable-ok: forwarded to the content transformer + p for p in content_list if not is_call(p) + ] + + transformed_content: Final = ( + LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(non_call_parts) + if non_call_parts + else None + ) + + return [ # mutable-ok: single-message result list is the documented return shape + ChatCompletionResponseMessage( + role="assistant", + content=transformed_content, + tool_calls=tool_calls, + ) + ] + @staticmethod def _resolve_file_id(item: Mapping[str, object]) -> object: """ diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_responses_mixed_content_function_call.py b/tests/test_litellm/responses/litellm_completion_transformation/test_responses_mixed_content_function_call.py new file mode 100644 index 00000000000..2740e12bdd2 --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_responses_mixed_content_function_call.py @@ -0,0 +1,119 @@ +""" +Regression test for issue #28978: + +Responses API: function_call not converted to tool_calls in mixed-content +assistant messages. + +Before the fix, an assistant input item whose `content` list mixed text and +`function_call` parts had the function_call silently dropped, leaving the +matching `function_call_output` orphaned and breaking the downstream Chat +Completions conversation with: + + Missing corresponding tool call for tool response message. +""" + +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) + + +def _make_input(): + """Repro payload from issue #28978.""" + return [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "ok"}, + { + "type": "function_call", + "id": "call_good", + "name": "test", + "arguments": "{}", + }, + ], + }, + { + "type": "function_call_output", + "call_id": "call_good", + "output": "result", + }, + { + "role": "user", + "content": [{"type": "input_text", "text": "continue"}], + }, + ] + + +def _get(msg, key, default=None): + return msg.get(key, default) if isinstance(msg, dict) else getattr(msg, key, default) + + +def test_mixed_content_function_call_emits_tool_calls(): + """The function_call part inside an assistant.content list must be lifted + into the assistant message's `tool_calls`, with text parts preserved as + content. The follow-up function_call_output must still be paired by + matching call_id.""" + messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message( + input=_make_input() + ) + + roles = [_get(m, "role") for m in messages] + assert "assistant" in roles, f"missing assistant message: roles={roles}" + + assistant_msg = next(m for m in messages if _get(m, "role") == "assistant") + tool_calls = _get(assistant_msg, "tool_calls") or [] + assert len(tool_calls) == 1, ( + f"expected exactly 1 tool_call lifted from mixed content, got {len(tool_calls)} (assistant={assistant_msg})" + ) + tc = tool_calls[0] + tc_id = _get(tc, "id") + tc_fn = _get(tc, "function") or {} + assert tc_id == "call_good", f"tool_call id mismatch: {tc_id!r}" + assert _get(tc_fn, "name") == "test" + assert _get(tc_fn, "arguments") == "{}" + # index must be the position among tool_calls (0), not the position in the + # raw content list (would be 1 here, after the leading text part). #29328 + assert _get(tc, "index") == 0, f"tool_call index should be 0, got {_get(tc, 'index')!r}" + + # Text part should survive as content (either as a list with text block + # or as the normalized string "ok"). What we care about is that "ok" + # is still reachable. + content = _get(assistant_msg, "content") + if isinstance(content, list): + text_blob = "".join((p.get("text") if isinstance(p, dict) else "") or "" for p in content) + else: + text_blob = content or "" + assert "ok" in text_blob, f"text part lost: content={content!r}" + + +def test_mixed_content_pairs_with_function_call_output(): + """The matching `function_call_output` (a `role=tool` message after the + transformation) must be present and reference the same call_id.""" + messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message( + input=_make_input() + ) + + tool_msgs = [m for m in messages if _get(m, "role") == "tool"] + assert len(tool_msgs) == 1, ( + f"expected exactly 1 tool message paired with the lifted tool_call, " + f"got {len(tool_msgs)} (roles={[_get(m, 'role') for m in messages]})" + ) + assert _get(tool_msgs[0], "tool_call_id") == "call_good" + + +def test_text_only_assistant_unchanged(): + """Regression guard — a text-only assistant.content list must not gain a + tool_calls entry from the new code path.""" + messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message( + input=[ + { + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + } + ] + ) + assert len(messages) == 1 + assert _get(messages[0], "role") == "assistant" + assert not (_get(messages[0], "tool_calls") or []), ( + f"text-only assistant gained spurious tool_calls: {messages[0]!r}" + )