This commit is contained in:
Zhibo Zhang 2026-11-06 05:26:57 +00:00 • committed by GitHub
commit 1e00dfa571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 200 additions and 2 deletions

View file

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

View file

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