diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 338902aa919..7c58704fa1b 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -2279,7 +2279,7 @@ def _map_azure_exception( else: # if no status code then it is an APIConnectionError: https://github.com/openai/openai-python#handling-errors raise APIConnectionError( - message=f"{exception_provider} APIConnectionError - {message}\n{redact_string(traceback.format_exc())}", + message=f"{exception_provider} APIConnectionError - {message or type(original_exception).__name__}", llm_provider="azure", model=model, litellm_debug_info=extra_information, diff --git a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py index bc7827f4f07..09dcd1a77d3 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -1864,6 +1864,34 @@ def test_an_unmapped_exception_with_no_model_or_provider_message_keeps_traceback assert "Traceback (most recent call last)" in raised.value.message +@pytest.mark.parametrize("custom_llm_provider", ["azure", "azure_text"]) +def test_an_azure_stream_cut_maps_to_the_error_line_without_a_traceback( + custom_llm_provider: str, quiet_exception_mapping +) -> None: + stream_cut: Final = httpx.ReadError( + "Response payload is not completed: " + ) + + with pytest.raises(litellm.APIConnectionError) as raised: + _raise_and_map(model="gpt-5.4-mini", original_exception=stream_cut, custom_llm_provider=custom_llm_provider) + + assert raised.value.message.endswith(f"APIConnectionError - {stream_cut}") + assert "test_exception_mapping_utils.py" not in str(raised.value) + + +@pytest.mark.parametrize("custom_llm_provider", ["azure", "azure_text"]) +def test_an_azure_connection_reset_with_no_message_names_the_error_type( + custom_llm_provider: str, quiet_exception_mapping +) -> None: + with pytest.raises(litellm.APIConnectionError) as raised: + _raise_and_map( + model="gpt-5.4-mini", original_exception=httpx.ReadError(""), custom_llm_provider=custom_llm_provider + ) + + assert raised.value.message.endswith("APIConnectionError - ReadError") + + CONTEXT_WINDOW_MESSAGE = "This model's maximum context length is 4096 tokens." CONTENT_POLICY_MESSAGE = '{"error": {"type": "invalid_request_error", "code": "content_policy_violation"}}' TIMEOUT_MESSAGE = "Request timed out." diff --git a/tests/unit/proxy/proxy_server/conftest.py b/tests/unit/proxy/proxy_server/conftest.py index fdd251968e5..e125dac3506 100644 --- a/tests/unit/proxy/proxy_server/conftest.py +++ b/tests/unit/proxy/proxy_server/conftest.py @@ -634,3 +634,70 @@ def failed_spend_row( yield last_row ProxyLogging._callback_capabilities_cache.clear() + + +AZURE_STREAM_CUT_ERROR: Final = "Response payload is not completed" + + +@pytest.fixture +def azure_stream_cut(monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter) -> None: + import httpx + + import litellm + from litellm import Router + from litellm.proxy import proxy_server + + class _CutAfterFirstChunk(httpx.AsyncByteStream): + def __init__(self, first_chunk: str) -> None: + self._first_chunk: Final = first_chunk + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield f"data: {self._first_chunk}\n\n".encode() + raise httpx.ReadError(AZURE_STREAM_CUT_ERROR) + + def _streams(first_chunk: str) -> Callable[[httpx.Request], httpx.Response]: + return lambda request: httpx.Response( + 200, headers={"content-type": "text/event-stream"}, stream=_CutAfterFirstChunk(first_chunk) + ) + + api_base: Final = "https://stream-cut.openai.azure.example" + respx_mock.post(url__regex=rf"^{api_base}/openai/deployments/[^/]+/chat/completions").mock( + side_effect=_streams( + '{"id":"chatcmpl-1","object":"chat.completion.chunk","created":0,"model":"gpt-5.4-mini",' + '"choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"},"finish_reason":null}]}' + ) + ) + respx_mock.post(url__regex=rf"^{api_base}/openai/deployments/[^/]+/completions").mock( + side_effect=_streams( + '{"id":"cmpl-1","object":"text_completion","created":0,"model":"gpt-3.5-turbo-instruct",' + '"choices":[{"index":0,"text":"Hel","finish_reason":null,"logprobs":null}]}' + ) + ) + router: Final = Router( + model_list=[ + { + "model_name": model_name, + "litellm_params": { + "model": model, + "api_base": api_base, + "api_key": "azure-key", + "api_version": "2024-10-21", + "max_retries": 0, + }, + } + for model_name, model in ( + ("gpt-5.4-mini", "azure/gpt-5.4-mini"), + ("gpt-3.5-turbo-instruct", "azure_text/gpt-3.5-turbo-instruct"), + ) + ], + num_retries=0, + ) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + for name, value in ( + ("llm_router", router), + ("llm_model_list", router.registry().all()), + ("prisma_client", None), + ("general_settings", {}), + ("user_model", None), + ): + monkeypatch.setattr(proxy_server, name, value) diff --git a/tests/unit/proxy/proxy_server/test_routes_chat_completions.py b/tests/unit/proxy/proxy_server/test_routes_chat_completions.py index e124bde8120..8591bcc1da4 100644 --- a/tests/unit/proxy/proxy_server/test_routes_chat_completions.py +++ b/tests/unit/proxy/proxy_server/test_routes_chat_completions.py @@ -9,6 +9,7 @@ Pins (PR2): from __future__ import annotations +import json from unittest.mock import AsyncMock, MagicMock import pytest @@ -113,6 +114,18 @@ def test_chat_completion_happy_path(client, auth_as, patched_chat, path): } +def test_an_azure_chat_stream_cut_answers_the_client_with_the_error_line_alone(client, auth_as, azure_stream_cut): + payload = {"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "hi"}], "stream": True} + with auth_as(): + response = client.post("/v1/chat/completions", json=payload) + _, error_frame, done = response.text.removesuffix("\n\n").split("\n\n") + assert response.status_code == 200 + assert json.loads(error_frame.removeprefix("data: "))["error"]["message"] == ( + "litellm.APIConnectionError: AzureException APIConnectionError - Response payload is not completed" + ) + assert done == "data: [DONE]" + + @pytest.mark.parametrize("path", _CHAT_PATHS) def test_chat_completion_pipeline_error(client, auth_as, patched_chat_error, path): """Pins all four ``POST .../chat/completions`` aliases (error: 400). diff --git a/tests/unit/proxy/proxy_server/test_routes_completions.py b/tests/unit/proxy/proxy_server/test_routes_completions.py index 2b585357549..230b56758e4 100644 --- a/tests/unit/proxy/proxy_server/test_routes_completions.py +++ b/tests/unit/proxy/proxy_server/test_routes_completions.py @@ -9,6 +9,7 @@ Pins (PR2): from __future__ import annotations +import json from unittest.mock import AsyncMock, MagicMock import pytest @@ -128,6 +129,18 @@ def test_completion_pipeline_error(client, auth_as, completion_pipeline_raises, assert response.headers.get("content-type", "").startswith("application/json") +def test_an_azure_text_stream_cut_answers_the_client_with_the_error_line_alone(client, auth_as, azure_stream_cut): + payload = {"model": "gpt-3.5-turbo-instruct", "prompt": "hi", "stream": True} + with auth_as(): + response = client.post("/v1/completions", json=payload) + _, error_frame, done = response.text.removesuffix("\n\n").split("\n\n") + assert response.status_code == 200 + assert json.loads(error_frame.removeprefix("data: "))["error"]["message"].endswith( + "litellm.APIConnectionError: Azure_textException APIConnectionError - Response payload is not completed" + ) + assert done == "data: [DONE]" + + @pytest.mark.parametrize( ("failure", "expected_status", "expected_type"), [