mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
refactor(mcp): make bridge-mint resolvers return tagged unions so status is truthful by construction
Three findings landed together, all one defect: a resolution step crushed several distinct outcomes into a single None or a silent default, so the mint's error mapper could not tell them apart and assigned the wrong status. Identity resolution mapped a database outage to the same None as a missing credential, which the mint reported as 400 invalid_request, blaming the caller for a gateway outage while admission statuses the same outage 503/500. Lifetime coercion mapped an explicit non-positive expires_in to the same None as an absent one, so an upstream token the IdP reports as already dead was sealed into an hour-long envelope. And the refresh_token grant was run through the upstream exchange (which can rotate the client's upstream refresh credential) and its result then discarded, even though a bridge server seals no refresh_token and the client never holds one to present. Rather than add a mapping branch per finding, the fix changes the return types so a wrong status is not representable. Each resolution step now returns a precise tagged value instead of None: identity resolution returns a _ResolvedKey or one of no_active_key / unavailable / unresolvable, classified the same way admission's _reload_admitted_key classifies the same conditions; upstream-lifetime classification returns a positive number of seconds, "unspecified" (absent or unparseable, which the envelope caps), or "expired" (a parseable non-positive value, an already-dead token); and upstream-grant validation returns a typed grant or one of no_access_token / expired_lifetime. Thin exhaustive mappers (match plus assert_never) lift each vocabulary into one bridge-mint taxonomy of eight named failures, and a single _bridge_mint_error_response gives each its truthful RFC 6749 §5.2 status: 400 for the caller's missing credential or an unsupported grant, 503 for a transient auth-DB outage, 500 for a gateway that cannot resolve identity or is not configured, and 502 for an upstream response with no usable token, an already-expired lifetime, or a token too large to seal. Adding a failure mode now requires a new literal and a match arm the type checker forces, so the class of wrong-status bug cannot recur silently. The refresh_token grant is rejected in _prepare_bridge_mint before the exchange with unsupported_grant_type, so it can never rotate or consume the client's upstream refresh credential; renewal is re-running authorization_code, as the sealed refresh_token=None already intends. An absent or unparseable expires_in still mints a capped envelope (the by-design behaviour for an upstream that omits the field); only an explicitly-dead lifetime is rejected. Tests cover the resolver's three failure classes (including a real connection-error outage and a missing prisma_client), the mint statuses for each (503 before the upstream exchange, 500, 502 on an expired upstream lifetime, and a capped mint on an unknown one), and the refresh-grant rejection before any exchange. The three findings are mutation-checked: reverting each fix turns its regression test red.
This commit is contained in:
parent
4ba7221b7a
commit
a07aba0579
2 changed files with 424 additions and 169 deletions
|
|
@ -377,74 +377,92 @@ def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> str | None:
|
|||
return key_obj.user_id if _key_is_active(key_obj) else None
|
||||
|
||||
|
||||
async def _resolve_active_litellm_key(request: Request) -> tuple[str, "UserAPIKeyAuth"] | None:
|
||||
"""Resolve the presented litellm key to ``(its hash, the live active key record)``, or ``None``
|
||||
when the key is absent, unresolvable, or blocked/expired.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ResolvedKey:
|
||||
"""An active litellm key resolved from the token request: its hash (the value ``get_key_object``
|
||||
and the cache/DB layer key the record by) and the live record."""
|
||||
|
||||
Single resolution path the OAuth token endpoint reuses. Resolves authoritatively via
|
||||
``get_key_object`` (cache first, then DB) instead of a raw cache peek. On a multi-replica gateway
|
||||
the token-exchange request can land on a worker whose in-memory cache never saw the key, and a
|
||||
cross-replica Redis hit deserializes to a plain ``dict`` rather than a ``UserAPIKeyAuth``; the
|
||||
previous code read only ``Authorization`` and did ``getattr(cached, "user_id")`` with no
|
||||
``model_type`` rehydration and no DB fallback, so it silently returned ``None``. The resolved key
|
||||
is validated (``_key_is_active``) before it is trusted, so a blocked or expired key resolves to
|
||||
``None``, while a valid team-scoped or service-account key (no ``user_id``) still resolves so it
|
||||
can mint a bridge envelope. The returned hash is the value ``get_key_object`` and the cache/DB
|
||||
layer key the record by. Callers derive the ``user_id`` (per-user token store) or seal the hash
|
||||
(dcr_bridge envelope) from the result.
|
||||
"""
|
||||
key_hash: str
|
||||
key: "UserAPIKeyAuth"
|
||||
|
||||
|
||||
_KeyResolutionFailure = Literal["no_active_key", "unavailable", "unresolvable"]
|
||||
"""Why a token request yielded no active litellm key, kept distinct so a caller statuses each truthfully
|
||||
instead of blaming the client for a gateway problem:
|
||||
- ``no_active_key``: none was presented, or the presented key is unknown / blocked / expired (the
|
||||
caller's request is at fault)
|
||||
- ``unavailable``: the auth database was transiently unreachable while resolving (retryable)
|
||||
- ``unresolvable``: the gateway cannot resolve identity right now (no DB connection, or an unexpected
|
||||
error) -- a gateway fault, not the caller's
|
||||
The classification mirrors admission's ``_reload_admitted_key`` so the mint (ingress) and admission
|
||||
(egress) never disagree on the status of the same outage."""
|
||||
|
||||
|
||||
async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyResolutionFailure":
|
||||
"""Resolve the presented litellm key to an active key record, or say precisely why not.
|
||||
|
||||
Single resolution path the OAuth token endpoint reuses, resolving authoritatively via
|
||||
``get_key_object`` (cache first, then DB). The failure is a value, not a bare ``None``, so a caller
|
||||
can tell "the client sent no usable credential" (a request error) apart from "the gateway could not
|
||||
check" (an infrastructure error) and status each truthfully; collapsing both to ``None`` is what let
|
||||
a DB outage read as a 400. A resolved key is still gated by ``_key_is_active``, so a blocked or
|
||||
expired key is ``no_active_key`` while a valid team-scoped or service-account key (no ``user_id``)
|
||||
resolves. Classification mirrors admission's ``_reload_admitted_key``: no DB connection is a gateway
|
||||
fault, a ``ProxyException`` / ``HTTPException`` from ``get_key_object`` is an unknown or invalid key,
|
||||
a database-service-unavailable error is a retryable outage, and anything else is an unexpected
|
||||
gateway fault."""
|
||||
token = _litellm_key_from_request(request)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from litellm.proxy._types import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
hash_token,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
get_key_object,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
return "no_active_key"
|
||||
from litellm.proxy._types import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
ProxyException,
|
||||
hash_token,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
get_key_object,
|
||||
)
|
||||
from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
PrismaDBExceptionHandler,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
key_hash = hash_token(token)
|
||||
if prisma_client is None:
|
||||
return "unresolvable"
|
||||
key_hash = hash_token(token)
|
||||
try:
|
||||
key_obj = await get_key_object(
|
||||
hashed_token=key_hash,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 # fail closed to None on any key-resolution error
|
||||
except (ProxyException, HTTPException):
|
||||
return "no_active_key"
|
||||
except Exception as exc: # noqa: BLE001 # classify: a DB outage is retryable, anything else is an opaque gateway fault
|
||||
if PrismaDBExceptionHandler.is_database_service_unavailable_error(exc):
|
||||
return "unavailable"
|
||||
verbose_logger.debug(
|
||||
"_resolve_active_litellm_key: could not resolve the presented key (%s)",
|
||||
"_resolve_active_litellm_key: unexpected key-resolution error (%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
return None
|
||||
return "unresolvable"
|
||||
if not _key_is_active(key_obj):
|
||||
return None
|
||||
return key_hash, key_obj
|
||||
return "no_active_key"
|
||||
return _ResolvedKey(key_hash=key_hash, key=key_obj)
|
||||
|
||||
|
||||
async def _extract_user_id_from_request(request: Request) -> str | None:
|
||||
"""The LiteLLM ``user_id`` for the token request, so a per-user token is stored under the same
|
||||
identity the egress later reads it by (``user_api_key_auth.user_id``). ``None`` when no active
|
||||
key is present. See :func:`_resolve_active_litellm_key` for the resolution and active-key gate.
|
||||
"""
|
||||
"""The litellm ``user_id`` for the token request, so a per-user token is stored under the same
|
||||
identity the egress later reads it by. Storage is best-effort, so every non-resolved outcome
|
||||
(including a transient DB outage) collapses to ``None`` here and the caller simply skips the store;
|
||||
the bridge mint, which must status those outcomes differently, consumes
|
||||
:func:`_resolve_active_litellm_key` directly."""
|
||||
resolved = await _resolve_active_litellm_key(request)
|
||||
return _active_key_user_id(resolved[1]) if resolved else None
|
||||
|
||||
|
||||
async def _extract_active_key_hash_from_request(request: Request) -> str | None:
|
||||
"""The hash of the litellm key that authorized the token request, when it maps to an active key.
|
||||
|
||||
A DCR-bridge envelope seals this hash so admission can reload the live ``UserAPIKeyAuth`` record
|
||||
and enforce the key's current team/org/tool restrictions and revocation, rather than trusting a
|
||||
frozen identity. The hash is a one-way digest, not a usable credential (the edge rejects a bare
|
||||
hash presented as a bearer). ``None`` when no active key is present, so no envelope is minted for
|
||||
a missing, unresolvable, or revoked key.
|
||||
"""
|
||||
resolved = await _resolve_active_litellm_key(request)
|
||||
return resolved[0] if resolved else None
|
||||
if not isinstance(resolved, _ResolvedKey):
|
||||
return None
|
||||
return _active_key_user_id(resolved.key)
|
||||
|
||||
|
||||
async def _store_per_user_token_server_side(
|
||||
|
|
@ -713,38 +731,51 @@ async def authorize_with_server(
|
|||
return response
|
||||
|
||||
|
||||
def _coerce_positive_expires_in(value: object) -> int | None:
|
||||
"""Coerce an upstream ``expires_in`` to a positive int, or ``None`` when it is absent or not a
|
||||
usable number. IdPs return it as an int, a float (``3600.0``), or a numeric string (``"3600"``);
|
||||
accepting only ``int`` would drop the float/string cases to ``None`` and fall back to the
|
||||
envelope's 1h cap, which can outlive a shorter-lived upstream token and forward a stale bearer.
|
||||
``bool`` is excluded (it is an ``int`` subclass but never a real lifetime). Total over hostile
|
||||
input: a non-numeric string, ``NaN``, ``Infinity``, or an over-large value all resolve to
|
||||
``None`` rather than raising (``int(float(...))`` can raise ``ValueError`` or ``OverflowError``),
|
||||
so a malformed upstream ``expires_in`` never surfaces as a 500 from the token endpoint."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
return None
|
||||
_UpstreamGrantRejection = Literal["no_access_token", "expired_lifetime"]
|
||||
"""Why an upstream token response cannot back a bridge envelope:
|
||||
- ``no_access_token``: the response carries no usable ``access_token``
|
||||
- ``expired_lifetime``: the response reports a parseable, non-positive ``expires_in``, i.e. an upstream
|
||||
token that is already dead, so sealing it would forward a bearer the edge cannot use
|
||||
An absent or unparseable ``expires_in`` is NOT a rejection; the lifetime is merely unknown and the
|
||||
envelope caps it, the by-design behaviour for an upstream that omits the field."""
|
||||
|
||||
|
||||
def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']":
|
||||
"""Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent
|
||||
or unparseable, so the envelope caps it), or ``"expired"`` (a parseable non-positive value the
|
||||
upstream reports as already elapsed). Telling "we do not know the lifetime" apart from "the upstream
|
||||
says it is already dead" is what stops an explicitly-expired token from silently receiving the
|
||||
envelope's 1h cap. ``bool`` is excluded (an ``int`` subclass but never a real lifetime), and
|
||||
``int(float(...))`` can raise on ``NaN`` / ``Infinity`` / oversized input, which reads as
|
||||
unparseable rather than surfacing as a 500."""
|
||||
if raw_expires_in is None or isinstance(raw_expires_in, bool) or not isinstance(raw_expires_in, (int, float, str)):
|
||||
return "unspecified"
|
||||
try:
|
||||
seconds = int(float(value))
|
||||
seconds = int(float(raw_expires_in))
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return None
|
||||
return seconds if seconds > 0 else None
|
||||
return "unspecified"
|
||||
return seconds if seconds > 0 else "expired"
|
||||
|
||||
|
||||
def _bridge_grant_from_token_response(token_response: object) -> Optional["UpstreamTokenGrant"]:
|
||||
"""Validate an upstream OAuth token response into a typed grant, or None when it lacks a usable
|
||||
access token. Each field is isinstance-checked so nothing untyped from ``response.json()`` flows
|
||||
into the grant; ``expires_in`` is numerically coerced so a float/string lifetime is honored
|
||||
rather than dropped to the envelope's default cap."""
|
||||
def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenGrant | _UpstreamGrantRejection":
|
||||
"""Validate an upstream OAuth token response into a typed grant, or say why it cannot back an
|
||||
envelope. Each field is isinstance-checked so nothing untyped from ``response.json()`` reaches the
|
||||
grant. ``expires_in`` is read three ways (see :func:`_classify_upstream_lifetime`): an unknown
|
||||
lifetime leaves the grant ``expires_in`` ``None`` for the envelope to cap, a positive value is
|
||||
honoured, and an explicit already-elapsed value is a rejection rather than a silent fall-through to
|
||||
the cap."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
UpstreamTokenGrant,
|
||||
)
|
||||
|
||||
if not isinstance(token_response, dict):
|
||||
return None
|
||||
return "no_access_token"
|
||||
access = token_response.get("access_token")
|
||||
if not isinstance(access, str) or not access:
|
||||
return None
|
||||
return "no_access_token"
|
||||
lifetime = _classify_upstream_lifetime(token_response.get("expires_in"))
|
||||
if lifetime == "expired":
|
||||
return "expired_lifetime"
|
||||
token_type = token_response.get("token_type")
|
||||
scope = token_response.get("scope")
|
||||
return UpstreamTokenGrant(
|
||||
|
|
@ -756,7 +787,7 @@ def _bridge_grant_from_token_response(token_response: object) -> Optional["Upstr
|
|||
# follow-up (a dedicated refresh-envelope); the client re-runs authorization_code at the cap.
|
||||
refresh_token=None,
|
||||
scope=scope if isinstance(scope, str) and scope else None,
|
||||
expires_in=_coerce_positive_expires_in(token_response.get("expires_in")),
|
||||
expires_in=lifetime if isinstance(lifetime, int) else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -774,7 +805,16 @@ def _bridge_grant_from_token_response(token_response: object) -> Optional["Upstr
|
|||
# shape are uniform. Adding a failure mode is a new literal plus a match arm the type checker forces.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BridgeMintError = Literal["not_configured", "no_identity", "no_upstream_token", "too_large"]
|
||||
_BridgeMintError = Literal[
|
||||
"no_identity",
|
||||
"unsupported_grant",
|
||||
"identity_unavailable",
|
||||
"identity_unresolvable",
|
||||
"not_configured",
|
||||
"no_upstream_token",
|
||||
"upstream_token_expired",
|
||||
"too_large",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -788,45 +828,105 @@ class _BridgeMintReady:
|
|||
|
||||
|
||||
def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse:
|
||||
"""Map a bridge-mint failure value to its token-endpoint response. One place, RFC 6749 §5.2 shape
|
||||
(top-level ``error``, no-store) for every case, with a status truthful about where the failure is:
|
||||
the caller's request (400), the gateway config (500), or the upstream (502)."""
|
||||
if error == "no_identity":
|
||||
status, code, desc = (
|
||||
400,
|
||||
"invalid_request",
|
||||
(
|
||||
"""Map a bridge-mint failure value to its token-endpoint response: one place, RFC 6749 §5.2 shape
|
||||
(top-level ``error``, no-store headers) for every case, with a status truthful about where the
|
||||
failure is. The caller's request is 400, a transient gateway outage is 503, a gateway
|
||||
misconfiguration is 500, and an upstream problem is 502. The identity-resolution statuses match how
|
||||
admission statuses the same conditions on the egress side, so mint and admit never disagree under
|
||||
one outage."""
|
||||
match error:
|
||||
case "no_identity":
|
||||
status, code, desc = (
|
||||
400,
|
||||
"invalid_request",
|
||||
"this server issues a gateway-bound credential; send a litellm credential "
|
||||
"(x-litellm-api-key or Authorization) on the token request"
|
||||
),
|
||||
)
|
||||
elif error == "not_configured":
|
||||
status, code, desc = (
|
||||
500,
|
||||
"server_error",
|
||||
("the gateway is not configured to mint a gateway-bound credential (master_key is not set)"),
|
||||
)
|
||||
elif error == "no_upstream_token":
|
||||
status, code, desc = 502, "server_error", "the upstream token response has no usable access_token"
|
||||
elif error == "too_large":
|
||||
status, code, desc = (
|
||||
502,
|
||||
"server_error",
|
||||
("the upstream token is too large to seal into a gateway-bound credential"),
|
||||
)
|
||||
else:
|
||||
assert_never(error)
|
||||
"(x-litellm-api-key or Authorization) on the token request",
|
||||
)
|
||||
case "unsupported_grant":
|
||||
status, code, desc = (
|
||||
400,
|
||||
"unsupported_grant_type",
|
||||
"this server issues a gateway-bound credential and supports only the authorization_code "
|
||||
"grant; re-run authorization_code to renew rather than refresh_token",
|
||||
)
|
||||
case "identity_unavailable":
|
||||
status, code, desc = (
|
||||
503,
|
||||
"temporarily_unavailable",
|
||||
"the authentication database is temporarily unreachable; retry shortly",
|
||||
)
|
||||
case "identity_unresolvable":
|
||||
status, code, desc = (
|
||||
500,
|
||||
"server_error",
|
||||
"the gateway could not resolve the litellm identity for this request",
|
||||
)
|
||||
case "not_configured":
|
||||
status, code, desc = (
|
||||
500,
|
||||
"server_error",
|
||||
"the gateway is not configured to mint a gateway-bound credential (master_key is not set)",
|
||||
)
|
||||
case "no_upstream_token":
|
||||
status, code, desc = (
|
||||
502,
|
||||
"server_error",
|
||||
"the upstream token response has no usable access_token",
|
||||
)
|
||||
case "upstream_token_expired":
|
||||
status, code, desc = (
|
||||
502,
|
||||
"server_error",
|
||||
"the upstream token response reports an already-expired lifetime",
|
||||
)
|
||||
case "too_large":
|
||||
status, code, desc = (
|
||||
502,
|
||||
"server_error",
|
||||
"the upstream token is too large to seal into a gateway-bound credential",
|
||||
)
|
||||
case _:
|
||||
assert_never(error)
|
||||
return JSONResponse(
|
||||
status_code=status, content={"error": code, "error_description": desc}, headers=TOKEN_NO_CACHE_HEADERS
|
||||
)
|
||||
|
||||
|
||||
async def _prepare_bridge_mint(request: Request) -> "_BridgeMintReady | _BridgeMintError":
|
||||
"""Phase 1, BEFORE the upstream exchange: validate that the gateway can mint (master_key set) and
|
||||
that the request carries a resolvable litellm identity, and derive the envelope keys. Returns a
|
||||
ready context or a failure value. Running before the exchange is what makes a missing master_key or
|
||||
an unresolvable identity fail closed without consuming the single-use code / rotating a refresh
|
||||
token, for both grant types."""
|
||||
def _key_resolution_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError:
|
||||
"""Lift an identity-resolution failure into the mint taxonomy, preserving origin so the status stays
|
||||
truthful: the caller's missing credential is 400, a transient DB outage is 503, and a gateway that
|
||||
cannot resolve identity is 500."""
|
||||
match failure:
|
||||
case "no_active_key":
|
||||
return "no_identity"
|
||||
case "unavailable":
|
||||
return "identity_unavailable"
|
||||
case "unresolvable":
|
||||
return "identity_unresolvable"
|
||||
case _:
|
||||
assert_never(failure)
|
||||
|
||||
|
||||
def _upstream_rejection_to_mint_error(rejection: _UpstreamGrantRejection) -> _BridgeMintError:
|
||||
"""Lift an upstream-response rejection into the mint taxonomy; both are upstream faults (502)."""
|
||||
match rejection:
|
||||
case "no_access_token":
|
||||
return "no_upstream_token"
|
||||
case "expired_lifetime":
|
||||
return "upstream_token_expired"
|
||||
case _:
|
||||
assert_never(rejection)
|
||||
|
||||
|
||||
async def _prepare_bridge_mint(request: Request, grant_type: str) -> "_BridgeMintReady | _BridgeMintError":
|
||||
"""Phase 1, BEFORE the upstream exchange: reject a grant this mint does not support, confirm the
|
||||
gateway can mint (master_key set), resolve the litellm identity, and derive the envelope keys.
|
||||
Returns a ready context or a precise failure value. Running before the exchange is what makes every
|
||||
failure here fail closed without consuming the single-use code or rotating a refresh token. A bridge
|
||||
server issues only envelopes and seals no upstream refresh_token, so the client holds none to
|
||||
present: the refresh_token grant is rejected up front rather than exchanged (which could rotate the
|
||||
upstream credential) and its result then discarded. Identity-resolution failures keep their origin
|
||||
so the mapper statuses each truthfully."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
envelope_keys_from_master_key,
|
||||
)
|
||||
|
|
@ -834,12 +934,14 @@ async def _prepare_bridge_mint(request: Request) -> "_BridgeMintReady | _BridgeM
|
|||
master_key,
|
||||
)
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return "unsupported_grant"
|
||||
if not master_key:
|
||||
return "not_configured"
|
||||
key_hash = await _extract_active_key_hash_from_request(request)
|
||||
if not key_hash:
|
||||
return "no_identity"
|
||||
return _BridgeMintReady(key_hash=key_hash, keys=envelope_keys_from_master_key(master_key))
|
||||
resolved = await _resolve_active_litellm_key(request)
|
||||
if not isinstance(resolved, _ResolvedKey):
|
||||
return _key_resolution_failure_to_mint_error(resolved)
|
||||
return _BridgeMintReady(key_hash=resolved.key_hash, keys=envelope_keys_from_master_key(master_key))
|
||||
|
||||
|
||||
def _finish_bridge_mint(
|
||||
|
|
@ -848,18 +950,20 @@ def _finish_bridge_mint(
|
|||
"""Phase 3, AFTER the upstream exchange: seal the upstream grant into the client-held envelope using
|
||||
the pre-resolved identity and keys, so the client holds one bearer that admits it and forwards the
|
||||
upstream token with nothing stored server-side. The only failures here are properties of the
|
||||
upstream response (no usable token, or a token too large to seal), returned as values."""
|
||||
upstream response (no usable token, an already-expired lifetime, or a token too large to seal),
|
||||
returned as values."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
build_bridge_token_response,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
EnvelopeIdentity,
|
||||
SealedEnvelope,
|
||||
UpstreamTokenGrant,
|
||||
)
|
||||
|
||||
grant = _bridge_grant_from_token_response(token_response)
|
||||
if grant is None:
|
||||
return "no_upstream_token"
|
||||
if not isinstance(grant, UpstreamTokenGrant):
|
||||
return _upstream_rejection_to_mint_error(grant)
|
||||
identity = EnvelopeIdentity(server_id=mcp_server.server_id, key_hash=ready.key_hash)
|
||||
sealed = build_bridge_token_response(identity, grant, ready.keys, now)
|
||||
if not isinstance(sealed, SealedEnvelope):
|
||||
|
|
@ -949,7 +1053,7 @@ async def exchange_token_with_server(
|
|||
# phase 3. A failure here returns without ever touching the upstream credential.
|
||||
bridge_mint_ready: _BridgeMintReady | None = None
|
||||
if mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge:
|
||||
prepared = await _prepare_bridge_mint(request)
|
||||
prepared = await _prepare_bridge_mint(request, grant_type)
|
||||
if not isinstance(prepared, _BridgeMintReady):
|
||||
return _bridge_mint_error_response(prepared)
|
||||
bridge_mint_ready = prepared
|
||||
|
|
|
|||
|
|
@ -4367,6 +4367,7 @@ _BRIDGE_MASTER_KEY = "sk-bridge-producer-master-key-0123456789abcdef"
|
|||
|
||||
async def _exchange_for_bridge_server(server, upstream_body, key_hash, fake_client_out=None):
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_ResolvedKey,
|
||||
exchange_token_with_server,
|
||||
)
|
||||
|
||||
|
|
@ -4375,7 +4376,10 @@ async def _exchange_for_bridge_server(server, upstream_body, key_hash, fake_clie
|
|||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
key_resolver = AsyncMock(return_value=key_hash)
|
||||
# The mint consumes _resolve_active_litellm_key's tagged result: an active key resolves to a
|
||||
# _ResolvedKey carrying its hash; a request with no usable credential resolves to "no_active_key".
|
||||
resolution = _ResolvedKey(key_hash=key_hash, key=MagicMock()) if key_hash is not None else "no_active_key"
|
||||
key_resolver = AsyncMock(return_value=resolution)
|
||||
if fake_client_out is not None:
|
||||
fake_client_out["client"] = fake_http_client
|
||||
|
||||
|
|
@ -4385,7 +4389,7 @@ async def _exchange_for_bridge_server(server, upstream_body, key_hash, fake_clie
|
|||
return_value=fake_http_client,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._extract_active_key_hash_from_request",
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_active_litellm_key",
|
||||
new=key_resolver,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
||||
|
|
@ -4511,10 +4515,12 @@ async def test_bridge_envelope_does_not_seal_upstream_refresh_token():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_refresh_grant_fails_closed_before_upstream_when_no_identity():
|
||||
"""The pre-exchange identity gate covers the refresh_token grant, not just authorization_code: an
|
||||
unresolvable litellm identity fails closed with invalid_request BEFORE the upstream refresh is
|
||||
exchanged, so the client's refresh token is not rotated/consumed on a rejected request."""
|
||||
async def test_bridge_refresh_grant_is_rejected_before_upstream():
|
||||
"""A bridge oauth_delegate server issues only envelopes and seals no upstream refresh_token, so the
|
||||
client never holds one to present. _prepare_bridge_mint rejects the refresh_token grant up front
|
||||
with unsupported_grant_type, BEFORE any upstream exchange, so a stray refresh request can never
|
||||
rotate or consume the client's upstream refresh credential; renewal is re-running
|
||||
authorization_code. This is checked before identity resolution, so it holds even with a valid key."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
|
|
@ -4526,10 +4532,6 @@ async def test_bridge_refresh_grant_fails_closed_before_upstream_when_no_identit
|
|||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=fake_http_client,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._extract_active_key_hash_from_request",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
||||
):
|
||||
response = await exchange_token_with_server(
|
||||
|
|
@ -4545,7 +4547,7 @@ async def test_bridge_refresh_grant_fails_closed_before_upstream_when_no_identit
|
|||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body)["error"] == "invalid_request"
|
||||
assert json.loads(response.body)["error"] == "unsupported_grant_type"
|
||||
fake_http_client.post.assert_not_called()
|
||||
|
||||
|
||||
|
|
@ -4567,8 +4569,8 @@ async def test_bridge_mint_fails_closed_before_upstream_when_master_key_unset():
|
|||
return_value=fake_http_client,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._extract_active_key_hash_from_request",
|
||||
new=AsyncMock(return_value="hashed-litellm-key-77"),
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_active_litellm_key",
|
||||
new=AsyncMock(return_value="no_active_key"),
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.master_key", None),
|
||||
):
|
||||
|
|
@ -4588,6 +4590,93 @@ async def test_bridge_mint_fails_closed_before_upstream_when_master_key_unset():
|
|||
fake_http_client.post.assert_not_called()
|
||||
|
||||
|
||||
async def _prepare_only_bridge_exchange(resolver_result):
|
||||
"""Drive exchange_token_with_server for a bridge oauth_delegate authorization_code request with the
|
||||
identity resolver stubbed to a given tagged result, returning (response, post_mock) so a test can
|
||||
assert the mapped status and that the single-use code was never exchanged."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=fake_http_client,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_active_litellm_key",
|
||||
new=AsyncMock(return_value=resolver_result),
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
||||
):
|
||||
response = await exchange_token_with_server(
|
||||
request=_bridge_mock_request(),
|
||||
mcp_server=server,
|
||||
grant_type="authorization_code",
|
||||
code="auth-code",
|
||||
redirect_uri="https://claude.ai/api/mcp/auth_callback",
|
||||
client_id="dcr-client-123",
|
||||
client_secret=None,
|
||||
code_verifier="verifier",
|
||||
)
|
||||
return response, fake_http_client.post
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_mint_db_outage_is_503_before_upstream():
|
||||
"""A DB outage while resolving identity is a retryable gateway failure, so the mint returns 503
|
||||
temporarily_unavailable WITHOUT consuming the single-use code, matching how admission statuses the
|
||||
same outage on the egress side. Collapsing every resolution failure to None used to blame the
|
||||
client with 400 invalid_request for an infrastructure problem."""
|
||||
response, post = await _prepare_only_bridge_exchange("unavailable")
|
||||
assert response.status_code == 503
|
||||
assert json.loads(response.body)["error"] == "temporarily_unavailable"
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_mint_unresolvable_identity_is_500_before_upstream():
|
||||
"""An unresolvable identity (no DB connection, or an unexpected resolution error) is a gateway
|
||||
fault, so the mint returns 500 server_error before the exchange, a status distinct from both the
|
||||
caller's 400 and the transient 503, matching admission's 500-vs-503 split for the same conditions."""
|
||||
response, post = await _prepare_only_bridge_exchange("unresolvable")
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.body)["error"] == "server_error"
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_mint_upstream_expired_lifetime_is_502():
|
||||
"""An upstream token response reporting an already-elapsed lifetime (a parseable non-positive
|
||||
expires_in) is rejected with 502 rather than sealed into an hour-long envelope around a dead
|
||||
bearer. Regression for expires_in<=0 silently falling through to the 1h cap."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0}
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
||||
assert response.status_code == 502
|
||||
assert json.loads(response.body)["error"] == "server_error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_mint_unknown_lifetime_is_capped_not_rejected():
|
||||
"""An absent or unparseable expires_in leaves the lifetime unknown, which the envelope caps (never
|
||||
inventing a longer life than the upstream stated); it is NOT rejected. Only an explicitly-dead
|
||||
lifetime fails, so a metadata glitch on an otherwise-valid token still mints a bounded envelope."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": "not-a-number"}
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
assert body["access_token"].startswith("llm_env_")
|
||||
assert 0 < body["expires_in"] <= 3600
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_reported_expires_in_does_not_overstate_jwt_exp():
|
||||
"""The reported expires_in is derived from the envelope JWT's second-truncated exp (rounding the
|
||||
|
|
@ -4638,34 +4727,51 @@ def test_bridge_reported_expires_in_can_be_zero_at_jwt_exp_boundary():
|
|||
assert json.loads(response.body)["expires_in"] == 0
|
||||
|
||||
|
||||
def test_bridge_grant_coerces_numeric_expires_in():
|
||||
"""expires_in from an IdP may be an int, a float (3600.0), or a numeric string ("3600"); coerce
|
||||
it to a positive int so the envelope TTL honors the real lifetime instead of dropping a non-int
|
||||
value and defaulting to the 1h cap (which can outlive a shorter-lived upstream token). bool and
|
||||
non-numeric values become None."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_bridge_grant_from_token_response,
|
||||
)
|
||||
def test_classify_upstream_lifetime():
|
||||
"""expires_in from an IdP may be an int, a float (3600.0), or a numeric string ("3600"); each
|
||||
coerces to a positive number of seconds. Absent or unparseable input (bool, non-numeric, NaN/inf,
|
||||
oversized) is "unspecified" so the envelope caps it, while a parseable non-positive value is
|
||||
"expired": the upstream reporting an already-dead token, which the mint must reject rather than
|
||||
silently give the 1h cap."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _classify_upstream_lifetime
|
||||
|
||||
def ei(v):
|
||||
return _bridge_grant_from_token_response({"access_token": "x", "expires_in": v}).expires_in
|
||||
assert _classify_upstream_lifetime(300) == 300
|
||||
assert _classify_upstream_lifetime(300.0) == 300
|
||||
assert _classify_upstream_lifetime("300") == 300
|
||||
assert _classify_upstream_lifetime(" 300 ") == 300
|
||||
# explicit, parseable, non-positive -> the upstream says the token is already dead
|
||||
assert _classify_upstream_lifetime(0) == "expired"
|
||||
assert _classify_upstream_lifetime(-5) == "expired"
|
||||
# unknown lifetime -> cap (never invent a longer life than the upstream stated)
|
||||
assert _classify_upstream_lifetime(None) == "unspecified"
|
||||
assert _classify_upstream_lifetime(True) == "unspecified"
|
||||
assert _classify_upstream_lifetime("nope") == "unspecified"
|
||||
# hostile numerics must not raise (int(float(...)) can OverflowError) -> unspecified
|
||||
assert _classify_upstream_lifetime("inf") == "unspecified"
|
||||
assert _classify_upstream_lifetime("1e999") == "unspecified"
|
||||
assert _classify_upstream_lifetime("-inf") == "unspecified"
|
||||
assert _classify_upstream_lifetime("nan") == "unspecified"
|
||||
assert _classify_upstream_lifetime(float("inf")) == "unspecified"
|
||||
assert _classify_upstream_lifetime(10**400) == "unspecified"
|
||||
|
||||
assert ei(300) == 300
|
||||
assert ei(300.0) == 300
|
||||
assert ei("300") == 300
|
||||
assert ei(" 300 ") == 300
|
||||
assert ei(True) is None
|
||||
assert ei("nope") is None
|
||||
assert ei(0) is None
|
||||
assert ei(-5) is None
|
||||
assert ei(None) is None
|
||||
# hostile numerics must not raise (int(float(...)) can OverflowError) -> None
|
||||
assert ei("inf") is None
|
||||
assert ei("1e999") is None
|
||||
assert ei("-inf") is None
|
||||
assert ei("nan") is None
|
||||
assert ei(float("inf")) is None
|
||||
assert ei(10**400) is None
|
||||
|
||||
def test_bridge_grant_honors_and_rejects_upstream_lifetime():
|
||||
"""The grant validator honors a positive lifetime, leaves an unknown one None for the envelope to
|
||||
cap, and rejects an explicitly-expired one with "expired_lifetime" so a dead upstream token is
|
||||
never sealed into an hour-long envelope."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _bridge_grant_from_token_response
|
||||
|
||||
def grant(v):
|
||||
return _bridge_grant_from_token_response({"access_token": "x", "expires_in": v})
|
||||
|
||||
assert grant(300).expires_in == 300
|
||||
assert grant(120.0).expires_in == 120
|
||||
# unknown lifetime backs a grant whose expires_in the envelope caps; it is not a rejection
|
||||
assert grant("nope").expires_in is None
|
||||
assert _bridge_grant_from_token_response({"access_token": "x"}).expires_in is None
|
||||
# an explicitly already-dead lifetime is rejected, not silently capped at 1h
|
||||
assert grant(0) == "expired_lifetime"
|
||||
assert grant(-5) == "expired_lifetime"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -5073,13 +5179,14 @@ async def test_extract_user_id_rejects_expired_key(proxy_globals):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_active_key_hash_returns_hash_for_active_key(proxy_globals):
|
||||
async def test_resolve_active_litellm_key_returns_resolved_key_for_active_key(proxy_globals):
|
||||
"""The dcr_bridge mint seals the hash of the authorizing key so admission can reload the live
|
||||
record. For an active key the resolver returns exactly hash_token(key), the same value
|
||||
get_key_object and the whole cache/DB layer key the record by, so the sealed reference resolves
|
||||
back to this key at admission."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_extract_active_key_hash_from_request,
|
||||
_resolve_active_litellm_key,
|
||||
_ResolvedKey,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
|
@ -5095,19 +5202,22 @@ async def test_extract_active_key_hash_returns_hash_for_active_key(proxy_globals
|
|||
proxy_globals.prisma_client = object()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
|
||||
assert await _extract_active_key_hash_from_request(request) == hash_token(key)
|
||||
resolved = await _resolve_active_litellm_key(request)
|
||||
assert isinstance(resolved, _ResolvedKey)
|
||||
assert resolved.key_hash == hash_token(key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_active_key_hash_returns_hash_for_active_key_without_user_id(proxy_globals):
|
||||
async def test_resolve_active_litellm_key_resolves_key_without_user_id(proxy_globals):
|
||||
"""A valid team-scoped or service-account key has no user_id but is a legitimate credential, so it
|
||||
must still resolve to a hash and be able to mint a bridge envelope. Gating the resolver on user_id
|
||||
presence wrongly rejected these keys with invalid_request; the active-state gate now checks only
|
||||
blocked and expiry, and the key hash (not the user) is what the mint seals. The per-user token
|
||||
store still gets no user for such a key, since there is none to key a stored credential by."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_extract_active_key_hash_from_request,
|
||||
_extract_user_id_from_request,
|
||||
_resolve_active_litellm_key,
|
||||
_ResolvedKey,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
|
@ -5123,16 +5233,18 @@ async def test_extract_active_key_hash_returns_hash_for_active_key_without_user_
|
|||
proxy_globals.prisma_client = object()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
|
||||
assert await _extract_active_key_hash_from_request(request) == hash_token(key)
|
||||
resolved = await _resolve_active_litellm_key(request)
|
||||
assert isinstance(resolved, _ResolvedKey)
|
||||
assert resolved.key_hash == hash_token(key)
|
||||
assert await _extract_user_id_from_request(request) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_active_key_hash_rejects_blocked_key(proxy_globals):
|
||||
async def test_resolve_active_litellm_key_rejects_blocked_key(proxy_globals):
|
||||
"""A blocked key must not yield a hash, so no gateway-bound envelope is minted for a revoked key;
|
||||
the mint fails closed with invalid_request instead."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_extract_active_key_hash_from_request,
|
||||
_resolve_active_litellm_key,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
|
@ -5145,17 +5257,17 @@ async def test_extract_active_key_hash_rejects_blocked_key(proxy_globals):
|
|||
proxy_globals.prisma_client = _FakePrisma()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": "sk-blocked-key"})
|
||||
assert await _extract_active_key_hash_from_request(request) is None
|
||||
assert await _resolve_active_litellm_key(request) == "no_active_key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_active_key_hash_fails_closed_on_malformed_expiry(proxy_globals):
|
||||
async def test_resolve_active_litellm_key_fails_closed_on_malformed_expiry(proxy_globals):
|
||||
"""A key whose stored expires string does not parse must fail closed to no-hash (the mint then
|
||||
returns invalid_request), not surface an unhandled 500. The active-state check runs outside the
|
||||
resolver's try, so it must be total over a bad expires rather than letting datetime.fromisoformat
|
||||
raise. Before the fix this raised a ValueError instead of returning None."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_extract_active_key_hash_from_request,
|
||||
_resolve_active_litellm_key,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
|
@ -5168,14 +5280,14 @@ async def test_extract_active_key_hash_fails_closed_on_malformed_expiry(proxy_gl
|
|||
proxy_globals.prisma_client = _FakePrisma()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": "sk-bad-expiry-key"})
|
||||
assert await _extract_active_key_hash_from_request(request) is None
|
||||
assert await _resolve_active_litellm_key(request) == "no_active_key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_active_key_hash_none_without_litellm_key(proxy_globals):
|
||||
async def test_resolve_active_litellm_key_no_active_key_without_litellm_key(proxy_globals):
|
||||
"""No LiteLLM key on the request yields no hash without consulting the resolver."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_extract_active_key_hash_from_request,
|
||||
_resolve_active_litellm_key,
|
||||
)
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
|
|
@ -5183,7 +5295,46 @@ async def test_extract_active_key_hash_none_without_litellm_key(proxy_globals):
|
|||
proxy_globals.prisma_client = object()
|
||||
|
||||
request = _token_request({"content-type": "application/json"})
|
||||
assert await _extract_active_key_hash_from_request(request) is None
|
||||
assert await _resolve_active_litellm_key(request) == "no_active_key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_active_litellm_key_db_outage_is_unavailable(proxy_globals):
|
||||
"""A database outage while resolving the presented key is a retryable infrastructure failure, not
|
||||
the caller's fault, so the resolver reports "unavailable" (the mint statuses it 503) rather than
|
||||
collapsing it to the same value as a missing credential. is_database_service_unavailable_error
|
||||
classifies a connection error (an OSError) as an outage, matching admission's egress-side handling."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_resolve_active_litellm_key,
|
||||
)
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
class _OutagePrisma:
|
||||
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
||||
raise ConnectionError("connection refused")
|
||||
|
||||
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
||||
proxy_globals.prisma_client = _OutagePrisma()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": "sk-during-outage"})
|
||||
assert await _resolve_active_litellm_key(request) == "unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_active_litellm_key_no_database_is_unresolvable(proxy_globals):
|
||||
"""With no database connection configured the gateway cannot verify the presented key at all, so
|
||||
the resolver reports "unresolvable" (the mint statuses it 500) instead of blaming the caller.
|
||||
Mirrors admission, which 500s a missing prisma_client on the egress side."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_resolve_active_litellm_key,
|
||||
)
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
||||
proxy_globals.prisma_client = None
|
||||
|
||||
request = _token_request({"x-litellm-api-key": "sk-no-db"})
|
||||
assert await _resolve_active_litellm_key(request) == "unresolvable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue