From dd5e19dd134690e8187c0e78ea0536f42471843d Mon Sep 17 00:00:00 2001 From: Tom Ron Date: Fri, 2 Oct 2026 10:08:00 +0300 Subject: [PATCH 1/6] fix(responses): report truncated chat output as incomplete in the Responses bridge Co-Authored-By: Claude Sonnet 5.5 --- .../bedrock/chat/converse_transformation.py | 2 +- .../streaming_iterator.py | 10 ++- .../transformation.py | 17 +++-- .../chat/test_converse_transformation.py | 33 +++++++++ .../test_incomplete_status_transformation.py | 72 +++++++++++++++++++ 5 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 8ce2b982955..c8b0c2a28fd 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -2192,7 +2192,7 @@ class AmazonConverseConfig(BaseConfig): # When json_mode filtered out all synthetic tool calls the response # is plain content, not a pending tool invocation. Fix finish_reason # so callers (e.g. OpenAI SDK) don't misinterpret it. - if json_mode and not filtered_tools and tools: + if json_mode and not filtered_tools and tools and initial_finish_reason != "length": initial_finish_reason = "stop" ( diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index cf69654d15d..c70a16b5634 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -31,6 +31,7 @@ from litellm.types.llms.openai import ( ReasoningSummaryTextDeltaEvent, ReasoningSummaryTextDoneEvent, ResponseCompletedEvent, + ResponseIncompleteEvent, ResponseCreatedEvent, ResponseInProgressEvent, ResponseInputParam, @@ -1037,7 +1038,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): chat_completion_delta: ChatCompletionDelta = choice.delta return chat_completion_delta.content or "" - def _emit_response_completed_event(self, litellm_model_response: ModelResponse) -> ResponseCompletedEvent | None: + def _emit_response_completed_event( + self, litellm_model_response: ModelResponse + ) -> ResponseCompletedEvent | ResponseIncompleteEvent | None: if litellm_model_response: # Add cost to usage object if include_cost_in_streaming_usage is True if litellm.include_cost_in_streaming_usage and self.litellm_logging_obj is not None: @@ -1069,6 +1072,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): litellm_metadata=self.litellm_metadata, ) + if encoded_response.status == "incomplete": + return ResponseIncompleteEvent( + type=ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, + response=encoded_response, + ) return ResponseCompletedEvent( type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=encoded_response, diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 6b1ca3564e3..a922e95912d 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -22,6 +22,7 @@ from litellm.responses.litellm_completion_transformation.session_handler import ResponsesSessionHandler, ) from litellm.types.llms.openai import ( + IncompleteDetails, AllMessageValues, ChatCompletionImageObject, ChatCompletionImageUrlObject, @@ -1482,6 +1483,14 @@ class LiteLLMCompletionResponsesConfig: # Default to completed for unknown finish reasons return "completed" + @staticmethod + def _incomplete_details_from_finish_reason(finish_reason: str | None) -> IncompleteDetails | None: + if finish_reason == "length": + return IncompleteDetails(reason="max_output_tokens") + if finish_reason in ("content_filter", "refusal"): + return IncompleteDetails(reason="content_filter") + return None + @staticmethod def _tool_call_id_from_responses_item(item_id: str | None, call_id: str | None) -> str: """Bedrock Mantle returns a non-unique, index-based ``call_id`` (``call_0``, @@ -1607,7 +1616,7 @@ class LiteLLMCompletionResponsesConfig: model=chat_completion_response.model, object="response", error=getattr(chat_completion_response, "error", None), - incomplete_details=getattr(chat_completion_response, "incomplete_details", None), + incomplete_details=LiteLLMCompletionResponsesConfig._incomplete_details_from_finish_reason(finish_reason), instructions=getattr(chat_completion_response, "instructions", None), metadata=getattr(chat_completion_response, "metadata", {}), output=LiteLLMCompletionResponsesConfig._transform_chat_completion_choices_to_responses_output( @@ -1616,11 +1625,11 @@ class LiteLLMCompletionResponsesConfig: responses_api_request=responses_api_request, ), parallel_tool_calls=getattr(chat_completion_response, "parallel_tool_calls", False), - temperature=getattr(chat_completion_response, "temperature", 0), + temperature=responses_api_request.get("temperature"), tool_choice=getattr(chat_completion_response, "tool_choice", "auto"), tools=getattr(chat_completion_response, "tools", []), - top_p=getattr(chat_completion_response, "top_p", None), - max_output_tokens=getattr(chat_completion_response, "max_output_tokens", None), + top_p=responses_api_request.get("top_p"), + max_output_tokens=responses_api_request.get("max_output_tokens"), previous_response_id=getattr(chat_completion_response, "previous_response_id", None), reasoning=None, status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status( diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index cca4f4232f2..86961c8ae7b 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -5862,3 +5862,36 @@ def test_adaptive_thinking_dropped_when_max_tokens_too_small_converse(): ) assert "thinking" not in optional_params + + +@pytest.mark.parametrize( + "stop_reason, expected_finish_reason", + [("max_tokens", "length"), ("end_turn", "stop")], +) +def test_json_mode_filtered_tool_call_keeps_length_finish_reason(stop_reason, expected_finish_reason): + body = { + "output": { + "message": { + "role": "assistant", + "content": [{"toolUse": {"toolUseId": "t1", "name": "json_tool_call", "input": {"a": "x"}}}], + } + }, + "stopReason": stop_reason, + "usage": {"inputTokens": 10, "outputTokens": 60, "totalTokens": 70}, + } + http_response = MagicMock(status_code=200, headers={}, text="") + http_response.json.return_value = body + + out = AmazonConverseConfig()._transform_response( + model="anthropic.claude-sonnet-4-5", + response=http_response, + model_response=ModelResponse(), + stream=False, + logging_obj=MagicMock(), + optional_params={"json_mode": True}, + api_key="", + data={}, + messages=[], + encoding=None, + ) + assert out.choices[0].finish_reason == expected_finish_reason diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py new file mode 100644 index 00000000000..92785b52849 --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py @@ -0,0 +1,72 @@ +from unittest.mock import AsyncMock + +import pytest + +from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, +) +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) +from litellm.types.llms.openai import ResponsesAPIStreamEvents +from litellm.types.utils import Choices, Message, ModelResponse, Usage + + +def _chat_response(finish_reason: str) -> ModelResponse: + choice = Choices(index=0, finish_reason="stop", message=Message(role="assistant", content="cut off")) + choice.finish_reason = finish_reason + return ModelResponse( + id="chatcmpl-1", + model="some-model", + choices=[choice], + usage=Usage(prompt_tokens=10, completion_tokens=60, total_tokens=70), + ) + + +def _transform(finish_reason: str, request: dict): + return LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="hi", + responses_api_request=request, + chat_completion_response=_chat_response(finish_reason), + ) + + +@pytest.mark.parametrize( + "finish_reason, reason", + [("length", "max_output_tokens"), ("content_filter", "content_filter")], +) +def test_truncated_finish_reason_sets_incomplete_details(finish_reason, reason): + result = _transform(finish_reason, {}) + assert result.status == "incomplete" + assert result.incomplete_details is not None + assert result.incomplete_details.reason == reason + + +def test_stop_finish_reason_has_no_incomplete_details(): + result = _transform("stop", {}) + assert result.status == "completed" + assert result.incomplete_details is None + + +def test_request_sampling_params_are_echoed(): + result = _transform("stop", {"temperature": 0.3, "top_p": 0.9, "max_output_tokens": 60}) + assert (result.temperature, result.top_p, result.max_output_tokens) == (0.3, 0.9, 60) + + +@pytest.mark.parametrize( + "finish_reason, event_type", + [ + ("length", ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE), + ("stop", ResponsesAPIStreamEvents.RESPONSE_COMPLETED), + ], +) +def test_stream_terminal_event_follows_status(finish_reason, event_type): + iterator = LiteLLMCompletionStreamingIterator( + model="some-model", + litellm_custom_stream_wrapper=AsyncMock(), + request_input="hi", + responses_api_request={}, + ) + event = iterator._emit_response_completed_event(_chat_response(finish_reason)) + assert event is not None + assert event.type == event_type From 276b5695681ac4f545362df9cf4127b2e1f92788 Mon Sep 17 00:00:00 2001 From: Tom Ron Date: Fri, 2 Oct 2026 11:18:57 +0300 Subject: [PATCH 2/6] fix(responses): emit response.incomplete on replayed streams and default temperature to 0 Co-Authored-By: Claude Sonnet 5.5 --- .../transformation.py | 4 ++-- litellm/responses/streaming_iterator.py | 8 +++++++ .../test_incomplete_status_transformation.py | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index a922e95912d..2b050c56098 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -243,7 +243,7 @@ class LiteLLMCompletionResponsesConfig: "tools": tools, "top_p": responses_api_request.get("top_p"), "user": responses_api_request.get("user"), - "temperature": responses_api_request.get("temperature"), + "temperature": responses_api_request.get("temperature", 0), "parallel_tool_calls": responses_api_request.get("parallel_tool_calls"), "max_tokens": responses_api_request.get("max_output_tokens"), "stream": stream, @@ -1625,7 +1625,7 @@ class LiteLLMCompletionResponsesConfig: responses_api_request=responses_api_request, ), parallel_tool_calls=getattr(chat_completion_response, "parallel_tool_calls", False), - temperature=responses_api_request.get("temperature"), + temperature=responses_api_request.get("temperature", 0), tool_choice=getattr(chat_completion_response, "tool_choice", "auto"), tools=getattr(chat_completion_response, "tools", []), top_p=responses_api_request.get("top_p"), diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index dab666ff0d9..5844c34eaa2 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -1253,6 +1253,14 @@ def _build_synthetic_response_events( ) ) + if transformed.status == "incomplete": + events.append( + openai_types.ResponseIncompleteEvent( + type=openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, + response=transformed, + ) + ) + return events events.append( openai_types.ResponseCompletedEvent( type=openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED, diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py index 92785b52849..4ae7b8c75a5 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py @@ -8,6 +8,7 @@ from litellm.responses.litellm_completion_transformation.streaming_iterator impo from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, ) +from litellm.responses.streaming_iterator import _build_synthetic_response_events from litellm.types.llms.openai import ResponsesAPIStreamEvents from litellm.types.utils import Choices, Message, ModelResponse, Usage @@ -70,3 +71,23 @@ def test_stream_terminal_event_follows_status(finish_reason, event_type): event = iterator._emit_response_completed_event(_chat_response(finish_reason)) assert event is not None assert event.type == event_type + + +@pytest.mark.parametrize( + "finish_reason, event_type", + [ + ("length", ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE), + ("stop", ResponsesAPIStreamEvents.RESPONSE_COMPLETED), + ], +) +def test_replayed_stream_terminal_event_follows_status(finish_reason, event_type): + events = _build_synthetic_response_events( + transformed=_transform(finish_reason, {}), + logging_obj=None, + chunk_size=10, + ) + assert events[-1].type == event_type + + +def test_omitted_temperature_defaults_to_zero(): + assert _transform("stop", {}).temperature == 0 From 1fd2f32853c70742571c544ffe3940a56eba5b80 Mon Sep 17 00:00:00 2001 From: Tom Ron Date: Fri, 2 Oct 2026 12:33:44 +0300 Subject: [PATCH 3/6] fix(responses): log spend for incomplete replayed streams Co-Authored-By: Claude Sonnet 5.5 --- litellm/responses/streaming_iterator.py | 20 ++++++++++++---- .../test_incomplete_status_transformation.py | 24 +++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 5844c34eaa2..29053ae340b 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -902,7 +902,10 @@ class MockResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): evt = self._events[self._idx] self._idx += 1 openai_types = _get_openai_response_types() - if getattr(evt, "type", None) == openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED: + if getattr(evt, "type", None) in ( + openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, + ): self.completed_response = evt self._log_completed_response(is_async=True) return evt @@ -916,7 +919,10 @@ class MockResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): evt = self._events[self._idx] self._idx += 1 openai_types = _get_openai_response_types() - if getattr(evt, "type", None) == openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED: + if getattr(evt, "type", None) in ( + openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, + ): self.completed_response = evt self._log_completed_response(is_async=False) return evt @@ -969,7 +975,10 @@ class CachedResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): evt = self._events[self._idx] self._idx += 1 openai_types = _get_openai_response_types() - if getattr(evt, "type", None) == openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED: + if getattr(evt, "type", None) in ( + openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, + ): self.completed_response = evt self._log_completed_response(is_async=True) return evt @@ -983,7 +992,10 @@ class CachedResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): evt = self._events[self._idx] self._idx += 1 openai_types = _get_openai_response_types() - if getattr(evt, "type", None) == openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED: + if getattr(evt, "type", None) in ( + openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, + ): self.completed_response = evt self._log_completed_response(is_async=False) return evt diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py index 4ae7b8c75a5..f463864ed5d 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_incomplete_status_transformation.py @@ -1,4 +1,6 @@ -from unittest.mock import AsyncMock +import asyncio + +from unittest.mock import AsyncMock, MagicMock import pytest @@ -8,7 +10,10 @@ from litellm.responses.litellm_completion_transformation.streaming_iterator impo from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, ) -from litellm.responses.streaming_iterator import _build_synthetic_response_events +from litellm.responses.streaming_iterator import ( + CachedResponsesAPIStreamingIterator, + _build_synthetic_response_events, +) from litellm.types.llms.openai import ResponsesAPIStreamEvents from litellm.types.utils import Choices, Message, ModelResponse, Usage @@ -91,3 +96,18 @@ def test_replayed_stream_terminal_event_follows_status(finish_reason, event_type def test_omitted_temperature_defaults_to_zero(): assert _transform("stop", {}).temperature == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("finish_reason", ["length", "stop"]) +async def test_replayed_stream_logs_success_exactly_once(finish_reason): + logging_obj = MagicMock() + logging_obj.dispatch_success_handlers = AsyncMock() + iterator = CachedResponsesAPIStreamingIterator( + response=_transform(finish_reason, {}), + logging_obj=logging_obj, + ) + async for _ in iterator: + pass + await asyncio.sleep(0) + assert logging_obj.dispatch_success_handlers.await_count == 1 From 40e87a2a5fdcce70ef1c804a5728c4408d7075e0 Mon Sep 17 00:00:00 2001 From: Tom Ron Date: Fri, 2 Oct 2026 13:00:17 +0300 Subject: [PATCH 4/6] fix(responses): stop forwarding an omitted temperature as 0 and move tests into the mapped file Co-Authored-By: Claude Sonnet 5.5 --- .../transformation.py | 2 +- .../test_incomplete_status_transformation.py | 114 ----------------- .../test_litellm_completion_responses.py | 115 ++++++++++++++++++ 3 files changed, 116 insertions(+), 115 deletions(-) delete mode 100644 tests/unit/responses/litellm_completion_transformation/test_incomplete_status_transformation.py diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 11dc0c94756..5299fcf9a2a 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -448,7 +448,7 @@ class LiteLLMCompletionResponsesConfig: "tools": tools, "top_p": responses_api_request.get("top_p"), "user": responses_api_request.get("user"), - "temperature": responses_api_request.get("temperature", 0), + "temperature": responses_api_request.get("temperature"), "parallel_tool_calls": responses_api_request.get("parallel_tool_calls"), "max_tokens": responses_api_request.get("max_output_tokens"), "stream": stream, diff --git a/tests/unit/responses/litellm_completion_transformation/test_incomplete_status_transformation.py b/tests/unit/responses/litellm_completion_transformation/test_incomplete_status_transformation.py deleted file mode 100644 index 3410a56c42e..00000000000 --- a/tests/unit/responses/litellm_completion_transformation/test_incomplete_status_transformation.py +++ /dev/null @@ -1,114 +0,0 @@ -import asyncio - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from litellm.responses.litellm_completion_transformation.streaming_iterator import ( - LiteLLMCompletionStreamingIterator, -) -from litellm.responses.litellm_completion_transformation.transformation import ( - LiteLLMCompletionResponsesConfig, -) -from litellm.responses.streaming_iterator import ( - CachedResponsesAPIStreamingIterator, - build_synthetic_response_events, -) -from litellm.types.llms.openai import ResponsesAPIStreamEvents -from litellm.types.utils import Choices, Message, ModelResponse, Usage - - -def _chat_response(finish_reason: str) -> ModelResponse: - choice = Choices(index=0, finish_reason="stop", message=Message(role="assistant", content="cut off")) - choice.finish_reason = finish_reason - return ModelResponse( - id="chatcmpl-1", - model="some-model", - choices=[choice], - usage=Usage(prompt_tokens=10, completion_tokens=60, total_tokens=70), - ) - - -def _transform(finish_reason: str, request: dict): - return LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="hi", - responses_api_request=request, - chat_completion_response=_chat_response(finish_reason), - ) - - -@pytest.mark.parametrize( - "finish_reason, reason", - [("length", "max_output_tokens"), ("content_filter", "content_filter")], -) -def test_truncated_finish_reason_sets_incomplete_details(finish_reason, reason): - result = _transform(finish_reason, {}) - assert result.status == "incomplete" - assert result.incomplete_details is not None - assert result.incomplete_details.reason == reason - - -def test_stop_finish_reason_has_no_incomplete_details(): - result = _transform("stop", {}) - assert result.status == "completed" - assert result.incomplete_details is None - - -def test_request_sampling_params_are_echoed(): - result = _transform("stop", {"temperature": 0.3, "top_p": 0.9, "max_output_tokens": 60}) - assert (result.temperature, result.top_p, result.max_output_tokens) == (0.3, 0.9, 60) - - -@pytest.mark.parametrize( - "finish_reason, event_type", - [ - ("length", ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE), - ("stop", ResponsesAPIStreamEvents.RESPONSE_COMPLETED), - ], -) -def test_stream_terminal_event_follows_status(finish_reason, event_type): - iterator = LiteLLMCompletionStreamingIterator( - model="some-model", - litellm_custom_stream_wrapper=AsyncMock(), - request_input="hi", - responses_api_request={}, - ) - event = iterator._emit_response_completed_event(_chat_response(finish_reason)) - assert event is not None - assert event.type == event_type - - -@pytest.mark.parametrize( - "finish_reason, event_type", - [ - ("length", ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE), - ("stop", ResponsesAPIStreamEvents.RESPONSE_COMPLETED), - ], -) -def test_replayed_stream_terminal_event_follows_status(finish_reason, event_type): - events = build_synthetic_response_events( - transformed=_transform(finish_reason, {}), - logging_obj=None, - chunk_size=10, - ) - assert events[-1].type == event_type - - -def test_omitted_temperature_defaults_to_zero(): - assert _transform("stop", {}).temperature == 0 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("finish_reason", ["length", "stop"]) -async def test_replayed_stream_logs_success_exactly_once(finish_reason): - logging_obj = MagicMock() - logging_obj._on_deferred_stream_complete = None - logging_obj.dispatch_success_handlers = AsyncMock() - iterator = CachedResponsesAPIStreamingIterator( - response=_transform(finish_reason, {}), - logging_obj=logging_obj, - ) - async for _ in iterator: - pass - await asyncio.sleep(0) - assert logging_obj.dispatch_success_handlers.await_count == 1 diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 7b9de4644b4..1a55176e64e 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -1,5 +1,7 @@ +import asyncio import json from copy import deepcopy +from unittest.mock import AsyncMock, MagicMock from typing import Final, Literal import pytest @@ -17,6 +19,14 @@ from litellm.responses.litellm_completion_transformation.transformation import ( TOOL_CALLS_CACHE, LiteLLMCompletionResponsesConfig, ) +from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, +) +from litellm.responses.streaming_iterator import ( + CachedResponsesAPIStreamingIterator, + build_synthetic_response_events, +) +from litellm.types.llms.openai import ResponsesAPIStreamEvents from litellm.types.responses.main import build_web_search_call from litellm.types.utils import ( ChatCompletionMessageToolCall, @@ -5172,3 +5182,108 @@ async def test_bridge_rejects_untranslatable_tool_choice_with_a_400(stream: bool ) assert exc_info.value.status_code == 400 assert "tool_choice={'type': 'file_search'}" in str(exc_info.value) + + +def _chat_response(finish_reason: str) -> ModelResponse: + choice = Choices(index=0, finish_reason="stop", message=Message(role="assistant", content="cut off")) + choice.finish_reason = finish_reason + return ModelResponse( + id="chatcmpl-1", + model="some-model", + choices=[choice], + usage=Usage(prompt_tokens=10, completion_tokens=60, total_tokens=70), + ) + + +def _transform(finish_reason: str, request: dict): + return LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="hi", + responses_api_request=request, + chat_completion_response=_chat_response(finish_reason), + ) + + +@pytest.mark.parametrize( + "finish_reason, reason", + [("length", "max_output_tokens"), ("content_filter", "content_filter")], +) +def test_truncated_finish_reason_sets_incomplete_details(finish_reason, reason): + result = _transform(finish_reason, {}) + assert result.status == "incomplete" + assert result.incomplete_details is not None + assert result.incomplete_details.reason == reason + + +def test_stop_finish_reason_has_no_incomplete_details(): + result = _transform("stop", {}) + assert result.status == "completed" + assert result.incomplete_details is None + + +def test_request_sampling_params_are_echoed(): + result = _transform("stop", {"temperature": 0.3, "top_p": 0.9, "max_output_tokens": 60}) + assert (result.temperature, result.top_p, result.max_output_tokens) == (0.3, 0.9, 60) + + +@pytest.mark.parametrize( + "finish_reason, event_type", + [ + ("length", ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE), + ("stop", ResponsesAPIStreamEvents.RESPONSE_COMPLETED), + ], +) +def test_stream_terminal_event_follows_status(finish_reason, event_type): + iterator = LiteLLMCompletionStreamingIterator( + model="some-model", + litellm_custom_stream_wrapper=AsyncMock(), + request_input="hi", + responses_api_request={}, + ) + event = iterator._emit_response_completed_event(_chat_response(finish_reason)) + assert event is not None + assert event.type == event_type + + +@pytest.mark.parametrize( + "finish_reason, event_type", + [ + ("length", ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE), + ("stop", ResponsesAPIStreamEvents.RESPONSE_COMPLETED), + ], +) +def test_replayed_stream_terminal_event_follows_status(finish_reason, event_type): + events = build_synthetic_response_events( + transformed=_transform(finish_reason, {}), + logging_obj=None, + chunk_size=10, + ) + assert events[-1].type == event_type + + +def test_omitted_temperature_defaults_to_zero(): + assert _transform("stop", {}).temperature == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("finish_reason", ["length", "stop"]) +async def test_replayed_stream_logs_success_exactly_once(finish_reason): + logging_obj = MagicMock() + logging_obj._on_deferred_stream_complete = None + logging_obj.dispatch_success_handlers = AsyncMock() + iterator = CachedResponsesAPIStreamingIterator( + response=_transform(finish_reason, {}), + logging_obj=logging_obj, + ) + async for _ in iterator: + pass + await asyncio.sleep(0) + assert logging_obj.dispatch_success_handlers.await_count == 1 + + +def test_omitted_temperature_is_not_forwarded_to_the_chat_request(): + result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( + model="some-model", + input="hi", + responses_api_request={}, + ) + assert result.get("temperature") is None From f09c9899d5712c234dd804a25cb36dac5ff23747 Mon Sep 17 00:00:00 2001 From: Tom Ron Date: Fri, 2 Oct 2026 13:09:55 +0300 Subject: [PATCH 5/6] test(responses): type the incomplete-status test helpers Co-Authored-By: Claude Sonnet 5.5 --- .../test_litellm_completion_responses.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 1a55176e64e..b1f540b2579 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -26,7 +26,11 @@ from litellm.responses.streaming_iterator import ( CachedResponsesAPIStreamingIterator, build_synthetic_response_events, ) -from litellm.types.llms.openai import ResponsesAPIStreamEvents +from litellm.types.llms.openai import ( + ResponsesAPIOptionalRequestParams, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, +) from litellm.types.responses.main import build_web_search_call from litellm.types.utils import ( ChatCompletionMessageToolCall, @@ -5195,7 +5199,7 @@ def _chat_response(finish_reason: str) -> ModelResponse: ) -def _transform(finish_reason: str, request: dict): +def _transform(finish_reason: str, request: ResponsesAPIOptionalRequestParams) -> ResponsesAPIResponse: return LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( request_input="hi", responses_api_request=request, @@ -5207,20 +5211,20 @@ def _transform(finish_reason: str, request: dict): "finish_reason, reason", [("length", "max_output_tokens"), ("content_filter", "content_filter")], ) -def test_truncated_finish_reason_sets_incomplete_details(finish_reason, reason): +def test_truncated_finish_reason_sets_incomplete_details(finish_reason: str, reason: str) -> None: result = _transform(finish_reason, {}) assert result.status == "incomplete" assert result.incomplete_details is not None assert result.incomplete_details.reason == reason -def test_stop_finish_reason_has_no_incomplete_details(): +def test_stop_finish_reason_has_no_incomplete_details() -> None: result = _transform("stop", {}) assert result.status == "completed" assert result.incomplete_details is None -def test_request_sampling_params_are_echoed(): +def test_request_sampling_params_are_echoed() -> None: result = _transform("stop", {"temperature": 0.3, "top_p": 0.9, "max_output_tokens": 60}) assert (result.temperature, result.top_p, result.max_output_tokens) == (0.3, 0.9, 60) @@ -5232,7 +5236,7 @@ def test_request_sampling_params_are_echoed(): ("stop", ResponsesAPIStreamEvents.RESPONSE_COMPLETED), ], ) -def test_stream_terminal_event_follows_status(finish_reason, event_type): +def test_stream_terminal_event_follows_status(finish_reason: str, event_type: ResponsesAPIStreamEvents) -> None: iterator = LiteLLMCompletionStreamingIterator( model="some-model", litellm_custom_stream_wrapper=AsyncMock(), @@ -5251,7 +5255,9 @@ def test_stream_terminal_event_follows_status(finish_reason, event_type): ("stop", ResponsesAPIStreamEvents.RESPONSE_COMPLETED), ], ) -def test_replayed_stream_terminal_event_follows_status(finish_reason, event_type): +def test_replayed_stream_terminal_event_follows_status( + finish_reason: str, event_type: ResponsesAPIStreamEvents +) -> None: events = build_synthetic_response_events( transformed=_transform(finish_reason, {}), logging_obj=None, @@ -5260,13 +5266,13 @@ def test_replayed_stream_terminal_event_follows_status(finish_reason, event_type assert events[-1].type == event_type -def test_omitted_temperature_defaults_to_zero(): +def test_omitted_temperature_defaults_to_zero() -> None: assert _transform("stop", {}).temperature == 0 @pytest.mark.asyncio @pytest.mark.parametrize("finish_reason", ["length", "stop"]) -async def test_replayed_stream_logs_success_exactly_once(finish_reason): +async def test_replayed_stream_logs_success_exactly_once(finish_reason: str) -> None: logging_obj = MagicMock() logging_obj._on_deferred_stream_complete = None logging_obj.dispatch_success_handlers = AsyncMock() @@ -5280,7 +5286,7 @@ async def test_replayed_stream_logs_success_exactly_once(finish_reason): assert logging_obj.dispatch_success_handlers.await_count == 1 -def test_omitted_temperature_is_not_forwarded_to_the_chat_request(): +def test_omitted_temperature_is_not_forwarded_to_the_chat_request() -> None: result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( model="some-model", input="hi", From f02487ef4b76bd078a27301fcfa5771f96d2eec5 Mon Sep 17 00:00:00 2001 From: Tom Ron Date: Fri, 2 Oct 2026 13:10:04 +0300 Subject: [PATCH 6/6] style(tests): sort imports Co-Authored-By: Claude Sonnet 5.5 --- .../test_litellm_completion_responses.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index b1f540b2579..7dcde644cbb 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -1,8 +1,8 @@ import asyncio import json from copy import deepcopy -from unittest.mock import AsyncMock, MagicMock from typing import Final, Literal +from unittest.mock import AsyncMock, MagicMock import pytest from openai.types.responses.response_function_web_search import ( @@ -15,13 +15,13 @@ from openai.types.responses.response_function_web_search import ( import litellm from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt +from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, +) from litellm.responses.litellm_completion_transformation.transformation import ( TOOL_CALLS_CACHE, LiteLLMCompletionResponsesConfig, ) -from litellm.responses.litellm_completion_transformation.streaming_iterator import ( - LiteLLMCompletionStreamingIterator, -) from litellm.responses.streaming_iterator import ( CachedResponsesAPIStreamingIterator, build_synthetic_response_events,