fix(responses): reorder function_call_output adjacent to function_call before chat-completion conversion

Bedrock Converse requires every ``toolResult`` block to live in the
``user`` message *immediately* following the assistant message that
emitted the matching ``toolUse``. The OpenAI Responses API does not
enforce this, so upstream clients sometimes inject a ``message`` between
a ``function_call`` and its ``function_call_output``. One concrete
trigger is OpenAI Codex CLI: when an ``apply_patch <<EOF`` heredoc is
detected inside an ``exec_command``, Codex short-circuits via the native
``apply_patch`` tool and inserts a user-visible warning message between
the ``function_call`` and the synthesised ``function_call_output``.

When a Responses payload like that is converted to Chat Completions and
then to Converse, the adjacency invariant is silently lost and Bedrock
returns HTTP 400::

    BedrockException - The number of toolResult blocks at messages.N.content
    exceeds the number of toolUse blocks of previous turn.

Restore the adjacency at the Responses → Chat Completions boundary.
This is the right layer for the fix: the Responses API and most
downstream providers tolerate the reordered input, only Bedrock
*requires* it. Doing it once here means every provider sees a payload
that satisfies the strictest contract, without coupling the conversion
layer to provider-specific knowledge.

The new helper is purely structural: it pairs items by ``call_id``, is
idempotent, preserves relative order for everything else, and is a
no-op when the input is already adjacent. It only touches items that
can be paired; orphan ``function_call_output`` items keep their
original position.

A dedicated test module covers: idempotence, the Codex heredoc trigger,
multiple interleaved call/output pairs, orphan outputs, and mixed
non-dict inputs. The test suite is added in a follow-up commit so this
commit can be reviewed in isolation.
This commit is contained in:
parisni 2026-05-14 21:13:03 +02:00
parent e58a561caa
commit 543762a95a

View file

@ -382,6 +382,16 @@ class LiteLLMCompletionResponsesConfig:
if isinstance(input, str):
messages.append(ChatCompletionUserMessage(role="user", content=input))
elif isinstance(input, list):
# Bedrock Converse rejects requests where a ``toolResult`` is not
# adjacent to its ``toolUse``. The Responses API itself does not
# require strict adjacency, so upstream clients sometimes inject a
# ``message`` between a ``function_call`` and its
# ``function_call_output``. Normalise that here, before the
# conversion runs, so every downstream provider sees a payload
# that satisfies the strictest contract.
input = LiteLLMCompletionResponsesConfig._reorder_function_call_outputs_adjacent(
list(input)
)
existing_tool_call_ids: Set[str] = set()
for _input in input:
chat_completion_messages = LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message(
@ -1009,6 +1019,66 @@ class LiteLLMCompletionResponsesConfig:
"tool_result", # Anthropic/MCP format
]
@staticmethod
def _reorder_function_call_outputs_adjacent(
items: List[Any],
) -> List[Any]:
"""Move every ``function_call_output`` immediately after its matching
``function_call`` (by ``call_id``).
OpenAI's Responses API tolerates other input items (for example a
``message`` injected by an upstream client) between a ``function_call``
and its ``function_call_output``. Bedrock Converse, however, requires
each ``toolResult`` block to live in the ``user`` message *immediately*
following the assistant message that emitted the matching ``toolUse``.
When a Responses-API request is converted to Chat Completions and then
to Converse, that adjacency invariant is silently lost and Bedrock
rejects the request with HTTP 400::
BedrockException - The number of toolResult blocks at messages.N.content
exceeds the number of toolUse blocks of previous turn.
Restoring the adjacency at the Responses → Chat Completions boundary is
provider-agnostic: OpenAI accepts the reordered input as well, so this
is safe for every backend that consumes the converted request.
The helper preserves relative order for everything else, is idempotent,
and only touches items it can pair by ``call_id``.
"""
if not isinstance(items, list) or not items:
return items
result = list(items)
i = 0
while i < len(result):
it = result[i]
if (
isinstance(it, dict)
and it.get("type") == "function_call_output"
):
call_id = it.get("call_id")
if call_id:
# Find the most recent matching function_call before i.
fc_idx: Optional[int] = None
for k in range(i - 1, -1, -1):
prev = result[k]
if (
isinstance(prev, dict)
and prev.get("type") == "function_call"
and prev.get("call_id") == call_id
):
fc_idx = k
break
if fc_idx is not None and fc_idx != i - 1:
out = result.pop(i)
result.insert(fc_idx + 1, out)
# Position i now holds what was at i+1, so don't
# advance the cursor.
continue
i += 1
return result
@staticmethod
def _is_input_item_function_call(input_item: Any) -> bool:
"""