diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 6662cf4a2c2..1d97be41930 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -477,10 +477,11 @@ class PermissionDeniedError(openai.PermissionDeniedError): message, llm_provider, model, - response: httpx.Response, + response: httpx.Response | None = None, 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,8 +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: + self.response = response + else: + self.response = error_response_without_credentials(self.status_code, response, "https://litellm.ai") super().__init__( - self.message, response=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 6d39f272f3e..f2e1eb831ea 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -439,6 +439,15 @@ def _map_openai_exception( litellm_debug_info=extra_information, body=getattr(original_exception, "body", None), ) + if invalid_request_status == 403: + raise PermissionDeniedError( + message=f"{exception_provider} - {message}", + llm_provider=custom_llm_provider, + model=model, + response=response, + litellm_debug_info=extra_information, + body=getattr(original_exception, "body", None), + ) if invalid_request_status == 404: raise NotFoundError( message=f"{exception_provider} - {message}", @@ -516,6 +525,15 @@ def _map_openai_exception( response=response, litellm_debug_info=extra_information, ) + elif original_exception.status_code == 403: + raise PermissionDeniedError( + message=f"PermissionDeniedError: {exception_provider} - {message}", + llm_provider=custom_llm_provider, + model=model, + response=response, + litellm_debug_info=extra_information, + body=getattr(original_exception, "body", None), + ) elif original_exception.status_code == 404: raise NotFoundError( message=f"NotFoundError: {exception_provider} - {message}", 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),