diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 29347f6554a..5600e25750f 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -2701,13 +2701,13 @@ class AmazonConverseConfig(BaseConfig): ## HANDLE TOOL CALLS _message: Final = Message(**chat_completion_message) - initial_finish_reason = map_finish_reason(completion_response["stopReason"]) + mapped_finish_reason: Final = map_finish_reason(completion_response["stopReason"]) - # 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 resolved_json_mode and not filtered_tools and tools: - initial_finish_reason = "stop" + initial_finish_reason: Final = ( + "stop" + if resolved_json_mode and not filtered_tools and tools and mapped_finish_reason == "tool_calls" + else mapped_finish_reason + ) ( returned_message, diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index d0006f1a091..6d5d39877d8 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -1550,7 +1550,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): def _unmask_responses_api_completed_chunk(self, chunk: object, pii_tokens: dict[str, str]) -> None: """ - Unmask PII tokens in-place for a ``response.completed`` Responses API event. + Unmask PII tokens in-place for a ``response.completed`` / ``response.incomplete`` Responses API event. The chunk carries a ``response`` attribute (ResponsesAPIResponse) whose ``output`` list holds message items. Each item has a ``content`` list of @@ -1610,7 +1610,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): yield buffered_chunk remaining_chunks = [] chunk_type = getattr(chunk, "type", None) - if chunk_type == "response.completed" and pii_tokens: + if chunk_type in ("response.completed", "response.incomplete") and pii_tokens: self._unmask_responses_api_completed_chunk(chunk, pii_tokens) saw_non_chat_chunk = True yield chunk diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index c215e3f8395..f01e515d356 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -35,6 +35,7 @@ from litellm.types.llms.openai import ( ReasoningSummaryTextDoneEvent, ResponseCompletedEvent, ResponseCreatedEvent, + ResponseIncompleteEvent, ResponseInProgressEvent, ResponseInputParam, ResponsesAPIOptionalRequestParams, @@ -918,7 +919,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): raise StopAsyncIteration self.finished = self.is_stream_finished() - response_completed_event: Final = self._emit_response_completed_event(self.litellm_model_response) + response_completed_event: Final = self._emit_terminal_response_event(self.litellm_model_response) if response_completed_event: # Latch so wrappers (FallbackResponsesStreamWrapper) + proxy # container-ownership hook can read completed_response. @@ -1266,7 +1267,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): ) return reasoning_aligned - def _emit_response_completed_event(self, litellm_model_response: ModelResponse) -> ResponseCompletedEvent | None: + def _emit_terminal_response_event( + self, litellm_model_response: ModelResponse + ) -> ResponseCompletedEvent | ResponseIncompleteEvent | None: if litellm_model_response: # Transform the response responses_api_response: Final = ( @@ -1286,6 +1289,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): # Encode the response ID to match non-streaming behavior encoded_response: Final = self._with_encoded_response_id(responses_api_response) + if responses_api_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 e1c7cd4b890..13b353dbdd0 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -118,6 +118,44 @@ _INCOMPLETE_REASON_BY_FINISH_REASON: Final[Mapping[str, Literal["max_output_toke ) +class _EchoedResponsesRequestParams(TypedDict, total=False): + """Responses API request fields that the response object echoes back.""" + + instructions: ReadOnly[str] + metadata: ReadOnly[dict[str, object]] + parallel_tool_calls: ReadOnly[bool] + temperature: ReadOnly[float] + tools: ReadOnly[list[dict[str, object]]] + top_p: ReadOnly[float] + max_output_tokens: ReadOnly[int] + previous_response_id: ReadOnly[str] + reasoning: ReadOnly[dict[str, object]] + text: ReadOnly[dict[str, object]] + truncation: ReadOnly[Literal["auto", "disabled"]] + user: ReadOnly[str] + store: ReadOnly[bool] + + +_ECHOED_PARAMS_ADAPTER: Final = TypeAdapter(_EchoedResponsesRequestParams) + + +def _is_echoable(name: str, value: object) -> bool: + try: + _ = _ECHOED_PARAMS_ADAPTER.validate_python({name: value}) + except ValidationError: + return False + return True + + +def _echoable_request_params(request: Mapping[str, object]) -> _EchoedResponsesRequestParams: + echoable: Final = { + name: request[name] + for name in _EchoedResponsesRequestParams.__optional_keys__ + if name in request and _is_echoable(name, request[name]) + } + return _ECHOED_PARAMS_ADAPTER.validate_python(echoable) + + @dataclass(frozen=True, slots=True) class ResponsesToolChatForm: chat_tools: tuple[ChatToolParam, ...] @@ -2258,7 +2296,7 @@ class LiteLLMCompletionResponsesConfig: The corresponding responses API status value (one of ResponsesAPIStatus) """ if finish_reason is None: - return "completed" + return "incomplete" # Map finish reasons to status if finish_reason in ["stop", "tool_calls", "function_call"]: @@ -2273,11 +2311,18 @@ class LiteLLMCompletionResponsesConfig: def _incomplete_details_for_finish_reason( finish_reason: str | None, existing: IncompleteDetails | None, + completion_tokens: int | None = None, + max_output_tokens: int | None = None, ) -> IncompleteDetails | None: if existing is not None: return existing if finish_reason is None: - return None + hit_cap: Final = ( + completion_tokens is not None + and max_output_tokens is not None + and completion_tokens >= max_output_tokens + ) + return IncompleteDetails(reason="max_output_tokens") if hit_cap else None reason: Final = _INCOMPLETE_REASON_BY_FINISH_REASON.get(finish_reason) return IncompleteDetails(reason=reason) if reason is not None else None @@ -2397,9 +2442,13 @@ class LiteLLMCompletionResponsesConfig: if choices and len(choices) > 0: finish_reason = choices[0].finish_reason + echoed: Final = _echoable_request_params(responses_api_request) + chat_usage: Final = getattr(chat_completion_response, "usage", None) incomplete_details: Final = LiteLLMCompletionResponsesConfig._incomplete_details_for_finish_reason( finish_reason=finish_reason, existing=getattr(chat_completion_response, "incomplete_details", None), + completion_tokens=getattr(chat_usage, "completion_tokens", None), + max_output_tokens=echoed.get("max_output_tokens"), ) responses_api_response: Final[ResponsesAPIResponse] = ResponsesAPIResponse( @@ -2409,32 +2458,33 @@ class LiteLLMCompletionResponsesConfig: object="response", error=getattr(chat_completion_response, "error", None), incomplete_details=incomplete_details, - instructions=getattr(chat_completion_response, "instructions", None), - metadata=getattr(chat_completion_response, "metadata", {}), + instructions=echoed.get("instructions"), + metadata=echoed.get("metadata") or {}, output=LiteLLMCompletionResponsesConfig._transform_chat_completion_choices_to_responses_output( chat_completion_response=chat_completion_response, choices=getattr(chat_completion_response, "choices", []), responses_api_request=responses_api_request, ), - parallel_tool_calls=getattr(chat_completion_response, "parallel_tool_calls", False), - temperature=getattr(chat_completion_response, "temperature", 0), + parallel_tool_calls=echoed.get("parallel_tool_calls", False), + temperature=echoed.get("temperature"), tool_choice=LiteLLMCompletionResponsesConfig._transform_tool_choice_for_responses_api_response( responses_api_request.get("tool_choice") ), - 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), - previous_response_id=getattr(chat_completion_response, "previous_response_id", None), - reasoning=None, + tools=echoed.get("tools") or [], + top_p=echoed.get("top_p"), + max_output_tokens=echoed.get("max_output_tokens"), + previous_response_id=echoed.get("previous_response_id"), + reasoning=echoed.get("reasoning"), status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status( finish_reason ), - text={}, - truncation=getattr(chat_completion_response, "truncation", None), + text=echoed.get("text") or {}, + truncation=echoed.get("truncation"), usage=LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( chat_completion_response=chat_completion_response ), - user=getattr(chat_completion_response, "user", None), + user=echoed.get("user"), + store=echoed.get("store"), ) responses_api_response._hidden_params = getattr(chat_completion_response, "_hidden_params", {}) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 5e045c3e84f..c1dabf8a5c9 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -1822,6 +1822,8 @@ RESPONSES_WS_LOGGED_EVENT_TYPES: Final = [ "error", ] +_HISTORY_TERMINAL_EVENT_TYPES: Final = frozenset({"response.completed", "response.incomplete"}) + RESPONSES_WS_MASKABLE_TEXT_BLOCK_TYPES: Final = frozenset({"input_text", "output_text", "text"}) _RESPONSES_WS_FAILURE_EVENT_TYPES: Final = frozenset({"error", "response.failed"}) @@ -2605,7 +2607,7 @@ class ManagedResponsesWebSocketHandler: @staticmethod def _extract_response_id(completed_event: _MutableJsonObject) -> str | None: """ - Pull the raw (decoded) response ID out of a ``response.completed`` event. + Pull the raw (decoded) response ID out of a terminal (``response.completed`` or ``response.incomplete``) event. Returns *None* if the event doesn't contain a usable ID. """ resp_obj: Final = completed_event.get("response", {}) @@ -2621,8 +2623,8 @@ class ManagedResponsesWebSocketHandler: completed_event: _MutableJsonObject, ) -> list[dict[str, object]]: """ - Convert the output items in a ``response.completed`` event into - Responses API message dicts suitable for the next turn's ``input``. + Convert output items in a terminal (``response.completed`` or ``response.incomplete``) event into Responses + API message dicts suitable for the next turn's ``input``. """ resp_obj: Final = completed_event.get("response", {}) if not isinstance(resp_obj, dict): @@ -2863,11 +2865,11 @@ class ManagedResponsesWebSocketHandler: """ Stream ``litellm.aresponses`` and forward every chunk over the WebSocket. - Captures the ``response.completed`` event type from the chunk object - directly (before serialization) to avoid a redundant JSON round-trip on - every chunk. Returns the completed event dict, or ``None``. + Captures the first terminal (``response.completed`` or ``response.incomplete``) event from the chunk object + directly (before serialization) to avoid a redundant JSON round-trip on every chunk. Returns the terminal + event dict, or ``None``. """ - completed_event: _MutableJsonObject | None = None + terminal_event: _MutableJsonObject | None = None stream_response: Final = await litellm.aresponses(model=model, **call_kwargs) async for chunk in stream_response: if chunk is None: @@ -2877,31 +2879,31 @@ class ManagedResponsesWebSocketHandler: serialized = self._serialize_chunk(chunk) if serialized is None: continue - if chunk_type == "response.completed" and completed_event is None: + if chunk_type in _HISTORY_TERMINAL_EVENT_TYPES and terminal_event is None: try: - completed_event = _load_json_object(serialized) + terminal_event = _load_json_object(serialized) except Exception: pass try: await self.websocket.send_text(serialized) except Exception as send_exc: verbose_logger.debug("ManagedResponsesWS: error sending chunk to client: %s", send_exc) - return completed_event # Client disconnected - return completed_event + return terminal_event # Client disconnected + return terminal_event def _save_turn_history( self, - completed_event: _MutableJsonObject | None, + terminal_event: _MutableJsonObject | None, prior_history: list[dict[str, object]], current_messages: list[dict[str, object]], ) -> None: - """Store this turn in in-memory history for future previous_response_id lookups.""" - if completed_event is None: + """Store this terminal (``response.completed`` or ``response.incomplete``) event for future history lookups.""" + if terminal_event is None: return - new_response_id: Final = self._extract_response_id(completed_event) + new_response_id: Final = self._extract_response_id(terminal_event) if not new_response_id: return - output_msgs: Final = self._extract_output_messages(completed_event) + output_msgs: Final = self._extract_output_messages(terminal_event) all_messages: Final = prior_history + current_messages + output_msgs self._store_history(new_response_id, all_messages) verbose_logger.debug( @@ -2927,7 +2929,8 @@ class ManagedResponsesWebSocketHandler: 2. Prepend those messages to the current ``input`` so the model has full conversation context. 3. After the stream completes, extract the new response ID and output - messages from ``response.completed`` and store them in + messages from the terminal (``response.completed`` or + ``response.incomplete``) event and store them in ``self._session_history`` for the next turn. This in-memory approach avoids the async DB-write race condition that @@ -2981,13 +2984,13 @@ class ManagedResponsesWebSocketHandler: call_kwargs.update(self.extra_kwargs) try: - completed_event: Final = await self._stream_and_forward(model, call_kwargs) + terminal_event: Final = await self._stream_and_forward(model, call_kwargs) except Exception as exc: verbose_logger.exception("ManagedResponsesWS: error processing response.create: %s", exc) await self._send_error(str(exc)) return - self._save_turn_history(completed_event, prior_history, current_messages) + self._save_turn_history(terminal_event, prior_history, current_messages) # ------------------------------------------------------------------ # Main entry point diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index e4a50317190..6db3836f68e 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -5949,6 +5949,67 @@ def test_transform_response_finish_reason_stop_when_json_mode_filters_all_tools( assert result.choices[0].finish_reason == "stop" +def test_transform_response_json_mode_truncated_tool_call_keeps_length_finish_reason(): + """ + When json_mode filters out the synthetic json_tool_call but Bedrock + stopped on max_tokens, finish_reason must stay "length", not be + downgraded to "stop" — otherwise truncated structured output looks + completed. + """ + from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig + from litellm.types.utils import ModelResponse + + response_json = { + "metrics": {"latencyMs": 100}, + "output": { + "message": { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "tooluse_001", + "name": "json_tool_call", + "input": {"a": "cut"}, + } + } + ], + } + }, + "stopReason": "max_tokens", + "usage": { + "inputTokens": 10, + "outputTokens": 60, + "totalTokens": 70, + }, + } + + class MockResponse: + def json(self) -> dict[str, object]: + return response_json + + @property + def text(self) -> str: + return json.dumps(response_json) + + config = AmazonConverseConfig() + model_response = ModelResponse() + + result = config._transform_response( + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + response=MockResponse(), + model_response=model_response, + stream=False, + logging_obj=None, + optional_params={"json_mode": True}, + api_key=None, + data=None, + messages=[], + encoding=None, + ) + + assert result.choices[0].finish_reason == "length" + + def test_transform_response_citations_content_maps_to_annotations(): from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig from litellm.types.utils import ModelResponse 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..c13054f1cee 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 @@ -4382,11 +4382,11 @@ class TestEnsureOutputItemContentPartAdded: namespace_map.assert_called_once_with(request["tools"]) - def test_emit_response_completed_uses_stream_finish_reason(self): + def test_emit_terminal_event_uses_stream_finish_reason(self): """ - When the assembled model response carries finish_reason="content_filter" - (snapshotted from the underlying stream before any pending events fire), - _emit_response_completed_event must produce status="incomplete". + The terminal stream event type must track the assembled response + status: finish_reason="content_filter" yields response.incomplete, + finish_reason="stop" yields response.completed. """ from unittest.mock import Mock @@ -4394,6 +4394,7 @@ class TestEnsureOutputItemContentPartAdded: from litellm.responses.litellm_completion_transformation.streaming_iterator import ( LiteLLMCompletionStreamingIterator, ) + from litellm.types.llms.openai import ResponsesAPIStreamEvents mock_stream_wrapper = Mock(spec=litellm.CustomStreamWrapper) mock_stream_wrapper.logging_obj = Mock() @@ -4406,28 +4407,32 @@ class TestEnsureOutputItemContentPartAdded: custom_llm_provider="anthropic", ) - litellm_model_response = ModelResponse( - id="chatcmpl-test", - created=1234567890, - model="anthropic/claude-sonnet-4-6", - object="chat.completion", - choices=[ - Choices( - finish_reason="content_filter", - index=0, - message=Message(content="", role="assistant"), - ) - ], - usage=Usage(prompt_tokens=10, completion_tokens=1, total_tokens=11), - ) + def _response(finish_reason: str) -> ModelResponse: + return ModelResponse( + id="chatcmpl-test", + created=1234567890, + model="anthropic/claude-sonnet-4-6", + object="chat.completion", + choices=[ + Choices( + finish_reason=finish_reason, + index=0, + message=Message(content="", role="assistant"), + ) + ], + usage=Usage(prompt_tokens=10, completion_tokens=1, total_tokens=11), + ) - completed_event = iterator._emit_response_completed_event( - litellm_model_response - ) + incomplete_event = iterator._emit_terminal_response_event(_response("content_filter")) + assert incomplete_event is not None + assert incomplete_event.type == ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE + assert incomplete_event.response.status == "incomplete" + assert incomplete_event.response.output[0].status == "incomplete" + completed_event = iterator._emit_terminal_response_event(_response("stop")) assert completed_event is not None - assert completed_event.response.status == "incomplete" - assert completed_event.response.output[0].status == "incomplete" + assert completed_event.type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED + assert completed_event.response.status == "completed" def test_reasoning_item_does_not_emit_content_part_added(self): """Reasoning items should not get a content_part.added event.""" @@ -5016,7 +5021,7 @@ class TestStreamingSnapshotItemIds: ) assert streamed_event is not None - completed_event = iterator._emit_response_completed_event( + completed_event = iterator._emit_terminal_response_event( _bridged_chat_completion_response() ) @@ -5031,7 +5036,7 @@ class TestStreamingSnapshotItemIds: self._make_chunk("apple") ) - completed_event = iterator._emit_response_completed_event( + completed_event = iterator._emit_terminal_response_event( _bridged_chat_completion_response() ) @@ -5088,7 +5093,7 @@ class TestStreamingSnapshotItemIds: ) assert streamed_event is not None - completed_event = iterator._emit_response_completed_event( + completed_event = iterator._emit_terminal_response_event( self._reasoning_chat_completion_response() ) @@ -5159,6 +5164,140 @@ def test_transform_chat_completion_response_incomplete_details(): assert result_existing.incomplete_details == existing_details +def test_transform_chat_completion_response_null_finish_reason_is_incomplete(): + """ + A choice with no finish signal (e.g. Gemini thinking consuming the whole + output budget, or a provider that never sent one) must not report + status "completed". Output at the requested cap reads as + max_output_tokens; below it, no reason can be claimed. + """ + choice_at_cap = Choices.model_construct( + index=0, + finish_reason=None, + message=Message(content="", role="assistant"), + ) + resp_at_cap = ModelResponse( + id="resp-null-cap", + choices=[choice_at_cap], + model="gemini-2.5-pro", + usage=Usage(prompt_tokens=10, completion_tokens=60, total_tokens=70), + ) + result_at_cap = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test prompt", + responses_api_request={"max_output_tokens": 60}, + chat_completion_response=resp_at_cap, + ) + assert result_at_cap.status == "incomplete" + assert result_at_cap.incomplete_details is not None + assert result_at_cap.incomplete_details.reason == "max_output_tokens" + + choice_under_cap = Choices.model_construct( + index=0, + finish_reason=None, + message=Message(content="", role="assistant"), + ) + resp_under_cap = ModelResponse( + id="resp-null-under", + choices=[choice_under_cap], + model="gemini-2.5-pro", + usage=Usage(prompt_tokens=10, completion_tokens=30, total_tokens=40), + ) + result_under_cap = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test prompt", + responses_api_request={"max_output_tokens": 60}, + chat_completion_response=resp_under_cap, + ) + assert result_under_cap.status == "incomplete" + assert result_under_cap.incomplete_details is None + + resp_empty = ModelResponse(id="resp-empty", choices=[], model="gemini-2.5-pro") + result_empty = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test prompt", + responses_api_request={}, + chat_completion_response=resp_empty, + ) + assert result_empty.status == "incomplete" + + +def test_transform_chat_completion_response_echoes_request_params(): + """ + A chat completion response never carries the caller's request fields, so + the bridged response echoes them from the request. Before this fix + temperature reported 0 and the rest reported None or empty defaults. + """ + resp = ModelResponse( + id="resp-echo", + choices=[Choices(index=0, finish_reason="stop", message=Message(content="hi", role="assistant"))], + model="gpt-4o", + ) + result = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test prompt", + responses_api_request={ + "temperature": 0.7, + "top_p": 0.9, + "max_output_tokens": 128, + "instructions": "be terse", + "metadata": {"trace": "abc"}, + "user": "user-123", + "truncation": "disabled", + "store": True, + "parallel_tool_calls": True, + }, + chat_completion_response=resp, + ) + assert result.temperature == 0.7 + assert result.top_p == 0.9 + assert result.max_output_tokens == 128 + assert result.instructions == "be terse" + assert result.metadata == {"trace": "abc"} + assert result.user == "user-123" + assert result.truncation == "disabled" + assert result.store is True + assert result.parallel_tool_calls is True + + # Fields the request never set fall back to the response defaults. + result_unset = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test prompt", + responses_api_request={}, + chat_completion_response=resp, + ) + assert result_unset.temperature is None + assert result_unset.metadata == {} + assert result_unset.parallel_tool_calls is False + assert result_unset.tools == [] + assert result_unset.text == {} + + +@pytest.mark.parametrize( + ("field", "invalid_value", "expected"), + [ + ("user", 123, None), + ("instructions", 5, None), + ("text", "plain", {}), + ("truncation", "bogus", None), + ("parallel_tool_calls", "maybe", False), + ], +) +def test_transform_drops_request_params_the_response_cannot_echo( + field: str, invalid_value: object, expected: object +) -> None: + request: Final[dict[str, object]] = {"temperature": 0.7, field: invalid_value} + response: Final = ModelResponse( + id="resp-invalid-echo", + choices=[Choices(index=0, finish_reason="stop", message=Message(content="hi", role="assistant"))], + model="gpt-4o", + ) + + result: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test prompt", + responses_api_request=request, + chat_completion_response=response, + ) + + assert getattr(result, field) == expected, f"invalid {field} value {invalid_value!r}" + assert result.temperature == 0.7, f"valid temperature was dropped with invalid {field}" + + @pytest.mark.parametrize("stream", [True, False]) async def test_bridge_rejects_untranslatable_tool_choice_with_a_400(stream: bool): with pytest.raises(litellm.BadRequestError) as exc_info: diff --git a/tests/unit/responses/test_responses_websocket_all_providers.py b/tests/unit/responses/test_responses_websocket_all_providers.py index 6f346a25d9c..222cc641b6b 100644 --- a/tests/unit/responses/test_responses_websocket_all_providers.py +++ b/tests/unit/responses/test_responses_websocket_all_providers.py @@ -8,6 +8,7 @@ Tests that: """ import json +from typing import Final from unittest.mock import MagicMock import pytest @@ -283,6 +284,91 @@ class TestManagedWebSocketHandlerIntegration: assert captured["model"] == "bedrock_mantle/openai.gpt-5.5" + @pytest.mark.parametrize("terminal_type", ["response.completed", "response.incomplete"]) + @pytest.mark.asyncio + async def test_truncated_turn_is_kept_as_history_for_the_next_turn(self, monkeypatch, terminal_type: str): + from unittest.mock import AsyncMock, MagicMock + + import litellm + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.responses.streaming_iterator import ManagedResponsesWebSocketHandler + + terminal_response: Final = { + "type": terminal_type, + "response": { + "id": "resp_turn1", + "status": "incomplete" if terminal_type == "response.incomplete" else "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "PELICAN noted"}], + } + ], + }, + } + + async def first_stream(): + yield terminal_response + + async def empty_stream(): + return + yield {} + + aresponses_mock: Final = AsyncMock(side_effect=(first_stream(), empty_stream())) + monkeypatch.setattr(litellm, "aresponses", aresponses_mock) + + websocket: Final = MagicMock() + websocket.send_text = AsyncMock() + handler: Final = ManagedResponsesWebSocketHandler( + websocket=websocket, + model="bedrock_mantle/openai.gpt-5.5", + logging_obj=Logging( + model="bedrock_mantle/openai.gpt-5.5", + messages=[], + stream=True, + call_type="aresponses", + start_time=0, + litellm_call_id="test-id", + function_id="test-func", + ), + litellm_metadata={"model_group": "gpt-5.5-mantle"}, + ) + + turn_one_user: Final[dict[str, object]] = { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "My secret word is PELICAN"}], + } + turn_two_user: Final[dict[str, object]] = { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "What is my secret word?"}], + } + turn_one_frame: Final = json.dumps({"type": "response.create", "input": [turn_one_user]}) + turn_two_frame: Final = json.dumps( + { + "type": "response.create", + "previous_response_id": "resp_turn1", + "input": [turn_two_user], + } + ) + + await handler._process_response_create(turn_one_frame) + await handler._process_response_create(turn_two_frame) + + expected_input: Final[list[dict[str, object]]] = [ + turn_one_user, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "PELICAN noted"}], + }, + turn_two_user, + ] + second_call_input: Final = aresponses_mock.call_args_list[1].kwargs["input"] + assert second_call_input == expected_input, terminal_type + @pytest.mark.asyncio async def test_warmup_frame_skips_provider_and_sends_synthetic_ack( self, monkeypatch