diff --git a/litellm/llms/anthropic/batches/transformation.py b/litellm/llms/anthropic/batches/transformation.py index 750dd002ff9..e335d9f6087 100644 --- a/litellm/llms/anthropic/batches/transformation.py +++ b/litellm/llms/anthropic/batches/transformation.py @@ -42,18 +42,19 @@ def validate_environment( api_base: Optional[str] = None, ) -> dict: """Validate and prepare environment-specific headers and parameters.""" + from ..common_utils import set_anthropic_headers + # Resolve api_key from environment if not provided api_key = api_key or self.anthropic_model_info.get_api_key() if api_key is None: raise ValueError( "Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params" ) - _headers = { + _headers = set_anthropic_headers(api_key, { "accept": "application/json", "anthropic-version": "2023-06-01", "content-type": "application/json", - "x-api-key": api_key, - } + }) # Add beta header for message batches if "anthropic-beta" not in headers: headers["anthropic-beta"] = "message-batches-2024-09-24" diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index cb23d21fbc9..be8fcbb787a 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -22,14 +22,59 @@ from litellm.types.llms.openai import AllMessageValues +def is_anthropic_oauth_key(value: Optional[str]) -> bool: + """ + Check if a value is an Anthropic OAuth token. + + Handles both formats: + - "Bearer sk-ant-oat01-xxx" (Authorization header format) + - "sk-ant-oat01-xxx" (raw API key format) + """ + if not value: + return False + + # Handle "Bearer sk-ant-oat01-xxx" format + scheme, _, token = value.partition(" ") + if scheme.lower() == "bearer" and token.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX): + return True + + # Handle raw token "sk-ant-oat01-xxx" format + return value.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX) + + +def set_anthropic_headers(api_key: str, headers: Optional[dict] = None) -> dict: + """ + Create headers dict with appropriate auth header for Anthropic API requests. + + OAuth tokens use the Authorization header, regular API keys use x-api-key. + + Args: + api_key: The API key or OAuth token + headers: Optional base headers to merge with auth header + + Returns: + New dict with auth header and any base headers merged + """ + result = dict(headers) if headers else {} + if is_anthropic_oauth_key(api_key): + if api_key.lower().startswith("bearer "): + result["authorization"] = api_key + else: + result["authorization"] = f"Bearer {api_key}" + else: + result["x-api-key"] = api_key + + return result + + def optionally_handle_anthropic_oauth( headers: dict, api_key: Optional[str] ) -> tuple[dict, Optional[str]]: """ Handle Anthropic OAuth token detection and header setup. - If an OAuth token is detected in the Authorization header, extracts it - and sets the required OAuth headers. + Checks both the Authorization header and the api_key parameter for OAuth tokens. + If found, sets the required OAuth beta headers. Args: headers: Request headers dict @@ -38,14 +83,22 @@ def optionally_handle_anthropic_oauth( Returns: Tuple of (updated headers, api_key) """ - auth_header = headers.get("authorization", "") - if auth_header and auth_header.startswith(f"Bearer {ANTHROPIC_OAUTH_TOKEN_PREFIX}"): + # Check Authorization header first (case-insensitive lookup) + auth_header = "" + for key, value in headers.items(): + if key.lower() == "authorization": + auth_header = value + break + if auth_header and is_anthropic_oauth_key(auth_header): api_key = auth_header.replace("Bearer ", "") headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA_HEADER headers["anthropic-dangerous-direct-browser-access"] = "true" + # Also check if api_key is directly an OAuth token + elif api_key and is_anthropic_oauth_key(api_key): + headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA_HEADER + headers["anthropic-dangerous-direct-browser-access"] = "true" return headers, api_key - class AnthropicError(BaseLLMException): def __init__( self, @@ -366,12 +419,11 @@ def get_anthropic_headers( if container_with_skills_used: betas.add("skills-2025-10-02") - headers = { + headers = set_anthropic_headers(api_key, { "anthropic-version": anthropic_version or "2023-06-01", - "x-api-key": api_key, "accept": "application/json", "content-type": "application/json", - } + }) if user_anthropic_beta_headers is not None: betas.update(user_anthropic_beta_headers) @@ -475,9 +527,10 @@ def get_models( raise ValueError( "ANTHROPIC_API_BASE or ANTHROPIC_API_KEY is not set. Please set the environment variable, to query Anthropic's `/models` endpoint." ) + headers = set_anthropic_headers(api_key, {"anthropic-version": "2023-06-01"}) response = litellm.module_level_client.get( url=f"{api_base}/v1/models", - headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"}, + headers=headers, ) try: diff --git a/litellm/llms/anthropic/completion/transformation.py b/litellm/llms/anthropic/completion/transformation.py index a8798cd5d0e..aff5dc02656 100644 --- a/litellm/llms/anthropic/completion/transformation.py +++ b/litellm/llms/anthropic/completion/transformation.py @@ -91,16 +91,17 @@ def validate_environment( api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: + from litellm.llms.anthropic.common_utils import set_anthropic_headers + if api_key is None: raise ValueError( "Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params" ) - _headers = { + _headers = set_anthropic_headers(api_key, { "accept": "application/json", "anthropic-version": "2023-06-01", "content-type": "application/json", - "x-api-key": api_key, - } + }) headers.update(_headers) return headers diff --git a/litellm/llms/anthropic/count_tokens/transformation.py b/litellm/llms/anthropic/count_tokens/transformation.py index c3ad72436b4..a289b444c66 100644 --- a/litellm/llms/anthropic/count_tokens/transformation.py +++ b/litellm/llms/anthropic/count_tokens/transformation.py @@ -7,6 +7,7 @@ from typing import Any, Dict, List from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION +from litellm.llms.anthropic.common_utils import set_anthropic_headers class AnthropicCountTokensConfig: @@ -63,12 +64,11 @@ def get_required_headers(self, api_key: str) -> Dict[str, str]: Returns: Dictionary of required headers """ - return { + return set_anthropic_headers(api_key, { "Content-Type": "application/json", - "x-api-key": api_key, "anthropic-version": "2023-06-01", "anthropic-beta": ANTHROPIC_TOKEN_COUNTING_BETA_VERSION, - } + }) def validate_request( self, model: str, messages: List[Dict[str, Any]] diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 308bf367d06..39f335d4d5a 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -21,6 +21,7 @@ AnthropicError, AnthropicModelInfo, optionally_handle_anthropic_oauth, + set_anthropic_headers, ) DEFAULT_ANTHROPIC_API_BASE = "https://api.anthropic.com" @@ -78,8 +79,10 @@ def validate_anthropic_messages_environment( if api_key is None: api_key = os.getenv("ANTHROPIC_API_KEY") - if "x-api-key" not in headers and api_key: - headers["x-api-key"] = api_key + # Case-insensitive check for existing auth headers + headers_lower = {k.lower(): v for k, v in headers.items()} + if "x-api-key" not in headers_lower and "authorization" not in headers_lower and api_key: + headers.update(set_anthropic_headers(api_key)) if "anthropic-version" not in headers: headers["anthropic-version"] = DEFAULT_ANTHROPIC_API_VERSION if "content-type" not in headers: diff --git a/litellm/llms/anthropic/files/handler.py b/litellm/llms/anthropic/files/handler.py index d46fc401310..02b74cd4484 100644 --- a/litellm/llms/anthropic/files/handler.py +++ b/litellm/llms/anthropic/files/handler.py @@ -22,7 +22,7 @@ from litellm.types.utils import CallTypes, LlmProviders, ModelResponse from ..chat.transformation import AnthropicConfig -from ..common_utils import AnthropicModelInfo +from ..common_utils import AnthropicModelInfo, set_anthropic_headers # Map Anthropic error types to HTTP status codes ANTHROPIC_ERROR_STATUS_CODE_MAP = { @@ -94,11 +94,10 @@ async def afile_content( results_url = f"{api_base.rstrip('/')}/v1/messages/batches/{batch_id}/results" # Prepare headers - headers = { + headers = set_anthropic_headers(api_key, { "accept": "application/json", "anthropic-version": "2023-06-01", - "x-api-key": api_key, - } + }) # Make the request to Anthropic async_client = get_async_httpx_client(llm_provider=LlmProviders.ANTHROPIC) diff --git a/litellm/llms/anthropic/skills/transformation.py b/litellm/llms/anthropic/skills/transformation.py index 832b74cf51d..e5811ec5b8d 100644 --- a/litellm/llms/anthropic/skills/transformation.py +++ b/litellm/llms/anthropic/skills/transformation.py @@ -33,7 +33,7 @@ def validate_environment( self, headers: dict, litellm_params: Optional[GenericLiteLLMParams] ) -> dict: """Add Anthropic-specific headers""" - from litellm.llms.anthropic.common_utils import AnthropicModelInfo + from litellm.llms.anthropic.common_utils import AnthropicModelInfo, set_anthropic_headers # Get API key api_key = None @@ -45,7 +45,7 @@ def validate_environment( raise ValueError("ANTHROPIC_API_KEY is required for Skills API") # Add required headers - headers["x-api-key"] = api_key + headers.update(set_anthropic_headers(api_key)) headers["anthropic-version"] = "2023-06-01" # Add beta header for skills API diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 064538ef3bf..a2b81afbbde 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -26,6 +26,7 @@ ) from litellm.proxy.auth.route_checks import RouteChecks from litellm.router import Router +from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key from litellm.types.llms.anthropic import ANTHROPIC_API_HEADERS from litellm.types.services import ServiceTypes from litellm.types.utils import ( @@ -320,6 +321,7 @@ def _get_forwardable_headers( Looks for any `x-` headers and sends them to the LLM Provider. [07/09/2025] - Support 'anthropic-beta' header as well. + [01/26/2026] - Support 'authorization' header for Anthropic OAuth tokens. """ forwarded_headers = {} for header, value in headers.items(): @@ -329,6 +331,9 @@ def _get_forwardable_headers( forwarded_headers[header] = value elif header.lower().startswith("anthropic-beta"): forwarded_headers[header] = value + elif header.lower() == "authorization" and is_anthropic_oauth_key(value): + # Forward Authorization header for Anthropic OAuth tokens (sk-ant-oat*) + forwarded_headers[header] = value return forwarded_headers diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index b079e161519..9e71a6538db 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -594,12 +594,31 @@ async def anthropic_proxy_route( base_url = httpx.URL(base_target_url) updated_url = base_url.copy_with(path=encoded_endpoint) - # Add or update query parameters - anthropic_api_key = passthrough_endpoint_router.get_credentials( - custom_llm_provider="anthropic", - region_name=None, + # Check for OAuth token in incoming request, otherwise use stored credentials + from litellm.llms.anthropic.common_utils import ( + optionally_handle_anthropic_oauth, + set_anthropic_headers, ) + incoming_headers = dict(request.headers) + oauth_headers, oauth_api_key = optionally_handle_anthropic_oauth( + headers=incoming_headers, api_key=None + ) + + if oauth_api_key: + # OAuth token found - use Authorization header with OAuth beta headers + custom_headers = set_anthropic_headers(oauth_api_key) + custom_headers["anthropic-dangerous-direct-browser-access"] = oauth_headers.get( + "anthropic-dangerous-direct-browser-access", "true" + ) + else: + # No OAuth token - use stored API key with x-api-key + anthropic_api_key = passthrough_endpoint_router.get_credentials( + custom_llm_provider="anthropic", + region_name=None, + ) + custom_headers = {"x-api-key": "{}".format(anthropic_api_key)} + ## check for streaming is_streaming_request = await is_streaming_request_fn(request) @@ -607,7 +626,7 @@ async def anthropic_proxy_route( endpoint_func = create_pass_through_route( endpoint=endpoint, target=str(updated_url), - custom_headers={"x-api-key": "{}".format(anthropic_api_key)}, + custom_headers=custom_headers, _forward_headers=True, is_streaming_request=is_streaming_request, ) # dynamically construct pass-through endpoint based on incoming path diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index 0a397d116e7..6e0e0ce9d36 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -5,6 +5,8 @@ import os import sys +from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key + # Add litellm to path sys.path.insert(0, os.path.abspath("../../../../..")) @@ -19,7 +21,7 @@ def test_oauth_detection_in_common_utils(): headers = {"authorization": f"Bearer {FAKE_OAUTH_TOKEN}"} updated_headers, extracted_api_key = optionally_handle_anthropic_oauth(headers, None) - assert extracted_api_key == FAKE_OAUTH_TOKEN + assert is_anthropic_oauth_key(extracted_api_key) assert updated_headers["anthropic-beta"] == "oauth-2025-04-20" assert updated_headers["anthropic-dangerous-direct-browser-access"] == "true" @@ -41,7 +43,9 @@ def test_oauth_integration_in_validate_environment(): api_base=None, ) - assert updated_headers["x-api-key"] == FAKE_OAUTH_TOKEN + # OAuth should use Authorization header, not x-api-key + assert "x-api-key" not in updated_headers + assert "authorization" in updated_headers assert updated_headers["anthropic-dangerous-direct-browser-access"] == "true" @@ -64,7 +68,9 @@ def test_oauth_detection_in_messages_transformation(): api_base=None, ) - assert updated_headers["x-api-key"] == FAKE_OAUTH_TOKEN + # OAuth should use Authorization header, not x-api-key + assert "x-api-key" not in updated_headers + assert "authorization" in updated_headers assert "oauth-2025-04-20" in updated_headers["anthropic-beta"] assert updated_headers["anthropic-dangerous-direct-browser-access"] == "true" @@ -81,4 +87,151 @@ 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 \ No newline at end of file + assert "anthropic-dangerous-direct-browser-access" not in updated_headers + + +def test_is_anthropic_oauth_key_formats(): + """Test 5: is_anthropic_oauth_key handles both raw and Bearer formats""" + # Raw token format (after extraction from Authorization header) + assert is_anthropic_oauth_key(FAKE_OAUTH_TOKEN) is True + + # Bearer token format (from Authorization header) + assert is_anthropic_oauth_key(f"Bearer {FAKE_OAUTH_TOKEN}") is True + + # Case insensitive Bearer + assert is_anthropic_oauth_key(f"bearer {FAKE_OAUTH_TOKEN}") is True + assert is_anthropic_oauth_key(f"BEARER {FAKE_OAUTH_TOKEN}") is True + + # Regular API keys should return False + assert is_anthropic_oauth_key("sk-ant-api03-regular-key-123") is False + assert is_anthropic_oauth_key("Bearer sk-ant-api03-regular-key-123") is False + + # Edge cases + assert is_anthropic_oauth_key(None) is False + assert is_anthropic_oauth_key("") is False + assert is_anthropic_oauth_key("Bearer ") is False + + +def test_oauth_with_direct_api_key(): + """Test 6: OAuth token passed directly as api_key (not via Authorization header)""" + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + + config = AnthropicModelInfo() + headers = {} + + updated_headers = config.validate_environment( + headers=headers, + model="claude-3-haiku-20240307", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=FAKE_OAUTH_TOKEN, # OAuth token passed directly + api_base=None, + ) + + # OAuth should use Authorization header, not x-api-key + assert "x-api-key" not in updated_headers + assert "authorization" in updated_headers + assert updated_headers["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}" + # OAuth beta headers should also be set + assert "oauth-2025-04-20" in updated_headers["anthropic-beta"] + assert updated_headers["anthropic-dangerous-direct-browser-access"] == "true" + + +def test_proxy_forwards_oauth_authorization_header(): + """Test 7: Proxy _get_forwardable_headers forwards OAuth Authorization header""" + from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup + + # OAuth token in Authorization header should be forwarded + oauth_headers = {"authorization": f"Bearer {FAKE_OAUTH_TOKEN}"} + forwarded = LiteLLMProxyRequestSetup._get_forwardable_headers(oauth_headers) + assert "authorization" in forwarded + assert forwarded["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}" + + # Regular API key in Authorization header should NOT be forwarded + regular_headers = {"authorization": "Bearer sk-ant-api03-regular-key-123"} + forwarded = LiteLLMProxyRequestSetup._get_forwardable_headers(regular_headers) + assert "authorization" not in forwarded + + # x-* headers should still be forwarded + x_headers = {"x-custom-header": "value", "authorization": "Bearer sk-regular"} + forwarded = LiteLLMProxyRequestSetup._get_forwardable_headers(x_headers) + assert "x-custom-header" in forwarded + assert "authorization" not in forwarded + + +def test_oauth_case_insensitive_authorization_header(): + """Test 8: OAuth detection works with different Authorization header cases""" + from litellm.llms.anthropic.common_utils import optionally_handle_anthropic_oauth + + # Test with capital 'A' (how HTTP headers typically come through) + headers_capital = {"Authorization": f"Bearer {FAKE_OAUTH_TOKEN}"} + updated_headers, extracted_api_key = optionally_handle_anthropic_oauth(headers_capital, None) + assert is_anthropic_oauth_key(extracted_api_key) + assert updated_headers["anthropic-beta"] == "oauth-2025-04-20" + + # Test with lowercase (our test default) + headers_lower = {"authorization": f"Bearer {FAKE_OAUTH_TOKEN}"} + updated_headers, extracted_api_key = optionally_handle_anthropic_oauth(headers_lower, None) + assert is_anthropic_oauth_key(extracted_api_key) + + # Test with mixed case + headers_mixed = {"AUTHORIZATION": f"Bearer {FAKE_OAUTH_TOKEN}"} + updated_headers, extracted_api_key = optionally_handle_anthropic_oauth(headers_mixed, None) + assert is_anthropic_oauth_key(extracted_api_key) + + +def test_oauth_passthrough_endpoint_headers(): + """Test 9: Pass-through endpoint correctly builds OAuth headers""" + from litellm.llms.anthropic.common_utils import ( + optionally_handle_anthropic_oauth, + set_anthropic_headers, + ) + + # Simulate pass-through endpoint flow with OAuth token + incoming_headers = {"authorization": f"Bearer {FAKE_OAUTH_TOKEN}"} + oauth_headers, oauth_api_key = optionally_handle_anthropic_oauth( + headers=incoming_headers, api_key=None + ) + + # Should extract OAuth API key + assert oauth_api_key is not None + assert oauth_api_key == FAKE_OAUTH_TOKEN + + # Build custom headers like pass-through endpoint does + custom_headers = set_anthropic_headers(oauth_api_key) + custom_headers["anthropic-dangerous-direct-browser-access"] = oauth_headers.get( + "anthropic-dangerous-direct-browser-access", "true" + ) + + # Should use Authorization header, not x-api-key + assert "x-api-key" not in custom_headers + assert "authorization" in custom_headers + assert custom_headers["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}" + assert custom_headers["anthropic-dangerous-direct-browser-access"] == "true" + + +def test_regular_key_passthrough_endpoint_headers(): + """Test 10: Pass-through endpoint uses x-api-key for regular keys""" + from litellm.llms.anthropic.common_utils import ( + optionally_handle_anthropic_oauth, + set_anthropic_headers, + ) + + # Simulate pass-through endpoint flow with no OAuth token in request + incoming_headers = {} + oauth_headers, oauth_api_key = optionally_handle_anthropic_oauth( + headers=incoming_headers, api_key=None + ) + + # No OAuth token found + assert oauth_api_key is None + + # When no OAuth token, use regular x-api-key format + regular_key = "sk-ant-api03-regular-key" + custom_headers = set_anthropic_headers(regular_key) + + # Should use x-api-key, not authorization + assert "authorization" not in custom_headers + assert "x-api-key" in custom_headers + assert custom_headers["x-api-key"] == regular_key \ No newline at end of file diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_oauth_manual.py b/tests/test_litellm/llms/anthropic/test_anthropic_oauth_manual.py new file mode 100644 index 00000000000..fbe3afddb02 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/test_anthropic_oauth_manual.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Manual integration tests for Anthropic OAuth token handling. + +These tests require a real Anthropic OAuth token and make actual API calls. +They are skipped by default in CI - run manually with: + + ANTHROPIC_OAUTH_TOKEN=sk-ant-oat... poetry run pytest tests/test_litellm/llms/anthropic/test_anthropic_oauth_manual.py -v -s + +Or run as a script: + + poetry run python tests/test_litellm/llms/anthropic/test_anthropic_oauth_manual.py sk-ant-oat... +""" + +import json +import os +import sys +from typing import Any, Dict, Optional + +import pytest + +# Get OAuth token from env or skip +OAUTH_TOKEN: Optional[str] = os.getenv("ANTHROPIC_OAUTH_TOKEN") + +# Skip all tests if no OAuth token is available +pytestmark = pytest.mark.skipif( + OAUTH_TOKEN is None, + reason="ANTHROPIC_OAUTH_TOKEN environment variable not set" +) + + +def _print_headers(headers: Dict[str, Any], title: str = "Headers") -> None: + """Helper to print headers in a readable format.""" + print(f"\n{title}:") + print(json.dumps(headers, indent=2)) + + +def _print_response(response: Any, title: str = "Response") -> None: + """Helper to print response in a readable format.""" + print(f"\n{title}:") + if hasattr(response, "model_dump"): + print(json.dumps(response.model_dump(), indent=2, default=str)) + elif hasattr(response, "json"): + print(json.dumps(response.json(), indent=2, default=str)) + else: + print(json.dumps(dict(response), indent=2, default=str)) + + +class TestOAuthPassThroughHeaders: + """Test OAuth header transformation for pass-through API.""" + + def test_pass_through_headers_transformation(self) -> None: + """Verify pass-through correctly transforms OAuth headers.""" + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + assert OAUTH_TOKEN is not None + config = AnthropicMessagesConfig() + headers = {"authorization": f"Bearer {OAUTH_TOKEN}"} + + updated_headers, _ = config.validate_anthropic_messages_environment( + headers=headers, + model="claude-3-haiku-20240307", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + + _print_headers(updated_headers, "Pass-through Transformed Headers") + + # Verify OAuth headers + assert "authorization" in updated_headers + assert "x-api-key" not in updated_headers + assert "oauth-2025-04-20" in updated_headers.get("anthropic-beta", "") + assert updated_headers.get("anthropic-dangerous-direct-browser-access") == "true" + + +class TestOAuthDirectApiKey: + """Test OAuth header transformation when token passed as api_key.""" + + def test_direct_api_key_headers_transformation(self) -> None: + """Verify direct api_key correctly transforms OAuth headers.""" + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + + assert OAUTH_TOKEN is not None + config = AnthropicModelInfo() + headers: Dict[str, str] = {} + + updated_headers = config.validate_environment( + headers=headers, + model="claude-3-haiku-20240307", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=OAUTH_TOKEN, + api_base=None, + ) + + _print_headers(updated_headers, "Direct api_key Transformed Headers") + + # Verify OAuth headers + assert "authorization" in updated_headers + assert "x-api-key" not in updated_headers + assert "oauth-2025-04-20" in updated_headers.get("anthropic-beta", "") + assert updated_headers.get("anthropic-dangerous-direct-browser-access") == "true" + + +class TestOAuthRealAPICalls: + """Test real API calls with OAuth token.""" + + def test_litellm_completion(self) -> None: + """Test litellm.completion() with OAuth token.""" + import litellm + + assert OAUTH_TOKEN is not None + response = litellm.completion( + model="anthropic/claude-3-haiku-20240307", + messages=[{"role": "user", "content": "Say 'OAuth test successful' in exactly 3 words"}], + api_key=OAUTH_TOKEN, + max_tokens=20, + ) + + _print_response(response, "litellm.completion Response") + + assert response.choices[0].message.content is not None + + def test_pass_through_api_call(self) -> None: + """Test direct API call with pass-through transformed headers.""" + import httpx + + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + assert OAUTH_TOKEN is not None + config = AnthropicMessagesConfig() + headers = {"authorization": f"Bearer {OAUTH_TOKEN}"} + + updated_headers, _ = config.validate_anthropic_messages_environment( + headers=headers, + model="claude-3-haiku-20240307", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + + response = httpx.post( + "https://api.anthropic.com/v1/messages", + headers=updated_headers, + json={ + "model": "claude-3-haiku-20240307", + "max_tokens": 20, + "messages": [{"role": "user", "content": "Say 'pass-through works' in 3 words"}], + }, + timeout=30.0, + ) + + print(f"\nStatus: {response.status_code}") + _print_headers(dict(response.headers), "Response Headers") + _print_response(response, "Response Body") + + assert response.status_code == 200 + assert "content" in response.json() + + +# Allow running as a script +if __name__ == "__main__": + # Get token from CLI arg if provided + if len(sys.argv) > 1: + OAUTH_TOKEN = sys.argv[1] + + if not OAUTH_TOKEN: + print("Usage: python test_anthropic_oauth_manual.py ") + print(" or: ANTHROPIC_OAUTH_TOKEN=sk-ant-oat... python test_anthropic_oauth_manual.py") + sys.exit(1) + + if not OAUTH_TOKEN.startswith("sk-ant-oat"): + print(f"Warning: Token doesn't look like an OAuth token (expected sk-ant-oat prefix)") + + print("Anthropic OAuth Token Manual Test Suite") + print(f"Token: {OAUTH_TOKEN[:15]}...{OAUTH_TOKEN[-5:]}") + print("=" * 60) + + # Run tests manually + test_passthrough = TestOAuthPassThroughHeaders() + test_passthrough.test_pass_through_headers_transformation() + + test_direct = TestOAuthDirectApiKey() + test_direct.test_direct_api_key_headers_transformation() + + test_api = TestOAuthRealAPICalls() + test_api.test_litellm_completion() + test_api.test_pass_through_api_call() + + print("\n" + "=" * 60) + print("All tests passed!")