fix(exceptions)!: honor status code when mapping OpenAI invalid_request_error exceptions (#32537)

* fix: openai exception mapper ignored status code for invalid_request_error

_map_openai_exception coerced anything whose body carried type
invalid_request_error into BadRequestError, guarded only by excluding the single
phrase Incorrect API key provided, and this ran before the status-code checks.
OpenAI and openai-compatible providers return 401 and 404 bodies with type
invalid_request_error, so an auth failure like Invalid API key mapped to a 400
instead of AuthenticationError, and a 404 mapped to BadRequestError instead of
NotFoundError. That changes the status returned to proxy clients and router
retry, cooldown, and fallback handling. Mirror the Azure mapper by only coercing
to BadRequestError when status_code is None or 400, so 401 and 404 fall through
to their existing handlers.

* fix(exceptions): map OpenAI-family invalid_request_error 404s to NotFoundError

Move the contributor's test to the mapped unit test file, assert the raised
class, status code and message, and cover the 400 and status-less cases that
must still raise BadRequestError

Add a regression test for the flow the bug was found on. Retrieving a missing
OpenAI vector store, sync or async, now raises NotFoundError with status 404

The router test that pinned an OpenAI 404 typed invalid_request_error as
BadRequestError now expects NotFoundError

* fix(a2a): answer a provider NotFoundError as JSON-RPC invalid params

* fix(exceptions)!: limit the invalid_request_error status guard to 401 and 404

Every other status keeps raising BadRequestError as on main, so 402-415, 429 and 5xx bodies typed invalid_request_error keep their class and router retry behavior. A2A goes back to main's mapping, which answers NotFoundError and AuthenticationError as JSON-RPC internal errors.

* fix(exceptions): keep the provider error fields on mapped 401 and 404 invalid_request_errors

Raise AuthenticationError and NotFoundError straight from the invalid_request_error
branch with the provider body, so code, param and type survive and the proxy's
error.type matches main. The Rust mapper copy skips 401 and 404 the same way.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Otis Cui 2026-10-29 23:05:47 +08:00 • committed by GitHub
parent cd58014315
commit c13ecb3085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 219 additions and 4 deletions

View file

@ -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<u16>,
#[case] expected: Option<PublicError>,
) {
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",

View file

@ -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):

View file

@ -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,

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.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")

View file

@ -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(

View file

@ -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