From 9407f979af931efef7c50849cedadecdfabf01ef Mon Sep 17 00:00:00 2001 From: Zhong Zhiyi <42856345+zzy1130@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:05:14 +0800 Subject: [PATCH 1/6] fix(exception-mapping): preserve provider request_id in extracted error messages get_error_message() returned only the nested error.message, dropping a provider request_id returned as a sibling of the error object (e.g. DashScope/Bailian: {"error": {...}, "request_id": "..."}). The final error text therefore lacked the request id users need to investigate failures, even though it was present in the upstream body and response headers. Append the request id to the extracted message when available - from body["request_id"], body["error"]["request_id"], or the SDK exception's request_id attribute - unless it is already present. Fixes #47839 --- .../exception_mapping_utils.py | 54 ++++++++++- .../test_exception_mapping_utils.py | 93 +++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 1f49cc2c4bb..26cd070fcfe 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -167,19 +167,65 @@ def get_error_message(error_obj) -> str | None: if error_obj is None: return None + message: str | None = None if hasattr(error_obj, "body"): _error_obj_body: Final = getattr(error_obj, "body") if isinstance(_error_obj_body, dict): # OpenAI-style: {"message": "...", "type": "...", ...} if _error_obj_body.get("message"): - return _error_obj_body.get("message") + message = _error_obj_body.get("message") + else: + # Azure-style: {"error": {"message": "...", ...}} + nested_error: Final = _error_obj_body.get("error") + if isinstance(nested_error, dict): + message = nested_error.get("message") + + if message is None: + return None + + # Some providers (e.g. DashScope/Bailian) return the request id as a + # top-level sibling of `error` in the response body, not inside + # `error.message`. Extracting only the message drops it, leaving users + # without the provider request id needed to investigate the failure + # (https://github.com/BerriAI/litellm/issues/47839). Append it to the + # extracted message when it is available and not already present. + request_id: Final = _get_error_request_id(error_obj) + if request_id is not None and isinstance(message, str) and request_id not in message: + message = f"{message} (request_id: {request_id})" + + return message + except Exception: + return None + + +def _get_error_request_id(error_obj) -> str | None: + """ + Return the provider request id carried by an error object, if any. + + Checks, in order: + - `body["request_id"]` — top-level sibling of `error` + (e.g. `{"error": {...}, "request_id": "..."}` from DashScope) + - `body["error"]["request_id"]` — nested inside the error object + - the `request_id` attribute the OpenAI SDK sets on its exceptions + """ + try: + if hasattr(error_obj, "body"): + _error_obj_body: Final = getattr(error_obj, "body") + if isinstance(_error_obj_body, dict): + _body_request_id: Final = _error_obj_body.get("request_id") + if isinstance(_body_request_id, str) and _body_request_id: + return _body_request_id - # Azure-style: {"error": {"message": "...", ...}} nested_error: Final = _error_obj_body.get("error") if isinstance(nested_error, dict): - return nested_error.get("message") + _nested_request_id: Final = nested_error.get("request_id") + if isinstance(_nested_request_id, str) and _nested_request_id: + return _nested_request_id + + _attr_request_id: Final = getattr(error_obj, "request_id", None) + if isinstance(_attr_request_id, str) and _attr_request_id: + return _attr_request_id - # If all else fails, return None return None except Exception: return None 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 5ee14ff530d..6b5f728de73 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -19,6 +19,7 @@ from litellm.litellm_core_utils.exception_mapping_utils import ( _get_response_headers, exception_type, extract_and_raise_litellm_exception, + get_error_message, ) from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.bedrock.common_utils import BedrockError @@ -508,6 +509,98 @@ 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_get_error_message_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(error_obj=error_obj) == ( + "Incorrect API key provided. (request_id: 343604ce-3795-9363-8be6-f4aa3f4613ee)" + ) + + +def test_get_error_message_appends_request_id_for_flat_body(): + error_obj = _FakeErrorObj(body={"message": "Something went wrong", "request_id": "req_flat_123"}) + + assert get_error_message(error_obj=error_obj) == "Something went wrong (request_id: req_flat_123)" + + +def test_get_error_message_appends_nested_error_request_id(): + error_obj = _FakeErrorObj(body={"error": {"message": "Bad request", "request_id": "req_nested_456"}}) + + assert get_error_message(error_obj=error_obj) == "Bad request (request_id: req_nested_456)" + + +def test_get_error_message_appends_sdk_request_id_attribute(): + error_obj = _FakeErrorObj(body={"error": {"message": "Unauthorized"}}, request_id="req_sdk_789") + + assert get_error_message(error_obj=error_obj) == "Unauthorized (request_id: req_sdk_789)" + + +def test_get_error_message_without_request_id_is_unchanged(): + error_obj = _FakeErrorObj(body={"error": {"message": "Plain error", "code": "some_code"}}) + + assert get_error_message(error_obj=error_obj) == "Plain error" + + +def test_get_error_message_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(error_obj=error_obj) == "Failed (request_id: req_dup_1)" + + +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", [ From f078786940effc783588c4c3241a32ee0a3e9b8e Mon Sep 17 00:00:00 2001 From: Zhong Zhiyi <42856345+zzy1130@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:13:58 +0800 Subject: [PATCH 2/6] refactor(exception-mapping): satisfy strict ruff delta gate in request-id helper Annotate the error_obj parameter, use 3-arg getattr with isinstance guards instead of a try/except, so _get_error_request_id adds no new ANN001/B009/BLE001/TRY300 violations versus the merge-base. --- .../exception_mapping_utils.py | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 26cd070fcfe..47c223e5374 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -198,7 +198,7 @@ def get_error_message(error_obj) -> str | None: return None -def _get_error_request_id(error_obj) -> str | None: +def _get_error_request_id(error_obj: object) -> str | None: """ Return the provider request id carried by an error object, if any. @@ -208,27 +208,23 @@ def _get_error_request_id(error_obj) -> str | None: - `body["error"]["request_id"]` — nested inside the error object - the `request_id` attribute the OpenAI SDK sets on its exceptions """ - try: - if hasattr(error_obj, "body"): - _error_obj_body: Final = getattr(error_obj, "body") - if isinstance(_error_obj_body, dict): - _body_request_id: Final = _error_obj_body.get("request_id") - if isinstance(_body_request_id, str) and _body_request_id: - return _body_request_id + _error_obj_body: Final = getattr(error_obj, "body", None) + if isinstance(_error_obj_body, dict): + _body_request_id: Final = _error_obj_body.get("request_id") + if isinstance(_body_request_id, str) and _body_request_id: + return _body_request_id - nested_error: Final = _error_obj_body.get("error") - if isinstance(nested_error, dict): - _nested_request_id: Final = nested_error.get("request_id") - if isinstance(_nested_request_id, str) and _nested_request_id: - return _nested_request_id + nested_error: Final = _error_obj_body.get("error") + if isinstance(nested_error, dict): + _nested_request_id: Final = nested_error.get("request_id") + if isinstance(_nested_request_id, str) and _nested_request_id: + return _nested_request_id - _attr_request_id: Final = getattr(error_obj, "request_id", None) - if isinstance(_attr_request_id, str) and _attr_request_id: - return _attr_request_id + _attr_request_id: Final = getattr(error_obj, "request_id", None) + if isinstance(_attr_request_id, str) and _attr_request_id: + return _attr_request_id - return None - except Exception: - return None + return None ####### EXCEPTION MAPPING ################ From 33fc58d7a0044c3e50d15460d9fbb29f73cdae75 Mon Sep 17 00:00:00 2001 From: Zhong Zhiyi <42856345+zzy1130@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:24:16 +0800 Subject: [PATCH 3/6] refactor(exception-mapping): type-discipline-clean request-id helpers Move the extraction helpers below _ProviderHTTPException, type them with that protocol, and validate the body through the existing _ERROR_BODY_ADAPTER (Mapping-typed) instead of getattr/isinstance chains on Any. This keeps the type-discipline delta gate at zero new LIT010/LIT001 violations and drops the SDK request_id attribute fallback (the body-carried id from #47839 is unaffected; the attribute was never part of the extracted message before). --- .../exception_mapping_utils.py | 109 ++++++++++-------- .../test_exception_mapping_utils.py | 6 +- 2 files changed, 65 insertions(+), 50 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 47c223e5374..c920efb233c 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -163,24 +163,11 @@ def get_error_message(error_obj) -> str | None: } """ try: - # First, try to access the message directly from the 'body' key if error_obj is None: return None - message: str | None = None - if hasattr(error_obj, "body"): - _error_obj_body: Final = getattr(error_obj, "body") - if isinstance(_error_obj_body, dict): - # OpenAI-style: {"message": "...", "type": "...", ...} - if _error_obj_body.get("message"): - message = _error_obj_body.get("message") - else: - # Azure-style: {"error": {"message": "...", ...}} - nested_error: Final = _error_obj_body.get("error") - if isinstance(nested_error, dict): - message = nested_error.get("message") - - if message is None: + extracted_message: Final = _extract_error_message(error_obj) + if extracted_message is None: return None # Some providers (e.g. DashScope/Bailian) return the request id as a @@ -190,43 +177,14 @@ def get_error_message(error_obj) -> str | None: # (https://github.com/BerriAI/litellm/issues/47839). Append it to the # extracted message when it is available and not already present. request_id: Final = _get_error_request_id(error_obj) - if request_id is not None and isinstance(message, str) and request_id not in message: - message = f"{message} (request_id: {request_id})" + if request_id is not None and request_id not in extracted_message: + return f"{extracted_message} (request_id: {request_id})" - return message + return extracted_message except Exception: return None -def _get_error_request_id(error_obj: object) -> str | None: - """ - Return the provider request id carried by an error object, if any. - - Checks, in order: - - `body["request_id"]` — top-level sibling of `error` - (e.g. `{"error": {...}, "request_id": "..."}` from DashScope) - - `body["error"]["request_id"]` — nested inside the error object - - the `request_id` attribute the OpenAI SDK sets on its exceptions - """ - _error_obj_body: Final = getattr(error_obj, "body", None) - if isinstance(_error_obj_body, dict): - _body_request_id: Final = _error_obj_body.get("request_id") - if isinstance(_body_request_id, str) and _body_request_id: - return _body_request_id - - nested_error: Final = _error_obj_body.get("error") - if isinstance(nested_error, dict): - _nested_request_id: Final = nested_error.get("request_id") - if isinstance(_nested_request_id, str) and _nested_request_id: - return _nested_request_id - - _attr_request_id: Final = getattr(error_obj, "request_id", None) - if isinstance(_attr_request_id, str) and _attr_request_id: - return _attr_request_id - - return None - - ####### EXCEPTION MAPPING ################ _DECODED_ERROR_BODY: Final = TypeAdapter(object) @@ -314,6 +272,63 @@ class _ProviderHTTPException(Protocol): _ERROR_BODY_ADAPTER: Final = TypeAdapter(dict[str, object]) +def _validated_error_body(error_obj: _ProviderHTTPException) -> Mapping[str, object] | None: + """The exception's parsed error body as a dict, or None when it is not one.""" + raw_body: Final = error_obj.body + if raw_body is None: + return None + try: + validated: Final = _ERROR_BODY_ADAPTER.validate_python(raw_body) + except ValidationError: + return None + return validated + + +def _extract_error_message(error_obj: _ProviderHTTPException) -> str | None: + """Extract the message from an error object's `body`, if it carries one.""" + body: Final = _validated_error_body(error_obj) + if body is None: + return None + + # OpenAI-style: {"message": "...", "type": "...", ...} + flat_message: Final = body.get("message") + if flat_message: + return flat_message if isinstance(flat_message, str) else None + + # Azure-style: {"error": {"message": "...", ...}} + nested_error: Final = body.get("error") + if isinstance(nested_error, dict): + nested_message: Final = nested_error.get("message") + return nested_message if isinstance(nested_message, str) else None + + return None + + +def _get_error_request_id(error_obj: _ProviderHTTPException) -> str | None: + """ + Return the provider request id carried in an error object's body, if any. + + Checks `body["request_id"]` (a top-level sibling of `error`, e.g. + `{"error": {...}, "request_id": "..."}` from DashScope/Bailian) and + `body["error"]["request_id"]`. + """ + body: Final = _validated_error_body(error_obj) + if body is None: + 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 = body.get("error") + if isinstance(nested_error, dict): + nested_request_id: Final = nested_error.get("request_id") + if isinstance(nested_request_id, str) and nested_request_id: + return nested_request_id + + return None + + def _openai_cyber_policy_body(original_exception: _ProviderHTTPException) -> Mapping[str, object] | None: raw_body: Final[object] = getattr(original_exception, "body", None) try: 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 6b5f728de73..94e5b296d4a 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -551,10 +551,10 @@ def test_get_error_message_appends_nested_error_request_id(): assert get_error_message(error_obj=error_obj) == "Bad request (request_id: req_nested_456)" -def test_get_error_message_appends_sdk_request_id_attribute(): - error_obj = _FakeErrorObj(body={"error": {"message": "Unauthorized"}}, request_id="req_sdk_789") +def test_get_error_message_with_non_dict_body_returns_none(): + error_obj = _FakeErrorObj(body="not-a-dict") - assert get_error_message(error_obj=error_obj) == "Unauthorized (request_id: req_sdk_789)" + assert get_error_message(error_obj=error_obj) is None def test_get_error_message_without_request_id_is_unchanged(): From 44bebe068767d104b2fa1ba6d5230cc2a253dfd8 Mon Sep 17 00:00:00 2001 From: Zhong Zhiyi <42856345+zzy1130@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:24:07 +0800 Subject: [PATCH 4/6] fix(exception-mapping): update request_id fix for latest main Rebase onto current main (60fc121c): resolve conflicts with upstream changes since Oct 22, including the #47789 typing refactor (route the new _validated_error_body helper through attribute_or_none) and the #47954, #32537, #49079, #48616, #48837, #43290 exception-mapping changes. No change to the #47839 fix itself or its tests; all 7 request_id tests pass on the rebased tree. --- .../exception_mapping_utils.py | 138 +++++-- .../test_exception_mapping_utils.py | 356 +++++++++++++++++- 2 files changed, 456 insertions(+), 38 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index c920efb233c..1fe51b484b5 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -16,8 +16,10 @@ from litellm.litellm_core_utils.bug_report import ( build_bug_report, should_report_bug, ) -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none from litellm.litellm_core_utils.secret_redaction import redact_string as redact_secret_string +from litellm.llms.bedrock.common_utils import bedrock_model_reaches_converse_by_default +from litellm.llms.bigmodel.common_utils import raise_bigmodel_business_error from litellm.types.utils import LlmProviders from ..exceptions import ( @@ -36,6 +38,7 @@ from ..exceptions import ( ServiceUnavailableError, Timeout, UnprocessableEntityError, + error_response_without_credentials, ) @@ -95,6 +98,7 @@ class ExceptionCheckers: known_exception_substrings: Final = [ "exceed context limit", "this model's maximum context length is", + "requested token count exceeds the model's maximum context length", "string too long. expected a string with maximum length", "model's maximum context limit", "is longer than the model's context length", @@ -102,6 +106,7 @@ class ExceptionCheckers: "`inputs` tokens + `max_new_tokens` must be", "exceeds the available context size", # llama.cpp/Lemonade "exceeds the maximum number of tokens allowed", # Gemini + '"code": "context_length_exceeded"', ] for substring in known_exception_substrings: if substring in _error_str_lowercase: @@ -163,9 +168,6 @@ def get_error_message(error_obj) -> str | None: } """ try: - if error_obj is None: - return None - extracted_message: Final = _extract_error_message(error_obj) if extracted_message is None: return None @@ -274,7 +276,7 @@ _ERROR_BODY_ADAPTER: Final = TypeAdapter(dict[str, object]) def _validated_error_body(error_obj: _ProviderHTTPException) -> Mapping[str, object] | None: """The exception's parsed error body as a dict, or None when it is not one.""" - raw_body: Final = error_obj.body + raw_body: Final = attribute_or_none(error_obj, "body") if raw_body is None: return None try: @@ -360,6 +362,16 @@ def _litellm_proxy_response( ) +def _without_credentials(response: httpx.Response | None) -> httpx.Response | None: + if not isinstance(response, httpx.Response): + return response + return error_response_without_credentials(response.status_code, response, "https://docs.litellm.ai/docs") + + +def _upstream_reply(response: httpx.Response | None) -> httpx.Response | None: + return response if response is not None and (response.headers or response.content) else None + + def _map_openai_exception( *, model: str, @@ -370,7 +382,7 @@ def _map_openai_exception( exception_provider: str, extra_information: str, ) -> None: - response: Final = _litellm_proxy_response(original_exception, custom_llm_provider) + response: Final = _without_credentials(_litellm_proxy_response(original_exception, custom_llm_provider)) if (cyber_policy_body := _openai_cyber_policy_body(original_exception)) is not None: raise ContentPolicyViolationError( message=f"ContentPolicyViolationError: {exception_provider} - {original_exception}", @@ -433,6 +445,7 @@ def _map_openai_exception( model=model, llm_provider=custom_llm_provider, litellm_debug_info=extra_information, + response=_upstream_reply(response), ) elif ( ("invalid_request_error" in error_str and "content_policy_violation" in error_str) @@ -468,6 +481,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, @@ -485,6 +517,7 @@ def _map_openai_exception( model=model, llm_provider=custom_llm_provider, body=getattr(original_exception, "body", None), + response=_upstream_reply(response), ) elif "Request too large" in error_str: raise RateLimitError( @@ -515,6 +548,7 @@ def _map_openai_exception( model=model, request=_request, litellm_debug_info=extra_information, + response=_upstream_reply(response), ) elif hasattr(original_exception, "status_code"): if original_exception.status_code == 400: @@ -548,6 +582,7 @@ def _map_openai_exception( model=model, llm_provider=custom_llm_provider, litellm_debug_info=extra_information, + response=_upstream_reply(response), ) elif original_exception.status_code == 422: raise BadRequestError( @@ -599,6 +634,7 @@ def _map_openai_exception( llm_provider=custom_llm_provider, litellm_debug_info=extra_information, exception_status_code=original_exception.status_code, + response=_upstream_reply(response), ) else: raise APIError( @@ -608,6 +644,7 @@ def _map_openai_exception( model=model, request=getattr(original_exception, "request", None), litellm_debug_info=extra_information, + response=_upstream_reply(response), ) else: # if no status code then it is an APIConnectionError: https://github.com/openai/openai-python#handling-errors @@ -957,13 +994,22 @@ _BEDROCK_MANTLE_CONTEXT_WINDOW_GENERIC_MESSAGE: Final = ( ) -def _bedrock_mantle_context_window_response(original_exception: _ProviderHTTPException) -> httpx.Response: +def _bedrock_response_with_status(original_exception: _ProviderHTTPException, status_code: int) -> httpx.Response: original_response: Final = getattr(original_exception, "response", None) if not isinstance(original_response, httpx.Response) or getattr(original_response, "_request", None) is None: - return httpx.Response(status_code=400, request=httpx.Request(method="POST", url="https://api.openai.com/v1/")) - if original_response.status_code == 400: + return httpx.Response( + status_code=status_code, request=httpx.Request(method="POST", url="https://api.openai.com/v1/") + ) + if original_response.status_code == status_code: return original_response - return httpx.Response(status_code=400, headers=original_response.headers, request=original_response.request) + return httpx.Response(status_code=status_code, headers=original_response.headers, request=original_response.request) + + +def _came_from_bedrock_converse(original_exception: _ProviderHTTPException) -> bool: + original_response: Final = getattr(original_exception, "response", None) + if not isinstance(original_response, httpx.Response) or getattr(original_response, "_request", None) is None: + return False + return original_response.request.url.path.endswith(("/converse", "/converse-stream")) def _get_bedrock_mantle_context_window_message(error_str: str) -> str | None: @@ -999,7 +1045,7 @@ def _map_bedrock_exception( message=mantle_context_window_message, model=model, llm_provider=custom_llm_provider, - response=_bedrock_mantle_context_window_response(original_exception), + response=_bedrock_response_with_status(original_exception, 400), ) if ( "too many tokens" in error_str @@ -1022,6 +1068,18 @@ def _map_bedrock_exception( llm_provider="bedrock", response=getattr(original_exception, "response", None), ) + elif ( + "The provided model identifier is invalid" in error_str + and bool(model) + and _came_from_bedrock_converse(original_exception) + and bedrock_model_reaches_converse_by_default(model) + ): + raise NotFoundError( + message=f"BedrockException - {error_str}", + model=model, + llm_provider="bedrock", + response=_bedrock_response_with_status(original_exception, 404), + ) elif "Malformed input request" in error_str: raise BadRequestError( message=f"BedrockException - {error_str}", @@ -2055,6 +2113,7 @@ def _map_azure_exception( exception_provider: str, extra_information: str, ) -> None: + response: Final = _without_credentials(getattr(original_exception, "response", None)) message = get_error_message(error_obj=original_exception) if message is None: if hasattr(original_exception, "message"): @@ -2090,15 +2149,7 @@ def _map_azure_exception( llm_provider="azure", model=model, litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), - ) - elif "This model's maximum context length is" in error_str: - raise ContextWindowExceededError( - message=f"AzureException ContextWindowExceededError - {message}", - llm_provider="azure", - model=model, - litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif "DeploymentNotFound" in error_str: raise NotFoundError( @@ -2106,7 +2157,7 @@ def _map_azure_exception( llm_provider="azure", model=model, litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif azure_error_code == "content_policy_violation" or ExceptionCheckers.is_azure_content_policy_violation_error( error_str @@ -2120,6 +2171,7 @@ def _map_azure_exception( model=model, extra_information=extra_information, original_exception=original_exception, + response=response, ) elif azure_error_code == "invalid_encrypted_content" or "could not be verified" in error_str: helpful_message: Final = ( @@ -2138,7 +2190,19 @@ def _map_azure_exception( llm_provider="azure", model=model, litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, + body=getattr(original_exception, "body", None), + ) + elif getattr(original_exception, "status_code", None) in (None, 400) and ( + azure_error_code == "context_length_exceeded" + or ExceptionCheckers.is_error_str_context_window_exceeded(error_str) + ): + raise ContextWindowExceededError( + message=f"AzureException ContextWindowExceededError - {message}", + llm_provider="azure", + model=model, + litellm_debug_info=extra_information, + response=response, body=getattr(original_exception, "body", None), ) elif "invalid_request_error" in error_str and getattr(original_exception, "status_code", None) in (None, 400): @@ -2147,7 +2211,7 @@ def _map_azure_exception( llm_provider="azure", model=model, litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, body=getattr(original_exception, "body", None), ) elif "The api_key client option must be set either by passing api_key to the client or by setting" in error_str: @@ -2156,7 +2220,7 @@ def _map_azure_exception( llm_provider=custom_llm_provider, model=model, litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif "Connection error" in error_str: raise APIConnectionError( @@ -2172,7 +2236,7 @@ def _map_azure_exception( llm_provider="azure", model=model, litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, body=getattr(original_exception, "body", None), ) elif original_exception.status_code == 401: @@ -2181,7 +2245,7 @@ def _map_azure_exception( llm_provider="azure", model=model, litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif original_exception.status_code == 404: raise NotFoundError( @@ -2189,7 +2253,7 @@ def _map_azure_exception( llm_provider="azure", model=model, litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif original_exception.status_code == 408: raise Timeout( @@ -2197,6 +2261,7 @@ def _map_azure_exception( model=model, litellm_debug_info=extra_information, llm_provider="azure", + response=_upstream_reply(response), ) elif original_exception.status_code == 422: raise BadRequestError( @@ -2204,7 +2269,7 @@ def _map_azure_exception( model=model, llm_provider="azure", litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif original_exception.status_code == 429: raise RateLimitError( @@ -2212,7 +2277,7 @@ def _map_azure_exception( model=model, llm_provider="azure", litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif original_exception.status_code == 502: raise BadGatewayError( @@ -2220,7 +2285,7 @@ def _map_azure_exception( model=model, llm_provider="azure", litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif original_exception.status_code == 503: raise ServiceUnavailableError( @@ -2228,7 +2293,7 @@ def _map_azure_exception( model=model, llm_provider="azure", litellm_debug_info=extra_information, - response=getattr(original_exception, "response", None), + response=response, ) elif original_exception.status_code == 504: # gateway timeout error raise Timeout( @@ -2237,6 +2302,7 @@ def _map_azure_exception( litellm_debug_info=extra_information, llm_provider="azure", exception_status_code=original_exception.status_code, + response=_upstream_reply(response), ) else: raise APIError( @@ -2246,6 +2312,7 @@ def _map_azure_exception( litellm_debug_info=extra_information, model=model, request=httpx.Request(method="POST", url="https://openai.com/"), + response=_upstream_reply(response), ) else: # if no status code then it is an APIConnectionError: https://github.com/openai/openai-python#handling-errors @@ -2621,17 +2688,20 @@ def exception_type( model=model, llm_provider=custom_llm_provider, litellm_debug_info=extra_information, + response=_upstream_reply( + _without_credentials(_litellm_proxy_response(mappable_exception, custom_llm_provider)) + ), ) if ( custom_llm_provider == "litellm_proxy" ): # handle special case where calling litellm proxy + exception str contains error message extract_and_raise_litellm_exception( - response=_litellm_proxy_response(mappable_exception, custom_llm_provider), + response=_without_credentials(_litellm_proxy_response(mappable_exception, custom_llm_provider)), error_str=error_str, model=model, custom_llm_provider=custom_llm_provider, - body=getattr(original_exception, "body", None), + body=getattr(mappable_exception, "body", None), ) if ( custom_llm_provider == "openai" @@ -2814,6 +2884,8 @@ def exception_type( exception_provider=exception_provider, extra_information=extra_information, ) + if custom_llm_provider == "bigmodel": + raise_bigmodel_business_error(model, f"{exception_provider} - {error_str}", original_exception) # pyright: ignore[reportUnknownArgumentType] # exception_type takes untyped parameters if custom_llm_provider == "openrouter": _map_openrouter_exception( model=model, 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 94e5b296d4a..6acaa7f8982 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -25,6 +25,8 @@ 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.llms.vertex_ai.common_utils import VertexAIError +from litellm.proxy.common_utils.openai_error_payload import openai_error_type from litellm.types.utils import LlmProviders @@ -54,12 +56,11 @@ def test_openai_cyber_policy_uses_the_structured_error_code(shape: str, code: st assert caught.value.code == code assert caught.value.body == body assert caught.value.status_code == 400 - assert caught.value.response is response - assert caught.value.request is request + assert caught.value.response.status_code == 400 + assert caught.value.request.url == request.url assert caught.value.response.headers["x-request-id"] == "cyber-policy-request" - def _debug_info_for_api_base(configured_api_base: str) -> str: request: Final = httpx.Request("POST", "https://gateway.example/v1/chat/completions") original: Final = OpenAIError( @@ -161,6 +162,12 @@ def test_api_base_line_keeps_an_at_sign_in_the_path(api_base: str) -> None: # Tuple format: (error_message, expected_result) context_window_test_cases = [ # Positive cases (should return True) + ( + '"{\n "error": {\n "message": "Your input exceeds the context window of this model. ' + 'Please adjust your input and try again.",\n "type": "invalid_request_error",\n' + ' "param": "input",\n "code": "context_length_exceeded"\n }\n}"', + True, + ), ( "An error occurred: The input exceeds the model's maximum context limit of 8192 tokens.", True, @@ -434,6 +441,67 @@ def test_gemini_context_window_error_mapping(error_message, should_raise_context ) +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.parametrize("streaming", [True, False]) +@pytest.mark.parametrize( + ("error_message", "expected_type"), + [ + ( + ( + "Requested token count exceeds the model's maximum context length of 131072 tokens. " + "You requested a total of 140000 tokens: 130000 tokens from the input messages and " + "10000 tokens for the completion. Please reduce the number of tokens in the input " + "messages or the completion to fit within the limit." + ), + litellm.ContextWindowExceededError, + ), + ( + ( + "requested token count exceeds the model's maximum context length of 8192 tokens. " + "you requested a total of 9000 tokens" + ), + litellm.ContextWindowExceededError, + ), + ("max_tokens must be a positive integer", litellm.BadRequestError), + ], + ids=["sglang-context-overflow", "lowercase-context-overflow", "unrelated-bad-request"], +) +@pytest.mark.asyncio +async def test_sglang_context_window_error_mapping( + sync_mode: bool, + streaming: bool, + error_message: str, + expected_type: type[litellm.BadRequestError], +) -> None: + def reject_request(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + request=request, + json={"object": "error", "message": error_message, "type": "BadRequestError", "param": None, "code": 400}, + ) + + transport: Final = httpx.MockTransport(reject_request) + if sync_mode: + with ( + httpx.Client(transport=transport) as http_client, + OpenAI(api_key="sk-test", max_retries=0, http_client=http_client) as client, + pytest.raises(litellm.BadRequestError) as caught, + ): + _call_openai_api_sync("openai/sglang-model", "chat_completion", streaming, client) + else: + async with ( + httpx.AsyncClient(transport=transport) as async_http_client, + AsyncOpenAI(api_key="sk-test", max_retries=0, http_client=async_http_client) as async_client, + ): + with pytest.raises(litellm.BadRequestError) as caught: + await _call_openai_api_async("openai/sglang-model", "chat_completion", streaming, async_client) + + assert type(caught.value) is expected_type + assert caught.value.status_code == 400 + assert error_message in str(caught.value) + assert caught.value.llm_provider == "openai" + + def test_lemonade_context_window_error_mapping(): """Lemonade's llama.cpp backend should map context overflows to LiteLLM's standard error.""" @@ -1102,6 +1170,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") @@ -1555,6 +1755,75 @@ def test_bedrock_mantle_400_maps_to_bad_request(): assert type(excinfo.value) is litellm.BadRequestError +class _ErrorWithoutResponse(Exception): + def __init__(self, message: str, status_code: int) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + + +def test_bedrock_mantle_context_overflow_without_a_provider_response_answers_400(): + with pytest.raises(litellm.ContextWindowExceededError) as excinfo: + exception_type( + model="openai.gpt-5.6-sol", + original_exception=_ErrorWithoutResponse( + '{"error":{"code":"context_length_exceeded","message":"too long"}}', 400 + ), + custom_llm_provider="bedrock_mantle", + ) + + assert excinfo.value.response.status_code == 400 + + +def _invalid_model_identifier_from(url: str) -> BedrockError: + return BedrockError( + status_code=400, + message='{"message":"The provided model identifier is invalid."}', + response=httpx.Response(status_code=400, request=httpx.Request(method="POST", url=url)), + ) + + +@pytest.mark.parametrize( + "path", + ["/model/us.newvendor.typo-1/converse", "/model/us.newvendor.typo-1/converse-stream"], +) +def test_bedrock_converse_rejecting_an_id_with_no_invoke_provider_answers_404(path): + with pytest.raises(litellm.NotFoundError) as excinfo: + exception_type( + model="us.newvendor.typo-1", + original_exception=_invalid_model_identifier_from(f"https://bedrock-runtime.us-east-1.amazonaws.com{path}"), + custom_llm_provider="bedrock", + ) + + assert excinfo.value.response.status_code == 404 + + +@pytest.mark.parametrize( + "original_exception", + [ + _invalid_model_identifier_from("https://bedrock-runtime.us-east-1.amazonaws.com/model/claude-typo-9/invoke"), + _ErrorWithoutResponse("The provided model identifier is invalid.", 400), + ], + ids=["invoke", "no-response"], +) +def test_bedrock_invalid_model_identifier_not_from_converse_stays_a_bad_request(original_exception): + with pytest.raises(litellm.BadRequestError) as excinfo: + exception_type(model="claude-typo-9", original_exception=original_exception, custom_llm_provider="bedrock") + + assert excinfo.value.status_code == 400 + + +def test_bedrock_invalid_model_identifier_without_a_model_stays_a_bad_request(): + with pytest.raises(litellm.BadRequestError) as excinfo: + exception_type( + model=None, + original_exception=_ErrorWithoutResponse("The provided model identifier is invalid.", 400), + custom_llm_provider="bedrock", + ) + + assert excinfo.value.status_code == 400 + + def test_bedrock_mantle_context_overflow_maps_to_context_window_exceeded(): from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -1603,9 +1872,9 @@ def test_bedrock_mantle_openai_envelope_context_overflow_maps_to_context_window_ def test_bedrock_mantle_streamed_context_overflow_event_maps_to_context_window_exceeded(): - from litellm.responses.streaming_iterator import _map_stream_error_to_exception + from litellm.responses.streaming_iterator import map_stream_error_to_exception - mapped_exception = _map_stream_error_to_exception( + mapped_exception = map_stream_error_to_exception( { "code": "context_length_exceeded", "message": "Your input exceeds the context window of this model. Please adjust your input and try again.", @@ -2894,3 +3163,80 @@ def test_transport_timeout_maps_to_timeout( assert exc_info.value.status_code == 408 assert f"Request timed out. Error_str: {reason}" in str(exc_info.value) + + +def test_bedrock_server_error_keeps_the_aws_body_without_the_signed_request() -> None: + body: Final = '{"message":"Bedrock is unable to process your request."}' + signed_request: Final = httpx.Request( + "POST", + "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-opus-5-5/converse", + headers={"authorization": "AWS4-HMAC-SHA256 Credential=AKIDSIGNED/20261029/us-east-1/bedrock/aws4_request"}, + content=b'{"messages":[{"role":"user","content":[{"text":"hi"}]}]}', + ) + upstream: Final = httpx.Response( + 500, content=body, headers={"x-amzn-requestid": "req-aws"}, request=signed_request + ) + + with pytest.raises(litellm.ServiceUnavailableError) as raised: + exception_type( + model="bedrock/anthropic.claude-opus-5-5", + original_exception=BedrockError(status_code=500, message=body, response=upstream), + custom_llm_provider="bedrock", + ) + + assert raised.value.response.text == body + assert raised.value.response.headers["x-amzn-requestid"] == "req-aws" + assert "AKIDSIGNED" not in str(raised.value.request.headers) + assert raised.value.request.content == b"" + + +@pytest.mark.parametrize("provider", ("gemini", "vertex_ai")) +def test_gemini_server_error_response_carries_the_upstream_error_body(provider: str) -> None: + body: Final = '{"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}' + + with pytest.raises(litellm.InternalServerError) as raised: + exception_type( + model="gemini-3.1-pro", + original_exception=VertexAIError(status_code=500, message=body, headers=None), + custom_llm_provider=provider, + ) + + assert raised.value.response.status_code == 500 + assert raised.value.response.json() == json.loads(body) + + +@pytest.mark.parametrize( + ("status_code", "message", "mapped_error"), + ( + (500, "The server had an error processing your request. Sorry about that!", litellm.InternalServerError), + (520, "Web server is returning an unknown error", litellm.InternalServerError), + (504, "A timeout occurred", litellm.Timeout), + (500, "Mistral API raised a streaming error", litellm.APIError), + ), +) +def test_openai_error_matched_by_its_message_keeps_the_upstream_body( + status_code: int, message: str, mapped_error: type[Exception] +) -> None: + body: Final = json.dumps({"error": {"message": message, "type": "server_error", "param": None, "code": None}}) + sent_request: Final = httpx.Request( + "POST", + "https://api.openai.com/v1/chat/completions", + headers={"authorization": "Bearer sk-openai-secret"}, + content=b'{"messages":[{"role":"user","content":"hi"}]}', + ) + upstream: Final = httpx.Response( + status_code, content=body, headers={"x-request-id": "req-openai"}, request=sent_request + ) + + with pytest.raises(mapped_error) as raised: + exception_type( + model="gpt-5.6", + original_exception=openai.APIStatusError(message, response=upstream, body=json.loads(body)), + custom_llm_provider="openai", + ) + + error_response: Final = getattr(raised.value, "response") + assert error_response.text == body + assert error_response.headers["x-request-id"] == "req-openai" + assert "sk-openai-secret" not in str(error_response.request.headers) + assert error_response.request.content == b"" From d60cc56d5d25d98a3d54f67a89a5fe5a9b2296c1 Mon Sep 17 00:00:00 2001 From: Zhong Zhiyi <42856345+zzy1130@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:33:42 +0800 Subject: [PATCH 5/6] fix(exception-mapping): make request_id fix mergeable with latest main The previous update kept the helper-based get_error_message rewrite, which still conflicted with upstream's #47789 change to the same function. Restructure instead: leave get_error_message byte-identical to main and preserve the provider request id in a new _get_error_message_with_request_id wrapper, used by _map_openai_exception (the DashScope path from #47839). The Azure caller is intentionally left untouched: wrapping it conflicted with a nearby upstream hunk. Verified: the resulting tree merges cleanly into current main, all 7 request_id tests pass, and strict-ruff / type-discipline counts match main. --- .../exception_mapping_utils.py | 71 ++++++++++--------- .../test_exception_mapping_utils.py | 26 +++---- 2 files changed, 51 insertions(+), 46 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 1fe51b484b5..fc8cbf144df 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -168,21 +168,24 @@ def get_error_message(error_obj) -> str | None: } """ try: - extracted_message: Final = _extract_error_message(error_obj) - if extracted_message is None: + # First, try to access the message directly from the 'body' key + if error_obj is None: return None - # Some providers (e.g. DashScope/Bailian) return the request id as a - # top-level sibling of `error` in the response body, not inside - # `error.message`. Extracting only the message drops it, leaving users - # without the provider request id needed to investigate the failure - # (https://github.com/BerriAI/litellm/issues/47839). Append it to the - # extracted message when it is available and not already present. - 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})" + if hasattr(error_obj, "body"): + _error_obj_body: Final = attribute_or_none(error_obj, "body") + if isinstance(_error_obj_body, dict): + # OpenAI-style: {"message": "...", "type": "...", ...} + if _error_obj_body.get("message"): + return _error_obj_body.get("message") - return extracted_message + # Azure-style: {"error": {"message": "...", ...}} + nested_error: Final = _error_obj_body.get("error") + if isinstance(nested_error, dict): + return nested_error.get("message") + + # If all else fails, return None + return None except Exception: return None @@ -286,26 +289,6 @@ def _validated_error_body(error_obj: _ProviderHTTPException) -> Mapping[str, obj return validated -def _extract_error_message(error_obj: _ProviderHTTPException) -> str | None: - """Extract the message from an error object's `body`, if it carries one.""" - body: Final = _validated_error_body(error_obj) - if body is None: - return None - - # OpenAI-style: {"message": "...", "type": "...", ...} - flat_message: Final = body.get("message") - if flat_message: - return flat_message if isinstance(flat_message, str) else None - - # Azure-style: {"error": {"message": "...", ...}} - nested_error: Final = body.get("error") - if isinstance(nested_error, dict): - nested_message: Final = nested_error.get("message") - return nested_message if isinstance(nested_message, str) else None - - return None - - def _get_error_request_id(error_obj: _ProviderHTTPException) -> str | None: """ Return the provider request id carried in an error object's body, if any. @@ -331,6 +314,28 @@ def _get_error_request_id(error_obj: _ProviderHTTPException) -> str | None: return None +def _get_error_message_with_request_id(error_obj: _ProviderHTTPException) -> str | None: + """ + Extract the error message, preserving the provider request id when present. + + Some providers (e.g. DashScope/Bailian) return the request id as a + top-level sibling of `error` in the response body, not inside + `error.message`. Extracting only the message drops it, leaving users + without the provider request id needed to investigate the failure + (https://github.com/BerriAI/litellm/issues/47839). Append it to the + extracted message when it is available and not already present. + """ + 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: @@ -393,7 +398,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 6acaa7f8982..1a78c92eb16 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -16,10 +16,10 @@ 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, - get_error_message, ) from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.bedrock.common_utils import BedrockError @@ -586,7 +586,7 @@ class _FakeErrorObj: self.request_id = request_id -def test_get_error_message_appends_top_level_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).""" @@ -602,39 +602,39 @@ def test_get_error_message_appends_top_level_request_id(): } ) - assert get_error_message(error_obj=error_obj) == ( + assert _get_error_message_with_request_id(error_obj=error_obj) == ( "Incorrect API key provided. (request_id: 343604ce-3795-9363-8be6-f4aa3f4613ee)" ) -def test_get_error_message_appends_request_id_for_flat_body(): +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(error_obj=error_obj) == "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_get_error_message_appends_nested_error_request_id(): +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(error_obj=error_obj) == "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_get_error_message_with_non_dict_body_returns_none(): +def test_error_message_with_request_id_with_non_dict_body_returns_none(): error_obj = _FakeErrorObj(body="not-a-dict") - assert get_error_message(error_obj=error_obj) is None + assert _get_error_message_with_request_id(error_obj=error_obj) is None -def test_get_error_message_without_request_id_is_unchanged(): +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(error_obj=error_obj) == "Plain error" + assert _get_error_message_with_request_id(error_obj=error_obj) == "Plain error" -def test_get_error_message_does_not_duplicate_request_id(): +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(error_obj=error_obj) == "Failed (request_id: req_dup_1)" + assert _get_error_message_with_request_id(error_obj=error_obj) == "Failed (request_id: req_dup_1)" def test_dashscope_auth_error_message_includes_provider_request_id(): From c780fef1c2c94b861ab5ce19ea0ec9e6596e3c52 Mon Sep 17 00:00:00 2001 From: Zhong Zhiyi <42856345+zzy1130@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:04:27 +0800 Subject: [PATCH 6/6] fix(exception-mapping): preserve request IDs from raw provider errors --- .../exception_mapping_utils.py | 61 ++++++++----------- .../test_exception_mapping_utils.py | 58 ++++++++++++++++++ 2 files changed, 83 insertions(+), 36 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index d80bfd561ca..9d82cc5d444 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -278,54 +278,43 @@ class _ProviderHTTPException(Protocol): _ERROR_BODY_ADAPTER: Final = TypeAdapter(dict[str, object]) -def _validated_error_body(error_obj: _ProviderHTTPException) -> Mapping[str, object] | None: - """The exception's parsed error body as a dict, or None when it is not one.""" - raw_body: Final = attribute_or_none(error_obj, "body") - if raw_body is None: - return None +def _body_request_id(raw_body: object) -> str | None: try: - validated: Final = _ERROR_BODY_ADAPTER.validate_python(raw_body) + body: Final = _ERROR_BODY_ADAPTER.validate_python(raw_body) except ValidationError: return None - return validated - - -def _get_error_request_id(error_obj: _ProviderHTTPException) -> str | None: - """ - Return the provider request id carried in an error object's body, if any. - - Checks `body["request_id"]` (a top-level sibling of `error`, e.g. - `{"error": {...}, "request_id": "..."}` from DashScope/Bailian) and - `body["error"]["request_id"]`. - """ - body: Final = _validated_error_body(error_obj) - if body is None: - 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 = body.get("error") - if isinstance(nested_error, dict): - nested_request_id: Final = nested_error.get("request_id") - if isinstance(nested_request_id, str) and nested_request_id: - return nested_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 - return 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: - """ - Extract the error message, preserving the provider request id when present. - - Some providers (e.g. DashScope/Bailian) return the request id as a - top-level sibling of `error` in the response body, not inside - `error.message`. Extracting only the message drops it, leaving users - without the provider request id needed to investigate the failure - (https://github.com/BerriAI/litellm/issues/47839). Append it to the - extracted message when it is available and not already present. - """ extracted_message: Final = get_error_message(error_obj=error_obj) if extracted_message is None: return None 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 7c94c56de13..f7abcce2a1e 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 @@ -637,6 +638,63 @@ def test_error_message_with_request_id_does_not_duplicate_request_id(): 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