From 10870853224c6816d9451df89003faec77a3f59f Mon Sep 17 00:00:00 2001 From: sidsri14 Date: Thu, 5 Nov 2026 22:15:12 +0530 Subject: [PATCH] fix(openai): preserve 403 error metadata and fallback responses --- litellm/exceptions.py | 10 ++- .../exception_mapping_utils.py | 2 + .../test_exception_mapping_utils.py | 47 +++++++++++-- .../unit/test_openai_403_exception_mapping.py | 70 ------------------- 4 files changed, 48 insertions(+), 81 deletions(-) delete mode 100644 tests/unit/test_openai_403_exception_mapping.py diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 9e9fcbe621a..1d97be41930 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -481,6 +481,7 @@ class PermissionDeniedError(openai.PermissionDeniedError): litellm_debug_info: str | None = None, max_retries: int | None = None, num_retries: int | None = None, + body: object | None = None, ): self.status_code = 403 self.message = f"litellm.PermissionDeniedError: {message}" @@ -489,15 +490,12 @@ class PermissionDeniedError(openai.PermissionDeniedError): self.litellm_debug_info = litellm_debug_info self.max_retries = max_retries self.num_retries = num_retries - if response is not None and getattr(response, "request", None) is not None: + if response is not None and getattr(response, "_request", None) is not None: self.response = response else: - self.response = httpx.Response( - status_code=self.status_code, - request=httpx.Request(method="POST", url="https://litellm.ai"), - ) + self.response = error_response_without_credentials(self.status_code, response, "https://litellm.ai") super().__init__( - self.message, response=self.response, body=None + self.message, response=self.response, body=body ) # Call the base class constructor with the parameters it needs def __str__(self): diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 51e9ed22f1a..f2e1eb831ea 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -446,6 +446,7 @@ def _map_openai_exception( model=model, response=response, litellm_debug_info=extra_information, + body=getattr(original_exception, "body", None), ) if invalid_request_status == 404: raise NotFoundError( @@ -531,6 +532,7 @@ def _map_openai_exception( model=model, response=response, litellm_debug_info=extra_information, + body=getattr(original_exception, "body", None), ) elif original_exception.status_code == 404: raise NotFoundError( 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 32a99a37870..81f03fbbbe4 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -29,6 +29,41 @@ from litellm.proxy.common_utils.openai_error_payload import openai_error_type from litellm.types.utils import LlmProviders +def test_permission_denied_preserves_response_without_request() -> None: + upstream: Final = httpx.Response( + 403, content=b'{"error":"denied"}', headers={"x-request-id": "denied-request"} + ) + error: Final = litellm.PermissionDeniedError( + message="Forbidden", model="gpt-4o", llm_provider="openai", response=upstream + ) + assert (error.status_code, error.response.status_code, error.request.method) == (403, 403, "POST") + assert error.response.content == b'{"error":"denied"}' + assert error.response.headers["x-request-id"] == "denied-request" + + +def test_permission_denied_can_be_created_without_upstream_response() -> None: + error: Final = litellm.PermissionDeniedError(message="Forbidden", model="gpt-4o", llm_provider="openai") + assert (error.status_code, error.response.status_code, error.request.method) == (403, 403, "POST") + + +@pytest.mark.parametrize("error_type", ("request_forbidden", "invalid_request_error")) +def test_openai_403_keeps_upstream_response(error_type: str) -> None: + payload: Final = {"error": {"message": "Forbidden", "type": error_type, "param": "organization", "code": "denied"}} + request: Final = httpx.Request("POST", "https://provider.example/v1/chat/completions") + upstream: Final = httpx.Response(403, request=request, json=payload, headers={"x-request-id": "denied-request"}) + original: Final = openai.PermissionDeniedError( + f"Error code: 403 - {json.dumps(payload)}", response=upstream, body=payload["error"] + ) + with pytest.raises(litellm.PermissionDeniedError) as caught: + exception_type(model="gpt-4o", original_exception=original, custom_llm_provider="openai") + assert caught.value.request.url == request.url + assert (caught.value.status_code, caught.value.model, caught.value.llm_provider) == (403, "gpt-4o", "openai") + assert caught.value.response.json() == payload + assert caught.value.response.headers["x-request-id"] == "denied-request" + assert caught.value.body == payload["error"] + assert (caught.value.type, caught.value.param, caught.value.code) == (error_type, "organization", "denied") + + @pytest.mark.parametrize("shape", ["sdk", "http"]) @pytest.mark.parametrize("code", ["cyber_policy", "invalid_prompt"]) def test_openai_cyber_policy_uses_the_structured_error_code(shape: str, code: str) -> None: @@ -1103,6 +1138,7 @@ def test_azure_404_with_invalid_request_error_type_maps_to_not_found(): [ (400, "Invalid 'temperature'", litellm.BadRequestError), (401, "Invalid API key", litellm.AuthenticationError), + (403, "Forbidden", litellm.PermissionDeniedError), (404, "The model does not exist", litellm.NotFoundError), ], ) @@ -1141,8 +1177,8 @@ def test_openai_invalid_request_error_without_a_status_code_maps_to_bad_request( @pytest.mark.parametrize("provider", ["openai", "groq", "deepseek"]) -@pytest.mark.parametrize("status_code", [403, 409, 422, 429, 500, 503]) -def test_openai_invalid_request_error_keeps_bad_request_for_statuses_other_than_401_and_404( +@pytest.mark.parametrize("status_code", [409, 422, 429, 500, 503]) +def test_openai_invalid_request_error_keeps_bad_request_for_other_statuses( provider: str, status_code: int ) -> None: message: Final = "Cannot cancel a batch that is already completed." @@ -1244,7 +1280,7 @@ OPENAI_SHAPED = { 400: (litellm.BadRequestError, 400), 401: (litellm.AuthenticationError, 401), 402: (litellm.PaymentRequiredError, 402), - 403: (litellm.APIError, 403), + 403: (litellm.PermissionDeniedError, 403), 404: (litellm.NotFoundError, 404), 408: (litellm.Timeout, 408), 422: (litellm.BadRequestError, 422), @@ -1260,7 +1296,7 @@ STATUS_KEYED = {**OPENAI_SHAPED, 403: PERMISSION_DENIED} DEVIATIONS_FROM_THE_OPENAI_SHAPE = { "ai21": {402: (litellm.APIError, 402)}, "anthropic": {403: PERMISSION_DENIED}, - "azure": {402: (litellm.APIError, 402), 500: (litellm.APIError, 500)}, + "azure": {402: (litellm.APIError, 402), 403: (litellm.APIError, 403), 500: (litellm.APIError, 500)}, "azure_ai": {402: (litellm.APIError, 402)}, "bedrock": { 403: PERMISSION_DENIED, @@ -1278,6 +1314,7 @@ DEVIATIONS_FROM_THE_OPENAI_SHAPE = { "groq": {402: (litellm.APIError, 402)}, "huggingface": { 402: (litellm.APIError, 402), + 403: (litellm.APIError, 403), 404: (litellm.APIError, 404), 422: (litellm.APIError, 422), 500: (litellm.APIError, 500), @@ -1297,7 +1334,7 @@ DEVIATIONS_FROM_THE_OPENAI_SHAPE = { }, "ollama": {403: PERMISSION_DENIED}, "openai": {402: (litellm.APIError, 402)}, - "openrouter": {402: (litellm.APIError, 402), 500: (litellm.APIError, 500)}, + "openrouter": {402: (litellm.APIError, 402), 403: (litellm.APIError, 403), 500: (litellm.APIError, 500)}, "perplexity": {402: (litellm.APIError, 402)}, "replicate": { 403: (litellm.APIError, 500), diff --git a/tests/unit/test_openai_403_exception_mapping.py b/tests/unit/test_openai_403_exception_mapping.py deleted file mode 100644 index 82e83b6d07e..00000000000 --- a/tests/unit/test_openai_403_exception_mapping.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Unit test verifying that OpenAI HTTP 403 responses map to litellm.PermissionDeniedError. - -Closes #49936: [Bug]: OpenAI HTTP 403 raises APIError or BadRequestError instead of PermissionDeniedError -""" - -import pytest -import litellm -from litellm.exceptions import PermissionDeniedError -from litellm.litellm_core_utils.exception_mapping_utils import exception_type - - -class MockOpenAIHTTP403Exception(Exception): - def __init__(self, status_code: int = 403, body: dict = None, message: str = "Forbidden"): - super().__init__(message) - self.status_code = status_code - self.body = body or {} - self.message = message - - -def test_openai_403_request_forbidden_raises_permission_denied_error(): - """Verify HTTP 403 with request_forbidden body raises PermissionDeniedError.""" - exc = MockOpenAIHTTP403Exception( - status_code=403, - body={ - "error": { - "message": "Country, region, or territory not supported", - "type": "request_forbidden", - "param": None, - "code": "unsupported_country_region_territory", - } - }, - message="Country, region, or territory not supported", - ) - - with pytest.raises(PermissionDeniedError) as exc_info: - exception_type( - model="gpt-4o", - original_exception=exc, - custom_llm_provider="openai", - ) - - assert exc_info.value.status_code == 403 - assert isinstance(exc_info.value, PermissionDeniedError) - - -def test_openai_403_invalid_request_error_raises_permission_denied_error(): - """Verify HTTP 403 with invalid_request_error body raises PermissionDeniedError.""" - exc = MockOpenAIHTTP403Exception( - status_code=403, - body={ - "error": { - "message": "Forbidden", - "type": "invalid_request_error", - "param": None, - "code": None, - } - }, - message="Forbidden: invalid_request_error", - ) - - with pytest.raises(PermissionDeniedError) as exc_info: - exception_type( - model="gpt-4o", - original_exception=exc, - custom_llm_provider="openai", - ) - - assert exc_info.value.status_code == 403 - assert isinstance(exc_info.value, PermissionDeniedError)