mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
fix(proxy): preserve and forward OAuth Authorization headers through proxy layer
PR #21039 fixed OAuth token handling at the LLM layer (Authorization: Bearer instead of x-api-key), but the proxy layer still strips the Authorization header in clean_headers() before it reaches the Anthropic code. This breaks OAuth for proxy users (e.g., Claude Code Max through LiteLLM proxy). Changes: - Add is_anthropic_oauth_key() helper to detect OAuth tokens (sk-ant-oat*) - Preserve OAuth Authorization headers in clean_headers() instead of stripping - Forward OAuth Authorization via ProviderSpecificHeader in add_provider_specific_headers_to_request() so tokens only reach Anthropic-compatible providers (anthropic, bedrock, vertex_ai) Fixes #19618
This commit is contained in:
parent
61ed8f9e03
commit
76f5cb85b8
3 changed files with 166 additions and 2 deletions
|
|
@ -22,6 +22,15 @@ from litellm.types.llms.anthropic import (
|
|||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
||||
|
||||
def is_anthropic_oauth_key(value: Optional[str]) -> 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 <token>" format
|
||||
if value.startswith("Bearer "):
|
||||
value = value[7:]
|
||||
return value.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX)
|
||||
|
||||
def optionally_handle_anthropic_oauth(
|
||||
headers: dict, api_key: Optional[str]
|
||||
) -> tuple[dict, Optional[str]]:
|
||||
|
|
|
|||
|
|
@ -237,6 +237,8 @@ def clean_headers(
|
|||
"""
|
||||
Removes litellm api key from headers
|
||||
"""
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
clean_headers = {}
|
||||
litellm_key_lower = (
|
||||
litellm_key_header_name.lower() if litellm_key_header_name is not None else None
|
||||
|
|
@ -244,8 +246,13 @@ def clean_headers(
|
|||
|
||||
for header, value in headers.items():
|
||||
header_lower = header.lower()
|
||||
# Preserve Authorization header if it contains Anthropic OAuth token (sk-ant-oat*)
|
||||
# This allows OAuth tokens to be forwarded to Anthropic-compatible providers
|
||||
# via add_provider_specific_headers_to_request()
|
||||
if header_lower == "authorization" and is_anthropic_oauth_key(value):
|
||||
clean_headers[header] = value
|
||||
# Check if header should be excluded: either in special headers cache or matches custom litellm key
|
||||
if header_lower not in _SPECIAL_HEADERS_CACHE and (
|
||||
elif header_lower not in _SPECIAL_HEADERS_CACHE and (
|
||||
litellm_key_lower is None or header_lower != litellm_key_lower
|
||||
):
|
||||
clean_headers[header] = value
|
||||
|
|
@ -1605,6 +1612,8 @@ def add_provider_specific_headers_to_request(
|
|||
data: dict,
|
||||
headers: dict,
|
||||
):
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
anthropic_headers = {}
|
||||
# boolean to indicate if a header was added
|
||||
added_header = False
|
||||
|
|
@ -1614,6 +1623,14 @@ def add_provider_specific_headers_to_request(
|
|||
anthropic_headers[header] = header_value
|
||||
added_header = True
|
||||
|
||||
# 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
|
||||
for header, value in headers.items():
|
||||
if header.lower() == "authorization" and is_anthropic_oauth_key(value):
|
||||
anthropic_headers[header] = value
|
||||
added_header = True
|
||||
break
|
||||
if added_header is True:
|
||||
# Anthropic headers work across multiple providers
|
||||
# Store as comma-separated list so retrieval can match any of them
|
||||
|
|
|
|||
|
|
@ -81,4 +81,142 @@ def test_regular_api_keys_still_work():
|
|||
# Regular key should be unchanged
|
||||
assert extracted_api_key == regular_key
|
||||
# OAuth headers should NOT be added
|
||||
assert "anthropic-dangerous-direct-browser-access" not in updated_headers
|
||||
assert "anthropic-dangerous-direct-browser-access" not in updated_headers
|
||||
|
||||
class TestIsAnthropicOAuthKey:
|
||||
"""Tests for is_anthropic_oauth_key helper function."""
|
||||
|
||||
def test_oauth_token_raw(self):
|
||||
"""Raw OAuth token should be detected."""
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
assert is_anthropic_oauth_key("sk-ant-oat01-abc123") is True
|
||||
assert is_anthropic_oauth_key("sk-ant-oat02-xyz789") is True
|
||||
|
||||
def test_oauth_token_bearer_format(self):
|
||||
"""Bearer-prefixed OAuth token should be detected."""
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
assert is_anthropic_oauth_key("Bearer sk-ant-oat01-abc123") is True
|
||||
assert is_anthropic_oauth_key("Bearer sk-ant-oat02-xyz789") is True
|
||||
|
||||
def test_non_oauth_tokens(self):
|
||||
"""Non-OAuth values should return False."""
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
assert is_anthropic_oauth_key(None) is False
|
||||
assert is_anthropic_oauth_key("") is False
|
||||
assert is_anthropic_oauth_key("sk-ant-api01-abc123") is False
|
||||
assert is_anthropic_oauth_key("Bearer sk-ant-api01-abc123") is False
|
||||
|
||||
def test_case_sensitivity(self):
|
||||
"""OAuth prefix matching should be case-sensitive."""
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
assert is_anthropic_oauth_key("sk-ant-OAT01-abc123") is False
|
||||
assert is_anthropic_oauth_key("SK-ANT-OAT01-abc123") is False
|
||||
|
||||
def test_just_prefix(self):
|
||||
"""Just the prefix with no suffix should still match."""
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
assert is_anthropic_oauth_key("sk-ant-oat") is True
|
||||
|
||||
|
||||
class TestProxyOAuthHeaderForwarding:
|
||||
"""Tests for proxy-layer OAuth header preservation and forwarding."""
|
||||
|
||||
def test_clean_headers_preserves_oauth_authorization(self):
|
||||
"""clean_headers should preserve Authorization header with OAuth tokens."""
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from litellm.proxy.litellm_pre_call_utils import clean_headers
|
||||
|
||||
raw_headers = Headers(
|
||||
raw=[
|
||||
(b"authorization", f"Bearer {FAKE_OAUTH_TOKEN}".encode()),
|
||||
(b"content-type", b"application/json"),
|
||||
]
|
||||
)
|
||||
cleaned = clean_headers(raw_headers)
|
||||
|
||||
assert "authorization" in cleaned
|
||||
assert cleaned["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}"
|
||||
assert cleaned["content-type"] == "application/json"
|
||||
|
||||
def test_clean_headers_strips_non_oauth_authorization(self):
|
||||
"""clean_headers should strip Authorization header with regular API keys."""
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from litellm.proxy.litellm_pre_call_utils import clean_headers
|
||||
|
||||
raw_headers = Headers(
|
||||
raw=[
|
||||
(b"authorization", b"Bearer sk-regular-key-123"),
|
||||
(b"content-type", b"application/json"),
|
||||
]
|
||||
)
|
||||
cleaned = clean_headers(raw_headers)
|
||||
|
||||
assert "authorization" not in cleaned
|
||||
assert cleaned["content-type"] == "application/json"
|
||||
|
||||
def test_add_provider_specific_headers_forwards_oauth(self):
|
||||
"""add_provider_specific_headers_to_request should forward OAuth Authorization
|
||||
as a ProviderSpecificHeader scoped to Anthropic-compatible providers."""
|
||||
from litellm.proxy.litellm_pre_call_utils import (
|
||||
add_provider_specific_headers_to_request,
|
||||
)
|
||||
|
||||
data: dict = {}
|
||||
headers = {
|
||||
"authorization": f"Bearer {FAKE_OAUTH_TOKEN}",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
add_provider_specific_headers_to_request(data=data, headers=headers)
|
||||
|
||||
assert "provider_specific_header" in data
|
||||
psh = data["provider_specific_header"]
|
||||
assert "anthropic" in psh["custom_llm_provider"]
|
||||
assert "bedrock" in psh["custom_llm_provider"]
|
||||
assert "vertex_ai" in psh["custom_llm_provider"]
|
||||
assert psh["extra_headers"]["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}"
|
||||
|
||||
def test_add_provider_specific_headers_ignores_non_oauth(self):
|
||||
"""add_provider_specific_headers_to_request should not create a
|
||||
ProviderSpecificHeader for non-OAuth Authorization headers."""
|
||||
from litellm.proxy.litellm_pre_call_utils import (
|
||||
add_provider_specific_headers_to_request,
|
||||
)
|
||||
|
||||
data: dict = {}
|
||||
headers = {
|
||||
"authorization": "Bearer sk-regular-key-123",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
add_provider_specific_headers_to_request(data=data, headers=headers)
|
||||
|
||||
assert "provider_specific_header" not in data
|
||||
|
||||
def test_add_provider_specific_headers_combines_anthropic_and_oauth(self):
|
||||
"""When both anthropic-beta and OAuth Authorization are present, both
|
||||
should be included in the ProviderSpecificHeader."""
|
||||
from litellm.proxy.litellm_pre_call_utils import (
|
||||
add_provider_specific_headers_to_request,
|
||||
)
|
||||
|
||||
data: dict = {}
|
||||
headers = {
|
||||
"authorization": f"Bearer {FAKE_OAUTH_TOKEN}",
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
add_provider_specific_headers_to_request(data=data, headers=headers)
|
||||
|
||||
assert "provider_specific_header" in data
|
||||
psh = data["provider_specific_header"]
|
||||
assert psh["extra_headers"]["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}"
|
||||
assert psh["extra_headers"]["anthropic-beta"] == "oauth-2025-04-20"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue