diff --git a/litellm-rust/crates/core-utils/src/exception_mapping_utils/openai.rs b/litellm-rust/crates/core-utils/src/exception_mapping_utils/openai.rs index d45078c415e..1273fd4d7f1 100644 --- a/litellm-rust/crates/core-utils/src/exception_mapping_utils/openai.rs +++ b/litellm-rust/crates/core-utils/src/exception_mapping_utils/openai.rs @@ -53,6 +53,7 @@ pub(super) const RULES: &[Rule] = &[ |mapping| { mapping.error_str.contains("invalid_request_error") && !mapping.error_str.contains("Incorrect API key provided") + && !matches!(mapping.status, Some(401 | 404)) }, PublicError::BadRequest, ), @@ -132,6 +133,23 @@ mod tests { assert_eq!(classified(Some(400), text), Some(expected)); } + #[rstest::rstest] + #[case::unauthorized(Some(401), None)] + #[case::not_found(Some(404), None)] + #[case::forbidden(Some(403), Some(PublicError::BadRequest))] + #[case::rate_limited(Some(429), Some(PublicError::BadRequest))] + #[case::server_error(Some(500), Some(PublicError::BadRequest))] + #[case::no_status(None, Some(PublicError::BadRequest))] + fn invalid_request_errors_leave_401_and_404_to_the_status_mapping( + #[case] status: Option, + #[case] expected: Option, + ) { + assert_eq!( + classified(status, "invalid_request_error bad field"), + expected + ); + } + #[rstest::rstest] #[case::rate_limit_before_context_window( "rate limit and This model's maximum context length is 10", diff --git a/litellm/exceptions.py b/litellm/exceptions.py index cd5139f5f79..61e3a8fb5dc 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -139,6 +139,7 @@ class AuthenticationError(openai.AuthenticationError): litellm_debug_info: str | None = None, max_retries: int | None = None, num_retries: int | None = None, + body: Mapping[str, object] | None = None, ): self.status_code = 401 self.message = f"litellm.AuthenticationError: {message}" @@ -152,7 +153,7 @@ class AuthenticationError(openai.AuthenticationError): request=httpx.Request(method="GET", url="https://litellm.ai"), # mock request object ) 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): @@ -189,6 +190,7 @@ class NotFoundError(openai.NotFoundError): litellm_debug_info: str | None = None, max_retries: int | None = None, num_retries: int | None = None, + body: Mapping[str, object] | None = None, ): self.status_code = 404 self.message = f"litellm.NotFoundError: {message}" @@ -202,7 +204,7 @@ class NotFoundError(openai.NotFoundError): request=httpx.Request(method="GET", url="https://litellm.ai"), # mock request object ) 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 703550dd2b5..64a95eb5f28 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -413,6 +413,25 @@ def _map_openai_exception( body=getattr(original_exception, "body", None), ) elif "invalid_request_error" in error_str and "Incorrect API key provided" not in error_str: + invalid_request_status: Final = getattr(original_exception, "status_code", None) + if invalid_request_status == 401: + raise AuthenticationError( + 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}", + model=model, + llm_provider=custom_llm_provider, + response=response, + litellm_debug_info=extra_information, + body=getattr(original_exception, "body", None), + ) raise BadRequestError( message=f"{exception_provider} - {message}", llm_provider=custom_llm_provider, 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 55e7c47e2c9..19e5387da9a 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -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.proxy.common_utils.openai_error_payload import openai_error_type from litellm.types.utils import LlmProviders @@ -1015,6 +1016,138 @@ def test_azure_404_with_invalid_request_error_type_maps_to_not_found(): assert "Response with id 'resp_abc' not found." in excinfo.value.message +@pytest.mark.parametrize("provider", ["openai", "groq", "deepseek"]) +@pytest.mark.parametrize( + "status_code, message, expected_exception", + [ + (400, "Invalid 'temperature'", litellm.BadRequestError), + (401, "Invalid API key", litellm.AuthenticationError), + (404, "The model does not exist", litellm.NotFoundError), + ], +) +def test_openai_invalid_request_error_respects_status_code( + provider: str, status_code: int, message: str, expected_exception: type[openai.APIStatusError] +) -> None: + error_message: Final = json.dumps({"error": {"message": message, "type": "invalid_request_error"}}) + original_exception: Final = OpenAIError(status_code=status_code, message=error_message, headers={}) + + with pytest.raises(openai.APIError) as excinfo: + exception_type( + model="some-model", + original_exception=original_exception, + custom_llm_provider=provider, + ) + + assert type(excinfo.value) is expected_exception + assert excinfo.value.status_code == status_code + assert message in excinfo.value.message + + +@pytest.mark.parametrize("provider", ["openai", "groq", "deepseek"]) +def test_openai_invalid_request_error_without_a_status_code_maps_to_bad_request(provider: str) -> None: + message: Final = "Missing required parameter: 'input'." + original_exception: Final = Exception(json.dumps({"error": {"message": message, "type": "invalid_request_error"}})) + + with pytest.raises(openai.APIError) as excinfo: + exception_type( + model="some-model", + original_exception=original_exception, + custom_llm_provider=provider, + ) + + assert type(excinfo.value) is litellm.BadRequestError + assert message in excinfo.value.message + + +@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( + provider: str, status_code: int +) -> None: + message: Final = "Cannot cancel a batch that is already completed." + error_message: Final = json.dumps({"error": {"message": message, "type": "invalid_request_error"}}) + original_exception: Final = OpenAIError(status_code=status_code, message=error_message, headers={}) + + with pytest.raises(openai.APIError) as excinfo: + exception_type( + model="some-model", + original_exception=original_exception, + custom_llm_provider=provider, + ) + + assert type(excinfo.value) is litellm.BadRequestError + assert message in excinfo.value.message + + +def _openai_sdk_error( + sdk_error: type[openai.APIStatusError], status_code: int, provider_error: dict[str, str | None] +) -> openai.APIStatusError: + request: Final = httpx.Request("POST", "https://api.openai.com/v1/chat/completions") + return sdk_error( + f"Error code: {status_code} - {{'error': {provider_error}}}", + response=httpx.Response(status_code, request=request), + body=provider_error, + ) + + +@pytest.mark.parametrize( + "sdk_error, status_code, expected_exception, param, code", + [ + (openai.AuthenticationError, 401, litellm.AuthenticationError, "organization", "mismatched_organization"), + (openai.NotFoundError, 404, litellm.NotFoundError, "id", None), + ], +) +def test_openai_invalid_request_error_401_and_404_keep_the_provider_error_fields( + sdk_error: type[openai.APIStatusError], + status_code: int, + expected_exception: type[openai.APIStatusError], + param: str, + code: str | None, +) -> None: + provider_error: Final = { + "message": "The request was rejected.", + "type": "invalid_request_error", + "param": param, + "code": code, + } + + with pytest.raises(openai.APIError) as excinfo: + exception_type( + model="gpt-5-mini", + original_exception=_openai_sdk_error(sdk_error, status_code, provider_error), + custom_llm_provider="openai", + ) + + assert type(excinfo.value) is expected_exception + assert (excinfo.value.status_code, excinfo.value.type, excinfo.value.param, excinfo.value.code) == ( + status_code, + "invalid_request_error", + param, + code, + ) + assert excinfo.value.body == provider_error + assert openai_error_type(excinfo.value, status_code) == "invalid_request_error" + assert excinfo.value.message.count(expected_exception.__name__) == 1 + + +def test_openai_incorrect_api_key_keeps_the_proxy_authentication_error_type() -> None: + provider_error: Final = { + "message": "Incorrect API key provided: sk-litellmqa.", + "type": "invalid_request_error", + "param": None, + "code": "invalid_api_key", + } + + with pytest.raises(litellm.AuthenticationError) as excinfo: + exception_type( + model="gpt-5-mini", + original_exception=_openai_sdk_error(openai.AuthenticationError, 401, provider_error), + custom_llm_provider="openai", + ) + + assert openai_error_type(excinfo.value, 401) == "authentication_error" + + class _UpstreamHTTPError(Exception): def __init__(self, status_code: int) -> None: super().__init__("upstream failure") diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index b55b3c5efc2..ee3a70c7c1c 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -21632,7 +21632,7 @@ async def test_router_retry_policy_controls_upstream_attempt_count( "error_body,error_type", [ ({"message": "model is down", "type": "server_error"}, litellm.NotFoundError), - ({"message": "Response with id 'resp_x' not found.", "type": "invalid_request_error"}, litellm.BadRequestError), + ({"message": "Response with id 'resp_x' not found.", "type": "invalid_request_error"}, litellm.NotFoundError), ], ) @pytest.mark.parametrize( diff --git a/tests/unit/vector_stores/test_main.py b/tests/unit/vector_stores/test_main.py index 25a378270b1..21b1eee3a3e 100644 --- a/tests/unit/vector_stores/test_main.py +++ b/tests/unit/vector_stores/test_main.py @@ -15,12 +15,13 @@ from unittest.mock import MagicMock, patch import httpx import pytest +import litellm import litellm.vector_stores.main as vector_stores_main from litellm.llms.base_llm.vector_store.transformation import ( RouterVectorStoreEmbeddingExecutor, ) from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler -from litellm.vector_stores.main import acreate, aupdate, search +from litellm.vector_stores.main import acreate, aretrieve, aupdate, retrieve, search MOCK_SEARCH_RESPONSE = { "object": "vector_store.search_results.page", @@ -200,3 +201,45 @@ async def test_direct_sdk_vector_store_writes_send_the_callers_metadata_unchange sent_metadata: Final = await _metadata_sent_to_provider(operation, callers_metadata) assert sent_metadata == [callers_metadata] + + +def _vector_store_not_found(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 404, + json={ + "error": { + "message": "No vector store found with id 'vs_missing'.", + "type": "invalid_request_error", + "param": "vector_store_id", + "code": None, + } + }, + request=request, + ) + + +async def _retrieve_missing_openai_vector_store(is_async: bool) -> object: + transport: Final = httpx.MockTransport(_vector_store_not_found) + if is_async: + return await aretrieve( + vector_store_id="vs_missing", + custom_llm_provider="openai", + api_key="sk-test", + client=AsyncHTTPHandler(transport=transport), + ) + return retrieve( + vector_store_id="vs_missing", + custom_llm_provider="openai", + api_key="sk-test", + client=HTTPHandler(client=httpx.Client(transport=transport)), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [True, False]) +async def test_retrieving_a_missing_openai_vector_store_raises_not_found(is_async: bool): + with pytest.raises(litellm.NotFoundError) as caught: + await _retrieve_missing_openai_vector_store(is_async) + + assert caught.value.status_code == 404 + assert "No vector store found with id 'vs_missing'." in caught.value.message