fix(azure): drop the traceback from stream-cut errors sent to clients and alerts (#51760)

* fix(azure): drop the traceback from stream-cut errors sent to clients and alerts

An Azure chat or text completion stream cut mid-response raised an APIConnectionError whose message carried the full Python traceback, so the in-stream error frame, the /v1/completions 500 body, and every llm_exceptions alert showed server file paths. The Azure no-status branch now keeps the error line alone, and route_failure_proxy_exception redacts the message it answers clients with, the way the chat route already does

* fix(proxy): keep route error messages as raised and test the Azure stream cut through the proxy routes

Drop the route-level client message redaction, which rewrote valid guardrail and hook 400 messages, and cover the Azure chat and text stream cut end to end through respx

* fix(azure): name the error type when a connection error has no message

An httpx reset raises ReadError with an empty message, which left the client line with no cause once the traceback was gone

* test(proxy): drop the docstring from the Azure stream cut fixture

* test(proxy): stop pinning the MidStreamFallbackError prefix in the Azure text stream cut test

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
tavren27 2026-11-14 00:00:58 -08:00 • committed by GitHub
parent 93f8d86c89
commit c4c16c7b3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 122 additions and 1 deletions

View file

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

View file

@ -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: <TransferEncodingError: 400, "
"message='Not enough data to satisfy transfer length header.'>"
)
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."

View file

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

View file

@ -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).

View file

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