fix(responses): retain streamed tool termination with empty terminal output

This commit is contained in:
Marc Brinkmann 2026-09-08 12:39:16 +02:00
parent 1af7a403c6
commit b0c89d75c7
2 changed files with 63 additions and 6 deletions

View file

@ -1545,7 +1545,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
response_data: Final = parsed_chunk.get("response", {})
output_items: Final = response_data.get("output", []) if response_data else []
has_function_calls: Final = any(
has_function_calls: Final = bool(tool_call_index_map) or any(
item.get("type") in ("function_call", "custom_tool_call")
for item in output_items
if isinstance(item, dict)

View file

@ -1078,6 +1078,67 @@ def test_response_completed_with_function_calls_emits_tool_calls_finish_reason()
), "response.completed with function_call output should emit finish_reason='tool_calls'"
@pytest.mark.parametrize("tool_type", ("function_call", "custom_tool_call"))
@pytest.mark.parametrize(
("terminal_event", "reason", "expected"),
(
("response.completed", None, "tool_calls"),
("response.incomplete", "max_output_tokens", "length"),
("response.incomplete", "content_filter", "content_filter"),
),
)
def test_streamed_tool_calls_with_empty_terminal_output(
tool_type: Literal["function_call", "custom_tool_call"],
terminal_event: Literal["response.completed", "response.incomplete"],
reason: str | None,
expected: str,
) -> None:
"""Retain streamed tool calls without overriding incomplete responses or leaking state."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator: Final = OpenAiResponsesToChatCompletionStreamIterator(iter(()), sync_stream=True)
for index in (1, 2):
item: Final = {
"type": tool_type,
"id": f"fc_{index}",
"call_id": f"call_{index}",
"name": "read_file",
**({"arguments": "{}"} if tool_type == "function_call" else {"input": "file.txt"}),
}
added: Final = iterator.chunk_parser(
{"type": "response.output_item.added", "output_index": index, "item": item}
)
assert added.choices[0].delta.tool_calls[0].function.name == "read_file"
assert added.choices[0].delta.tool_calls[0].index == index - 1
assert added.choices[0].finish_reason is None
done: Final = iterator.chunk_parser(
{"type": "response.output_item.done", "output_index": index, "item": {**item, "status": "completed"}}
)
assert done.choices[0].finish_reason is None
assert not done.choices[0].delta.tool_calls
terminal: Final = {
"type": terminal_event,
"response": {
"status": "completed" if reason is None else "incomplete",
"output": [],
"incomplete_details": None if reason is None else {"reason": reason},
},
}
result: Final = iterator.chunk_parser(terminal)
assert result.choices[0].finish_reason == expected
assert not result.choices[0].delta.tool_calls
other_stream: Final = OpenAiResponsesToChatCompletionStreamIterator(iter(()), sync_stream=True)
assert other_stream.chunk_parser(terminal).choices[0].finish_reason == ("stop" if reason is None else expected)
stateless: Final = OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
terminal
)
assert stateless.choices[0].finish_reason == ("stop" if reason is None else expected)
def test_response_completed_with_message_only_emits_stop_finish_reason():
"""
Test that response.completed with only message output (no function_call) emits finish_reason='stop'.
@ -1281,13 +1342,9 @@ def test_text_plus_tool_calls_sequence():
function_done_result.choices[0].finish_reason is None
), "output_item.done for function_call must not emit finish_reason"
# Check response.completed (index 6) has finish_reason='stop'
# (the mock chunk has no nested 'response' data, so has_function_calls is False → 'stop')
completed_result = results[6]
assert len(completed_result.choices) > 0, "response.completed should have choices"
assert (
completed_result.choices[0].finish_reason == "stop"
), "response.completed should have finish_reason='stop'"
assert completed_result.choices[0].finish_reason == "tool_calls"
# =============================================================================