From b54a6c9fac1a67437c0b0bbd1bd56424eea9d912 Mon Sep 17 00:00:00 2001 From: Zhibo Zhang Date: Sun, 1 Nov 2026 15:25:33 +0800 Subject: [PATCH 1/7] fix(responses): preserve assistant phase through chat bridge Signed-off-by: Zhibo Zhang <24061721@hdu.edu.cn> --- .../transformation.py | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index bb59b4617af..dfe30d7db51 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -520,6 +520,22 @@ def _fold_choices_into_one( *chain.from_iterable(_message_reasoning_items(m) for m in messages), *trailing_reasoning_items, ) + # A single Chat Completions message can carry one scalar phase. Preserve it + # only when every folded Responses message has the same declared phase. + message_phases: Final = tuple( + (message.provider_specific_fields or {}).get("phase") for message in messages + ) + shared_phase: Final = ( + message_phases[0] + if message_phases + and isinstance(message_phases[0], str) + and all(phase == message_phases[0] for phase in message_phases) + else None + ) + provider_fields: Final = { + **(_tool_search_provider_fields(tool_search_items) or {}), + **({"phase": shared_phase} if shared_phase is not None else {}), + } return Choices( message=Message( role=messages[0].role if messages else "assistant", @@ -528,7 +544,7 @@ def _fold_choices_into_one( tool_calls=list(tool_calls) or None, reasoning_content=" ".join(value for value in reasoning_contents if value) or None, reasoning_items=_as_chat_reasoning_items(reasoning_items), - provider_specific_fields=_tool_search_provider_fields(tool_search_items), + provider_specific_fields=provider_fields or None, ), finish_reason="tool_calls" if tool_calls else "stop", index=0, @@ -825,10 +841,13 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): annotations = LiteLLMResponsesTransformationHandler._convert_annotations_to_chat_format( content_item.get("annotations", None) ) + phase = item.get("phase") + provider_specific_fields = {"phase": phase} if isinstance(phase, str) else None msg = Message( role=item.get("role", "assistant"), content=response_text if response_text else "", annotations=annotations, + provider_specific_fields=provider_specific_fields, ) choice = Choices(message=msg, finish_reason="stop", index=index) return choice, index + 1 @@ -1289,6 +1308,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): annotations = LiteLLMResponsesTransformationHandler._convert_annotations_to_chat_format( raw_annotations ) + phase = getattr(item, "phase", None) + provider_specific_fields = {"phase": phase} if isinstance(phase, str) else None msg = Message( role=item.role, content=response_text if response_text else "", @@ -1298,6 +1319,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): list[ChatCompletionReasoningItem] | None, (list(pending_reasoning_items) or None), ), + provider_specific_fields=provider_specific_fields, ) choices.append( @@ -1977,6 +1999,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): self._chat_completion_id: str | None = None self._served_service_tier: str | None = None self._tool_call_index_map: dict[int, int] = {} # mutable-ok: per-stream accumulator state + self._message_phase_by_output_index: dict[int, str] = {} # mutable-ok: per-stream message metadata self._structured_output: Final = structured_output self._first_message_item_done: bool = False self._reasoning_summary_streamed: bool = False @@ -2020,6 +2043,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): def translate_responses_chunk_to_openai_stream( parsed_chunk: dict | BaseModel, tool_call_index_map: dict[int, int] | None = None, # mutable-ok: per-stream state, remapped in place + message_phase_by_output_index: Mapping[int, str] | None = None, replays_openai_history: bool = False, structured_output: bool = False, reasoning_summary_streamed: bool = False, @@ -2118,6 +2142,18 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): ) ] ) + elif output_item.get("type") == "message": + phase = output_item.get("phase") + provider_specific_fields = {"phase": phase} if isinstance(phase, str) else None + return ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta(content="", provider_specific_fields=provider_specific_fields), + finish_reason=None, + ) + ] + ) elif event_type in ( ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA, ResponsesAPIStreamEvents.CUSTOM_TOOL_CALL_INPUT_DELTA, @@ -2202,11 +2238,17 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): # Content part added to output content_part = parsed_chunk.get("delta", None) if content_part is not None: + output_index = cast(int, parsed_chunk.get("output_index", 0)) + phase = message_phase_by_output_index.get(output_index) if message_phase_by_output_index else None + provider_specific_fields = {"phase": phase} if phase is not None else None return ModelResponseStream( choices=[ StreamingChoices( index=0, - delta=Delta(content=content_part), + delta=Delta( + content=content_part, + provider_specific_fields=provider_specific_fields, + ), finish_reason=None, ) ] @@ -2314,12 +2356,14 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): """ verbose_logger.debug("Chat provider: transform_streaming_response called with chunk: %s", chunk) self._remember_served_service_tier(chunk) + self._remember_message_phase(chunk) translated: Final = ( self._empty_content_chunk() if self._is_text_delta_after_structured_answer(chunk) else OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream( chunk, tool_call_index_map=self._tool_call_index_map, + message_phase_by_output_index=self._message_phase_by_output_index, replays_openai_history=self._replays_openai_history, structured_output=self._structured_output, reasoning_summary_streamed=self._reasoning_summary_streamed, @@ -2346,6 +2390,16 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): return ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(content=""), finish_reason=None)]) + def _remember_message_phase(self, chunk: dict[str, object]) -> None: + if _responses_event_type(chunk) != ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED.value: + return + output_item = chunk.get("item") + if not isinstance(output_item, dict) or output_item.get("type") != "message": + return + phase = output_item.get("phase") + if isinstance(phase, str): + self._message_phase_by_output_index[cast(int, chunk.get("output_index", 0))] = phase + def _remember_served_service_tier(self, chunk: dict[str, object]) -> None: response_payload: Final = chunk.get("response") if not isinstance(response_payload, dict): From 803995ee359e5509f3924d0fc71f82ec49c6a4c4 Mon Sep 17 00:00:00 2001 From: Zhibo Zhang Date: Sun, 1 Nov 2026 15:25:39 +0800 Subject: [PATCH 2/7] test(responses): cover typed and streamed assistant phase Signed-off-by: Zhibo Zhang <24061721@hdu.edu.cn> --- ...responses_transformation_transformation.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 4e6ed16e360..6bb56232693 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -34,6 +34,98 @@ if TYPE_CHECKING: from litellm.types.utils import ModelResponse +def test_responses_message_phase_is_exposed_on_chat_message(): + handler = LiteLLMResponsesTransformationHandler() + choice, _ = handler._handle_raw_dict_response_item( + { + "type": "message", + "role": "assistant", + "phase": "commentary", + "content": [{"type": "output_text", "text": "Here is a preamble."}], + }, + 0, + ) + assert choice is not None + assert choice.message.provider_specific_fields == {"phase": "commentary"} + + +def test_typed_responses_message_phase_survives_nonstreaming_fold(): + message = ResponseOutputMessage.model_validate( + { + "id": "msg_phase", + "type": "message", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "The answer.", "annotations": []}], + } + ) + choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices((message,)) + assert choices[0].message.provider_specific_fields == {"phase": "final_answer"} + + +def test_chat_assistant_phase_is_replayed_to_responses_input(): + handler = LiteLLMResponsesTransformationHandler() + response, _ = handler.convert_chat_completion_messages_to_responses_api( + [{"role": "assistant", "content": "The final answer.", + "provider_specific_fields": {"phase": "final_answer"}}], + replay_openai_history=True, + ) + assert response == [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "input_text", "text": "The final answer."}], + "phase": "final_answer", + } + ] + + +def test_streaming_responses_message_phase_is_retained_for_text_deltas(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True) + added_commentary = iterator.chunk_parser({ + "type": "response.output_item.added", "output_index": 2, + "item": {"type": "message", "role": "assistant", "phase": "commentary", "content": []}, + }) + added_final = iterator.chunk_parser({ + "type": "response.output_item.added", "output_index": 4, + "item": {"type": "message", "role": "assistant", "phase": "final_answer", "content": []}, + }) + commentary_delta = iterator.chunk_parser({ + "type": "response.output_text.delta", "output_index": 2, "delta": "Preamble", + }) + final_delta = iterator.chunk_parser({ + "type": "response.output_text.delta", "output_index": 4, "delta": "Answer", + }) + assert added_commentary.choices[0].delta.provider_specific_fields == {"phase": "commentary"} + assert added_final.choices[0].delta.provider_specific_fields == {"phase": "final_answer"} + assert commentary_delta.choices[0].delta.content == "Preamble" + assert commentary_delta.choices[0].delta.provider_specific_fields == {"phase": "commentary"} + assert final_delta.choices[0].delta.content == "Answer" + assert final_delta.choices[0].delta.provider_specific_fields == {"phase": "final_answer"} + + +def test_fold_does_not_invent_one_phase_for_mixed_responses_messages(): + commentary = ResponseOutputMessage.model_validate({ + "id": "msg_commentary", "type": "message", "role": "assistant", + "status": "completed", "phase": "commentary", + "content": [{"type": "output_text", "text": "Preamble.", "annotations": []}], + }) + final_answer = ResponseOutputMessage.model_validate({ + "id": "msg_final", "type": "message", "role": "assistant", + "status": "completed", "phase": "final_answer", + "content": [{"type": "output_text", "text": "Answer.", "annotations": []}], + }) + choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices( + (commentary, final_answer) + ) + assert choices[0].message.provider_specific_fields is None + + + def test_completion_over_responses_maps_anthropic_search_tool_to_openai_tool_search() -> None: response_body: Final = { "id": "resp_tool_search_bridge", From bde9c32bc3bf7981da809b44fab0c7f3e9558606 Mon Sep 17 00:00:00 2001 From: Zhibo Zhang Date: Sun, 1 Nov 2026 15:27:07 +0800 Subject: [PATCH 3/7] style(tests): format assistant phase bridge regressions Signed-off-by: Zhibo Zhang <24061721@hdu.edu.cn> --- ...responses_transformation_transformation.py | 86 ++++++++++++------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 6bb56232693..22e6559ccad 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from litellm.types.utils import ModelResponse -def test_responses_message_phase_is_exposed_on_chat_message(): +def test_responses_message_phase_is_exposed_on_chat_message() -> None: handler = LiteLLMResponsesTransformationHandler() choice, _ = handler._handle_raw_dict_response_item( { @@ -49,7 +49,7 @@ def test_responses_message_phase_is_exposed_on_chat_message(): assert choice.message.provider_specific_fields == {"phase": "commentary"} -def test_typed_responses_message_phase_survives_nonstreaming_fold(): +def test_typed_responses_message_phase_survives_nonstreaming_fold() -> None: message = ResponseOutputMessage.model_validate( { "id": "msg_phase", @@ -64,11 +64,16 @@ def test_typed_responses_message_phase_survives_nonstreaming_fold(): assert choices[0].message.provider_specific_fields == {"phase": "final_answer"} -def test_chat_assistant_phase_is_replayed_to_responses_input(): +def test_chat_assistant_phase_is_replayed_to_responses_input() -> None: handler = LiteLLMResponsesTransformationHandler() response, _ = handler.convert_chat_completion_messages_to_responses_api( - [{"role": "assistant", "content": "The final answer.", - "provider_specific_fields": {"phase": "final_answer"}}], + [ + { + "role": "assistant", + "content": "The final answer.", + "provider_specific_fields": {"phase": "final_answer"}, + } + ], replay_openai_history=True, ) assert response == [ @@ -81,25 +86,33 @@ def test_chat_assistant_phase_is_replayed_to_responses_input(): ] -def test_streaming_responses_message_phase_is_retained_for_text_deltas(): +def test_streaming_responses_message_phase_is_retained_for_text_deltas() -> None: from litellm.completion_extras.litellm_responses_transformation.transformation import ( OpenAiResponsesToChatCompletionStreamIterator, ) + iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True) - added_commentary = iterator.chunk_parser({ - "type": "response.output_item.added", "output_index": 2, - "item": {"type": "message", "role": "assistant", "phase": "commentary", "content": []}, - }) - added_final = iterator.chunk_parser({ - "type": "response.output_item.added", "output_index": 4, - "item": {"type": "message", "role": "assistant", "phase": "final_answer", "content": []}, - }) - commentary_delta = iterator.chunk_parser({ - "type": "response.output_text.delta", "output_index": 2, "delta": "Preamble", - }) - final_delta = iterator.chunk_parser({ - "type": "response.output_text.delta", "output_index": 4, "delta": "Answer", - }) + added_commentary = iterator.chunk_parser( + { + "type": "response.output_item.added", + "output_index": 2, + "item": {"type": "message", "role": "assistant", "phase": "commentary", "content": []}, + } + ) + added_final = iterator.chunk_parser( + { + "type": "response.output_item.added", + "output_index": 4, + "item": {"type": "message", "role": "assistant", "phase": "final_answer", "content": []}, + } + ) + commentary_delta = iterator.chunk_parser( + {"type": "response.output_text.delta", "output_index": 2, "delta": "Preamble"} + ) + final_delta = iterator.chunk_parser( + {"type": "response.output_text.delta", "output_index": 4, "delta": "Answer"} + ) + assert added_commentary.choices[0].delta.provider_specific_fields == {"phase": "commentary"} assert added_final.choices[0].delta.provider_specific_fields == {"phase": "final_answer"} assert commentary_delta.choices[0].delta.content == "Preamble" @@ -108,24 +121,33 @@ def test_streaming_responses_message_phase_is_retained_for_text_deltas(): assert final_delta.choices[0].delta.provider_specific_fields == {"phase": "final_answer"} -def test_fold_does_not_invent_one_phase_for_mixed_responses_messages(): - commentary = ResponseOutputMessage.model_validate({ - "id": "msg_commentary", "type": "message", "role": "assistant", - "status": "completed", "phase": "commentary", - "content": [{"type": "output_text", "text": "Preamble.", "annotations": []}], - }) - final_answer = ResponseOutputMessage.model_validate({ - "id": "msg_final", "type": "message", "role": "assistant", - "status": "completed", "phase": "final_answer", - "content": [{"type": "output_text", "text": "Answer.", "annotations": []}], - }) +def test_fold_does_not_invent_one_phase_for_mixed_responses_messages() -> None: + commentary = ResponseOutputMessage.model_validate( + { + "id": "msg_commentary", + "type": "message", + "role": "assistant", + "status": "completed", + "phase": "commentary", + "content": [{"type": "output_text", "text": "Preamble.", "annotations": []}], + } + ) + final_answer = ResponseOutputMessage.model_validate( + { + "id": "msg_final", + "type": "message", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "Answer.", "annotations": []}], + } + ) choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices( (commentary, final_answer) ) assert choices[0].message.provider_specific_fields is None - def test_completion_over_responses_maps_anthropic_search_tool_to_openai_tool_search() -> None: response_body: Final = { "id": "resp_tool_search_bridge", From 586cf654f96ea029fb99473bfad9163ff535ec2e Mon Sep 17 00:00:00 2001 From: Zhibo Zhang Date: Sun, 1 Nov 2026 15:27:13 +0800 Subject: [PATCH 4/7] docs(responses): document phase stream metadata state Signed-off-by: Zhibo Zhang <24061721@hdu.edu.cn> --- .../litellm_responses_transformation/transformation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index dfe30d7db51..6001e6c2074 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -2054,6 +2054,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): Args: parsed_chunk: Dict containing the Responses API event chunk tool_call_index_map: Per-stream output_index -> sequential tool_call index map + message_phase_by_output_index: Per-stream Responses message output_index -> phase mapping replays_openai_history: Record the completed output's item sequence for the next turn structured_output: Only the first message item became content reasoning_summary_streamed: Whether earlier chunks already streamed reasoning summary text From 8a74845513c3cc0207b1af278ed4daabf0da87fc Mon Sep 17 00:00:00 2001 From: Zhibo Zhang Date: Sun, 1 Nov 2026 15:27:50 +0800 Subject: [PATCH 5/7] fix(responses): retain phase for structured output fold Signed-off-by: Zhibo Zhang <24061721@hdu.edu.cn> --- .../litellm_responses_transformation/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 6001e6c2074..2781b060169 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -523,7 +523,7 @@ def _fold_choices_into_one( # A single Chat Completions message can carry one scalar phase. Preserve it # only when every folded Responses message has the same declared phase. message_phases: Final = tuple( - (message.provider_specific_fields or {}).get("phase") for message in messages + (message.provider_specific_fields or {}).get("phase") for message in content_messages ) shared_phase: Final = ( message_phases[0] From abe1de2019c67895039f984f974f5c62b3a615d8 Mon Sep 17 00:00:00 2001 From: Zhibo Zhang Date: Sun, 1 Nov 2026 15:27:57 +0800 Subject: [PATCH 6/7] test(responses): cover structured output phase selection Signed-off-by: Zhibo Zhang <24061721@hdu.edu.cn> --- ...responses_transformation_transformation.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 22e6559ccad..857ba5817d7 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -148,6 +148,38 @@ def test_fold_does_not_invent_one_phase_for_mixed_responses_messages() -> None: assert choices[0].message.provider_specific_fields is None + + + +def test_structured_output_fold_keeps_first_message_phase() -> None: + commentary = ResponseOutputMessage.model_validate( + { + "id": "msg_commentary", + "type": "message", + "role": "assistant", + "status": "completed", + "phase": "commentary", + "content": [{"type": "output_text", "text": "Preamble.", "annotations": []}], + } + ) + final_answer = ResponseOutputMessage.model_validate( + { + "id": "msg_final", + "type": "message", + "role": "assistant", + "status": "completed", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "Answer.", "annotations": []}], + } + ) + choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices( + (commentary, final_answer), + structured_output=True, + ) + assert choices[0].message.content == "Preamble." + assert choices[0].message.provider_specific_fields == {"phase": "commentary"} + + def test_completion_over_responses_maps_anthropic_search_tool_to_openai_tool_search() -> None: response_body: Final = { "id": "resp_tool_search_bridge", From d68e4f3d3934bbb7a65a68246aa2ebc09c4950af Mon Sep 17 00:00:00 2001 From: Zhibo Zhang Date: Mon, 2 Nov 2026 19:59:36 +0800 Subject: [PATCH 7/7] style(responses): normalize spacing between phase regression tests --- ...n_extras_litellm_responses_transformation_transformation.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 857ba5817d7..a20112535eb 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -148,9 +148,6 @@ def test_fold_does_not_invent_one_phase_for_mixed_responses_messages() -> None: assert choices[0].message.provider_specific_fields is None - - - def test_structured_output_fold_keeps_first_message_phase() -> None: commentary = ResponseOutputMessage.model_validate( {