fix(responses): extract choice helpers to cut complexity

I split _convert_response_output_to_choices into two static helpers.
One builds Choices from a ResponseOutputMessage's content blocks, the
other merges accumulated tool_calls into the choice list. This drops
the function's cyclomatic complexity from 17 to 12, back under the
ruff-strict C901 ceiling of 15 that scripts/ruff_strict_gate.py checks
against upstream/litellm_internal_staging.

Behavior is unchanged. All 71 existing tests in
test_completion_extras_litellm_responses_transformation_transformation.py
still pass.
This commit is contained in:
Stephen Chin 2026-08-06 19:31:46 +00:00 • committed by Stephen Chin
parent d7b2e0ca19
commit 834ab664cd

View file

@ -51,7 +51,11 @@ from litellm.types.llms.openai import (
from litellm.types.utils import GenericStreamingChunk, ModelResponseStream
if TYPE_CHECKING:
from openai.types.responses import ResponseInputImageParam, ResponseOutputItem
from openai.types.responses import (
ResponseInputImageParam,
ResponseOutputItem,
ResponseOutputMessage,
)
from openai.types.responses.response_text_config_param import (
ResponseTextConfigParam as ResponseText,
)
@ -658,6 +662,104 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
return request_data
@staticmethod
def _choices_from_response_output_message(
item: "ResponseOutputMessage",
starting_index: int,
reasoning_content: str | None,
pending_reasoning_item: dict[str, Any] | None,
) -> tuple[list[Any], int]:
"""Build one Choices per content block on a ResponseOutputMessage.
The first emitted choice carries the pending reasoning (content + items);
any subsequent content blocks emit choices with reasoning fields cleared,
matching the flush semantics of the original inline loop.
"""
from litellm.types.utils import Choices, Message
new_choices: Final[list[Any]] = []
current_index = starting_index
carry_reasoning_content = reasoning_content
carry_reasoning_item = pending_reasoning_item
for content in item.content:
response_text = getattr(content, "text", "")
raw_annotations = getattr(content, "annotations", None)
annotations = LiteLLMResponsesTransformationHandler._convert_annotations_to_chat_format(raw_annotations)
reasoning_items_for_msg = cast(
list[ChatCompletionReasoningItem] | None,
([carry_reasoning_item] if carry_reasoning_item is not None else None),
)
msg = Message(
role=item.role,
content=response_text or "",
reasoning_content=carry_reasoning_content,
annotations=annotations,
reasoning_items=reasoning_items_for_msg,
)
new_choices.append(Choices(message=msg, finish_reason="stop", index=current_index))
carry_reasoning_content = None
carry_reasoning_item = None
current_index += 1
return new_choices, current_index
@staticmethod
def _merge_accumulated_tool_calls_into_choices(
choices: list[Any],
accumulated_tool_calls: list[dict[str, Any]],
reasoning_content: str | None,
pending_reasoning_item: dict[str, Any] | None,
fallback_index: int,
) -> None:
"""Attach accumulated tool_calls to the last text-message choice, or
append a new tool-only choice if no text message was produced.
Backfills reasoning_content and reasoning_items onto the merged choice
so encrypted reasoning survives the assistant+tool_calls merge path.
"""
from litellm.types.utils import Choices, Message
last_msg_choice = next(
(
c
for c in reversed(choices)
if getattr(c, "message", None) is not None and not getattr(c.message, "tool_calls", None)
),
None,
)
if last_msg_choice is None:
reasoning_items_for_msg = cast(
list[ChatCompletionReasoningItem] | None,
([pending_reasoning_item] if pending_reasoning_item is not None else None),
)
msg = Message(
content=None,
tool_calls=accumulated_tool_calls,
reasoning_content=reasoning_content,
reasoning_items=reasoning_items_for_msg,
)
choices.append(Choices(message=msg, finish_reason="tool_calls", index=fallback_index))
return
last_msg_choice.message.tool_calls = accumulated_tool_calls
if getattr(last_msg_choice.message, "content", None) is None:
last_msg_choice.message.content = ""
last_msg_choice.finish_reason = "tool_calls"
needs_reasoning_content_backfill = (
reasoning_content is not None
and getattr(last_msg_choice.message, "reasoning_content", None) is None
)
if needs_reasoning_content_backfill:
last_msg_choice.message.reasoning_content = reasoning_content
needs_reasoning_items_backfill = (
pending_reasoning_item is not None
and getattr(last_msg_choice.message, "reasoning_items", None) is None
)
if needs_reasoning_items_backfill:
last_msg_choice.message.reasoning_items = cast(
list[ChatCompletionReasoningItem] | None,
[pending_reasoning_item],
)
@staticmethod
def _convert_response_output_to_choices(
output_items: Sequence[object],
@ -686,9 +788,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
except ImportError:
ResponseApplyPatchToolCall = None
from litellm.types.utils import Choices, Message
choices: Final[list[Choices]] = []
choices: Final[list[Any]] = []
index = 0
reasoning_content: str | None = None
pending_reasoning_item: _BuiltReasoningItem | None = None
@ -708,35 +808,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
reasoning_content = " ".join(s["text"] for s in pending_reasoning_item["summary"] if s.get("text"))
elif isinstance(item, ResponseOutputMessage):
for content in item.content:
response_text = getattr(content, "text", "")
# Extract annotations from content if present
raw_annotations = getattr(content, "annotations", None)
annotations = LiteLLMResponsesTransformationHandler._convert_annotations_to_chat_format(
raw_annotations
)
msg = Message(
role=item.role,
content=response_text if response_text else "",
reasoning_content=reasoning_content,
annotations=annotations,
reasoning_items=cast(
list[ChatCompletionReasoningItem] | None,
([pending_reasoning_item] if pending_reasoning_item is not None else None),
),
)
choices.append(
Choices(
message=msg,
finish_reason="stop",
index=index,
)
)
reasoning_content = None # flush
pending_reasoning_item = None # flush
index += 1
new_choices, index = LiteLLMResponsesTransformationHandler._choices_from_response_output_message(
item=item,
starting_index=index,
reasoning_content=reasoning_content,
pending_reasoning_item=pending_reasoning_item,
)
choices.extend(new_choices)
reasoning_content = None
pending_reasoning_item = None
elif isinstance(item, ResponseFunctionToolCall):
from litellm.responses.litellm_completion_transformation.transformation import (
@ -785,48 +865,13 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
pass # don't fail request if item in list is not supported
if accumulated_tool_calls:
last_msg_choice = next(
(
c
for c in reversed(choices)
if getattr(c, "message", None) is not None and not getattr(c.message, "tool_calls", None)
),
None,
LiteLLMResponsesTransformationHandler._merge_accumulated_tool_calls_into_choices(
choices=choices,
accumulated_tool_calls=accumulated_tool_calls,
reasoning_content=reasoning_content,
pending_reasoning_item=pending_reasoning_item,
fallback_index=index,
)
if last_msg_choice is not None:
last_msg_choice.message.tool_calls = accumulated_tool_calls
if getattr(last_msg_choice.message, "content", None) is None:
last_msg_choice.message.content = ""
last_msg_choice.finish_reason = "tool_calls"
if (
reasoning_content is not None
and getattr(last_msg_choice.message, "reasoning_content", None) is None
):
last_msg_choice.message.reasoning_content = reasoning_content
# Mirror the reasoning_content backfill above: reasoning_items carries
# encrypted_content, needed to round-trip reasoning to the provider on
# the next turn, and was being dropped on this merge path.
if (
pending_reasoning_item is not None
and getattr(last_msg_choice.message, "reasoning_items", None) is None
):
last_msg_choice.message.reasoning_items = cast(
list[ChatCompletionReasoningItem] | None,
[pending_reasoning_item],
)
else:
msg = Message(
content=None,
tool_calls=accumulated_tool_calls,
reasoning_content=reasoning_content,
reasoning_items=cast(
list[ChatCompletionReasoningItem] | None,
([pending_reasoning_item] if pending_reasoning_item is not None else None),
),
)
choices.append(Choices(message=msg, finish_reason="tool_calls", index=index))
reasoning_content = None
pending_reasoning_item = None
return choices