fix(responses): report truncated bridged output as incomplete and echo request params (#44460)

* fix Bedrock json mode bug

* fix finish reason when max tokens hit

* fix hardcoded completion for streaming responses

* fix: echo Responses API request params onto bridged response

* refactor(responses): drop explanatory comments and build null finish_reason test without mutation

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(responses): type the terminal event and Bedrock mock helpers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): keep truncated WebSocket turns in history and drop invalid echoed params

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Mrinal Chanshetty <mrinal@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-05 11:27:12 -07:00 • committed by GitHub
parent 14913548d1
commit f81a3f5243
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 416 additions and 69 deletions

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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", {})

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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