mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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:
parent
93f8d86c89
commit
c4c16c7b3f
5 changed files with 122 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue