diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 9902fe1d8fc..7e5fdc23ef9 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -535,6 +535,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 content_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", @@ -543,7 +559,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, @@ -855,10 +871,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 @@ -1329,6 +1348,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 "", @@ -1338,6 +1359,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): list[ChatCompletionReasoningItem] | None, (list(pending_reasoning_items) or None), ), + provider_specific_fields=provider_specific_fields, ) choices.append( @@ -2028,6 +2050,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 @@ -2071,6 +2094,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, @@ -2081,6 +2105,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 @@ -2169,6 +2194,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, @@ -2253,11 +2290,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, ) ] @@ -2365,12 +2408,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, @@ -2397,6 +2442,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): 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 19aefb430c7..b012ae7b649 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 @@ -38,6 +38,149 @@ if TYPE_CHECKING: from openai.types.responses.response_reasoning_item import ResponseReasoningItem +def test_responses_message_phase_is_exposed_on_chat_message() -> None: + 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() -> None: + 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() -> None: + 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() -> 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"} + ) + + 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() -> 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_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",