diff --git a/litellm/router_python_core.py b/litellm/router_python_core.py index 8ca2afb9a99..4643c493eb8 100644 --- a/litellm/router_python_core.py +++ b/litellm/router_python_core.py @@ -4696,11 +4696,11 @@ class PythonCore(DeprecatedRouterMembers): """What a fallback continues from once a Responses stream broke after text, raising the primary's error when the request constrains its output or the stream began a tool call, since a continued JSON document or tool call would not parse, and when a content filter cut it, so no other model finishes filtered text.""" + if source_iterator.started_tool_call: + PythonCore._raise_original_mid_stream_error(e) if _mid_stream_error_before_output(e): return None - if source_iterator.started_tool_call or isinstance( - e.original_exception, litellm_exceptions.ContentPolicyViolationError - ): + if isinstance(e.original_exception, litellm_exceptions.ContentPolicyViolationError): PythonCore._raise_original_mid_stream_error(e) cut_deployment_id: Final = (get_hidden_params(source_iterator) or {}).get("model_id") cut_deployment: Final = ( diff --git a/tests/unit/llms/base_llm/responses/test_transformation.py b/tests/unit/llms/base_llm/responses/test_transformation.py index 50d918fa5be..d0efdadabdd 100644 --- a/tests/unit/llms/base_llm/responses/test_transformation.py +++ b/tests/unit/llms/base_llm/responses/test_transformation.py @@ -1643,6 +1643,67 @@ async def test_responses_fallback_surfaces_the_primary_error_once_the_cut_stream router.discard() +def _openai_stream_cut_after_a_tool_call_without_text(failure: Mapping[str, object]) -> httpx.Response: + call: Final = { + "type": "function_call", "id": "fc_primary", "call_id": "call_primary", + "name": "lookup", "arguments": '{"city": "Paris"}', "status": "completed", + } + return _sse( + {"type": "response.created", "sequence_number": 0, "response": { + "id": "resp_primary", "object": "response", "created_at": 0, "status": "in_progress", + "model": "gpt-6-astra", "output": [], + }}, + { + "type": "response.output_item.added", "sequence_number": 1, "output_index": 0, + "item": {**call, "arguments": "", "status": "in_progress"}, + }, + { + "type": "response.function_call_arguments.delta", "sequence_number": 2, + "item_id": "fc_primary", "output_index": 0, "delta": call["arguments"], + }, + {"type": "response.output_item.done", "sequence_number": 3, "output_index": 0, "item": call}, + {**failure, "sequence_number": 4}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + [ + {"type": "error", "error": {"type": "server_error", "code": "internal_error", "message": "interrupted"}}, + {"type": "response.failed", "response": { + "id": "resp_primary", "object": "response", "created_at": 0, "status": "failed", + "model": "gpt-6-astra", "output": [], + "error": {"code": "server_error", "message": "interrupted"}, + }}, + ], + ids=["error", "response_failed"], +) +async def test_responses_fallback_surfaces_the_primary_error_once_the_cut_stream_delivered_a_tool_call_without_text( + failure: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + respx_mock.post("https://api.openai.com/v1/responses").mock( + return_value=_openai_stream_cut_after_a_tool_call_without_text(failure) + ) + fallback: Final = respx_mock.post("https://api.anthropic.com/v1/messages").mock( + return_value=_anthropic_stream("It is sunny in Paris.") + ) + router: Final = _primary_with_fallback("anthropic/claude-opus-5-5") + try: + stream: Final = await router.aresponses( + model="primary", + input="What is the weather in Paris?", + stream=True, + tools=[{"type": "function", "name": "lookup", "parameters": {"type": "object"}}], + ) + with pytest.raises(litellm.InternalServerError, match="interrupted"): + [event async for event in stream] + assert fallback.call_count == 0 + finally: + router.discard() + + def _openai_stream_cut_after_event(preamble: str, event: Mapping[str, object]) -> httpx.Response: return _sse( {