From cd6923c5eb0396ba33584aaa0cc1563f4cdc4b36 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:35:29 -0700 Subject: [PATCH] fix(responses): leave a turn without a phase when its content message cannot be read --- .../history.py | 23 +++++++++++++++---- ...itellm_responses_transformation_history.py | 8 +++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/history.py b/litellm/completion_extras/litellm_responses_transformation/history.py index de3aacd18c0..4435de64cde 100644 --- a/litellm/completion_extras/litellm_responses_transformation/history.py +++ b/litellm/completion_extras/litellm_responses_transformation/history.py @@ -51,6 +51,10 @@ _OutputItem: TypeAlias = Annotated[ _OUTPUT_ITEM: Final[TypeAdapter[_OutputItem]] = TypeAdapter(_OutputItem) +class _AnyMessage(_Frozen): + type: Literal["message"] + + class _MessageEntry(_Frozen): type: Literal["message"] parts: tuple[Annotated[int, Field(ge=0)], ...] @@ -155,6 +159,14 @@ def _parsed_output_item(item: object) -> _OutputItem | None: return None +def _is_message(item: object) -> bool: + try: + _AnyMessage.model_validate(_as_json(item)) + except ValidationError: + return False + return True + + def _chat_message(message: object) -> _ChatMessage | None: try: return _ChatMessage.model_validate(_as_json(message)) @@ -263,11 +275,14 @@ def _matches(items: Sequence[_OutputItem], message: _ChatMessage) -> bool: ) -def _content_phase(items: Sequence[_OutputItem | None], *, first_message_only: bool) -> Phase | None: - messages: Final = tuple(item for item in items if isinstance(item, _OutputMessage)) +def _content_phase( + output_items: Sequence[object], parsed: Sequence[_OutputItem | None], *, first_message_only: bool +) -> Phase | None: + messages: Final = tuple(item for raw, item in zip(output_items, parsed) if _is_message(raw)) if not messages: return None - return messages[0].phase if first_message_only else messages[-1].phase + content_message: Final = messages[0] if first_message_only else messages[-1] + return content_message.phase if isinstance(content_message, _OutputMessage) else None def _with_phase(message: _ChatMessage, phase: Phase | None) -> _ChatMessage: @@ -281,7 +296,7 @@ def replay_fields( output_items: Sequence[object], message: object, *, completed: bool, first_message_only: bool ) -> Mapping[str, object]: parsed: Final = tuple(map(_parsed_output_item, output_items)) - phase: Final = _content_phase(parsed, first_message_only=first_message_only) + phase: Final = _content_phase(output_items, parsed, first_message_only=first_message_only) phase_fields: Final[Mapping[str, object]] = MappingProxyType({PHASE_FIELD: phase} if phase else {}) items: Final = tuple(item for item in parsed if item is not None) parsed_message: Final = _chat_message(message) diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_history.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_history.py index f53d46c7cc8..a2efbfef5dc 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_history.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_history.py @@ -367,6 +367,14 @@ def test_a_lone_surrogate_in_the_reply_still_gets_a_record(stream: bool) -> None assert "openai_responses_replay" in _replayed_fields(assistant) +@pytest.mark.parametrize("stream", [False, True], ids=["json", "stream"]) +def test_a_last_message_the_bridge_cannot_read_leaves_the_turn_without_a_phase(stream: bool) -> None: + unreadable: Final = {**_with_text(FINAL_ANSWER, "It is raining in Paris."), "phase": "summary"} + assistant: Final = _assistant_turn((COMMENTARY, WEATHER_CALL, unreadable), stream=stream) + + assert "phase" not in _replayed_fields(assistant) + + def test_a_phase_openai_does_not_define_is_dropped_under_drop_params() -> None: assistant: Final = _assistant_turn(OUTPUT, stream=False)