mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
fix(responses): reject an assistant phase OpenAI does not define unless drop_params is set
This commit is contained in:
parent
71fc3e91ce
commit
ceca305556
3 changed files with 86 additions and 15 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue