From 80d3d352a9c8ce3cef3603df2c82f8af62a1087d Mon Sep 17 00:00:00 2001 From: RoyVivat Date: Mon, 23 Mar 2026 16:46:31 -0700 Subject: [PATCH] fix: address issue #24436 --- .../get_provider_specific_headers.py | 19 +--- litellm/proxy/litellm_pre_call_utils.py | 43 ++++++--- .../test_provider_specific_headers.py | 81 ---------------- .../proxy/test_litellm_pre_call_utils.py | 96 +++++++++++++++++++ 4 files changed, 125 insertions(+), 114 deletions(-) diff --git a/litellm/litellm_core_utils/get_provider_specific_headers.py b/litellm/litellm_core_utils/get_provider_specific_headers.py index 3f290284292..69a7ec72073 100644 --- a/litellm/litellm_core_utils/get_provider_specific_headers.py +++ b/litellm/litellm_core_utils/get_provider_specific_headers.py @@ -1,10 +1,7 @@ from typing import Dict, Optional -from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key from litellm.types.utils import ProviderSpecificHeader -_ANTHROPIC_PROVIDER = "anthropic" - class ProviderSpecificHeaderUtils: @staticmethod @@ -17,10 +14,6 @@ class ProviderSpecificHeaderUtils: Supports comma-separated provider lists for headers that work across multiple providers. - Anthropic OAuth tokens (sk-ant-oat*) in the Authorization header are stripped for - non-Anthropic providers to prevent them from overriding provider-specific auth - (e.g. AWS SigV4 for Bedrock, service account credentials for Vertex AI). - Returns: Dict: The provider specific headers for the given custom llm provider """ @@ -31,16 +24,6 @@ class ProviderSpecificHeaderUtils: provider_list = [p.strip() for p in stored_providers.split(",")] if custom_llm_provider in provider_list: - headers = provider_specific_header.get("extra_headers", {}) - # Anthropic OAuth tokens must not be forwarded to non-Anthropic providers. - # Forwarding them would overwrite provider-specific auth headers - # (e.g. Bedrock's SigV4 Authorization, Vertex AI service-account auth). - if custom_llm_provider != _ANTHROPIC_PROVIDER: - headers = { - k: v - for k, v in headers.items() - if not (k.lower() == "authorization" and is_anthropic_oauth_key(v)) - } - return headers + return provider_specific_header.get("extra_headers", {}) return {} diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index daf2867699e..d7783280e8d 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1928,29 +1928,42 @@ def add_provider_specific_headers_to_request( ): from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key - anthropic_headers = {} - # boolean to indicate if a header was added - added_header = False + # Headers that work identically across Anthropic, Bedrock, and Vertex AI + multi_provider_headers: dict = {} for header in ANTHROPIC_API_HEADERS: if header in headers: - header_value = headers[header] - anthropic_headers[header] = header_value - added_header = True + multi_provider_headers[header] = headers[header] - # Check for Authorization header with Anthropic OAuth token (sk-ant-oat*) - # This needs to be handled via provider-specific headers to ensure it only - # goes to Anthropic-compatible providers, not all providers in the router + # Detect an Anthropic OAuth token (sk-ant-oat*) in the Authorization header. + # OAuth tokens must be scoped to the Anthropic provider only — forwarding them + # to Bedrock or Vertex AI would overwrite those providers' own auth headers + # (AWS SigV4 Authorization for Bedrock, service-account credentials for Vertex AI), + # causing 403 errors. See https://github.com/BerriAI/litellm/issues/24436 + oauth_header_key: str = "" + oauth_header_value: str = "" for header, value in headers.items(): if header.lower() == "authorization" and is_anthropic_oauth_key(value): - anthropic_headers[header] = value - added_header = True + oauth_header_key = header + oauth_header_value = value break - if added_header is True: - # Anthropic headers work across multiple providers - # Store as comma-separated list so retrieval can match any of them + + if oauth_header_key: + # Scope ALL Anthropic headers (including the OAuth token) to the Anthropic + # provider only. Users sending an OAuth token are targeting Anthropic directly; + # Bedrock/Vertex have their own beta-header plumbing. + data["provider_specific_header"] = ProviderSpecificHeader( + custom_llm_provider=LlmProviders.ANTHROPIC.value, + extra_headers={ + **multi_provider_headers, + oauth_header_key: oauth_header_value, + }, + ) + elif multi_provider_headers: + # Regular Anthropic API headers (e.g. anthropic-beta, anthropic-version) + # work across all three Anthropic-format providers. data["provider_specific_header"] = ProviderSpecificHeader( custom_llm_provider=f"{LlmProviders.ANTHROPIC.value},{LlmProviders.BEDROCK.value},{LlmProviders.VERTEX_AI.value}", - extra_headers=anthropic_headers, + extra_headers=multi_provider_headers, ) return diff --git a/tests/test_litellm/litellm_core_utils/test_provider_specific_headers.py b/tests/test_litellm/litellm_core_utils/test_provider_specific_headers.py index 19962f3eb9a..ae28df20ca9 100644 --- a/tests/test_litellm/litellm_core_utils/test_provider_specific_headers.py +++ b/tests/test_litellm/litellm_core_utils/test_provider_specific_headers.py @@ -113,84 +113,3 @@ class TestProviderSpecificHeaderUtils: ) assert result == {} - def test_anthropic_oauth_token_not_forwarded_to_bedrock(self): - """ - Regression test for https://github.com/BerriAI/litellm/issues/24436. - - When Claude Code sets an Anthropic OAuth token (sk-ant-oat*) in Authorization, - it must NOT reach Bedrock — forwarding it overwrites the AWS SigV4 Authorization - header and causes a 403 from Bedrock. - """ - oauth_token = "Bearer sk-ant-oat01-abc123xyz" - provider_specific_header: ProviderSpecificHeader = { - "custom_llm_provider": "anthropic,bedrock,vertex_ai", - "extra_headers": { - "Authorization": oauth_token, - "anthropic-beta": "some-beta-feature", - }, - } - - # Anthropic should receive the OAuth token - result = ProviderSpecificHeaderUtils.get_provider_specific_headers( - provider_specific_header, "anthropic" - ) - assert result["Authorization"] == oauth_token - assert result["anthropic-beta"] == "some-beta-feature" - - # Bedrock must NOT receive the OAuth token (it would overwrite SigV4 auth) - result = ProviderSpecificHeaderUtils.get_provider_specific_headers( - provider_specific_header, "bedrock" - ) - assert "Authorization" not in result - assert result.get("anthropic-beta") == "some-beta-feature" - - # Vertex AI must NOT receive the OAuth token - result = ProviderSpecificHeaderUtils.get_provider_specific_headers( - provider_specific_header, "vertex_ai" - ) - assert "Authorization" not in result - assert result.get("anthropic-beta") == "some-beta-feature" - - def test_anthropic_oauth_token_not_forwarded_to_bedrock_converse(self): - """Anthropic OAuth token must not be forwarded to bedrock_converse either.""" - oauth_token = "Bearer sk-ant-oat02-xyz789" - provider_specific_header: ProviderSpecificHeader = { - "custom_llm_provider": "anthropic,bedrock,bedrock_converse,vertex_ai", - "extra_headers": {"Authorization": oauth_token}, - } - - result = ProviderSpecificHeaderUtils.get_provider_specific_headers( - provider_specific_header, "bedrock_converse" - ) - assert result == {} - - def test_non_oauth_authorization_still_forwarded_to_non_anthropic(self): - """A plain Bearer token (not Anthropic OAuth) should still pass through.""" - plain_token = "Bearer some-regular-api-key" - provider_specific_header: ProviderSpecificHeader = { - "custom_llm_provider": "openai,azure", - "extra_headers": {"Authorization": plain_token}, - } - - result = ProviderSpecificHeaderUtils.get_provider_specific_headers( - provider_specific_header, "openai" - ) - assert result["Authorization"] == plain_token - - def test_anthropic_oauth_token_raw_format_not_forwarded_to_bedrock(self): - """Anthropic OAuth token in raw format (without 'Bearer ' prefix) is also stripped.""" - raw_oauth_token = "sk-ant-oat01-abc123" - provider_specific_header: ProviderSpecificHeader = { - "custom_llm_provider": "anthropic,bedrock,vertex_ai", - "extra_headers": {"Authorization": raw_oauth_token}, - } - - result = ProviderSpecificHeaderUtils.get_provider_specific_headers( - provider_specific_header, "bedrock" - ) - assert "Authorization" not in result - - result = ProviderSpecificHeaderUtils.get_provider_specific_headers( - provider_specific_header, "anthropic" - ) - assert result["Authorization"] == raw_oauth_token diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index bc13cea939e..01a9d99f178 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -19,6 +19,7 @@ from litellm.proxy.litellm_pre_call_utils import ( _update_model_if_key_alias_exists, add_guardrails_from_policy_engine, add_litellm_data_to_request, + add_provider_specific_headers_to_request, check_if_token_is_service_account, ) @@ -1817,3 +1818,98 @@ async def test_bearer_token_not_in_debug_logs(): f"Bearer token leaked in debug logs. " f"Found token in log output:\n{log_output[:500]}" ) + + +class TestAddProviderSpecificHeadersOAuthScoping: + """ + Regression tests for https://github.com/BerriAI/litellm/issues/24436. + + When Claude Code sends an Anthropic OAuth token (sk-ant-oat*) via the + Authorization header, that token must NOT be forwarded to non-Anthropic + providers (Bedrock, Vertex AI). Doing so overwrites those providers' + own auth headers (AWS SigV4, service-account credentials) and causes 403s. + """ + + def test_oauth_token_scoped_to_anthropic_only(self): + """provider_specific_header must list only 'anthropic' when an OAuth token is present.""" + data: dict = {} + headers = {"Authorization": "Bearer sk-ant-oat01-abc123xyz"} + + add_provider_specific_headers_to_request(data=data, headers=headers) + + psh = data.get("provider_specific_header") + assert psh is not None + assert psh["custom_llm_provider"] == "anthropic" + assert psh["extra_headers"]["Authorization"] == "Bearer sk-ant-oat01-abc123xyz" + + def test_oauth_token_with_anthropic_beta_scoped_to_anthropic_only(self): + """ + When both an OAuth token and anthropic-beta are present, both are scoped to + anthropic only. Verify this end-to-end: anthropic receives both headers, while + bedrock and vertex_ai receive neither (not just "no Authorization" — they receive + nothing at all, because a user with an OAuth token is targeting Anthropic directly). + """ + from litellm.litellm_core_utils.get_provider_specific_headers import ( + ProviderSpecificHeaderUtils, + ) + + data: dict = {} + headers = { + "Authorization": "Bearer sk-ant-oat02-xyz789", + "anthropic-beta": "some-beta-feature", + } + + add_provider_specific_headers_to_request(data=data, headers=headers) + + psh = data.get("provider_specific_header") + assert psh is not None + assert psh["custom_llm_provider"] == "anthropic" + assert psh["extra_headers"]["Authorization"] == "Bearer sk-ant-oat02-xyz789" + assert psh["extra_headers"]["anthropic-beta"] == "some-beta-feature" + + # Anthropic receives both headers + anthropic_headers = ProviderSpecificHeaderUtils.get_provider_specific_headers( + psh, "anthropic" + ) + assert anthropic_headers["Authorization"] == "Bearer sk-ant-oat02-xyz789" + assert anthropic_headers["anthropic-beta"] == "some-beta-feature" + + # Bedrock and Vertex AI receive nothing — the OAuth token is not surgically + # stripped; instead the whole header set is scoped to anthropic only + assert ProviderSpecificHeaderUtils.get_provider_specific_headers(psh, "bedrock") == {} + assert ProviderSpecificHeaderUtils.get_provider_specific_headers(psh, "vertex_ai") == {} + assert ProviderSpecificHeaderUtils.get_provider_specific_headers(psh, "bedrock_converse") == {} + + def test_non_oauth_anthropic_headers_forwarded_to_multi_providers(self): + """Regular Anthropic API headers without an OAuth token go to anthropic,bedrock,vertex_ai.""" + data: dict = {} + headers = {"anthropic-beta": "some-beta-feature"} + + add_provider_specific_headers_to_request(data=data, headers=headers) + + psh = data.get("provider_specific_header") + assert psh is not None + assert "bedrock" in psh["custom_llm_provider"] + assert "vertex_ai" in psh["custom_llm_provider"] + assert psh["extra_headers"]["anthropic-beta"] == "some-beta-feature" + assert "Authorization" not in psh["extra_headers"] + + def test_no_provider_specific_header_when_no_relevant_headers(self): + """No provider_specific_header is set when there are no relevant headers.""" + data: dict = {} + headers = {"x-some-other-header": "value"} + + add_provider_specific_headers_to_request(data=data, headers=headers) + + assert "provider_specific_header" not in data + + def test_raw_oauth_token_scoped_to_anthropic_only(self): + """OAuth token without 'Bearer ' prefix is also scoped to anthropic only.""" + data: dict = {} + headers = {"Authorization": "sk-ant-oat01-rawtoken"} + + add_provider_specific_headers_to_request(data=data, headers=headers) + + psh = data.get("provider_specific_header") + assert psh is not None + assert psh["custom_llm_provider"] == "anthropic"