From 65f8403097de0827dbed2173e301dafabd929e34 Mon Sep 17 00:00:00 2001 From: Jinquan Zhang Date: Mon, 26 Oct 2026 17:38:39 +0800 Subject: [PATCH] fix(anthropic): forward use_bearer_for_custom_base in /v1/messages passthrough auth The /v1/messages pass-through path never forwarded the deployment-level use_bearer_for_custom_base flag to AnthropicModelInfo.get_auth_header(), so gateways that only accept Authorization: Bearer (e.g. internal vLLM front-ends) answered 401 while the chat completions path worked. Forward the flag from litellm_params at both call sites (sync and async environment validation) via a small helper that reads it from either a bare dict or an object such as GenericLiteLLMParams. Refs #33055 --- .../messages/native/transformation.py | 19 ++++- .../test_anthropic_messages_transformation.py | 79 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/messages/native/transformation.py b/litellm/llms/anthropic/messages/native/transformation.py index cc53858f842..cf81cc89ebb 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -1,5 +1,5 @@ from collections.abc import AsyncIterator, Mapping, Sequence -from typing import ClassVar, Final +from typing import ClassVar, Final, cast # noqa: TID251 # narrows the shared untyped litellm_params dict import httpx @@ -310,12 +310,16 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): headers, api_key = optionally_handle_anthropic_oauth(headers=headers, api_key=api_key) if not _carries_caller_credential(headers): + bearer_params: Final = cast( # cast-ok: litellm_params is the untyped dict of the shared signature + dict[str, object], litellm_params + ) self._apply_env_auth_header( headers, self._require_auth_header( AnthropicModelInfo.get_auth_header( api_key, api_base=api_base, + use_bearer_for_custom_base=_bearer_for_custom_base(bearer_params), litellm_params=litellm_params, allow_workload_identity=self._allows_workload_identity, ), @@ -350,12 +354,16 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): oauth_headers, oauth_api_key = optionally_handle_anthropic_oauth(headers=headers, api_key=api_key) if not _carries_caller_credential(oauth_headers): + bearer_params: Final = cast( # cast-ok: litellm_params is the untyped dict of the shared signature + dict[str, object], litellm_params + ) self._apply_env_auth_header( oauth_headers, self._require_auth_header( await AnthropicModelInfo.aget_auth_header( oauth_api_key, api_base=api_base, + use_bearer_for_custom_base=_bearer_for_custom_base(bearer_params), litellm_params=litellm_params, allow_workload_identity=self._allows_workload_identity, ), @@ -872,3 +880,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if forwarded_beta_values: merged["anthropic-beta"] = ",".join(sorted(forwarded_beta_values)) return merged + + +def _bearer_for_custom_base(litellm_params: object) -> bool: + if isinstance(litellm_params, dict): + params: Final = cast( # cast-ok: callers pass the untyped dict of the shared signature + dict[str, object], litellm_params + ) + return bool(params.get("use_bearer_for_custom_base", False)) + return bool(getattr(litellm_params, "use_bearer_for_custom_base", False)) diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py index aa6625561ba..52a6af9bffc 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py @@ -416,3 +416,82 @@ def test_drop_params_forwards_a_non_object_message_item_as_sent_on_the_native_re assert request["messages"][1] == 7 assert request["messages"][2]["content"][0]["content"] == [{"type": "text", "text": "sunny"}] + + +class TestPassthroughBearerForCustomBase: + """Regression tests for #33055: the /v1/messages passthrough dropped the deployment-level + ``use_bearer_for_custom_base`` flag, so gateways that accept only ``Authorization: Bearer`` + answered 401 while the chat path worked. The flag now threads from ``litellm_params`` into + ``AnthropicModelInfo.get_auth_header`` on both the sync and async environment validation.""" + + @staticmethod + def _env( + monkeypatch: pytest.MonkeyPatch, + litellm_params: Mapping[str, object] | object, + api_key: str, + api_base: str, + ) -> tuple[Mapping[str, object], str | None]: + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + return AnthropicMessagesConfig().validate_anthropic_messages_environment( + headers={}, + model="claude-sonnet-4-6", + messages=[], + optional_params={}, + litellm_params=litellm_params, + api_key=api_key, + api_base=api_base, + ) + + def test_flag_from_litellm_params_uses_bearer(self, monkeypatch: pytest.MonkeyPatch) -> None: + headers, _ = self._env( + monkeypatch, {"use_bearer_for_custom_base": True}, "test-custom-key", "https://internal-gateway.example.com" + ) + assert headers.get("authorization") == "Bearer test-custom-key" + assert "x-api-key" not in headers + + def test_no_flag_keeps_x_api_key_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + headers, _ = self._env(monkeypatch, {}, "test-custom-key", "https://internal-gateway.example.com") + assert headers.get("x-api-key") == "test-custom-key" + assert "authorization" not in headers + + def test_flag_is_inert_on_the_real_anthropic_base(self, monkeypatch: pytest.MonkeyPatch) -> None: + headers, _ = self._env( + monkeypatch, {"use_bearer_for_custom_base": True}, "test-custom-key", "https://api.anthropic.com" + ) + assert headers.get("x-api-key") == "test-custom-key" + assert "authorization" not in headers + + def test_flag_is_inert_for_standard_anthropic_keys(self, monkeypatch: pytest.MonkeyPatch) -> None: + headers, _ = self._env( + monkeypatch, + {"use_bearer_for_custom_base": True}, + "sk-ant-api03-test", + "https://internal-gateway.example.com", + ) + assert headers.get("x-api-key") == "sk-ant-api03-test" + assert "authorization" not in headers + + def test_flag_from_generic_params_model_uses_bearer(self, monkeypatch: pytest.MonkeyPatch) -> None: + # The proxy passes a GenericLiteLLMParams instance, not a bare dict: the helper must + # fall through to the attribute read when ``litellm_params`` is not a Mapping. + params: Final = GenericLiteLLMParams(use_bearer_for_custom_base=True) + headers, _ = self._env(monkeypatch, params, "test-custom-key", "https://internal-gateway.example.com") + assert headers.get("authorization") == "Bearer test-custom-key" + assert "x-api-key" not in headers + + @pytest.mark.asyncio + async def test_async_validation_forwards_the_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + headers, _ = await AnthropicMessagesConfig().avalidate_anthropic_messages_environment( + headers={}, + model="claude-sonnet-4-6", + messages=[], + optional_params={}, + litellm_params={"use_bearer_for_custom_base": True}, + api_key="test-custom-key", + api_base="https://internal-gateway.example.com", + ) + assert headers.get("authorization") == "Bearer test-custom-key" + assert "x-api-key" not in headers