This commit is contained in:
joshua-berri 2026-10-11 12:27:46 +00:00 • committed by GitHub
commit e9483a6897
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 538 additions and 67 deletions

View file

@ -283,30 +283,31 @@ def _gateway_dcr_challenge_target(
return targets[0]
def _is_gateway_dcr_challenge_scope(
route: str,
def _cold_start_fallback(
request: Request,
request_route: str,
mcp_servers: list[str] | None,
mcp_auth_header: str | None,
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
exc: Exception,
client_ip: str | None,
) -> bool:
"""True when an unauthenticated MCP request should receive the RFC 9728 401
challenge that advertises the gateway as the authorization server.
Fires only for a genuine 401 with no client-supplied MCP auth headers (those mean
the caller is not a cold-start DCR client), on the scopes the gateway's keyless
flow serves: the aggregate ``/mcp`` endpoint, an ``x-mcp-servers``-scoped request
(the resource the client configured is still ``/mcp``), or a per-server path whose
single target advertises gateway-owned sign-in. Every other named target keeps
its existing behavior, failing closed to the original admission error."""
if not _is_litellm_auth_admission_error(exc):
return False
bearer_presented: bool,
) -> Literal["defer_to_route_challenge", "gateway_challenge"] | None:
"""Classify the structural cold-start response without inspecting an error."""
if _has_client_supplied_mcp_auth(mcp_auth_header, mcp_server_auth_headers):
return False
if len(MCPRequestHandler.extract_target_server_names_from_path(route)) == 0:
return True
return _gateway_dcr_challenge_target(route, mcp_servers, client_ip) is not None
return None
mcp_servers_from_path: Final = _parse_mcp_server_names_from_path(request_route, mcp_servers)
client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
if mcp_servers_from_path is not None and (
_is_mcp_passthrough_cold_start(mcp_servers_from_path, client_ip=client_ip)
or (not bearer_presented and _is_legacy_delegate_cold_start(mcp_servers_from_path, client_ip=client_ip))
):
return "defer_to_route_challenge"
if (
len(MCPRequestHandler.extract_target_server_names_from_path(request_route)) == 0
or _gateway_dcr_challenge_target(request_route, mcp_servers, client_ip) is not None
):
return "gateway_challenge"
return None
def _gateway_dcr_challenge(
@ -351,48 +352,45 @@ def _admission_failure_fallback(
exc: Exception,
bearer_presented: bool,
) -> UserAPIKeyAuth:
"""Map a failed LiteLLM admission to its anonymous fallback or challenge.
"""Apply the structural cold-start response to an admission error."""
if not _is_litellm_auth_admission_error(exc):
raise exc
Two fallbacks exist, both gated on a genuine 401 with no client-supplied
MCP auth headers. The pass-through cold start (RFC 9728 / MCP
Authorization spec discovery return) admits anonymously so the route's
401 emitter can produce the per-server challenge. The aggregate
gateway-DCR scope converts the failure into the gateway's own
resource_metadata challenge, with the RFC 6750 ``invalid_token`` error
code when the caller DID present a bearer (an expired gateway session
must re-authorize, not retry a dead token). Anything else re-raises the
original admission error unchanged."""
mcp_servers_from_path: Final = _parse_mcp_server_names_from_path(request_route, mcp_servers)
match _cold_start_fallback(
request,
request_route,
mcp_servers,
mcp_auth_header,
mcp_server_auth_headers,
bearer_presented,
):
case "defer_to_route_challenge":
verbose_logger.debug("MCP pass-through cold start: deferring admission to route 401 emitter")
return UserAPIKeyAuth()
case "gateway_challenge":
raise _gateway_dcr_challenge(
request,
request_route,
mcp_servers,
invalid_token=bearer_presented,
) from exc
case None:
raise exc
def _has_litellm_credential_header(headers: Headers) -> bool:
if (
mcp_servers_from_path is not None
and not _has_client_supplied_mcp_auth(mcp_auth_header, mcp_server_auth_headers)
and _is_litellm_auth_admission_error(exc)
and (
_is_mcp_passthrough_cold_start(
mcp_servers_from_path,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
)
or (
not bearer_presented
and _is_legacy_delegate_cold_start(
mcp_servers_from_path,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
)
)
)
MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY in headers
or MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_SECONDARY in headers
):
verbose_logger.debug("MCP pass-through cold start: deferring admission to route 401 emitter")
return UserAPIKeyAuth()
if _is_gateway_dcr_challenge_scope(
route=request_route,
mcp_servers=mcp_servers,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
exc=exc,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
):
raise _gateway_dcr_challenge(request, request_route, mcp_servers, invalid_token=bearer_presented) from exc
raise exc
return True
from litellm.proxy.proxy_server import general_settings # pyright: ignore[reportUnknownVariableType] # legacy map
custom_header_name: Final = OPTIONAL_STRING_ADAPTER.validate_python(
general_settings.get("litellm_key_header_name") # pyright: ignore[reportUnknownMemberType] # legacy settings
)
return custom_header_name is not None and custom_header_name in headers
@dataclass(frozen=True, slots=True)
@ -627,6 +625,21 @@ class MCPRequestHandler:
bearer_presented=True,
)
else:
from litellm.proxy.auth.auth_utils import mark_expected_credential_challenge
if not _has_litellm_credential_header(headers) and (
_cold_start_fallback(
request=request,
request_route=request_route,
mcp_servers=mcp_servers,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
bearer_presented=False,
)
is not None
):
mark_expected_credential_challenge(request)
try:
validated_user_api_key_auth = await user_api_key_auth(api_key=litellm_api_key, request=request)
except (HTTPException, ProxyException) as exc:

View file

@ -23,6 +23,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.auth_utils import ( # noqa: F401 # legacy module exports
_get_request_ip_address, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
get_request_ip_address,
is_expected_credential_challenge,
is_invalid_virtual_key_error,
mark_invalid_virtual_key_error,
normalize_request_route,
@ -193,14 +194,27 @@ class UserAPIKeyAuthExceptionHandler:
log_extra: Final = {"requester_ip": requester_ip}
is_invalid_virtual_key: Final = is_invalid_virtual_key_error(e)
is_quiet_log: Final = is_invalid_virtual_key and not litellm.log_client_error_tracebacks
logger: Final = verbose_proxy_stdout_logger if is_quiet_log else verbose_proxy_logger
is_expected_challenge: Final = is_expected_credential_challenge(e, request)
log_level: Final = (
logging.DEBUG if is_expected_challenge else logging.WARNING if is_quiet_log else logging.ERROR
)
logger: Final = (
verbose_proxy_stdout_logger if is_quiet_log and not is_expected_challenge else verbose_proxy_logger
)
exc_info: Final = (
None
if is_expected_challenge
else True
if litellm.log_client_error_tracebacks or not is_expected_client_error(e)
else None
)
logger.log(
logging.WARNING if is_quiet_log else logging.ERROR,
log_level,
"litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - %s\nRequester IP Address:%s%s",
e,
requester_ip,
_identity_log_suffix(resolved_identity),
exc_info=True if litellm.log_client_error_tracebacks or not is_expected_client_error(e) else None,
exc_info=exc_info,
extra=log_extra,
)
@ -250,8 +264,10 @@ class UserAPIKeyAuthExceptionHandler:
e = transformed_exception
final_exception: Final = mark_invalid_virtual_key_error(_as_proxy_exception(e), is_invalid_virtual_key)
# If a quiet-logged malformed-key transform yields non-401, escalate to ERROR
if is_quiet_log and str(final_exception.code) != str(status.HTTP_401_UNAUTHORIZED):
# Escalate callback-transformed quiet logs and expected challenges with non-401 codes
if (is_quiet_log or is_expected_challenge) and str(final_exception.code) != str(
status.HTTP_401_UNAUTHORIZED
):
verbose_proxy_logger.error(
"litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - %s\nRequester IP Address:%s",
final_exception,

View file

@ -41,6 +41,23 @@ from litellm.types.router import reject_server_owned_wif_params as _reject_serve
from litellm.types.utils import CustomPricingLiteLLMParams, oauth_token_exchange_litellm_params
class MissingCredentialError(Exception):
pass
EXPECTED_CREDENTIAL_CHALLENGE_STATE_KEY: Final = "litellm_expected_credential_challenge"
def mark_expected_credential_challenge(request: Request) -> None:
setattr(request.state, EXPECTED_CREDENTIAL_CHALLENGE_STATE_KEY, True)
def is_expected_credential_challenge(exception: BaseException, request: Request) -> bool:
return isinstance(exception, MissingCredentialError) and (
getattr(request.state, EXPECTED_CREDENTIAL_CHALLENGE_STATE_KEY, False) is True
)
def is_invalid_virtual_key_error(exception: BaseException | None) -> bool:
"""True when an authentication error rejects a malformed virtual key.

View file

@ -101,6 +101,7 @@ from litellm.proxy.auth.auth_object_prefetch import (
prefetch_identity_keys,
)
from litellm.proxy.auth.auth_utils import (
MissingCredentialError,
abbreviate_api_key,
fallback_target_model_name,
get_end_user_id_from_request_body,
@ -2470,7 +2471,7 @@ async def user_api_key_auth_builder(
raise Exception("No api key passed in.")
elif api_key == "":
# missing 'Bearer ' prefix
raise Exception("Malformed API Key passed in. Ensure Key has `Bearer ` prefix.")
raise MissingCredentialError("Malformed API Key passed in. Ensure Key has `Bearer ` prefix.")
if route == "/user/auth":
if general_settings.get("allow_user_auth", False) is True:

View file

@ -8,7 +8,7 @@ import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from typing import Final, Literal
from urllib.parse import parse_qs, urlsplit
import httpx
@ -31,7 +31,7 @@ from integration._support.mcp import (
)
from integration._support.mcp_grants import create_toolset
from integration._support.oauth_server import AuthorizationServer, oauth_server
from integration._support.process import owned_proxy
from integration._support.process import owned_proxy, owned_proxy_process
ADD: Final = {"a": 2, "b": 3}
CLIENT_REDIRECT: Final = "http://127.0.0.1:9/cb"
@ -357,6 +357,109 @@ def test_per_user_authorization_code_with_pkce_binds_the_token_to_the_authorizin
)
@pytest.mark.parametrize(
"path_template,server_auth_type,auth_headers,expected_status,expect_www_authenticate,"
"expected_challenge_fragment,expected_error_lines",
[
pytest.param(
"/{alias}/mcp",
"oauth2",
(),
401,
True,
"resource_metadata=",
0,
id="per-server-oauth-no-credentials",
),
pytest.param("/mcp", "oauth2", (), 401, True, None, 0, id="aggregate-oauth-no-credentials"),
pytest.param(
"/{alias}/mcp",
"oauth2",
(("Authorization", ""),),
401,
True,
None,
1,
id="oauth-empty-authorization",
),
pytest.param(
"/{alias}/mcp",
"api_key",
(),
401,
False,
None,
1,
id="non-oauth-api-key",
),
],
)
def test_mcp_cold_start_malformed_key_log_level(
gateway: Gateway,
tmp_path: Path,
path_template: str,
server_auth_type: Literal["oauth2", "api_key"],
auth_headers: tuple[tuple[str, str], ...],
expected_status: int,
expect_www_authenticate: bool,
expected_challenge_fragment: str | None,
expected_error_lines: int,
) -> None:
with mcp_peer() as peer, oauth_server() as auth, owned_proxy_process(gateway, tmp_path, {}) as owned:
with owned.gateway.scenario() as scenario:
alias: Final = "cold" + uuid.uuid4().hex[:8]
if server_auth_type == "oauth2":
_register_oauth(
scenario,
peer,
auth,
alias,
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={"client_id": "cold-client", "client_secret": "cold-secret"},
)
else:
register_mcp(
scenario,
peer,
alias,
auth_type="api_key",
credentials={"auth_value": "static-" + uuid.uuid4().hex},
extra_headers=["x-api-key"],
)
baseline_log: Final = owned.log.read_text()
request_path: Final = path_template.format(alias=alias)
request_headers: Final = [*ACCEPT.items(), *auth_headers]
request: Final = owned.gateway.client.build_request(
"POST",
request_path,
headers=request_headers,
json=INITIALIZE,
)
expected_authorization: Final = tuple(
value for name, value in auth_headers if name.lower() == "authorization"
)
assert tuple(request.headers.get_list("authorization")) == expected_authorization
response: Final = owned.gateway.client.send(request)
assert response.status_code == expected_status, response.text
challenge_header: Final = response.headers.get("www-authenticate")
assert (challenge_header is not None) is expect_www_authenticate
assert expected_challenge_fragment is None or (
challenge_header is not None and expected_challenge_fragment in challenge_header
)
request_log: Final = eventually(
owned.log.read_text,
lambda value: f"POST {request_path}" in value[len(baseline_log) :],
)
error_lines: Final = tuple(
line
for line in request_log[len(baseline_log) :].splitlines()
if "Malformed API Key" in line and "ERROR" in line
)
assert len(error_lines) == expected_error_lines, error_lines
def test_authorization_request_without_pkce_is_refused_before_reaching_the_authorization_server(
gateway: Gateway,
) -> None:

View file

@ -1,6 +1,7 @@
import contextlib
import json
import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock, patch
@ -9,6 +10,7 @@ import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.datastructures import Headers
from starlette.requests import Request
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
@ -17,15 +19,173 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
is_mcp_admitted_user_subject,
)
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
LiteLLM_ObjectPermissionTable,
ProxyException,
SpecialHeaders,
SpecialMCPServerNames,
UserAPIKeyAuth,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.types.agents import AgentCaller
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,auth_type,delegate_auth_to_upstream,pass_through,bearer_presented,mcp_auth_header,"
"mcp_server_auth_headers,mcp_servers,expected",
[
pytest.param(
"/mcp/legacy",
MCPAuth.oauth2,
True,
False,
False,
None,
None,
None,
"defer_to_route_challenge",
id="legacy-delegate-without-bearer",
),
pytest.param(
"/mcp/legacy",
MCPAuth.oauth2,
True,
False,
True,
None,
None,
None,
None,
id="legacy-delegate-with-bearer",
),
pytest.param(
"/mcp/passthrough",
None,
False,
True,
True,
None,
None,
None,
"defer_to_route_challenge",
id="pass-through-with-bearer",
),
pytest.param(
"/mcp/oauth",
MCPAuth.oauth2,
False,
False,
False,
None,
None,
None,
"gateway_challenge",
id="gateway-dcr-per-server",
),
pytest.param(
"/mcp",
None,
False,
False,
False,
None,
None,
None,
"gateway_challenge",
id="aggregate-mcp",
),
pytest.param(
"/mcp",
None,
False,
False,
False,
None,
None,
["oauth"],
"gateway_challenge",
id="aggregate-mcp-scoped-by-header",
),
pytest.param(
"/mcp/oauth",
MCPAuth.oauth2,
False,
False,
False,
"Bearer upstream",
None,
None,
None,
id="legacy-mcp-auth-header",
),
pytest.param(
"/mcp/oauth",
MCPAuth.oauth2,
False,
False,
False,
None,
{"oauth": {"Authorization": "Bearer upstream"}},
None,
None,
id="server-specific-mcp-auth-header",
),
],
)
async def test_cold_start_fallback_classifies_structural_challenges(
path: str,
auth_type: MCPAuth | None,
delegate_auth_to_upstream: bool,
pass_through: bool,
bearer_presented: bool,
mcp_auth_header: str | None,
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
mcp_servers: list[str] | None,
expected: Literal["defer_to_route_challenge", "gateway_challenge"] | None,
) -> None:
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import _cold_start_fallback
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
server_alias: Final = f"cold-start-{uuid.uuid4().hex}"
request_path: Final = f"/mcp/{server_alias}" if path != "/mcp" else path
request_mcp_servers: Final[list[str] | None] = [server_alias] if mcp_servers is not None else None
server_auth_type: Final = auth_type or (MCPAuth.oauth2 if mcp_servers is not None else MCPAuth.none)
server_row: Final = LiteLLM_MCPServerTable(
server_id=f"cold-start-{uuid.uuid4().hex}",
server_name=server_alias,
alias=server_alias,
url=None,
transport="http",
auth_type=server_auth_type,
oauth2_flow="authorization_code" if server_auth_type == MCPAuth.oauth2 else None,
delegate_auth_to_upstream=delegate_auth_to_upstream,
dcr_bridge=server_auth_type == MCPAuth.oauth2 and not delegate_auth_to_upstream,
oauth_passthrough=pass_through,
extra_headers=["Authorization"] if pass_through else [],
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
request: Final = Request({"type": "http", "method": "POST", "path": request_path, "headers": []})
try:
await global_mcp_server_manager.add_server(server_row)
registered_server: Final[MCPServer | None] = global_mcp_server_manager.get_mcp_server_by_name(server_alias)
assert isinstance(registered_server, MCPServer)
assert registered_server.is_oauth_passthrough is pass_through
result: Final = _cold_start_fallback(
request=request,
request_route=request_path,
mcp_servers=request_mcp_servers,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
bearer_presented=bearer_presented,
)
assert result == expected
finally:
global_mcp_server_manager.remove_server(server_row)
@pytest.mark.parametrize("headers,expected", [
({"Authorization": "Bearer caller.jwt.signature"}, "caller.jwt.signature"),
({"AUTHORIZATION": "bEaReR caller.jwt.signature"}, "caller.jwt.signature"),

View file

@ -1,5 +1,8 @@
import asyncio
import json
import logging
from collections.abc import Callable
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@ -29,6 +32,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import INVALID_VIRTUAL_KEY_ERROR_MARKER
from litellm.exceptions import BudgetExceededError
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy._types import (
ModelAccessDeniedProxyException,
ProxyErrorTypes,
@ -1147,6 +1151,163 @@ async def test_handle_authentication_error_keeps_internal_message_on_model_acces
assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == []
class _AuthenticationFailureCallback(CustomLogger):
def __init__(self, exception: HTTPException | None) -> None:
self._exception: Final[HTTPException | None] = exception
async def async_post_call_failure_hook(
self,
request_data: dict[str, object],
original_exception: Exception,
user_api_key_dict: UserAPIKeyAuth,
traceback_str: str | None = None,
) -> HTTPException | None:
return self._exception
@pytest.fixture
def register_authentication_failure_callback(
monkeypatch: pytest.MonkeyPatch,
) -> Callable[[HTTPException | None], _AuthenticationFailureCallback]:
def register(exception: HTTPException | None) -> _AuthenticationFailureCallback:
callback: Final = _AuthenticationFailureCallback(exception)
monkeypatch.setattr(litellm, "callbacks", [callback])
return callback
return register
async def _capture_authentication_exception(
exception: BaseException,
request: Request,
log_client_error_tracebacks: bool,
) -> ProxyException:
handler: Final = UserAPIKeyAuthExceptionHandler()
with (
patch.object(litellm, "log_client_error_tracebacks", log_client_error_tracebacks),
patch("litellm.proxy.proxy_server.general_settings", {"allow_requests_on_db_unavailable": False}),
):
try:
await handler.handle_authentication_error(exception, request, {}, "/mcp", None, "")
except ProxyException as raised:
return raised
pytest.fail("expected the authentication handler to raise a ProxyException")
@pytest.mark.asyncio
@pytest.mark.parametrize("tracebacks_enabled", [True, False])
async def test_expected_missing_credential_logs_debug_without_traceback_and_preserves_response(
tracebacks_enabled: bool,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
register_authentication_failure_callback: Callable[
[HTTPException | None], _AuthenticationFailureCallback
],
) -> None:
from litellm.proxy.auth.auth_utils import MissingCredentialError, mark_expected_credential_challenge
register_authentication_failure_callback(None)
flagged_request: Final = Request({"type": "http", "method": "POST", "path": "/mcp", "headers": []})
mark_expected_credential_challenge(flagged_request)
unflagged_request: Final = Request({"type": "http", "method": "POST", "path": "/mcp", "headers": []})
message: Final = "Malformed API Key passed in. Ensure Key has `Bearer ` prefix."
monkeypatch.setattr(verbose_proxy_logger, "propagate", True)
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
flagged_response: Final = await _capture_authentication_exception(
MissingCredentialError(message),
flagged_request,
tracebacks_enabled,
)
flagged_records: Final = tuple(
record for record in caplog.records if "user_api_key_auth(): Exception occured" in record.getMessage()
)
assert tuple(record.levelname for record in flagged_records) == ("DEBUG",)
assert flagged_records[0].exc_info is None
caplog.clear()
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
unflagged_response: Final = await _capture_authentication_exception(
MissingCredentialError(message),
unflagged_request,
tracebacks_enabled,
)
unflagged_records: Final = tuple(
record for record in caplog.records if "user_api_key_auth(): Exception occured" in record.getMessage()
)
assert tuple(record.levelname for record in unflagged_records) == ("ERROR",)
assert unflagged_records[0].exc_info is not None
assert vars(flagged_response) == vars(unflagged_response)
assert flagged_response.to_dict() == unflagged_response.to_dict()
@pytest.mark.asyncio
async def test_expected_missing_credential_callback_403_is_escalated_and_unchanged(
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
register_authentication_failure_callback: Callable[
[HTTPException | None], _AuthenticationFailureCallback
],
) -> None:
from litellm.proxy.auth.auth_utils import MissingCredentialError, mark_expected_credential_challenge
callback_exception: Final = HTTPException(status_code=403, detail="Forbidden")
register_authentication_failure_callback(callback_exception)
flagged_request: Final = Request({"type": "http", "method": "POST", "path": "/mcp", "headers": []})
mark_expected_credential_challenge(flagged_request)
unflagged_request: Final = Request({"type": "http", "method": "POST", "path": "/mcp", "headers": []})
message: Final = "Malformed API Key passed in. Ensure Key has `Bearer ` prefix."
monkeypatch.setattr(verbose_proxy_logger, "propagate", True)
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
flagged_response: Final = await _capture_authentication_exception(
MissingCredentialError(message),
flagged_request,
False,
)
flagged_records: Final = tuple(
record for record in caplog.records if "user_api_key_auth(): Exception occured" in record.getMessage()
)
assert tuple(record.levelname for record in flagged_records) == ("DEBUG", "ERROR")
assert flagged_response.code == str(status.HTTP_403_FORBIDDEN)
caplog.clear()
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
unflagged_response: Final = await _capture_authentication_exception(
MissingCredentialError(message),
unflagged_request,
False,
)
assert vars(flagged_response) == vars(unflagged_response)
assert flagged_response.to_dict() == unflagged_response.to_dict()
@pytest.mark.asyncio
async def test_expected_credential_marker_does_not_quiet_policy_rejection(
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
register_authentication_failure_callback: Callable[
[HTTPException | None], _AuthenticationFailureCallback
],
) -> None:
from litellm.proxy.auth.auth_utils import mark_expected_credential_challenge
register_authentication_failure_callback(None)
request: Final = Request({"type": "http", "method": "POST", "path": "/mcp", "headers": []})
mark_expected_credential_challenge(request)
monkeypatch.setattr(verbose_proxy_logger, "propagate", True)
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
response: Final = await _capture_authentication_exception(
HTTPException(status_code=403, detail="Policy rejected request"),
request,
False,
)
records: Final = tuple(
record for record in caplog.records if "user_api_key_auth(): Exception occured" in record.getMessage()
)
assert tuple(record.levelname for record in records) == ("ERROR",)
assert response.code == str(status.HTTP_403_FORBIDDEN)
def test_as_proxy_exception_keeps_jwt_scope_denial_message_shape():
detail = {"error": _DENIED_CLIENT_MESSAGE}
denial = ModelAccessDeniedHTTPException(