fix(openai): keep the upstream error body on e.response for OpenAI and Azure errors (#49079)

* fix(openai): keep the upstream error response on mapped exceptions so e.response.text has the wire body

* fix: keep the upstream error body on 429 and 5xx errors

RateLimitError, InternalServerError, BadGatewayError, and ServiceUnavailableError
rebuilt their response and dropped the upstream body. They now keep the body and
exactly the upstream headers, and an unread streaming response no longer raises.

* fix: keep a gzip-encoded 429 or 5xx error readable

The kept body is already decoded, so the rebuilt response drops content-encoding
instead of decoding the body a second time and raising DecodingError.

* fix: keep the upstream headers and drop credentials on every mapped provider error

The copied error response now keeps every upstream header and carries a
request with only the method and the URL, so no provider key, query
secret, or prompt reaches e.request. Timeout and APIError get the
response too, and azure/ chat, streaming, embedding, and image errors
attach it

* fix: keep the upstream error body on gateway timeouts and re-raised gateway errors

* test(azure): cover the 404, 500, 502, and 504 error bodies and the missing-key placeholder

* fix: keep no e.response on timeouts and errors litellm raises itself

* fix: keep the upstream elapsed time on error responses and the upstream body on azure_text errors

* fix: keep the upstream body without credentials on Azure content filter errors and message-matched OpenAI errors

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
mika-lt 2026-10-29 23:30:02 -07:00 • committed by GitHub
parent ccc56d27ad
commit f32ac94ad1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 895 additions and 80 deletions

View file

@ -9,6 +9,7 @@
## LiteLLM versions of the OpenAI Exception Types
import datetime
import enum
from collections.abc import Mapping, Sequence
from typing import Any, Final
@ -115,6 +116,54 @@ def validate_rate_limit_type(value: object) -> str | None:
return None
def _upstream_body(response: object) -> bytes:
if not isinstance(response, httpx.Response):
return b""
try:
return response.content
except httpx.ResponseNotRead:
return b""
def _request_without_credentials(upstream: object, fallback_url: str) -> httpx.Request:
if not isinstance(upstream, httpx.Response):
return httpx.Request(method="POST", url=fallback_url)
try:
upstream_request: Final = upstream.request
except RuntimeError:
return httpx.Request(method="POST", url=fallback_url)
return httpx.Request(
method=upstream_request.method,
url=upstream_request.url.copy_with(userinfo=b"", query=None, fragment=None),
)
def _upstream_elapsed(upstream: object) -> datetime.timedelta | None:
if not isinstance(upstream, httpx.Response):
return None
try:
return upstream.elapsed
except RuntimeError:
return None
def error_response_without_credentials(status_code: int, upstream: object, fallback_url: str) -> httpx.Response:
response: Final = httpx.Response(
status_code=status_code,
stream=httpx.ByteStream(_upstream_body(upstream)),
request=_request_without_credentials(upstream, fallback_url),
)
_ = response.read()
response.headers = httpx.Headers(upstream.headers if isinstance(upstream, httpx.Response) else None)
elapsed: Final = _upstream_elapsed(upstream)
if elapsed is not None:
response.elapsed = elapsed
return response
_NO_UPSTREAM_REQUEST_URL: Final = "https://cloud.google.com/vertex-ai/"
_MINIMAL_ERROR_RESPONSE: httpx.Response | None = None
@ -519,20 +568,11 @@ class RateLimitError(openai.RateLimitError):
# headers stay reachable on `e.response.headers` for callers that
# explicitly want them; only the proxy-supplied `headers=` kwarg
# makes it onto `self.headers`.
_response_headers: Final = getattr(response, "headers", None) if response is not None else None
self.headers: dict[str, str] | None = {k: str(v) for k, v in headers.items()} if headers else None
# Mirrors FastAPI HTTPException.detail so the same instance can be
# serialized through both the ProxyException and HTTPException paths.
self.detail = detail if detail is not None else self.message
self.response = httpx.Response(
status_code=429,
headers=_response_headers,
content=response.content if response is not None else None,
request=httpx.Request(
method="POST",
url=" https://cloud.google.com/vertex-ai/",
),
)
self.response = error_response_without_credentials(429, response, _NO_UPSTREAM_REQUEST_URL)
super().__init__(
self.message,
response=self.response, # pyright: ignore[reportArgumentType] # SDK accepts HTTPX at runtime
@ -757,15 +797,7 @@ class ServiceUnavailableError(openai.APIStatusError):
self.litellm_debug_info = litellm_debug_info
self.max_retries = max_retries
self.num_retries = num_retries
_response_headers: Final = getattr(response, "headers", None) if response is not None else None
self.response = httpx.Response(
status_code=self.status_code,
headers=_response_headers,
request=httpx.Request(
method="POST",
url=" https://cloud.google.com/vertex-ai/",
),
)
self.response = error_response_without_credentials(self.status_code, response, _NO_UPSTREAM_REQUEST_URL)
super().__init__(
self.message, response=self.response, body=None
) # Call the base class constructor with the parameters it needs
@ -808,15 +840,7 @@ class BadGatewayError(openai.APIStatusError):
self.litellm_debug_info = litellm_debug_info
self.max_retries = max_retries
self.num_retries = num_retries
_response_headers: Final = getattr(response, "headers", None) if response is not None else None
self.response = httpx.Response(
status_code=self.status_code,
headers=_response_headers,
request=httpx.Request(
method="POST",
url=" https://cloud.google.com/vertex-ai/",
),
)
self.response = error_response_without_credentials(self.status_code, response, _NO_UPSTREAM_REQUEST_URL)
super().__init__(
self.message,
response=self.response, # pyright: ignore[reportArgumentType] # SDK accepts HTTPX at runtime
@ -862,15 +886,7 @@ class InternalServerError(openai.InternalServerError):
self.litellm_debug_info = litellm_debug_info
self.max_retries = max_retries
self.num_retries = num_retries
_response_headers: Final = getattr(response, "headers", None) if response is not None else None
self.response = httpx.Response(
status_code=self.status_code,
headers=_response_headers,
request=httpx.Request(
method="POST",
url=" https://cloud.google.com/vertex-ai/",
),
)
self.response = error_response_without_credentials(self.status_code, response, _NO_UPSTREAM_REQUEST_URL)
super().__init__(
self.message,
response=self.response, # pyright: ignore[reportArgumentType] # SDK accepts HTTPX at runtime
@ -908,6 +924,7 @@ class APIError(openai.APIError):
max_retries: int | None = None,
num_retries: int | None = None,
body: object | None = None,
response: httpx.Response | None = None,
):
self.status_code = status_code
self.message = f"litellm.APIError: {message}"
@ -916,6 +933,8 @@ class APIError(openai.APIError):
self.litellm_debug_info = litellm_debug_info
self.max_retries = max_retries
self.num_retries = num_retries
if response is not None:
self.response = response
if request is None:
request = httpx.Request(method="POST", url="https://api.openai.com/v1")
super().__init__(self.message, request=request, body=body)

View file

@ -37,6 +37,7 @@ from ..exceptions import (
ServiceUnavailableError,
Timeout,
UnprocessableEntityError,
error_response_without_credentials,
)
@ -305,6 +306,16 @@ def _litellm_proxy_response(
)
def _without_credentials(response: httpx.Response | None) -> httpx.Response | None:
if not isinstance(response, httpx.Response):
return response
return error_response_without_credentials(response.status_code, response, "https://docs.litellm.ai/docs")
def _upstream_reply(response: httpx.Response | None) -> httpx.Response | None:
return response if response is not None and (response.headers or response.content) else None
def _map_openai_exception(
*,
model: str,
@ -315,7 +326,7 @@ def _map_openai_exception(
exception_provider: str,
extra_information: str,
) -> None:
response: Final = _litellm_proxy_response(original_exception, custom_llm_provider)
response: Final = _without_credentials(_litellm_proxy_response(original_exception, custom_llm_provider))
if (cyber_policy_body := _openai_cyber_policy_body(original_exception)) is not None:
raise ContentPolicyViolationError(
message=f"ContentPolicyViolationError: {exception_provider} - {original_exception}",
@ -378,6 +389,7 @@ def _map_openai_exception(
model=model,
llm_provider=custom_llm_provider,
litellm_debug_info=extra_information,
response=_upstream_reply(response),
)
elif (
("invalid_request_error" in error_str and "content_policy_violation" in error_str)
@ -449,6 +461,7 @@ def _map_openai_exception(
model=model,
llm_provider=custom_llm_provider,
body=getattr(original_exception, "body", None),
response=_upstream_reply(response),
)
elif "Request too large" in error_str:
raise RateLimitError(
@ -479,6 +492,7 @@ def _map_openai_exception(
model=model,
request=_request,
litellm_debug_info=extra_information,
response=_upstream_reply(response),
)
elif hasattr(original_exception, "status_code"):
if original_exception.status_code == 400:
@ -512,6 +526,7 @@ def _map_openai_exception(
model=model,
llm_provider=custom_llm_provider,
litellm_debug_info=extra_information,
response=_upstream_reply(response),
)
elif original_exception.status_code == 422:
raise BadRequestError(
@ -563,6 +578,7 @@ def _map_openai_exception(
llm_provider=custom_llm_provider,
litellm_debug_info=extra_information,
exception_status_code=original_exception.status_code,
response=_upstream_reply(response),
)
else:
raise APIError(
@ -572,6 +588,7 @@ def _map_openai_exception(
model=model,
request=getattr(original_exception, "request", None),
litellm_debug_info=extra_information,
response=_upstream_reply(response),
)
else:
# if no status code then it is an APIConnectionError: https://github.com/openai/openai-python#handling-errors
@ -2019,6 +2036,7 @@ def _map_azure_exception(
exception_provider: str,
extra_information: str,
) -> None:
response: Final = _without_credentials(getattr(original_exception, "response", None))
message = get_error_message(error_obj=original_exception)
if message is None:
if hasattr(original_exception, "message"):
@ -2054,7 +2072,7 @@ def _map_azure_exception(
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif "DeploymentNotFound" in error_str:
raise NotFoundError(
@ -2062,7 +2080,7 @@ def _map_azure_exception(
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif azure_error_code == "content_policy_violation" or ExceptionCheckers.is_azure_content_policy_violation_error(
error_str
@ -2076,6 +2094,7 @@ def _map_azure_exception(
model=model,
extra_information=extra_information,
original_exception=original_exception,
response=response,
)
elif azure_error_code == "invalid_encrypted_content" or "could not be verified" in error_str:
helpful_message: Final = (
@ -2094,7 +2113,7 @@ def _map_azure_exception(
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
body=getattr(original_exception, "body", None),
)
elif getattr(original_exception, "status_code", None) in (None, 400) and (
@ -2106,7 +2125,7 @@ def _map_azure_exception(
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
body=getattr(original_exception, "body", None),
)
elif "invalid_request_error" in error_str and getattr(original_exception, "status_code", None) in (None, 400):
@ -2115,7 +2134,7 @@ def _map_azure_exception(
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
body=getattr(original_exception, "body", None),
)
elif "The api_key client option must be set either by passing api_key to the client or by setting" in error_str:
@ -2124,7 +2143,7 @@ def _map_azure_exception(
llm_provider=custom_llm_provider,
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif "Connection error" in error_str:
raise APIConnectionError(
@ -2140,7 +2159,7 @@ def _map_azure_exception(
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
body=getattr(original_exception, "body", None),
)
elif original_exception.status_code == 401:
@ -2149,7 +2168,7 @@ def _map_azure_exception(
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif original_exception.status_code == 404:
raise NotFoundError(
@ -2157,7 +2176,7 @@ def _map_azure_exception(
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif original_exception.status_code == 408:
raise Timeout(
@ -2165,6 +2184,7 @@ def _map_azure_exception(
model=model,
litellm_debug_info=extra_information,
llm_provider="azure",
response=_upstream_reply(response),
)
elif original_exception.status_code == 422:
raise BadRequestError(
@ -2172,7 +2192,7 @@ def _map_azure_exception(
model=model,
llm_provider="azure",
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif original_exception.status_code == 429:
raise RateLimitError(
@ -2180,7 +2200,7 @@ def _map_azure_exception(
model=model,
llm_provider="azure",
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif original_exception.status_code == 502:
raise BadGatewayError(
@ -2188,7 +2208,7 @@ def _map_azure_exception(
model=model,
llm_provider="azure",
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif original_exception.status_code == 503:
raise ServiceUnavailableError(
@ -2196,7 +2216,7 @@ def _map_azure_exception(
model=model,
llm_provider="azure",
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
)
elif original_exception.status_code == 504: # gateway timeout error
raise Timeout(
@ -2205,6 +2225,7 @@ def _map_azure_exception(
litellm_debug_info=extra_information,
llm_provider="azure",
exception_status_code=original_exception.status_code,
response=_upstream_reply(response),
)
else:
raise APIError(
@ -2214,6 +2235,7 @@ def _map_azure_exception(
litellm_debug_info=extra_information,
model=model,
request=httpx.Request(method="POST", url="https://openai.com/"),
response=_upstream_reply(response),
)
else:
# if no status code then it is an APIConnectionError: https://github.com/openai/openai-python#handling-errors
@ -2589,17 +2611,20 @@ def exception_type(
model=model,
llm_provider=custom_llm_provider,
litellm_debug_info=extra_information,
response=_upstream_reply(
_without_credentials(_litellm_proxy_response(mappable_exception, custom_llm_provider))
),
)
if (
custom_llm_provider == "litellm_proxy"
): # handle special case where calling litellm proxy + exception str contains error message
extract_and_raise_litellm_exception(
response=_litellm_proxy_response(mappable_exception, custom_llm_provider),
response=_without_credentials(_litellm_proxy_response(mappable_exception, custom_llm_provider)),
error_str=error_str,
model=model,
custom_llm_provider=custom_llm_provider,
body=getattr(original_exception, "body", None),
body=getattr(mappable_exception, "body", None),
)
if (
custom_llm_provider == "openai"

View file

@ -42,6 +42,7 @@ from ..openai.common_utils import (
build_output_token_limit_response,
is_output_token_limit_error,
model_dump_provider_response,
upstream_error_response,
)
from .common_utils import (
AzureOpenAIError,
@ -399,6 +400,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
raise AzureOpenAIError(
status_code=status_code,
message=str(e),
response=upstream_error_response(e),
headers=error_headers,
body=error_body,
)
@ -662,6 +664,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
raise AzureOpenAIError(
status_code=status_code,
message=message,
response=upstream_error_response(e),
headers=error_headers,
body=error_body,
)
@ -855,7 +858,12 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
error_text = error_response.text
raise AzureOpenAIError(status_code=status_code, message=error_text, headers=error_headers)
raise AzureOpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
)
async def make_async_azure_httpx_request(
self,
@ -1190,7 +1198,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
except Exception as e:
error_code: Final[object] = getattr(e, "status_code", None)
if error_code is not None:
raise AzureOpenAIError(status_code=error_code, message=str(e))
raise AzureOpenAIError(status_code=error_code, message=str(e), response=upstream_error_response(e))
else:
raise AzureOpenAIError(status_code=500, message=str(e))

View file

@ -8,6 +8,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.litellm_core_utils.prompt_templates.factory import prompt_factory
from litellm.utils import CustomStreamWrapper, ModelResponse, TextCompletionResponse
from ...openai.common_utils import upstream_error_response
from ...openai.completion.transformation import OpenAITextCompletionConfig
from ..common_utils import AzureOpenAIError, BaseAzureLLM
@ -183,7 +184,12 @@ class AzureTextCompletion(BaseAzureLLM):
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise AzureOpenAIError(status_code=status_code, message=str(e), headers=error_headers)
raise AzureOpenAIError(
status_code=status_code,
message=str(e),
headers=error_headers,
response=upstream_error_response(e),
)
async def acompletion(
self,
@ -244,7 +250,12 @@ class AzureTextCompletion(BaseAzureLLM):
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise AzureOpenAIError(status_code=status_code, message=str(e), headers=error_headers)
raise AzureOpenAIError(
status_code=status_code,
message=str(e),
headers=error_headers,
response=upstream_error_response(e),
)
def streaming(
self,
@ -355,4 +366,9 @@ class AzureTextCompletion(BaseAzureLLM):
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise AzureOpenAIError(status_code=status_code, message=str(e), headers=error_headers)
raise AzureOpenAIError(
status_code=status_code,
message=str(e),
headers=error_headers,
response=upstream_error_response(e),
)

View file

@ -1,5 +1,7 @@
from typing import Final
import httpx
from litellm.exceptions import ContentPolicyViolationError
@ -14,6 +16,7 @@ class AzureOpenAIExceptionMapping:
model: str,
extra_information: str,
original_exception: Exception,
response: httpx.Response | None,
) -> ContentPolicyViolationError:
"""
Create a content policy violation error
@ -39,7 +42,7 @@ class AzureOpenAIExceptionMapping:
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
response=response,
provider_specific_fields={
# Preserve legacy key for backward compatibility.
"innererror": inner_error,

View file

@ -29,6 +29,7 @@ if TYPE_CHECKING:
from aiohttp import ClientSession
import litellm
from litellm.exceptions import error_response_without_credentials
from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
from litellm.litellm_core_utils.core_helpers import as_str_mapping, decoded_json_object
from litellm.litellm_core_utils.token_counter import token_counter, usage_tokenizer_model
@ -148,6 +149,14 @@ class OpenAIError(BaseLLMException):
)
def upstream_error_response(error: Exception) -> httpx.Response | None:
if not isinstance(error, (openai.APIStatusError, httpx.HTTPStatusError)):
return None
return error_response_without_credentials(
error.response.status_code, error.response, "https://docs.litellm.ai/docs"
)
_DECODED_JSON: Final = TypeAdapter(object)

View file

@ -10,7 +10,7 @@ from litellm.types.llms.openai import AllMessageValues, OpenAITextCompletionUser
from litellm.types.utils import LlmProviders, ModelResponse, TextCompletionResponse
from litellm.utils import ProviderConfigManager
from ..common_utils import BaseOpenAILLM, OpenAIError
from ..common_utils import BaseOpenAILLM, OpenAIError, upstream_error_response
from ..workload_identity import build_async_openai_client, build_openai_client
from .transformation import OpenAITextCompletionConfig
@ -164,7 +164,12 @@ class OpenAITextCompletion(BaseLLM):
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers)
raise OpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
)
async def acompletion(
self,
@ -220,7 +225,12 @@ class OpenAITextCompletion(BaseLLM):
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers)
raise OpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
)
def streaming(
self,
@ -261,7 +271,12 @@ class OpenAITextCompletion(BaseLLM):
error_response: object = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers)
raise OpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
)
streamwrapper: Final = CustomStreamWrapper(
completion_stream=response,
model=model,

View file

@ -14,7 +14,7 @@ from litellm.utils import ProviderConfigManager
from ...base_llm.image_variations.transformation import BaseImageVariationConfig
from ...custom_httpx.llm_http_handler import LiteLLMLoggingObj
from ..common_utils import OpenAIError
from ..common_utils import OpenAIError, upstream_error_response
from ..workload_identity import build_async_openai_client, build_openai_client
@ -129,7 +129,12 @@ class OpenAIImageVariationsHandler:
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers)
raise OpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
)
def image_variations(
self,
@ -245,4 +250,9 @@ class OpenAIImageVariationsHandler:
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers)
raise OpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
)

View file

@ -64,6 +64,7 @@ from .common_utils import (
is_openai_backed_api_base,
is_output_token_limit_error,
model_dump_provider_response,
upstream_error_response,
)
from .workload_identity import (
build_async_openai_client,
@ -983,6 +984,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
raise OpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
body=error_body,
)
@ -1110,6 +1112,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
raise OpenAIError(
status_code=status_code,
message=message,
response=upstream_error_response(e),
headers=error_headers,
body=exception_body,
)
@ -1282,6 +1285,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
raise OpenAIError(
status_code=getattr(e, "status_code", 500),
message=str(e),
response=upstream_error_response(e),
headers=error_headers,
body=exception_body,
)
@ -1409,7 +1413,12 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers)
raise OpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
)
def embedding(
self,
@ -1499,7 +1508,12 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
error_response: Final[object] = getattr(e, "response", None)
if error_headers is None and error_response:
error_headers = getattr(error_response, "headers", None)
raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers)
raise OpenAIError(
status_code=status_code,
message=error_text,
response=upstream_error_response(e),
headers=error_headers,
)
async def aimage_generation(
self,
@ -1666,7 +1680,11 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
original_response=str(e),
)
if hasattr(e, "status_code"):
raise OpenAIError(status_code=getattr(e, "status_code", 500), message=str(e))
raise OpenAIError(
status_code=getattr(e, "status_code", 500),
message=str(e),
response=upstream_error_response(e),
)
else:
raise OpenAIError(status_code=500, message=str(e))

View file

@ -24,6 +24,7 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.openai.common_utils import OpenAIError
from litellm.llms.vertex_ai.common_utils import VertexAIError
from litellm.proxy.common_utils.openai_error_payload import openai_error_type
from litellm.types.utils import LlmProviders
@ -54,8 +55,8 @@ def test_openai_cyber_policy_uses_the_structured_error_code(shape: str, code: st
assert caught.value.code == code
assert caught.value.body == body
assert caught.value.status_code == 400
assert caught.value.response is response
assert caught.value.request is request
assert caught.value.response.status_code == 400
assert caught.value.request.url == request.url
assert caught.value.response.headers["x-request-id"] == "cyber-policy-request"
@ -2940,3 +2941,80 @@ def test_transport_timeout_maps_to_timeout(
assert exc_info.value.status_code == 408
assert f"Request timed out. Error_str: {reason}" in str(exc_info.value)
def test_bedrock_server_error_keeps_the_aws_body_without_the_signed_request() -> None:
body: Final = '{"message":"Bedrock is unable to process your request."}'
signed_request: Final = httpx.Request(
"POST",
"https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-opus-5-5/converse",
headers={"authorization": "AWS4-HMAC-SHA256 Credential=AKIDSIGNED/20261029/us-east-1/bedrock/aws4_request"},
content=b'{"messages":[{"role":"user","content":[{"text":"hi"}]}]}',
)
upstream: Final = httpx.Response(
500, content=body, headers={"x-amzn-requestid": "req-aws"}, request=signed_request
)
with pytest.raises(litellm.ServiceUnavailableError) as raised:
exception_type(
model="bedrock/anthropic.claude-opus-5-5",
original_exception=BedrockError(status_code=500, message=body, response=upstream),
custom_llm_provider="bedrock",
)
assert raised.value.response.text == body
assert raised.value.response.headers["x-amzn-requestid"] == "req-aws"
assert "AKIDSIGNED" not in str(raised.value.request.headers)
assert raised.value.request.content == b""
@pytest.mark.parametrize("provider", ("gemini", "vertex_ai"))
def test_gemini_server_error_response_carries_the_upstream_error_body(provider: str) -> None:
body: Final = '{"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}'
with pytest.raises(litellm.InternalServerError) as raised:
exception_type(
model="gemini-3.1-pro",
original_exception=VertexAIError(status_code=500, message=body, headers=None),
custom_llm_provider=provider,
)
assert raised.value.response.status_code == 500
assert raised.value.response.json() == json.loads(body)
@pytest.mark.parametrize(
("status_code", "message", "mapped_error"),
(
(500, "The server had an error processing your request. Sorry about that!", litellm.InternalServerError),
(520, "Web server is returning an unknown error", litellm.InternalServerError),
(504, "A timeout occurred", litellm.Timeout),
(500, "Mistral API raised a streaming error", litellm.APIError),
),
)
def test_openai_error_matched_by_its_message_keeps_the_upstream_body(
status_code: int, message: str, mapped_error: type[Exception]
) -> None:
body: Final = json.dumps({"error": {"message": message, "type": "server_error", "param": None, "code": None}})
sent_request: Final = httpx.Request(
"POST",
"https://api.openai.com/v1/chat/completions",
headers={"authorization": "Bearer sk-openai-secret"},
content=b'{"messages":[{"role":"user","content":"hi"}]}',
)
upstream: Final = httpx.Response(
status_code, content=body, headers={"x-request-id": "req-openai"}, request=sent_request
)
with pytest.raises(mapped_error) as raised:
exception_type(
model="gpt-5.6",
original_exception=openai.APIStatusError(message, response=upstream, body=json.loads(body)),
custom_llm_provider="openai",
)
error_response: Final = getattr(raised.value, "response")
assert error_response.text == body
assert error_response.headers["x-request-id"] == "req-openai"
assert "sk-openai-secret" not in str(error_response.request.headers)
assert error_response.request.content == b""

View file

@ -1,3 +1,4 @@
import datetime
import json
from collections.abc import Awaitable, Callable, Mapping
from typing import Final
@ -12,6 +13,7 @@ import respx
import litellm
from litellm.exceptions import BadRequestError, ContentPolicyViolationError, ContextWindowExceededError
from litellm.litellm_core_utils.exception_mapping_utils import exception_type
from litellm.llms.azure.common_utils import AzureOpenAIError
@respx.mock
@ -632,3 +634,202 @@ def test_azure_content_policy_wins_over_context_length_code(monkeypatch: pytest.
error: Final = _azure_chat_error(monkeypatch, _azure_error_response(400, policy_error))
assert type(error) is ContentPolicyViolationError
_AZURE_REFUSAL_BODY: Final = '{"error":{"message":"azure refused","type":"upstream_refusal","code":"refused"}}'
_AZURE_DEPLOYMENT_NOT_FOUND_BODY: Final = '{"error":{"code":"DeploymentNotFound","message":"no such deployment"}}'
_AZURE_INTERNAL_ERROR_BODY: Final = '{"error":{"code":"InternalServerError","message":"Internal server error"}}'
_AZURE_CONTENT_FILTER_BODY: Final = json.dumps(
{
"error": {
"message": "The response was filtered due to the prompt triggering Azure OpenAI's content management policy.",
"type": None,
"param": "prompt",
"code": "content_filter",
"status": 400,
"innererror": {
"code": "ResponsibleAIPolicyViolation",
"content_filter_result": {"violence": {"filtered": True, "severity": "high"}},
},
}
}
)
_AZURE_KEY: Final = "azure-provider-secret"
_AZURE_BASE: Final = "https://azure-upstream.openai.azure.com"
_AZURE_ARGS: Final = {"api_base": _AZURE_BASE, "api_version": "2024-10-21", "api_key": _AZURE_KEY, "max_retries": 0}
async def _azure_completion() -> None:
litellm.completion(model="azure/gpt-4.1-mini", messages=[{"role": "user", "content": "hi"}], **_AZURE_ARGS)
async def _azure_completion_stream() -> None:
list(
litellm.completion(
model="azure/gpt-4.1-mini", messages=[{"role": "user", "content": "hi"}], stream=True, **_AZURE_ARGS
)
)
async def _azure_acompletion() -> None:
await litellm.acompletion(model="azure/gpt-4.1-mini", messages=[{"role": "user", "content": "hi"}], **_AZURE_ARGS)
async def _azure_acompletion_stream() -> None:
stream: Final = await litellm.acompletion(
model="azure/gpt-4.1-mini", messages=[{"role": "user", "content": "hi"}], stream=True, **_AZURE_ARGS
)
async for _ in stream:
pass
async def _azure_embedding() -> None:
litellm.embedding(model="azure/text-embedding-3-large", input=["hi"], **_AZURE_ARGS)
async def _azure_aembedding() -> None:
await litellm.aembedding(model="azure/text-embedding-3-large", input=["hi"], **_AZURE_ARGS)
async def _azure_image_generation() -> None:
litellm.image_generation(model="azure/gpt-image-2", prompt="a cat", **_AZURE_ARGS)
async def _azure_text_completion() -> None:
litellm.text_completion(model="azure_text/gpt-35-turbo-instruct", prompt="hi", **_AZURE_ARGS)
async def _azure_text_completion_stream() -> None:
list(litellm.text_completion(model="azure_text/gpt-35-turbo-instruct", prompt="hi", stream=True, **_AZURE_ARGS))
async def _azure_atext_completion() -> None:
await litellm.atext_completion(model="azure_text/gpt-35-turbo-instruct", prompt="hi", **_AZURE_ARGS)
async def _azure_atext_completion_stream() -> None:
stream: Final = await litellm.atext_completion(
model="azure_text/gpt-35-turbo-instruct", prompt="hi", stream=True, **_AZURE_ARGS
)
async for _ in stream:
pass
@pytest.mark.asyncio
@pytest.mark.parametrize(
"route",
(
_azure_completion,
_azure_completion_stream,
_azure_acompletion,
_azure_acompletion_stream,
_azure_embedding,
_azure_aembedding,
_azure_image_generation,
_azure_text_completion,
_azure_text_completion_stream,
_azure_atext_completion,
_azure_atext_completion_stream,
),
ids=lambda route: route.__name__.removeprefix("_azure_"),
)
@pytest.mark.parametrize(
("status_code", "body", "mapped_error"),
(
(400, _AZURE_REFUSAL_BODY, BadRequestError),
(400, _AZURE_CONTENT_FILTER_BODY, BadRequestError),
(403, _AZURE_REFUSAL_BODY, litellm.APIError),
(404, _AZURE_DEPLOYMENT_NOT_FOUND_BODY, litellm.NotFoundError),
(500, _AZURE_INTERNAL_ERROR_BODY, openai.APIError),
(502, _AZURE_REFUSAL_BODY, litellm.BadGatewayError),
(504, _AZURE_REFUSAL_BODY, litellm.Timeout),
),
)
@respx.mock
async def test_azure_error_keeps_the_upstream_body_without_the_api_key(
route: Callable[[], Awaitable[None]],
status_code: int,
body: str,
mapped_error: type[Exception],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
respx.post(url__regex=r"https://azure-upstream\.openai\.azure\.com/.*").mock(
return_value=httpx.Response(status_code, content=body, headers={"content-type": "application/json"})
)
with pytest.raises(mapped_error) as raised:
await route()
error_response: Final = getattr(raised.value, "response")
assert error_response.status_code == status_code
assert error_response.text == body
assert error_response.json() == json.loads(body)
for exposed in (error_response.request, getattr(raised.value, "request")):
assert _AZURE_KEY not in str(exposed.headers)
assert exposed.content == b""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"route",
(
_azure_completion,
_azure_completion_stream,
_azure_acompletion,
_azure_acompletion_stream,
_azure_embedding,
_azure_aembedding,
_azure_text_completion,
_azure_atext_completion,
_azure_atext_completion_stream,
),
ids=lambda route: route.__name__.removeprefix("_azure_"),
)
@respx.mock
async def test_azure_error_response_keeps_the_upstream_elapsed_time(
route: Callable[[], Awaitable[None]], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
respx.post(url__regex=r"https://azure-upstream\.openai\.azure\.com/.*").mock(
return_value=httpx.Response(400, content=_AZURE_REFUSAL_BODY, headers={"content-type": "application/json"})
)
with pytest.raises(BadRequestError) as raised:
await route()
assert raised.value.response.elapsed > datetime.timedelta(0)
def test_azure_client_side_missing_key_error_keeps_the_empty_placeholder_response() -> None:
missing_key: Final = openai.OpenAIError(
"The api_key client option must be set either by passing api_key to the client or by setting the "
"OPENAI_API_KEY environment variable"
)
with pytest.raises(litellm.AuthenticationError) as raised:
exception_type(model="azure/gpt-4.1-mini", original_exception=missing_key, custom_llm_provider="azure")
assert raised.value.response.status_code == 401
assert raised.value.response.text == ""
@pytest.mark.parametrize(
("status_code", "message", "mapped_error"),
(
(500, "azure_client is not an instance of AzureOpenAI or OpenAI", litellm.APIError),
(408, "the deployment did not answer in time", litellm.Timeout),
(504, "the deployment did not answer in time", litellm.Timeout),
),
)
def test_azure_error_litellm_raises_itself_has_no_upstream_response(
status_code: int, message: str, mapped_error: type[Exception]
) -> None:
with pytest.raises(mapped_error) as raised:
exception_type(
model="azure/gpt-4.1-mini",
original_exception=AzureOpenAIError(status_code=status_code, message=message),
custom_llm_provider="azure",
)
assert getattr(raised.value, "response", None) is None

View file

@ -93,24 +93,34 @@ async def test_acompletion_forwards_client_headers_to_provider(
assert request_headers["x-mycorp-llmcall-id"] == "abc-123"
_REJECTION_BODY: Final = '{"error":{"message":"bad prompt","type":"invalid_request_error"}}'
@pytest.fixture
def rejected_completions_endpoint():
return respx.post("https://api.openai.com/v1/completions").mock(
return_value=Response(
400,
json={"error": {"message": "bad prompt", "type": "invalid_request_error"}},
headers={"x-request-id": "req-1"},
content=_REJECTION_BODY,
headers={"x-request-id": "req-1", "content-type": "application/json"},
)
)
def _assert_carries_the_upstream_rejection(rejected: litellm.BadRequestError) -> None:
assert rejected.status_code == 400
assert rejected.litellm_response_headers["x-request-id"] == "req-1"
assert rejected.response.text == _REJECTION_BODY
assert rejected.response.json() == json.loads(_REJECTION_BODY)
assert rejected.response.headers["x-request-id"] == "req-1"
@respx.mock
def test_completion_surfaces_the_status_and_headers_of_a_rejected_request(rejected_completions_endpoint):
with pytest.raises(litellm.BadRequestError) as rejected:
text_completion(model="gpt-3.5-turbo-instruct", prompt="hello")
assert rejected.value.status_code == 400
assert rejected.value.litellm_response_headers["x-request-id"] == "req-1"
_assert_carries_the_upstream_rejection(rejected.value)
@respx.mock
@ -118,8 +128,7 @@ def test_streaming_completion_surfaces_the_status_and_headers_of_a_rejected_requ
with pytest.raises(litellm.BadRequestError) as rejected:
list(text_completion(model="gpt-3.5-turbo-instruct", prompt="hello", stream=True))
assert rejected.value.status_code == 400
assert rejected.value.litellm_response_headers["x-request-id"] == "req-1"
_assert_carries_the_upstream_rejection(rejected.value)
@respx.mock
@ -130,8 +139,7 @@ async def test_acompletion_surfaces_the_status_and_headers_of_a_rejected_request
with pytest.raises(litellm.BadRequestError) as rejected:
await atext_completion(model="gpt-3.5-turbo-instruct", prompt="hello")
assert rejected.value.status_code == 400
assert rejected.value.litellm_response_headers["x-request-id"] == "req-1"
_assert_carries_the_upstream_rejection(rejected.value)
@respx.mock

View file

@ -0,0 +1,51 @@
import json
from io import BytesIO
from typing import Final
import httpx
import pytest
import litellm
from litellm.llms.openai.common_utils import OpenAIError
_UPSTREAM_ERROR_BODY: Final = '{"error":{"message":"image is not a valid PNG","type":"invalid_request_error"}}'
def _upstream_rejection(_request: httpx.Request) -> httpx.Response:
return httpx.Response(
400,
content=_UPSTREAM_ERROR_BODY,
headers={"x-request-id": "req_variation_400", "content-type": "application/json"},
)
_VARIATION_KWARGS: Final = {
"custom_llm_provider": "openai",
"api_key": "transport-only",
"api_base": "https://upstream.example/v1",
"max_retries": 0,
}
async def _request_variation(is_async: bool) -> None:
if is_async:
await litellm.aimage_variation(image=BytesIO(b"image-bytes"), **_VARIATION_KWARGS)
return
litellm.image_variation(image=BytesIO(b"image-bytes"), **_VARIATION_KWARGS)
@pytest.mark.asyncio
@pytest.mark.parametrize("is_async", (False, True), ids=("sync", "async"))
async def test_rejected_image_variation_carries_the_upstream_error_response(
is_async: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
transport: Final = httpx.MockTransport(_upstream_rejection)
monkeypatch.setattr(litellm, "client_session", httpx.Client(transport=transport))
monkeypatch.setattr(litellm, "aclient_session", httpx.AsyncClient(transport=transport))
with pytest.raises((litellm.BadRequestError, OpenAIError)) as raised:
await _request_variation(is_async)
assert raised.value.response.text == _UPSTREAM_ERROR_BODY
assert raised.value.response.json() == json.loads(_UPSTREAM_ERROR_BODY)
assert raised.value.response.headers["x-request-id"] == "req_variation_400"

View file

@ -1,8 +1,9 @@
import asyncio
import gzip
import importlib
import json
import os
from collections.abc import Callable, Mapping
from collections.abc import Awaitable, Callable, Mapping
from types import ModuleType
from typing import Final, Literal, Protocol
from unittest.mock import MagicMock, Mock, patch
@ -375,6 +376,277 @@ async def test_acompletion_preserves_public_errors_for_both_http_clients(
assert not http_client.is_closed
_UPSTREAM_ERROR_BODY: Final = (
'{"error":{"message":"upstream rejected the request","type":"invalid_request_error",'
'"param":"response_format.type","code":"invalid_value"}}'
)
_UPSTREAM_BASE: Final = "https://upstream.example/v1"
_UPSTREAM_TRANSPORT: Final = httpx.MockTransport(
lambda _request: httpx.Response(
400,
content=_UPSTREAM_ERROR_BODY,
headers={"x-request-id": "req_upstream_400", "x-upstream-trace": "trace-7", "content-type": "application/json"},
)
)
_MESSAGES: Final = [{"role": "user", "content": "hi"}]
def _rejecting_client() -> OpenAI:
return OpenAI(
api_key="transport-only",
base_url=_UPSTREAM_BASE,
max_retries=0,
http_client=httpx.Client(transport=_UPSTREAM_TRANSPORT),
)
def _rejecting_async_client() -> AsyncOpenAI:
return AsyncOpenAI(
api_key="transport-only",
base_url=_UPSTREAM_BASE,
max_retries=0,
http_client=httpx.AsyncClient(transport=_UPSTREAM_TRANSPORT),
)
def _upstream(provider: str, model: str) -> dict[str, object]:
return {"model": f"{provider}/{model}", "api_base": _UPSTREAM_BASE, "api_key": "transport-only", "num_retries": 0}
async def _completion(provider: str) -> None:
litellm.completion(messages=_MESSAGES, client=_rejecting_client(), **_upstream(provider, "gpt-5.6"))
async def _completion_stream(provider: str) -> None:
stream: Final = litellm.completion(
messages=_MESSAGES, client=_rejecting_client(), stream=True, **_upstream(provider, "gpt-5.6")
)
list(stream)
async def _acompletion(provider: str) -> None:
await litellm.acompletion(messages=_MESSAGES, client=_rejecting_async_client(), **_upstream(provider, "gpt-5.6"))
async def _acompletion_stream(provider: str) -> None:
stream: Final = await litellm.acompletion(
messages=_MESSAGES, client=_rejecting_async_client(), stream=True, **_upstream(provider, "gpt-5.6")
)
async for _ in stream:
pass
async def _embedding(provider: str) -> None:
litellm.embedding(input=["hi"], client=_rejecting_client(), **_upstream(provider, "text-embedding-3-large"))
async def _aembedding(provider: str) -> None:
await litellm.aembedding(
input=["hi"], client=_rejecting_async_client(), **_upstream(provider, "text-embedding-3-large")
)
async def _image_generation(provider: str) -> None:
litellm.image_generation(prompt="a cat", client=_rejecting_client(), **_upstream(provider, "gpt-image-2"))
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
@pytest.mark.parametrize(
"route",
(_completion, _completion_stream, _acompletion, _acompletion_stream, _embedding, _aembedding, _image_generation),
ids=lambda route: route.__name__.lstrip("_"),
)
async def test_mapped_error_carries_the_upstream_error_response(
provider: str, route: Callable[[str], Awaitable[None]]
) -> None:
with pytest.raises(litellm.BadRequestError) as raised:
await route(provider)
assert raised.value.response.text == _UPSTREAM_ERROR_BODY
assert raised.value.response.json() == json.loads(_UPSTREAM_ERROR_BODY)
assert raised.value.response.headers["x-request-id"] == "req_upstream_400"
assert raised.value.response.headers["x-upstream-trace"] == "trace-7"
_UPSTREAM_SERVER_ERROR_BODY: Final = (
'{"error":{"message":"The server had an error while processing your request.","type":"server_error",'
'"param":null,"code":null}}'
)
_CHUNKED_UPSTREAM_HEADERS: Final = {
"content-type": "application/json",
"transfer-encoding": "chunked",
"x-request-id": "req_upstream_5xx",
}
def _chunked_upstream_client(status_code: int, content_encoding: Literal["gzip"] | None) -> httpx.AsyncClient:
body: Final = _UPSTREAM_SERVER_ERROR_BODY.encode()
encoding_headers: Final = {"content-encoding": content_encoding} if content_encoding else {}
return httpx.AsyncClient(
transport=httpx.MockTransport(
lambda _request: httpx.Response(
status_code,
headers={**_CHUNKED_UPSTREAM_HEADERS, **encoding_headers},
stream=httpx.ByteStream(gzip.compress(body) if content_encoding else body),
)
)
)
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
@pytest.mark.parametrize("stream", (False, True))
@pytest.mark.parametrize(
("status_code", "mapped_error"),
(
(429, litellm.RateLimitError),
(500, litellm.InternalServerError),
(502, litellm.BadGatewayError),
(503, litellm.ServiceUnavailableError),
),
)
@pytest.mark.parametrize("content_encoding", (None, "gzip"))
async def test_retryable_error_keeps_the_decoded_upstream_body_and_its_headers(
provider: str,
stream: bool,
status_code: int,
mapped_error: type[Exception],
content_encoding: Literal["gzip"] | None,
) -> None:
client: Final = AsyncOpenAI(
api_key="transport-only",
base_url=_UPSTREAM_BASE,
max_retries=0,
http_client=_chunked_upstream_client(status_code, content_encoding),
)
with pytest.raises(mapped_error) as raised:
await litellm.acompletion(messages=_MESSAGES, client=client, stream=stream, **_upstream(provider, "gpt-5.6"))
error_response: Final = getattr(raised.value, "response")
assert error_response.status_code == status_code
assert error_response.text == _UPSTREAM_SERVER_ERROR_BODY
assert error_response.json() == json.loads(_UPSTREAM_SERVER_ERROR_BODY)
assert dict(error_response.headers) == {
**_CHUNKED_UPSTREAM_HEADERS,
**({"content-encoding": content_encoding} if content_encoding else {}),
}
_REFUSAL_BODY: Final = '{"error":{"message":"upstream refused","type":"upstream_refusal","param":null,"code":null}}'
_GATEWAY_TIMEOUT_BODY: Final = (
'{"error":{"message":"litellm.Timeout: APITimeoutError - Request timed out.","type":"invalid_request_error",'
'"param":null,"code":"408"}}'
)
_PROVIDER_KEY: Final = "sk-provider-secret"
_QUERY_SECRET: Final = "query-secret"
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
@pytest.mark.parametrize(
("status_code", "mapped_error", "body"),
(
(400, litellm.BadRequestError, _REFUSAL_BODY),
(403, litellm.APIError, _REFUSAL_BODY),
(408, litellm.Timeout, _REFUSAL_BODY),
(408, litellm.Timeout, _GATEWAY_TIMEOUT_BODY),
(409, litellm.APIError, _REFUSAL_BODY),
(413, litellm.APIError, _REFUSAL_BODY),
(429, litellm.RateLimitError, _REFUSAL_BODY),
(500, litellm.InternalServerError, _REFUSAL_BODY),
(504, litellm.Timeout, _REFUSAL_BODY),
),
)
def test_mapped_error_keeps_the_upstream_body_without_the_provider_credentials(
provider: str, status_code: int, mapped_error: type[Exception], body: str
) -> None:
client: Final = OpenAI(
api_key=_PROVIDER_KEY,
base_url=_UPSTREAM_BASE,
default_query={"key": _QUERY_SECRET},
max_retries=0,
http_client=httpx.Client(
transport=httpx.MockTransport(
lambda _request: httpx.Response(
status_code, content=body, headers={"content-type": "application/json"}
)
)
),
)
with pytest.raises(mapped_error) as raised:
litellm.completion(
model=f"{provider}/gpt-5.6",
messages=_MESSAGES,
client=client,
api_base=_UPSTREAM_BASE,
api_key=_PROVIDER_KEY,
num_retries=0,
)
error_response: Final = getattr(raised.value, "response")
assert error_response.status_code == status_code
assert error_response.text == body
assert error_response.json() == json.loads(body)
assert str(error_response.request.url) == f"{_UPSTREAM_BASE}/chat/completions"
for exposed in (error_response.request, getattr(raised.value, "request")):
assert _QUERY_SECRET not in str(exposed.url)
assert _PROVIDER_KEY not in str(exposed.headers)
assert exposed.content == b""
def _time_out(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("no reply", request=request)
async def _sync_completion_timing_out(provider: str) -> None:
litellm.completion(
model=f"{provider}/gpt-5.6",
messages=_MESSAGES,
client=OpenAI(
api_key=_PROVIDER_KEY,
base_url=_UPSTREAM_BASE,
max_retries=0,
http_client=httpx.Client(transport=httpx.MockTransport(_time_out)),
),
api_base=_UPSTREAM_BASE,
api_key=_PROVIDER_KEY,
num_retries=0,
)
async def _async_stream_timing_out(provider: str) -> None:
stream: Final = await litellm.acompletion(
model=f"{provider}/gpt-5.6",
messages=_MESSAGES,
stream=True,
client=AsyncOpenAI(
api_key=_PROVIDER_KEY,
base_url=_UPSTREAM_BASE,
max_retries=0,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(_time_out)),
),
api_base=_UPSTREAM_BASE,
api_key=_PROVIDER_KEY,
num_retries=0,
)
async for _ in stream:
pass
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
@pytest.mark.parametrize("call", (_sync_completion_timing_out, _async_stream_timing_out))
async def test_timeout_before_any_reply_has_no_upstream_response(
provider: str, call: Callable[[str], Awaitable[None]]
) -> None:
with pytest.raises(litellm.Timeout) as raised:
await call(provider)
assert getattr(raised.value, "response", None) is None
@pytest.mark.asyncio
async def test_acompletion_streams_text_deltas_over_injected_transport(
http_backend: ModuleType, sdk_provider: str, monkeypatch: pytest.MonkeyPatch

View file

@ -2,6 +2,7 @@ import asyncio
import contextvars
import copy
import datetime
import gzip
import json
from collections.abc import AsyncIterable, Mapping
from types import MappingProxyType, SimpleNamespace
@ -13,6 +14,7 @@ from tests.unit.proxy.router_entry_point_stubs import route_entry_points_to_stub
import anthropic
import httpx
import openai
import pytest
import respx
from fastapi import HTTPException, Request, Response, status
@ -11675,6 +11677,67 @@ async def test_handle_llm_api_exception_forwards_litellm_response_headers_when_r
assert exc_info.value.headers["llm_provider-x-request-id"] == "req_openai_400"
_UPSTREAM_REJECTION_BODY: Final = b'{"error":{"message":"upstream said no","type":"upstream_rejection"}}'
def _upstream_rejection_wire(content_encoding: Literal["gzip"] | None) -> tuple[bytes, dict[str, str]]:
wire_body: Final = gzip.compress(_UPSTREAM_REJECTION_BODY) if content_encoding else _UPSTREAM_REJECTION_BODY
return wire_body, {
"x-request-id": "req_upstream",
"x-upstream-trace": "trace-7",
"content-type": "application/json",
"content-length": str(len(wire_body)),
**({"content-encoding": content_encoding} if content_encoding else {}),
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status_code", "mapped_error"),
(
(400, litellm.BadRequestError),
(429, litellm.RateLimitError),
(503, litellm.ServiceUnavailableError),
),
)
@pytest.mark.parametrize("content_encoding", (None, "gzip"))
async def test_handle_llm_api_exception_forwards_exactly_the_upstream_headers_of_a_mapped_openai_error(
status_code: int, mapped_error: type[Exception], content_encoding: Literal["gzip"] | None
) -> None:
wire_body, upstream_headers = _upstream_rejection_wire(content_encoding)
transport: Final = httpx.MockTransport(
lambda _request: httpx.Response(status_code, content=wire_body, headers=upstream_headers)
)
with pytest.raises(mapped_error) as mapped:
await litellm.acompletion(
model="openai/gpt-5.6",
messages=[{"role": "user", "content": "hi"}],
num_retries=0,
client=openai.AsyncOpenAI(
api_key="transport-only",
base_url="https://upstream.example/v1",
max_retries=0,
http_client=httpx.AsyncClient(transport=transport),
),
)
proxy_logging_obj: Final = MagicMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={})
with pytest.raises(ProxyException) as forwarded:
await ProxyBaseLLMRequestProcessing(data={}).handle_llm_api_exception(
e=mapped.value,
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"),
proxy_logging_obj=proxy_logging_obj,
)
assert forwarded.value.code == str(status_code)
assert "upstream said no" in forwarded.value.message
assert {name: value for name, value in forwarded.value.headers.items() if name.startswith("llm_provider-")} == {
f"llm_provider-{name}": value for name, value in upstream_headers.items()
}
@pytest.mark.asyncio
async def test_handle_llm_api_exception_logs_bug_report_for_unmapped_error(
monkeypatch: pytest.MonkeyPatch,

View file

@ -13,6 +13,7 @@ from typing import Final
import httpx
import pytest
import litellm
from litellm.exceptions import (
BadRequestError,
ContentPolicyViolationError,
@ -418,3 +419,21 @@ class TestBedrockErrorHeaders:
headers = get_response_headers(dict(_response_headers))
assert headers.get("llm_provider-x-amzn-requestid") == "req-proxy-500"
@pytest.mark.parametrize(
"error_class",
(RateLimitError, ServiceUnavailableError, litellm.BadGatewayError, litellm.InternalServerError),
)
def test_error_built_from_an_unread_streaming_response_keeps_its_headers(error_class: type[Exception]) -> None:
unread: Final = httpx.Response(
500,
headers={"x-request-id": "req-unread"},
stream=httpx.ByteStream(b'{"error":"never read"}'),
)
error: Final = error_class(message="upstream failed", llm_provider="openai", model="gpt-5.6", response=unread)
error_response: Final = getattr(error, "response")
assert error_response.text == ""
assert dict(error_response.headers) == {"x-request-id": "req-unread"}