mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
14913548d1
commit
f81a3f5243
8 changed files with 416 additions and 69 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue