fix(responses): reject an assistant phase OpenAI does not define unless drop_params is set

This commit is contained in:
mateo-berri 2026-10-28 05:42:11 -07:00
parent 71fc3e91ce
commit ceca305556
3 changed files with 86 additions and 15 deletions

View file

@ -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(

View file

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

View file

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