diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 6d39f272f3e..53671f1d019 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -279,6 +279,54 @@ class _ProviderHTTPException(Protocol): _ERROR_BODY_ADAPTER: Final = TypeAdapter(dict[str, object]) +def _body_request_id(raw_body: object) -> str | None: + try: + body: Final = _ERROR_BODY_ADAPTER.validate_python(raw_body) + except ValidationError: + return None + + body_request_id: Final = body.get("request_id") + if isinstance(body_request_id, str) and body_request_id: + return body_request_id + + nested_error: Final = as_str_mapping(body.get("error")) + if nested_error is None: + return None + nested_request_id: Final = nested_error.get("request_id") + return nested_request_id if isinstance(nested_request_id, str) and nested_request_id else None + + +def _get_error_request_id(error_obj: _ProviderHTTPException) -> str | None: + body_request_id: Final = _body_request_id(attribute_or_none(error_obj, "body")) + if body_request_id is not None: + return body_request_id + + exception_request_id: Final = attribute_or_none(error_obj, "request_id") + if isinstance(exception_request_id, str) and exception_request_id: + return exception_request_id + + response: Final = attribute_or_none(error_obj, "response") + if not isinstance(response, httpx.Response): + return None + try: + response_body: Final = _ERROR_BODY_ADAPTER.validate_json(response.content) + except (ValidationError, httpx.ResponseNotRead): + return None + return _body_request_id(response_body) + + +def _get_error_message_with_request_id(error_obj: _ProviderHTTPException) -> str | None: + extracted_message: Final = get_error_message(error_obj=error_obj) + if extracted_message is None: + return None + + request_id: Final = _get_error_request_id(error_obj) + if request_id is not None and request_id not in extracted_message: + return f"{extracted_message} (request_id: {request_id})" + + return extracted_message + + def _openai_cyber_policy_body(original_exception: _ProviderHTTPException) -> Mapping[str, object] | None: raw_body: Final[object] = getattr(original_exception, "body", None) try: @@ -341,7 +389,7 @@ def _map_openai_exception( body=dict(cyber_policy_body), ) # custom_llm_provider is openai, make it OpenAI - message = get_error_message(error_obj=original_exception) + message = _get_error_message_with_request_id(error_obj=original_exception) if message is None: if hasattr(original_exception, "message"): message = original_exception.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..52c7f35b33d 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -9,6 +9,7 @@ import openai import pytest from fastapi import HTTPException from openai import AsyncOpenAI, OpenAI +from pydantic import TypeAdapter import litellm from litellm import completion @@ -16,6 +17,7 @@ from litellm.exceptions import GuardrailRaisedException from litellm.litellm_core_utils.exception_mapping_utils import ( ExceptionCheckers, _get_body_error_code, + _get_error_message_with_request_id, _get_response_headers, exception_type, extract_and_raise_litellm_exception, @@ -576,6 +578,155 @@ def test_openai_compatible_429_still_maps_to_rate_limit(): assert excinfo.value.status_code == 429 +class _FakeErrorObj: + """Minimal stand-in for an SDK exception carrying a parsed error body.""" + + def __init__(self, body=None, request_id=None): + self.body = body + if request_id is not None: + self.request_id = request_id + + +def test_error_message_with_request_id_appends_top_level_request_id(): + """DashScope/Bailian returns `request_id` as a top-level sibling of + `error` in the response body. Extracting only `error.message` dropped it + from the final error text (issue #47839).""" + error_obj = _FakeErrorObj( + body={ + "error": { + "message": "Incorrect API key provided.", + "type": "invalid_request_error", + "param": None, + "code": "invalid_api_key", + }, + "request_id": "343604ce-3795-9363-8be6-f4aa3f4613ee", + } + ) + + assert _get_error_message_with_request_id(error_obj=error_obj) == ( + "Incorrect API key provided. (request_id: 343604ce-3795-9363-8be6-f4aa3f4613ee)" + ) + + +def test_error_message_with_request_id_appends_request_id_for_flat_body(): + error_obj = _FakeErrorObj(body={"message": "Something went wrong", "request_id": "req_flat_123"}) + + assert _get_error_message_with_request_id(error_obj=error_obj) == "Something went wrong (request_id: req_flat_123)" + + +def test_error_message_with_request_id_appends_nested_error_request_id(): + error_obj = _FakeErrorObj(body={"error": {"message": "Bad request", "request_id": "req_nested_456"}}) + + assert _get_error_message_with_request_id(error_obj=error_obj) == "Bad request (request_id: req_nested_456)" + + +def test_error_message_with_request_id_with_non_dict_body_returns_none(): + error_obj = _FakeErrorObj(body="not-a-dict") + + assert _get_error_message_with_request_id(error_obj=error_obj) is None + + +def test_error_message_with_request_id_without_request_id_is_unchanged(): + error_obj = _FakeErrorObj(body={"error": {"message": "Plain error", "code": "some_code"}}) + + assert _get_error_message_with_request_id(error_obj=error_obj) == "Plain error" + + +def test_error_message_with_request_id_does_not_duplicate_request_id(): + error_obj = _FakeErrorObj(body={"message": "Failed (request_id: req_dup_1)", "request_id": "req_dup_1"}) + + assert _get_error_message_with_request_id(error_obj=error_obj) == "Failed (request_id: req_dup_1)" + + +def test_error_message_includes_the_exception_request_id() -> None: + request_id: Final = "provider-request-from-exception" + original_exception: Final = _FakeErrorObj(body={"message": "Rejected request"}, request_id=request_id) + + assert _get_error_message_with_request_id(original_exception) == f"Rejected request (request_id: {request_id})" + + +@pytest.mark.parametrize("response_content", (b"not-json", b"[]", b'{"request_id": 42}', None)) +def test_error_message_survives_a_response_without_a_readable_request_id(response_content: bytes | None) -> None: + request: Final = httpx.Request("POST", "https://provider.example/v1/chat/completions") + response: Final = ( + httpx.Response(401, request=request, content=response_content) + if response_content is not None + else httpx.Response(401, request=request, stream=httpx.ByteStream(b"unread")) + ) + original_exception: Final = OpenAIError( + status_code=401, message="Rejected request", body={"message": "Rejected request"}, response=response + ) + + assert _get_error_message_with_request_id(original_exception) == "Rejected request" + + +@pytest.mark.parametrize("include_request_id_header", (False, True)) +def test_dashscope_auth_error_keeps_request_id_from_the_sdk_response(include_request_id_header: bool) -> None: + request_id: Final = "provider-request-from-response" + message: Final = "Incorrect API key provided." + provider_body: Final = {"error": {"message": message, "type": "invalid_request_error"}, "request_id": request_id} + + def reject_request(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 401, + request=request, + json=provider_body, + headers={"x-request-id": request_id} if include_request_id_header else {}, + ) + + with ( + httpx.Client(transport=httpx.MockTransport(reject_request)) as http_client, + OpenAI(api_key="unit-test-credential", http_client=http_client, max_retries=0) as client, + pytest.raises(openai.AuthenticationError) as sdk_error, + ): + client.chat.completions.create(model="test-model", messages=[{"role": "user", "content": "Hello"}]) + + original_exception: Final = OpenAIError( + status_code=sdk_error.value.status_code, + message=sdk_error.value.message, + response=sdk_error.value.response, + body=TypeAdapter(dict[str, object]).validate_python(sdk_error.value.body), + ) + + with pytest.raises(litellm.AuthenticationError) as mapped: + exception_type(model="test-model", original_exception=original_exception, custom_llm_provider="dashscope") + + assert mapped.value.message.count(request_id) == 1 + assert message in mapped.value.message + + +def test_dashscope_auth_error_message_includes_provider_request_id(): + """End-to-end (issue #47839): a DashScope 401 through the + OpenAI-compatible exception mapping must keep the provider request id + in the final AuthenticationError message.""" + dashscope_body = { + "error": { + "message": "Incorrect API key provided. For details, see: " + "https://help.aliyun.com/zh/model-studio/error-code#apikey-error", + "type": "invalid_request_error", + "param": None, + "code": "invalid_api_key", + }, + "request_id": "343604ce-3795-9363-8be6-f4aa3f4613ee", + } + original_exception = OpenAIError( + status_code=401, + message=f"Error code: 401 - {dashscope_body}", + headers={}, + body=dashscope_body, + ) + + with pytest.raises(litellm.AuthenticationError) as excinfo: + exception_type( + model="dashscope/deepseek-v4-flash", + original_exception=original_exception, + custom_llm_provider="dashscope", + ) + + assert "343604ce-3795-9363-8be6-f4aa3f4613ee" in str(excinfo.value) + assert "Incorrect API key provided" in str(excinfo.value) + + @pytest.mark.parametrize( "error_message", [