diff --git a/litellm/completion_extras/litellm_responses_transformation/history.py b/litellm/completion_extras/litellm_responses_transformation/history.py index 9567cd231fe..e5d75553da3 100644 --- a/litellm/completion_extras/litellm_responses_transformation/history.py +++ b/litellm/completion_extras/litellm_responses_transformation/history.py @@ -117,9 +117,21 @@ class _PhaseFields(_Frozen): phase: Phase | None = None -class _PhasedMessage(_Frozen): - phase: Phase | None = None - provider_specific_fields: _PhaseFields | None = None +class _RequestedPhaseFields(_Frozen): + phase: object = None + + +class _RequestedPhase(_Frozen): + phase: object = None + provider_specific_fields: _RequestedPhaseFields | None = None + + +@dataclass(frozen=True, slots=True) +class UnknownPhase: + value: object + + +_PHASE: Final[TypeAdapter[Phase]] = TypeAdapter(Phase) class _RetainedFields(_PhaseFields): @@ -150,13 +162,19 @@ def _chat_message(message: object) -> _ChatMessage | None: return None -def explicit_phase(message: object) -> Phase | None: +def requested_phase(message: object) -> Phase | UnknownPhase | None: try: - phased: Final = _PhasedMessage.model_validate(_as_json(message)) + requested: Final = _RequestedPhase.model_validate(_as_json(message)) except ValidationError: return None - fields: Final = phased.provider_specific_fields - return phased.phase or (fields.phase if fields else None) + fields: Final = requested.provider_specific_fields + value: Final = requested.phase or (fields.phase if fields else None) + if value is None: + return None + try: + return _PHASE.validate_python(value) + except ValidationError: + return UnknownPhase(value) def reasoning_input_item( diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 4ad3b5b4199..61a11678901 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -27,11 +27,12 @@ from litellm import ModelResponse from litellm._logging import verbose_logger from litellm.completion_extras.litellm_responses_transformation.history import ( PHASE_FIELD, - explicit_phase, + UnknownPhase, output_message_text, reasoning_input_item, replay_fields, replay_items, + requested_phase, ) from litellm.integrations.anthropic_cache_control_hook import supports_openai_prompt_cache_breakpoint from litellm.litellm_core_utils.hidden_params import get_hidden_params, get_or_create_hidden_params @@ -568,9 +569,20 @@ def completed_stream_history( return fields or None -def _assistant_phase_field(msg: object, replay_openai_history: bool) -> Mapping[str, object]: - phase: Final = explicit_phase(msg) if replay_openai_history else None - return {PHASE_FIELD: phase} if phase else {} +def _assistant_phase_field(msg: object, replay_openai_history: bool, drop_params: bool) -> Mapping[str, object]: + match requested_phase(msg) if replay_openai_history else None: + case None: + return {} + case UnknownPhase(value=value) if drop_params: + verbose_logger.debug("Chat provider: dropping assistant phase=%r under drop_params", value) + return {} + case UnknownPhase(value=value): + raise litellm.UnsupportedParamsError( + message=f"phase must be 'commentary' or 'final_answer', got {value!r}. " + "To drop it, set `litellm.drop_params=True` or pass `drop_params=True`." + ) + case phase: + return {PHASE_FIELD: phase} def _responses_event_type(chunk: Mapping[str, object]) -> object: @@ -966,7 +978,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): "content": self._convert_content_to_responses_format( content, "assistant", drop_params=drop_params ), - **_assistant_phase_field(msg, replay_openai_history), + **_assistant_phase_field(msg, replay_openai_history, drop_params), } ) for tool_call in tool_calls: @@ -1007,7 +1019,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): "content": self._convert_content_to_responses_format( content, cast(str, role), drop_params=drop_params ), - **_assistant_phase_field(msg, replay_openai_history and role == "assistant"), + **_assistant_phase_field(msg, replay_openai_history and role == "assistant", drop_params), } ) elif role == "assistant": 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 1ead71dc70a..3aaed84b9e4 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 @@ -208,6 +208,7 @@ def _next_turn_input( assistant: Mapping[str, object], model: str = f"openai/responses/{MODEL}", tools: Sequence[Mapping[str, object]] = TOOLS, + drop_params: bool = False, ) -> list[Mapping[str, object]]: with respx.mock(assert_all_called=True) as router: upstream: Final = router.post( @@ -220,6 +221,7 @@ def _next_turn_input( api_key="sk-test", api_base=None if model.startswith("openai/") else AZURE_API_BASE, api_version=None if model.startswith("openai/") else "2025-04-01-preview", + drop_params=drop_params, ) return json.loads(upstream.calls[0].request.content)["input"] @@ -321,15 +323,54 @@ def test_missing_or_corrupt_replay_metadata_falls_back_to_the_chat_fields( assert sent[3]["phase"] == "final_answer" -def test_a_phase_openai_does_not_define_is_left_off_the_fallback() -> None: +def test_a_phase_openai_does_not_define_is_dropped_under_drop_params() -> None: assistant: Final = _assistant_turn(OUTPUT, stream=False) - sent: Final = _next_turn_input({**assistant, "provider_specific_fields": {"phase": "analysis"}}) + sent: Final = _next_turn_input({**assistant, "provider_specific_fields": {"phase": "analysis"}}, drop_params=True) assert _types(sent[1:-1]) == ["reasoning", "reasoning", "message", "function_call"] assert "phase" not in sent[3] +def test_a_phase_openai_does_not_define_is_rejected_without_drop_params() -> None: + assistant: Final = _assistant_turn(OUTPUT, stream=False) + + with respx.mock(assert_all_called=False) as router: + upstream: Final = router.post(url__startswith=OPENAI_RESPONSES_URL).mock( + return_value=httpx.Response(200, json=_response((FOLLOW_UP,))) + ) + with pytest.raises(litellm.BadRequestError, match="'analysis'"): + litellm.completion( + model=f"openai/responses/{MODEL}", + messages=[USER, {**assistant, "phase": "analysis"}, TOOL_RESULT], + tools=list(TOOLS), + api_key="sk-test", + ) + + assert not upstream.called + + +@pytest.mark.parametrize("fields", [None, ["not", "a", "mapping"]], ids=["no-fields", "malformed-fields"]) +def test_an_assistant_message_the_app_wrote_replays_without_a_phase(fields: object) -> None: + sent: Final = _next_turn_input( + { + "role": "assistant", + "content": "Checking Paris.", + "tool_calls": [ + { + "id": "call_weather_paris", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + } + ], + "provider_specific_fields": fields, + } + ) + + assert _types(sent[1:-1]) == ["message", "function_call"] + assert "phase" not in sent[1] + + SEARCH_CALL: Final = { "type": "tool_search_call", "id": "tsc_weather",