mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge 67019f669d into 3746ba58d7
This commit is contained in:
commit
ea8ddc6a8f
2 changed files with 189 additions and 0 deletions
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue