mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
Merge 821628ac7e into e26a6450c8
This commit is contained in:
commit
98e74396cd
4 changed files with 246 additions and 4 deletions
|
|
@ -803,6 +803,7 @@ def get_api_key(
|
|||
Returns:
|
||||
Tuple[Optional[str], Optional[str]]: Tuple of the api_key and the passed_in_key
|
||||
"""
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.common_utils.http_parsing_utils import (
|
||||
_safe_get_request_query_params,
|
||||
|
|
@ -814,8 +815,24 @@ def get_api_key(
|
|||
passed_in_key = custom_litellm_key_header
|
||||
api_key = _get_bearer_token_or_received_api_key(custom_litellm_key_header)
|
||||
elif isinstance(api_key, str) and len(api_key) > 0:
|
||||
passed_in_key = api_key
|
||||
api_key = _get_bearer_token(api_key=api_key)
|
||||
bearer_api_key = _get_bearer_token(api_key=api_key)
|
||||
if (
|
||||
is_anthropic_oauth_key(bearer_api_key)
|
||||
and isinstance(anthropic_api_key_header, str)
|
||||
and len(anthropic_api_key_header) > 0
|
||||
):
|
||||
# The Authorization header carries an upstream Anthropic OAuth token
|
||||
# (`sk-ant-oat*`), which is never a LiteLLM credential. Clients like
|
||||
# the Claude Code tab in Claude Desktop send that session token
|
||||
# alongside the user's LiteLLM virtual key in `x-api-key` — use
|
||||
# `x-api-key` for proxy auth instead of failing the OAuth token's
|
||||
# lookup. The OAuth token itself stays on the request for the
|
||||
# provider passthrough handling in litellm_pre_call_utils.
|
||||
passed_in_key = anthropic_api_key_header
|
||||
api_key = anthropic_api_key_header
|
||||
else:
|
||||
passed_in_key = api_key
|
||||
api_key = bearer_api_key
|
||||
elif isinstance(azure_api_key_header, str):
|
||||
passed_in_key = azure_api_key_header
|
||||
api_key = azure_api_key_header
|
||||
|
|
|
|||
|
|
@ -2006,13 +2006,23 @@ async def add_litellm_data_to_request(
|
|||
forward_llm_auth = getattr(litellm, "forward_llm_provider_auth_headers", False)
|
||||
# Determine which header was used for authentication
|
||||
# This enables forwarding provider keys (e.g., x-api-key) when they weren't used for LiteLLM auth
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
authenticated_with_header = None
|
||||
if "x-litellm-api-key" in request.headers:
|
||||
# If x-litellm-api-key is present, it was used for auth
|
||||
authenticated_with_header = "x-litellm-api-key"
|
||||
elif "authorization" in request.headers:
|
||||
# Authorization header was used for auth
|
||||
authenticated_with_header = "authorization"
|
||||
if is_anthropic_oauth_key(request.headers.get("authorization")) and request.headers.get("x-api-key"):
|
||||
# Authorization carries an upstream Anthropic OAuth token
|
||||
# (`sk-ant-oat*`), so `user_api_key_auth.get_api_key` authenticated
|
||||
# with `x-api-key` instead (see #29190). Mirror that here so the
|
||||
# OAuth token stays forwardable to Anthropic and the LiteLLM
|
||||
# virtual key in `x-api-key` is not forwarded upstream.
|
||||
authenticated_with_header = "x-api-key"
|
||||
else:
|
||||
# Authorization header was used for auth
|
||||
authenticated_with_header = "authorization"
|
||||
else:
|
||||
# x-api-key or another header was used for auth
|
||||
authenticated_with_header = "x-api-key"
|
||||
|
|
|
|||
|
|
@ -88,6 +88,87 @@ def test_get_api_key():
|
|||
) == (api_key, passed_in_key)
|
||||
|
||||
|
||||
_ANTHROPIC_OAUTH_BEARER = "Bearer sk-ant-oat01-fake-claude-desktop-session-token"
|
||||
|
||||
|
||||
def _get_api_key_result(
|
||||
api_key=None,
|
||||
anthropic_api_key_header=None,
|
||||
custom_litellm_key_header=None,
|
||||
route="/v1/messages",
|
||||
):
|
||||
return get_api_key(
|
||||
custom_litellm_key_header=custom_litellm_key_header,
|
||||
api_key=api_key,
|
||||
azure_api_key_header=None,
|
||||
anthropic_api_key_header=anthropic_api_key_header,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
pass_through_endpoints=None,
|
||||
route=route,
|
||||
request=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
def test_get_api_key_prefers_x_api_key_over_anthropic_oauth_authorization():
|
||||
"""Claude Code (the Claude Desktop 3P tab) sends its Anthropic OAuth session
|
||||
token in `Authorization` alongside the user's LiteLLM virtual key in
|
||||
`x-api-key`. `sk-ant-oat*` is an upstream credential, never a LiteLLM key —
|
||||
proxy auth must use `x-api-key`. Regression test for #29190."""
|
||||
litellm_key = "sk-my-litellm-virtual-key"
|
||||
assert _get_api_key_result(
|
||||
api_key=_ANTHROPIC_OAUTH_BEARER,
|
||||
anthropic_api_key_header=litellm_key,
|
||||
) == (litellm_key, litellm_key)
|
||||
|
||||
|
||||
def test_get_api_key_authorization_still_wins_over_x_api_key_for_regular_keys():
|
||||
"""A regular bearer key in `Authorization` keeps precedence over `x-api-key`."""
|
||||
assert _get_api_key_result(
|
||||
api_key="Bearer sk-regular-litellm-key",
|
||||
anthropic_api_key_header="sk-some-other-key",
|
||||
) == ("sk-regular-litellm-key", "Bearer sk-regular-litellm-key")
|
||||
|
||||
|
||||
def test_get_api_key_oauth_authorization_without_x_api_key_unchanged():
|
||||
"""With no `x-api-key` fallback available, the OAuth bearer is still treated
|
||||
as the (failing) auth credential — existing behavior preserved."""
|
||||
assert _get_api_key_result(api_key=_ANTHROPIC_OAUTH_BEARER) == (
|
||||
"sk-ant-oat01-fake-claude-desktop-session-token",
|
||||
_ANTHROPIC_OAUTH_BEARER,
|
||||
)
|
||||
|
||||
|
||||
def test_get_api_key_oauth_authorization_with_empty_x_api_key_unchanged():
|
||||
"""An empty `x-api-key` is not a usable fallback credential."""
|
||||
assert _get_api_key_result(
|
||||
api_key=_ANTHROPIC_OAUTH_BEARER,
|
||||
anthropic_api_key_header="",
|
||||
) == (
|
||||
"sk-ant-oat01-fake-claude-desktop-session-token",
|
||||
_ANTHROPIC_OAUTH_BEARER,
|
||||
)
|
||||
|
||||
|
||||
def test_get_api_key_x_api_key_only_unchanged():
|
||||
"""`x-api-key` alone keeps working (Claude Code CLI / Cowork tab)."""
|
||||
litellm_key = "sk-my-litellm-virtual-key"
|
||||
assert _get_api_key_result(anthropic_api_key_header=litellm_key) == (
|
||||
litellm_key,
|
||||
litellm_key,
|
||||
)
|
||||
|
||||
|
||||
def test_get_api_key_custom_litellm_key_header_still_supersedes_oauth_handling():
|
||||
"""`x-litellm-api-key` keeps top precedence even when the OAuth + x-api-key
|
||||
combination is present."""
|
||||
assert _get_api_key_result(
|
||||
api_key=_ANTHROPIC_OAUTH_BEARER,
|
||||
anthropic_api_key_header="sk-my-litellm-virtual-key",
|
||||
custom_litellm_key_header="Bearer sk-custom-header-key",
|
||||
) == ("sk-custom-header-key", "Bearer sk-custom-header-key")
|
||||
|
||||
|
||||
def test_route_requires_auth_despite_public_for_metrics(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "require_auth_for_metrics_endpoint", True)
|
||||
|
||||
|
|
@ -1570,6 +1651,76 @@ async def test_master_key_auth_sets_via_virtual_key_marker():
|
|||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_builder_dual_headers_authenticates_via_x_api_key_when_authorization_is_anthropic_oauth():
|
||||
"""End-to-end regression test for #29190: `Authorization: Bearer sk-ant-oat...`
|
||||
(the Claude Desktop session's Anthropic OAuth token) together with a valid
|
||||
LiteLLM key in `x-api-key` must authenticate via `x-api-key` instead of
|
||||
failing the OAuth token's virtual-key lookup."""
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
|
||||
|
||||
master_key = "sk-master-key"
|
||||
|
||||
mock_cache = AsyncMock()
|
||||
mock_cache.async_get_cache = AsyncMock(return_value=None)
|
||||
mock_cache.delete_cache = MagicMock()
|
||||
|
||||
mock_proxy_logging_obj = MagicMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache = MagicMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = (
|
||||
AsyncMock()
|
||||
)
|
||||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
|
||||
_attrs_to_set = {
|
||||
"prisma_client": MagicMock(),
|
||||
"user_api_key_cache": mock_cache,
|
||||
"proxy_logging_obj": mock_proxy_logging_obj,
|
||||
"master_key": master_key,
|
||||
"general_settings": {},
|
||||
"llm_model_list": [],
|
||||
"llm_router": None,
|
||||
"open_telemetry_logger": None,
|
||||
"model_max_budget_limiter": MagicMock(),
|
||||
"user_custom_auth": None,
|
||||
"jwt_handler": None,
|
||||
"litellm_proxy_admin_name": "admin",
|
||||
}
|
||||
_original_values = {
|
||||
attr: getattr(_proxy_server_mod, attr, None) for attr in _attrs_to_set
|
||||
}
|
||||
try:
|
||||
for attr, val in _attrs_to_set.items():
|
||||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/v1/messages")
|
||||
|
||||
result = await _user_api_key_auth_builder(
|
||||
request=request,
|
||||
api_key=_ANTHROPIC_OAUTH_BEARER,
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=master_key,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
assert isinstance(result, UserAPIKeyAuth)
|
||||
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
assert result.api_key == LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
finally:
|
||||
for attr, val in _original_values.items():
|
||||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_virtual_key_auth_sets_via_virtual_key_marker():
|
||||
"""via_virtual_key gates overwrite_user_with_key_hash stamping and is
|
||||
|
|
|
|||
|
|
@ -6692,6 +6692,70 @@ async def test_add_litellm_data_to_request_redacts_oauth_header_from_logging_cop
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_dual_headers_keeps_oauth_forwardable_and_virtual_key_private():
|
||||
"""Claude Code (Desktop 3P tab) sends `authorization: Bearer sk-ant-oat...`
|
||||
plus the LiteLLM virtual key in `x-api-key`; proxy auth consumes `x-api-key`
|
||||
(#29190). The OAuth token must stay scoped for Anthropic passthrough, and the
|
||||
consumed virtual key must NOT be forwarded upstream even with
|
||||
`forward_llm_provider_auth_headers` enabled."""
|
||||
virtual_key = "sk-litellm-virtual-key-lit29190"
|
||||
request_mock = _make_request_mock(
|
||||
"/v1/messages",
|
||||
{
|
||||
"content-type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"authorization": _OAUTH_TOKEN,
|
||||
"x-api-key": virtual_key,
|
||||
},
|
||||
)
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "anthropic-claude", "messages": [{"role": "user", "content": "hello"}]},
|
||||
request=request_mock,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"forward_llm_provider_auth_headers": True},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
from litellm.litellm_core_utils.get_provider_specific_headers import (
|
||||
ProviderSpecificHeaderUtils,
|
||||
)
|
||||
|
||||
anthropic_headers = ProviderSpecificHeaderUtils.get_provider_specific_headers(
|
||||
provider_specific_header=updated["provider_specific_header"],
|
||||
custom_llm_provider="anthropic",
|
||||
)
|
||||
assert anthropic_headers["authorization"] == _OAUTH_TOKEN
|
||||
assert virtual_key not in json.dumps(updated["provider_specific_header"], default=repr)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_oauth_authorization_without_x_api_key_stays_consumed():
|
||||
"""Without an `x-api-key`, the Authorization header (even OAuth-shaped) is
|
||||
still the auth credential and is not forwarded — existing behavior."""
|
||||
request_mock = _make_request_mock(
|
||||
"/v1/messages",
|
||||
{
|
||||
"content-type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"authorization": _OAUTH_TOKEN,
|
||||
},
|
||||
)
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "anthropic-claude", "messages": [{"role": "user", "content": "hello"}]},
|
||||
request=request_mock,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"forward_llm_provider_auth_headers": True},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert "sk-ant-oat01" not in json.dumps(updated.get("provider_specific_header"), default=repr)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_keeps_every_forwarded_credential_out_of_logging_copies():
|
||||
"""Credentials kept for transport must not survive anywhere under proxy_server_request."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue