From abf911ec19adaefd4dc24cd40b412d4147a8a475 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Wed, 19 Aug 2026 02:13:06 +0000 Subject: [PATCH 1/2] fix(pass_through): don't inject server x-api-key over client-forwarded Anthropic OAuth The /anthropic passthrough route always attached the server-configured ANTHROPIC_API_KEY as x-api-key, even when the client already forwarded its own Anthropic OAuth token (e.g. Claude Code Max subscription auth) via the Authorization header. Anthropic authenticates off x-api-key when both are present, silently discarding the client's own identity. --- .../llm_passthrough_endpoints.py | 10 ++- .../test_llm_pass_through_endpoints.py | 71 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index c5ab7f1fc63..0c8515e2bba 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -24,7 +24,7 @@ from litellm.constants import ( ALLOWED_VERTEX_AI_PASSTHROUGH_HEADERS, BEDROCK_AGENT_RUNTIME_PASS_THROUGH_ROUTES, ) -from litellm.llms.anthropic.common_utils import AnthropicModelInfo +from litellm.llms.anthropic.common_utils import AnthropicModelInfo, is_anthropic_oauth_key from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.proxy._types import * from litellm.proxy.auth.route_checks import RouteChecks @@ -601,7 +601,13 @@ async def anthropic_proxy_route( is_streaming_request: Final = await is_streaming_request_fn(request) ## CREATE PASS-THROUGH - auth_header: Final = AnthropicModelInfo.get_auth_header(anthropic_api_key or None) + # A client-forwarded Anthropic OAuth token (e.g. Claude Code Max subscription auth) + # must not be sent alongside a server-configured x-api-key: Anthropic authenticates + # off x-api-key when both are present, silently discarding the client's own identity. + client_forwards_own_oauth: Final = is_anthropic_oauth_key(request.headers.get("authorization")) + auth_header: Final = ( + None if client_forwards_own_oauth else AnthropicModelInfo.get_auth_header(anthropic_api_key or None) + ) endpoint_func: Final = create_pass_through_route( endpoint=endpoint, target=str(updated_url), diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index b56a8da7c66..21815a1476b 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -23,6 +23,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( BaseOpenAIPassThroughHandler, RouteChecks, _join_url_paths, + anthropic_proxy_route, azure_proxy_route, bedrock_llm_proxy_route, bedrock_proxy_route, @@ -2933,6 +2934,76 @@ class TestOpenAIPassthroughRoute: assert result == {"id": "asst_123", "object": "assistant"} +class TestAnthropicProxyRoute: + """Regression (issue #37344): a client forwarding its own Anthropic OAuth token + (e.g. a Claude Code Max subscription) must not also get the server-configured + ANTHROPIC_API_KEY injected as x-api-key. Anthropic authenticates off x-api-key + when both are present, silently discarding the client's forwarded identity. + """ + + @pytest.mark.asyncio + async def test_anthropic_passthrough_omits_server_api_key_when_client_forwards_oauth(self): + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.headers = {"authorization": "Bearer sk-ant-oat01-canary"} + mock_request.query_params = {} + mock_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with ( + patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", + return_value="sk-ant-server-configured-key", + ), + patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route, + ): + mock_endpoint_func = AsyncMock(return_value={"id": "msg_123"}) + mock_create_route.return_value = mock_endpoint_func + + await anthropic_proxy_route( + endpoint="v1/messages", + request=mock_request, + fastapi_response=mock_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + call_args = mock_create_route.call_args[1] + assert call_args["custom_headers"] == {} + + @pytest.mark.asyncio + async def test_anthropic_passthrough_uses_server_api_key_without_client_oauth(self): + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.headers = {} + mock_request.query_params = {} + mock_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with ( + patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", + return_value="sk-ant-server-configured-key", + ), + patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route, + ): + mock_endpoint_func = AsyncMock(return_value={"id": "msg_123"}) + mock_create_route.return_value = mock_endpoint_func + + await anthropic_proxy_route( + endpoint="v1/messages", + request=mock_request, + fastapi_response=mock_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + call_args = mock_create_route.call_args[1] + assert call_args["custom_headers"] == {"x-api-key": "sk-ant-server-configured-key"} + + def _resolve_route_name(method: str, path: str) -> str | None: from starlette.routing import Match From ea490ec978b35d76deadb76cfe5819077233cfe5 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Wed, 19 Aug 2026 02:38:49 +0000 Subject: [PATCH 2/2] fix(anthropic): match Bearer scheme case-insensitively in OAuth key detection is_anthropic_oauth_key only stripped an exact "Bearer " prefix, so a lowercase or mixed-case Bearer scheme fell through to startswith() on the untouched "bearer ..." string and never matched, leaving the server x-api-key attached over the client's forwarded OAuth token. --- litellm/llms/anthropic/common_utils.py | 6 ++- .../llm_passthrough_endpoints.py | 3 -- .../test_llm_pass_through_endpoints.py | 37 ++++++++++++++++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 1cdbd60f943..ccbb471fc1c 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -54,8 +54,10 @@ def is_anthropic_oauth_key(value: str | None) -> bool: """Check if a value contains an Anthropic OAuth token (sk-ant-oat*).""" if value is None: return False - # Handle both raw token and "Bearer " format - value = value.removeprefix("Bearer ") + # Handle both raw token and "Bearer " format, case-insensitive scheme + scheme, _, token = value.partition(" ") + if scheme.lower() == "bearer": + value = token return value.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 0c8515e2bba..286b15e3b77 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -601,9 +601,6 @@ async def anthropic_proxy_route( is_streaming_request: Final = await is_streaming_request_fn(request) ## CREATE PASS-THROUGH - # A client-forwarded Anthropic OAuth token (e.g. Claude Code Max subscription auth) - # must not be sent alongside a server-configured x-api-key: Anthropic authenticates - # off x-api-key when both are present, silently discarding the client's own identity. client_forwards_own_oauth: Final = is_anthropic_oauth_key(request.headers.get("authorization")) auth_header: Final = ( None if client_forwards_own_oauth else AnthropicModelInfo.get_auth_header(anthropic_api_key or None) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 21815a1476b..95f1345192a 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -2935,11 +2935,7 @@ class TestOpenAIPassthroughRoute: class TestAnthropicProxyRoute: - """Regression (issue #37344): a client forwarding its own Anthropic OAuth token - (e.g. a Claude Code Max subscription) must not also get the server-configured - ANTHROPIC_API_KEY injected as x-api-key. Anthropic authenticates off x-api-key - when both are present, silently discarding the client's forwarded identity. - """ + """Regression test for issue #37344.""" @pytest.mark.asyncio async def test_anthropic_passthrough_omits_server_api_key_when_client_forwards_oauth(self): @@ -3003,6 +2999,37 @@ class TestAnthropicProxyRoute: call_args = mock_create_route.call_args[1] assert call_args["custom_headers"] == {"x-api-key": "sk-ant-server-configured-key"} + @pytest.mark.asyncio + async def test_anthropic_passthrough_omits_server_api_key_for_lowercase_bearer_scheme(self): + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.headers = {"authorization": "bearer sk-ant-oat01-canary"} + mock_request.query_params = {} + mock_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with ( + patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", + return_value="sk-ant-server-configured-key", + ), + patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route, + ): + mock_endpoint_func = AsyncMock(return_value={"id": "msg_123"}) + mock_create_route.return_value = mock_endpoint_func + + await anthropic_proxy_route( + endpoint="v1/messages", + request=mock_request, + fastapi_response=mock_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + call_args = mock_create_route.call_args[1] + assert call_args["custom_headers"] == {} + def _resolve_route_name(method: str, path: str) -> str | None: from starlette.routing import Match