From abf911ec19adaefd4dc24cd40b412d4147a8a475 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Wed, 19 Aug 2026 02:13:06 +0000 Subject: [PATCH] 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