From 666648d58c82b2d8a9d394f9eca850f5caa12ff4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:31:05 -0700 Subject: [PATCH 1/5] fix(otel): map /v1/messages provider errors before failure logging --- .../exception_mapping_utils.py | 12 ++++ .../messages/handler.py | 20 ++++--- tests/e2e/logging/test_otel_trace_e2e.py | 58 +++++++++++++++++++ .../test_exception_mapping_utils.py | 27 +++++++++ ...erimental_pass_through_messages_handler.py | 53 +++++++++++++++++ 5 files changed, 163 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 4ee726b67de..b76c97ad2de 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -2486,6 +2486,18 @@ def exception_type( exception_provider=exception_provider, extra_information=extra_information, ) + from litellm.llms.base_llm.chat.transformation import BaseLLMException + + if custom_llm_provider and isinstance(original_exception, BaseLLMException): + _map_openai_like_exception( + model=model, + original_exception=mappable_exception, + custom_llm_provider=custom_llm_provider, + error_str=error_str, + exception_type=exception_type, + exception_provider=exception_provider, + extra_information=extra_information, + ) if "BadRequestError.__init__() missing 1 required positional argument: 'param'" in str( original_exception ): # deal with edge-case invalid request error bug in openai-python sdk diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index f4d24bb933c..7459d1b2da5 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -12,6 +12,7 @@ from functools import partial from typing import Any, Final, cast import litellm +from litellm.litellm_core_utils.exception_mapping_utils import exception_type from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.anthropic.common_utils import ( flatten_unencrypted_web_search_results_in_anthropic_messages, @@ -382,13 +383,18 @@ async def anthropic_messages( ) ctx: Final = contextvars.copy_context() func_with_context: Final = partial(ctx.run, func) - init_response: Final = await loop.run_in_executor(None, func_with_context) - - if asyncio.iscoroutine(init_response): - response = await init_response - else: - response = init_response - return response + try: + init_response: Final = await loop.run_in_executor(None, func_with_context) + if asyncio.iscoroutine(init_response): + return await init_response + return init_response + except Exception as e: # noqa: BLE001 # the mapping boundary must see every provider-layer failure, like acompletion + raise exception_type( + model=model, + custom_llm_provider=custom_llm_provider, + original_exception=e, + extra_kwargs=kwargs, + ) def validate_anthropic_api_metadata(metadata: dict | None = None) -> dict | None: diff --git a/tests/e2e/logging/test_otel_trace_e2e.py b/tests/e2e/logging/test_otel_trace_e2e.py index d7b28c170c2..52cb691e2b7 100644 --- a/tests/e2e/logging/test_otel_trace_e2e.py +++ b/tests/e2e/logging/test_otel_trace_e2e.py @@ -743,3 +743,61 @@ class TestOtelTraceCompleteness: ) genai = next(span for span in hits[0].spans if span.operation_name == genai_span) _assert_error_span_contract(genai) + + @pytest.mark.covers("logging.otel.failure.exports_metric", exercised_on=["messages"]) + def test_failed_messages_error_span_attributes( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + """A failed `/v1/messages` request must carry the same error-span + contract as a failed `/chat/completions` request (LIT-6164). The + async messages entrypoint used to surface the provider handler's raw + BaseLLMException to the failure logger, so the model-call span came + out with error.type=BaseLLMException and no + litellm.provider.error.llm_provider attribute. + + Same setup as the chat sibling: a deployment with an invalid upstream + API key passes proxy auth and fails at the provider with a real 401, + and failed requests are not billed, so no cost-write span.""" + route = "/v1/messages" + _assert_otel_destination_configured(client) + + model_name = f"otel-err-{unique_marker()}" + model_id = client.create_model( + model_name, + LiteLLMParamsBody(model="anthropic/claude-haiku-4-5", api_key=INVALID_UPSTREAM_API_KEY), + ) + resources.defer(lambda: client.delete_model(model_id)) + key = client.key_with_alias(f"otel-err-{unique_marker()}", models=[model_name]) + resources.defer(lambda: client.delete_key(key)) + + deadline = time.monotonic() + client.proxy.poll_timeout + while True: + outcome = client.messages_raw(key, model_name, "trigger an upstream auth failure", max_tokens=16) + assert not outcome.ok, "the call must fail; the deployment's upstream key is invalid" + if "AnthropicException" in outcome.body or time.monotonic() >= deadline: + break + time.sleep(client.proxy.poll_interval) + assert "AnthropicException" in outcome.body, ( + "never saw the mapped upstream provider failure before the deadline; either the key is " + "still propagating or the messages route surfaced the raw unmapped provider error - " + f"last outcome {outcome.status_code}: {outcome.body[:200]}" + ) + assert outcome.status_code == 401, ( + f"an upstream auth failure must map to 401, got {outcome.status_code}: {outcome.body[:200]}" + ) + assert outcome.call_id is not None, "failed responses must still carry x-litellm-call-id" + + genai_span = f"chat {model_name}" + hits = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=genai_span, require_cost_span=False), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=genai_span, require_cost_span=False) + + root = next(span for span in hits[0].spans if not span.references) + assert str(_tag(root, "http.status_code")) == "401", ( + f"the SERVER span must record the 401 the client received, got {_tag(root, 'http.status_code')!r}" + ) + genai = next(span for span in hits[0].spans if span.operation_name == genai_span) + _assert_error_span_contract(genai) diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index 6f7ea9da640..3eb8094f914 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -1092,3 +1092,30 @@ def test_bedrock_mantle_context_overflow_maps_to_context_window_exceeded(): assert excinfo.value.status_code == 400 assert "prompt is too long: 1055489 tokens > 1050000 maximum" in excinfo.value.message + + +@pytest.mark.parametrize( + "status_code, expected_class", + [(401, litellm.AuthenticationError), (429, litellm.RateLimitError)], +) +def test_a_base_llm_exception_without_a_provider_branch_maps_by_status_code( + status_code, expected_class, quiet_exception_mapping +): + """Regression test for LIT-6164. Native /v1/messages handlers raise raw + BaseLLMException, and providers without an exception_type branch (e.g. + minimax) must keep the upstream status instead of collapsing every failure + into a 500 APIConnectionError once that route maps its exceptions.""" + from litellm.llms.base_llm.chat.transformation import BaseLLMException + + original_exception = BaseLLMException(status_code=status_code, message="upstream rejected the call") + + with pytest.raises(expected_class) as excinfo: + exception_type( + model="MiniMax-M2.5", + original_exception=original_exception, + custom_llm_provider="minimax", + ) + + assert excinfo.value.status_code == status_code + assert excinfo.value.llm_provider == "minimax" + assert "MinimaxException" in excinfo.value.message diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 9e58ded81bd..c88058bf215 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -1286,3 +1286,56 @@ class TestMessagesStreamingSuccessLogging: assert payload["call_type"] == "acompletion" assert payload["total_tokens"] > 0 assert payload["response_cost"] > 0 + + +class _FailureCapture(CustomLogger): + def __init__(self): + super().__init__() + self.error_information: List[Dict[str, Any]] = [] + + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + payload = kwargs.get("standard_logging_object") or {} + self.error_information.append(payload.get("error_information") or {}) + + +@pytest.mark.asyncio +async def test_anthropic_messages_maps_provider_exception_before_failure_logging(monkeypatch): + """Regression test for LIT-6164. The async /v1/messages entrypoint awaited the + provider handler without exception_type mapping, so the @client failure + handler (and every logger behind it, e.g. OTel error spans) saw the raw + BaseLLMException: error.type=BaseLLMException and no llm_provider.""" + from litellm.llms.anthropic.experimental_pass_through.messages import handler + + capture = _FailureCapture() + monkeypatch.setattr(litellm, "callbacks", [capture]) + + def upstream_rejects_the_key(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 401, + json={"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key"}}, + request=request, + ) + + upstream = AsyncHTTPHandler() + upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_rejects_the_key)) + + with pytest.raises(litellm.AuthenticationError) as excinfo: + await handler.anthropic_messages( + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + model="anthropic/claude-haiku-4-5", + custom_llm_provider="anthropic", + api_key="sk-invalid", + client=upstream, + ) + + assert excinfo.value.status_code == 401 + assert excinfo.value.llm_provider == "anthropic" + assert "AnthropicException" in excinfo.value.message + assert '"authentication_error"' in excinfo.value.message + + assert capture.error_information, "the failure handler must have logged the mapped exception" + error_information = capture.error_information[0] + assert error_information.get("error_class") == "AuthenticationError" + assert error_information.get("llm_provider") == "anthropic" + assert error_information.get("error_code") == "401" From e2e16d7e2db166f6f7a6a7eec6b479f9a9093b4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:51:33 +0000 Subject: [PATCH 2/5] fix(exceptions): map 403 to PermissionDeniedError in openai-like mapper --- litellm/litellm_core_utils/exception_mapping_utils.py | 9 ++++++++- .../litellm_core_utils/test_exception_mapping_utils.py | 8 ++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index b76c97ad2de..cf5e28073f5 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -755,12 +755,19 @@ def _map_openai_like_exception( llm_provider=custom_llm_provider, model=model, ) - elif original_exception.status_code == 401 or original_exception.status_code == 403: + elif original_exception.status_code == 401: raise AuthenticationError( message=f"{custom_llm_provider.capitalize()}Exception - {original_exception.message}", llm_provider=custom_llm_provider, model=model, ) + elif original_exception.status_code == 403: + raise PermissionDeniedError( + message=f"{custom_llm_provider.capitalize()}Exception - {original_exception.message}", + llm_provider=custom_llm_provider, + model=model, + response=getattr(original_exception, "response", None), + ) elif original_exception.status_code == 400: raise BadRequestError( message=f"{custom_llm_provider.capitalize()}Exception - {original_exception.message}", diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index 3eb8094f914..4f000eb18eb 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -805,7 +805,7 @@ DEVIATIONS_FROM_THE_OPENAI_SHAPE = { 503: UPSTREAM_STATUS_DISCARDED, }, "databricks": { - 403: (litellm.AuthenticationError, 401), + 403: (litellm.PermissionDeniedError, 403), 422: (litellm.BadRequestError, 400), }, "gemini": { @@ -1096,7 +1096,11 @@ def test_bedrock_mantle_context_overflow_maps_to_context_window_exceeded(): @pytest.mark.parametrize( "status_code, expected_class", - [(401, litellm.AuthenticationError), (429, litellm.RateLimitError)], + [ + (401, litellm.AuthenticationError), + (403, litellm.PermissionDeniedError), + (429, litellm.RateLimitError), + ], ) def test_a_base_llm_exception_without_a_provider_branch_maps_by_status_code( status_code, expected_class, quiet_exception_mapping From d2e4e7468503480dc7708e110a4d839ed7c68f87 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:52:30 -0700 Subject: [PATCH 3/5] fix(otel): drop the generic BaseLLMException fallback from exception_type The fallback mapped every unbranched provider error by status code on every route, which changed the exception class and HTTP status for those providers and failed four provider test suites in CI. The /v1/messages handler change alone covers the ticket, since the anthropic branch already maps its errors --- .../exception_mapping_utils.py | 12 --------- .../test_exception_mapping_utils.py | 27 ------------------- 2 files changed, 39 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index b76c97ad2de..4ee726b67de 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -2486,18 +2486,6 @@ def exception_type( exception_provider=exception_provider, extra_information=extra_information, ) - from litellm.llms.base_llm.chat.transformation import BaseLLMException - - if custom_llm_provider and isinstance(original_exception, BaseLLMException): - _map_openai_like_exception( - model=model, - original_exception=mappable_exception, - custom_llm_provider=custom_llm_provider, - error_str=error_str, - exception_type=exception_type, - exception_provider=exception_provider, - extra_information=extra_information, - ) if "BadRequestError.__init__() missing 1 required positional argument: 'param'" in str( original_exception ): # deal with edge-case invalid request error bug in openai-python sdk diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index 3eb8094f914..6f7ea9da640 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -1092,30 +1092,3 @@ def test_bedrock_mantle_context_overflow_maps_to_context_window_exceeded(): assert excinfo.value.status_code == 400 assert "prompt is too long: 1055489 tokens > 1050000 maximum" in excinfo.value.message - - -@pytest.mark.parametrize( - "status_code, expected_class", - [(401, litellm.AuthenticationError), (429, litellm.RateLimitError)], -) -def test_a_base_llm_exception_without_a_provider_branch_maps_by_status_code( - status_code, expected_class, quiet_exception_mapping -): - """Regression test for LIT-6164. Native /v1/messages handlers raise raw - BaseLLMException, and providers without an exception_type branch (e.g. - minimax) must keep the upstream status instead of collapsing every failure - into a 500 APIConnectionError once that route maps its exceptions.""" - from litellm.llms.base_llm.chat.transformation import BaseLLMException - - original_exception = BaseLLMException(status_code=status_code, message="upstream rejected the call") - - with pytest.raises(expected_class) as excinfo: - exception_type( - model="MiniMax-M2.5", - original_exception=original_exception, - custom_llm_provider="minimax", - ) - - assert excinfo.value.status_code == status_code - assert excinfo.value.llm_provider == "minimax" - assert "MinimaxException" in excinfo.value.message From 3fe65029bfa6f0647d78fae20ab5c7342bb0cd3b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:10 -0700 Subject: [PATCH 4/5] fix(exceptions): map anthropic 403 to PermissionDeniedError Now that /v1/messages routes provider failures through exception_type, an Anthropic permission_error fell through the anthropic branch to the generic APIConnectionError and reached the client as a 500 where the raw exception used to answer 403. Map 403 to PermissionDeniedError so the status survives on every route. --- .../exception_mapping_utils.py | 7 ++++ .../test_exception_mapping_utils.py | 5 ++- ...erimental_pass_through_messages_handler.py | 35 +++++++++++++------ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 4ee726b67de..5cbc69669d7 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -550,6 +550,13 @@ def _map_anthropic_exception( llm_provider="anthropic", model=model, ) + elif original_exception.status_code == 403: + raise PermissionDeniedError( + message=f"AnthropicException - {error_str}", + llm_provider="anthropic", + model=model, + response=original_exception.response, + ) elif original_exception.status_code == 400 or original_exception.status_code == 413: raise BadRequestError( message=f"AnthropicException - {error_str}", diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index 6f7ea9da640..d5d5004dbe8 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -790,7 +790,10 @@ UPSTREAM_STATUS_DISCARDED = (litellm.APIConnectionError, 500) PROVIDERS_THAT_DISCARD_THE_UPSTREAM_STATUS = ("cloudflare", "ollama", "vllm") DEVIATIONS_FROM_THE_OPENAI_SHAPE = { - "anthropic": {403: UPSTREAM_STATUS_DISCARDED, 422: UPSTREAM_STATUS_DISCARDED}, + "anthropic": { + 403: (litellm.PermissionDeniedError, 403), + 422: UPSTREAM_STATUS_DISCARDED, + }, "azure": {500: (litellm.APIError, 500)}, "bedrock": { 403: UPSTREAM_STATUS_DISCARDED, diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index c88058bf215..5c838789798 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -1299,27 +1299,40 @@ class _FailureCapture(CustomLogger): @pytest.mark.asyncio -async def test_anthropic_messages_maps_provider_exception_before_failure_logging(monkeypatch): +@pytest.mark.parametrize( + "upstream_status, upstream_error_type, expected_exception", + [ + (401, "authentication_error", litellm.AuthenticationError), + (403, "permission_error", litellm.PermissionDeniedError), + ], +) +async def test_anthropic_messages_maps_provider_exception_before_failure_logging( + monkeypatch, upstream_status, upstream_error_type, expected_exception +): """Regression test for LIT-6164. The async /v1/messages entrypoint awaited the provider handler without exception_type mapping, so the @client failure handler (and every logger behind it, e.g. OTel error spans) saw the raw - BaseLLMException: error.type=BaseLLMException and no llm_provider.""" + BaseLLMException: error.type=BaseLLMException and no llm_provider. + + The 403 row pins the upstream status on the way through the mapper: Anthropic's + documented permission_error must reach the caller as a 403, never as the mapper's + APIConnectionError 500 fallthrough.""" from litellm.llms.anthropic.experimental_pass_through.messages import handler capture = _FailureCapture() monkeypatch.setattr(litellm, "callbacks", [capture]) - def upstream_rejects_the_key(request: httpx.Request) -> httpx.Response: + def upstream_rejects_the_request(request: httpx.Request) -> httpx.Response: return httpx.Response( - 401, - json={"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key"}}, + upstream_status, + json={"type": "error", "error": {"type": upstream_error_type, "message": "rejected upstream"}}, request=request, ) upstream = AsyncHTTPHandler() - upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_rejects_the_key)) + upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_rejects_the_request)) - with pytest.raises(litellm.AuthenticationError) as excinfo: + with pytest.raises(expected_exception) as excinfo: await handler.anthropic_messages( max_tokens=16, messages=[{"role": "user", "content": "hi"}], @@ -1329,13 +1342,13 @@ async def test_anthropic_messages_maps_provider_exception_before_failure_logging client=upstream, ) - assert excinfo.value.status_code == 401 + assert excinfo.value.status_code == upstream_status assert excinfo.value.llm_provider == "anthropic" assert "AnthropicException" in excinfo.value.message - assert '"authentication_error"' in excinfo.value.message + assert f'"{upstream_error_type}"' in excinfo.value.message assert capture.error_information, "the failure handler must have logged the mapped exception" error_information = capture.error_information[0] - assert error_information.get("error_class") == "AuthenticationError" + assert error_information.get("error_class") == expected_exception.__name__ assert error_information.get("llm_provider") == "anthropic" - assert error_information.get("error_code") == "401" + assert error_information.get("error_code") == str(upstream_status) From 5cfc1608f95d6853dae7c0207ac0d5d375b32f21 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:31:18 -0700 Subject: [PATCH 5/5] fix(anthropic): map only provider failures on the /v1/messages boundary --- .../messages/handler.py | 3 +- ...erimental_pass_through_messages_handler.py | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index 7459d1b2da5..283c706e45e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -22,6 +22,7 @@ from litellm.llms.anthropic.common_utils import ( from litellm.llms.base_llm.anthropic_messages.transformation import ( BaseAnthropicMessagesConfig, ) +from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.types.llms.anthropic_messages.anthropic_request import AnthropicMetadata @@ -388,7 +389,7 @@ async def anthropic_messages( if asyncio.iscoroutine(init_response): return await init_response return init_response - except Exception as e: # noqa: BLE001 # the mapping boundary must see every provider-layer failure, like acompletion + except BaseLLMException as e: raise exception_type( model=model, custom_llm_provider=custom_llm_provider, diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 5c838789798..b690b3448ec 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -7,6 +7,7 @@ from typing import Any, Dict, List import httpx import pytest from fastapi.testclient import TestClient +from pydantic import ValidationError from unittest.mock import AsyncMock, MagicMock, patch @@ -1352,3 +1353,30 @@ async def test_anthropic_messages_maps_provider_exception_before_failure_logging assert error_information.get("error_class") == expected_exception.__name__ assert error_information.get("llm_provider") == "anthropic" assert error_information.get("error_code") == str(upstream_status) + + +@pytest.mark.asyncio +async def test_anthropic_messages_leaves_non_provider_failures_unmapped(): + """The mapping boundary is for provider failures only. A request rejected before + the provider call (here invalid metadata) must surface as the original exception, + not as the mapper's APIConnectionError, whose message embeds a server traceback.""" + from litellm.llms.anthropic.experimental_pass_through.messages import handler + + def upstream_must_not_be_called(request: httpx.Request) -> httpx.Response: + raise AssertionError("the provider must not be called for a request rejected locally") + + upstream = AsyncHTTPHandler() + upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_must_not_be_called)) + + with pytest.raises(ValidationError) as excinfo: + await handler.anthropic_messages( + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + model="anthropic/claude-haiku-4-5", + custom_llm_provider="anthropic", + api_key="sk-invalid", + client=upstream, + metadata={"user_id": 123}, + ) + + assert "Traceback" not in str(excinfo.value)