mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
Merge branch 'litellm_internal_staging' into litellm_fix_bedrock_mantle_oidc_33094
This commit is contained in:
commit
9feab387c3
18 changed files with 2021 additions and 202 deletions
|
|
@ -2,26 +2,8 @@ from typing import Optional
|
|||
|
||||
from litellm.llms.openai.data_residency import infer_openai_data_residency
|
||||
|
||||
# Pre-define optional kwargs keys as frozenset for O(1) lookups
|
||||
# These are extracted from kwargs only if present, avoiding unnecessary .get() calls
|
||||
OPTIONAL_KWARGS_KEYS = frozenset(
|
||||
AWS_CREDENTIAL_KWARGS_KEYS = frozenset(
|
||||
{
|
||||
"azure_ad_token",
|
||||
"tenant_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"azure_username",
|
||||
"azure_password",
|
||||
"azure_scope",
|
||||
"timeout",
|
||||
"gcs_bucket_name",
|
||||
"bucket_name",
|
||||
"vertex_credentials",
|
||||
"vertex_project",
|
||||
"vertex_location",
|
||||
"vertex_ai_project",
|
||||
"vertex_ai_location",
|
||||
"vertex_ai_credentials",
|
||||
"aws_region_name",
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
|
|
@ -34,14 +16,40 @@ OPTIONAL_KWARGS_KEYS = frozenset(
|
|||
"aws_external_id",
|
||||
"aws_bedrock_runtime_endpoint",
|
||||
"aws_bedrock_project_id",
|
||||
"tpm",
|
||||
"rpm",
|
||||
"itpm",
|
||||
"otpm",
|
||||
"use_xai_oauth",
|
||||
}
|
||||
)
|
||||
|
||||
# Pre-define optional kwargs keys as frozenset for O(1) lookups
|
||||
# These are extracted from kwargs only if present, avoiding unnecessary .get() calls
|
||||
OPTIONAL_KWARGS_KEYS = (
|
||||
frozenset(
|
||||
{
|
||||
"azure_ad_token",
|
||||
"tenant_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"azure_username",
|
||||
"azure_password",
|
||||
"azure_scope",
|
||||
"timeout",
|
||||
"gcs_bucket_name",
|
||||
"bucket_name",
|
||||
"vertex_credentials",
|
||||
"vertex_project",
|
||||
"vertex_location",
|
||||
"vertex_ai_project",
|
||||
"vertex_ai_location",
|
||||
"vertex_ai_credentials",
|
||||
"tpm",
|
||||
"rpm",
|
||||
"itpm",
|
||||
"otpm",
|
||||
"use_xai_oauth",
|
||||
}
|
||||
)
|
||||
| AWS_CREDENTIAL_KWARGS_KEYS
|
||||
)
|
||||
|
||||
# Backward-compatible alias for existing imports/tests.
|
||||
_OPTIONAL_KWARGS_KEYS = OPTIONAL_KWARGS_KEYS
|
||||
|
||||
|
|
|
|||
|
|
@ -878,7 +878,7 @@ class BaseAWSLLM:
|
|||
"Condition": {"Bool": {"aws:SecureTransport": "true"}},
|
||||
},
|
||||
{
|
||||
"Sid": "MantleLiteLLM",
|
||||
"Sid": "BedrockMantleLiteLLM",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock-mantle:CreateInference",
|
||||
|
|
|
|||
|
|
@ -92,7 +92,10 @@ from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
|
|||
from litellm.litellm_core_utils.request_timeout_resolver import (
|
||||
get_configured_request_timeout,
|
||||
)
|
||||
from litellm.litellm_core_utils.get_litellm_params import OPTIONAL_KWARGS_KEYS
|
||||
from litellm.litellm_core_utils.get_litellm_params import (
|
||||
AWS_CREDENTIAL_KWARGS_KEYS,
|
||||
OPTIONAL_KWARGS_KEYS,
|
||||
)
|
||||
from litellm.litellm_core_utils.dd_tracing import tracer
|
||||
from litellm.litellm_core_utils.get_provider_specific_headers import (
|
||||
ProviderSpecificHeaderUtils,
|
||||
|
|
@ -5322,7 +5325,7 @@ def completion( # type: ignore
|
|||
tpm=kwargs.get("tpm"),
|
||||
rpm=kwargs.get("rpm"),
|
||||
use_xai_oauth=kwargs.get("use_xai_oauth", False),
|
||||
aws_bedrock_project_id=kwargs.get("aws_bedrock_project_id"),
|
||||
**{key: kwargs[key] for key in AWS_CREDENTIAL_KWARGS_KEYS if key in kwargs},
|
||||
)
|
||||
cast(LiteLLMLoggingObj, logging).update_environment_variables(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import asyncio
|
||||
import html as _html
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
|
|
@ -10,7 +12,8 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|||
import httpx
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
|
|
@ -37,6 +40,10 @@ from litellm.types.mcp import MCPAuth, MCPCredentials
|
|||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
|
||||
EnvelopeKeys,
|
||||
UpstreamTokenGrant,
|
||||
)
|
||||
from litellm.proxy._types import LiteLLM_MCPServerTable, UserAPIKeyAuth
|
||||
|
||||
# TTL cache for upstream OAuth metadata fetched from pass-through MCP servers.
|
||||
|
|
@ -326,66 +333,136 @@ def _litellm_key_from_request(request: Request) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> Optional[str]:
|
||||
"""The key's ``user_id``, or ``None`` if the key is blocked or expired.
|
||||
def _key_is_active(key_obj: "UserAPIKeyAuth") -> bool:
|
||||
"""``True`` when the presented key is neither blocked nor past its expiry.
|
||||
|
||||
The OAuth token endpoint is unauthenticated, so the presented key is validated here before its
|
||||
identity is trusted to key a stored credential; a revoked or expired key must not be able to
|
||||
write or overwrite the per-user OAuth token. ``get_key_object`` resolves a row without these
|
||||
checks (the main ``user_api_key_auth`` pipeline enforces them downstream, which this endpoint
|
||||
bypasses), so they are applied here. Deleted keys are already rejected upstream, where
|
||||
``get_key_object`` raises on a row that no longer exists.
|
||||
The OAuth token endpoint is unauthenticated, so the presented key is validated here before it is
|
||||
trusted; a revoked or expired key must not mint a bridge envelope or write a stored credential.
|
||||
``get_key_object`` resolves a row without these checks (the main ``user_api_key_auth`` pipeline
|
||||
enforces them downstream, which this endpoint bypasses), so they are applied here. Deleted keys
|
||||
are already rejected upstream, where ``get_key_object`` raises on a row that no longer exists.
|
||||
|
||||
This is an active-state gate only; it deliberately does not require a ``user_id``. A valid
|
||||
team-scoped or service-account key has no ``user_id`` yet is a legitimate credential, so gating
|
||||
on ``user_id`` presence would wrongly reject it. Callers that need the user (the per-user token
|
||||
store) derive it separately via :func:`_active_key_user_id`.
|
||||
|
||||
Total by design: ``expires`` is typed ``str | datetime``, and an unparseable string would make
|
||||
``datetime.fromisoformat`` raise. Since the callers run this outside their key-resolution
|
||||
``try``, an uncaught parse error would surface as a 500 instead of the endpoint's fail-closed
|
||||
behavior, so a malformed expiry is treated as inactive (return ``False``) rather than raising.
|
||||
"""
|
||||
if key_obj.blocked is True:
|
||||
return None
|
||||
return False
|
||||
expires = key_obj.expires
|
||||
if expires is not None:
|
||||
expiry = expires if isinstance(expires, datetime) else datetime.fromisoformat(expires)
|
||||
if isinstance(expires, datetime):
|
||||
expiry = expires
|
||||
else:
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None:
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
if expiry < datetime.now(timezone.utc):
|
||||
return None
|
||||
return key_obj.user_id
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _extract_user_id_from_request(request: Request) -> Optional[str]:
|
||||
"""Resolve the LiteLLM ``user_id`` at the OAuth token endpoint so a per-user token is stored
|
||||
under the same identity the egress later reads it by (``user_api_key_auth.user_id``).
|
||||
def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> str | None:
|
||||
"""The active key's ``user_id``, or ``None`` when the key is blocked/expired or simply has no
|
||||
``user_id`` (a team-scoped or service-account key). Used only by the per-user token store, which
|
||||
needs a user to key the stored credential; the bridge mint uses the key hash and does not."""
|
||||
return key_obj.user_id if _key_is_active(key_obj) else None
|
||||
|
||||
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`` and the token was never persisted, which makes the egress 401 on every
|
||||
reconnect. The resolved key is validated (``_active_key_user_id``) before its identity is trusted,
|
||||
so a blocked or expired key cannot write. Returns ``None`` when no key is present, the key cannot
|
||||
be resolved, or it is 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."""
|
||||
|
||||
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 hash_token # noqa: PLC0415
|
||||
from litellm.proxy.auth.auth_checks import get_key_object # noqa: PLC0415
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415
|
||||
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,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
return "unresolvable"
|
||||
key_hash = hash_token(token)
|
||||
try:
|
||||
key_obj = await get_key_object(
|
||||
hashed_token=hash_token(token),
|
||||
hashed_token=key_hash,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
return _active_key_user_id(key_obj)
|
||||
except Exception as exc:
|
||||
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(
|
||||
"_extract_user_id_from_request: could not resolve a LiteLLM user_id for the presented "
|
||||
"key (%s); per-user token will not be stored server-side.",
|
||||
"_resolve_active_litellm_key: unexpected key-resolution error (%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
return "unresolvable"
|
||||
if not _key_is_active(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. 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)
|
||||
if not isinstance(resolved, _ResolvedKey):
|
||||
return None
|
||||
return _active_key_user_id(resolved.key)
|
||||
|
||||
|
||||
async def _store_per_user_token_server_side(
|
||||
|
|
@ -654,6 +731,255 @@ async def authorize_with_server(
|
|||
return response
|
||||
|
||||
|
||||
_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 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. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a
|
||||
positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the
|
||||
envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded
|
||||
(an ``int`` subclass but never a real lifetime), and the conversions 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:
|
||||
numeric = float(raw_expires_in)
|
||||
seconds = int(numeric)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return "unspecified"
|
||||
if numeric <= 0:
|
||||
return "expired"
|
||||
return max(1, seconds)
|
||||
|
||||
|
||||
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 "no_access_token"
|
||||
access = token_response.get("access_token")
|
||||
if not isinstance(access, str) or not access:
|
||||
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(
|
||||
access_token=SecretStr(access),
|
||||
token_type=token_type if isinstance(token_type, str) and token_type else "Bearer",
|
||||
# The upstream refresh_token is deliberately NOT sealed: the edge never consumes it (it forwards
|
||||
# only token_type + access_token), so it would be dead weight embedding a long-lived upstream
|
||||
# credential in the client-held bearer, and it enlarges the envelope. Refresh support is a
|
||||
# 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=lifetime if isinstance(lifetime, int) else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DCR-bridge oauth_delegate mint: a three-phase pipeline whose failures are values.
|
||||
#
|
||||
# prepare (before the upstream exchange) -> validate every precondition and resolve identity+keys
|
||||
# exchange (the single-use upstream code is consumed here, in exchange_token_with_server)
|
||||
# finish (after the exchange) -> seal the upstream grant into the client-held envelope
|
||||
#
|
||||
# Every precondition lives in ``prepare``, which runs BEFORE the exchange, so no failure can burn the
|
||||
# single-use code or rotate a refresh token, for either grant type -- that whole class of bug is gone
|
||||
# by construction rather than guarded case by case. Failures are values mapped to an OAuth-shaped
|
||||
# response in one place (``_bridge_mint_error_response``), so status codes and the RFC 6749 §5.2 body
|
||||
# shape are uniform. Adding a failure mode is a new literal plus a match arm the type checker forces.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_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)
|
||||
class _BridgeMintReady:
|
||||
"""Everything the seal needs, resolved once before the exchange: the authorizing key hash and the
|
||||
master-key-derived envelope keys. Passing this forward means identity resolution and key derivation
|
||||
happen exactly once, and ``_finish_bridge_mint`` has no preconditions left that could fail."""
|
||||
|
||||
key_hash: str
|
||||
keys: "EnvelopeKeys"
|
||||
|
||||
|
||||
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 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",
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
master_key,
|
||||
)
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return "unsupported_grant"
|
||||
if not master_key:
|
||||
return "not_configured"
|
||||
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(
|
||||
ready: "_BridgeMintReady", mcp_server: MCPServer, token_response: object, now: datetime
|
||||
) -> "JSONResponse | _BridgeMintError":
|
||||
"""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, 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 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):
|
||||
return "too_large"
|
||||
# Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the
|
||||
# client is never told the bearer lives past the point admission (which uses that exp) rejects it.
|
||||
expires_in = max(0, int(sealed.expires_at.timestamp()) - math.ceil(now.timestamp()))
|
||||
body = {"access_token": sealed.token.get_secret_value(), "token_type": "Bearer", "expires_in": expires_in}
|
||||
return JSONResponse(body, headers=TOKEN_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
async def exchange_token_with_server(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
|
|
@ -727,6 +1053,16 @@ async def exchange_token_with_server(
|
|||
if code_verifier:
|
||||
token_data["code_verifier"] = code_verifier
|
||||
|
||||
# Phase 1: for a bridge oauth_delegate mint, validate all preconditions and resolve identity+keys
|
||||
# BEFORE the exchange below consumes the single-use upstream code, and carry the ready context to
|
||||
# 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, grant_type)
|
||||
if not isinstance(prepared, _BridgeMintReady):
|
||||
return _bridge_mint_error_response(prepared)
|
||||
bridge_mint_ready = prepared
|
||||
|
||||
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
response = await async_client.post(
|
||||
mcp_server.token_url,
|
||||
|
|
@ -751,7 +1087,6 @@ async def exchange_token_with_server(
|
|||
)
|
||||
raise
|
||||
token_response = response.json()
|
||||
access_token = token_response["access_token"]
|
||||
|
||||
# Validate token response against server-configured rules before any storage.
|
||||
# This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc.
|
||||
|
|
@ -791,8 +1126,17 @@ async def exchange_token_with_server(
|
|||
mcp_server.server_id,
|
||||
)
|
||||
|
||||
# A DCR-bridge oauth_delegate server hands the client a gateway-bound envelope (identity plus the
|
||||
# upstream token) instead of the raw upstream token, so the one bearer both admits the caller and
|
||||
# forwards the upstream credential. Only this mode mints; every other server returns the raw token.
|
||||
if bridge_mint_ready is not None:
|
||||
# Phase 3: seal the upstream grant into the client-held envelope; failures map through the same
|
||||
# OAuth-shaped response as the phase-1 preconditions.
|
||||
minted = _finish_bridge_mint(bridge_mint_ready, mcp_server, token_response, datetime.now(timezone.utc))
|
||||
return minted if isinstance(minted, JSONResponse) else _bridge_mint_error_response(minted)
|
||||
|
||||
result = {
|
||||
"access_token": access_token,
|
||||
"access_token": token_response["access_token"],
|
||||
"token_type": token_response.get("token_type", "Bearer"),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3719,8 +3719,15 @@ if MCP_AVAILABLE:
|
|||
headers={"www-authenticate": upstream_www_authenticate},
|
||||
)
|
||||
|
||||
def _get_authorization_header_from_scope(scope: Scope) -> Optional[str]:
|
||||
"""First ``Authorization`` header value in the ASGI scope, or None."""
|
||||
for key, value in scope.get("headers", []):
|
||||
if key.lower() == b"authorization":
|
||||
return value.decode("latin-1")
|
||||
return None
|
||||
|
||||
def _scope_has_authorization_header(scope: Scope) -> bool:
|
||||
return any(key.lower() == b"authorization" for key, _ in scope.get("headers", []))
|
||||
return _get_authorization_header_from_scope(scope) is not None
|
||||
|
||||
def _get_forwarded_auth_from_scope(scope: Scope) -> Optional[str]:
|
||||
"""Return the upstream-bound ``Authorization`` header value, or None.
|
||||
|
|
@ -3733,17 +3740,24 @@ if MCP_AVAILABLE:
|
|||
``MCPRequestHandler.process_mcp_request``), and forwarding it upstream
|
||||
would leak the proxy key to a third-party MCP server.
|
||||
"""
|
||||
authorization = None
|
||||
has_litellm_key_header = False
|
||||
for key, value in scope.get("headers", []):
|
||||
key_lower = key.lower()
|
||||
if key_lower == b"authorization":
|
||||
authorization = value.decode("latin-1")
|
||||
elif key_lower == b"x-litellm-api-key":
|
||||
has_litellm_key_header = True
|
||||
has_litellm_key_header = any(key.lower() == b"x-litellm-api-key" for key, _ in scope.get("headers", []))
|
||||
if not has_litellm_key_header:
|
||||
return None
|
||||
return authorization
|
||||
return _get_authorization_header_from_scope(scope)
|
||||
|
||||
def _is_delegate_upstream_probe_target(server: MCPServer) -> bool:
|
||||
"""Whether ``server`` is an interactive delegate-auth server whose client-supplied
|
||||
token should be preflighted upstream.
|
||||
|
||||
Mirrors the anonymous-delegate gate in ``get_allowed_mcp_servers``: the flow is
|
||||
resolved via ``effective_oauth2_flow`` so an unstamped M2M-shape row fails closed
|
||||
(its stored client credentials drive egress; the caller's bearer is irrelevant).
|
||||
"""
|
||||
return (
|
||||
server.auth_type == MCPAuth.oauth2
|
||||
and server.delegate_auth_to_upstream is True
|
||||
and MCPServerManager.effective_oauth2_flow(server) != "client_credentials"
|
||||
)
|
||||
|
||||
async def _probe_upstream_auth(
|
||||
url: str,
|
||||
|
|
@ -3805,7 +3819,7 @@ if MCP_AVAILABLE:
|
|||
mcp_servers: Optional[List[str]],
|
||||
client_ip: Optional[str],
|
||||
) -> None:
|
||||
"""Probe pass-through upstream servers in parallel before the MCP session starts.
|
||||
"""Probe pass-through and delegate-auth upstream servers in parallel before the MCP session starts.
|
||||
|
||||
Only servers the caller's key is already authorized to reach are probed —
|
||||
the list is derived from _get_allowed_mcp_servers so that a user cannot
|
||||
|
|
@ -3813,11 +3827,42 @@ if MCP_AVAILABLE:
|
|||
|
||||
The MCP SDK commits HTTP 200 headers before invoking handlers, so a 401
|
||||
can only be returned before that point. This function raises HTTPException(401)
|
||||
with a WWW-Authenticate header if any upstream rejects the client token.
|
||||
with a WWW-Authenticate header if any upstream rejects the client token, or 403
|
||||
if the upstream accepts it but forbids the caller.
|
||||
Fails-open: network errors are logged and the request is allowed through.
|
||||
|
||||
Delegate-auth servers (``auth_type=oauth2`` + ``delegate_auth_to_upstream``)
|
||||
are probed with the caller's bare ``Authorization`` bearer. That bearer is only
|
||||
an upstream token (never a LiteLLM key) when admission took the delegate bypass,
|
||||
so the delegate target is resolved through ``get_mcp_server_by_name`` -- the same
|
||||
resolver admission used -- rather than the wider allowed-server prefix/access-group
|
||||
matching. A name that only reaches a delegate server via server_id or an access
|
||||
group would have been admitted as a real LiteLLM key, so probing it would leak that
|
||||
key upstream; requiring the admission-resolver match closes that gap. Without the
|
||||
probe a rejected token is absorbed by the tools/list handler and masked as an empty
|
||||
tool list. Gated to single-server routes so one rejected token cannot 401 a
|
||||
multi-server aggregate connect, matching the OBO preflight gating; the challenge
|
||||
echoes the requested name so aliased routes get the same resource_metadata URL as
|
||||
the tokenless preemptive challenge.
|
||||
"""
|
||||
forwarded_auth = _get_forwarded_auth_from_scope(scope)
|
||||
if not forwarded_auth:
|
||||
requested_single_target = mcp_servers[0] if mcp_servers is not None and len(mcp_servers) == 1 else None
|
||||
# The bare Authorization header (no x-litellm-api-key) is a valid upstream token
|
||||
# only when admission classified it as one, i.e. the single requested name resolves
|
||||
# to a delegate server under admission's own resolver. Resolve it the same way here
|
||||
# so a server_id- or access-group-named delegate (which admission would have treated
|
||||
# as a LiteLLM key) is never probed with that key.
|
||||
delegate_server = (
|
||||
global_mcp_server_manager.get_mcp_server_by_name(requested_single_target, client_ip=client_ip)
|
||||
if requested_single_target
|
||||
else None
|
||||
)
|
||||
delegate_auth = (
|
||||
_get_authorization_header_from_scope(scope)
|
||||
if delegate_server is not None and _is_delegate_upstream_probe_target(delegate_server)
|
||||
else None
|
||||
)
|
||||
if not forwarded_auth and not delegate_auth:
|
||||
return
|
||||
|
||||
# Use the authorized server set, not the raw user-supplied names, so that
|
||||
|
|
@ -3827,33 +3872,49 @@ if MCP_AVAILABLE:
|
|||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
passthrough_servers = [
|
||||
srv
|
||||
for srv in allowed_servers
|
||||
# Restrict to genuine OAuth pass-through servers (auth_type none +
|
||||
# Authorization in extra_headers). Gateway-managed OAuth2 servers
|
||||
# must not receive the ``resource_metadata=`` challenge emitted
|
||||
# below — they require ``authorization_uri=`` pointing at the
|
||||
# gateway AS metadata. ``is_oauth_passthrough`` already requires
|
||||
# ``auth_type in (None, MCPAuth.none)``, which is mutually
|
||||
# exclusive with ``has_client_credentials`` (oauth2 + M2M flow),
|
||||
# so M2M servers are implicitly excluded here.
|
||||
if srv.is_oauth_passthrough
|
||||
]
|
||||
if not passthrough_servers:
|
||||
passthrough_targets: Tuple[Tuple[MCPServer, str, str], ...] = (
|
||||
tuple(
|
||||
(srv, forwarded_auth, srv.name)
|
||||
for srv in allowed_servers
|
||||
# Restrict to genuine OAuth pass-through servers (auth_type none +
|
||||
# Authorization in extra_headers). Gateway-managed OAuth2 servers
|
||||
# must not receive the ``resource_metadata=`` challenge emitted
|
||||
# below — they require ``authorization_uri=`` pointing at the
|
||||
# gateway AS metadata. ``is_oauth_passthrough`` already requires
|
||||
# ``auth_type in (None, MCPAuth.none)``, which is mutually
|
||||
# exclusive with ``has_client_credentials`` (oauth2 + M2M flow),
|
||||
# so M2M servers are implicitly excluded here.
|
||||
if srv.is_oauth_passthrough
|
||||
)
|
||||
if forwarded_auth
|
||||
else ()
|
||||
)
|
||||
# Probe the admission-resolved delegate server only when the caller is actually
|
||||
# authorized for it (present in the IP-filtered allowed set), keyed by server_id.
|
||||
delegate_targets: Tuple[Tuple[MCPServer, str, str], ...] = (
|
||||
tuple(
|
||||
(srv, delegate_auth, requested_single_target)
|
||||
for srv in allowed_servers
|
||||
if delegate_server is not None and srv.server_id == delegate_server.server_id
|
||||
)
|
||||
if delegate_auth and requested_single_target
|
||||
else ()
|
||||
)
|
||||
probe_targets = passthrough_targets + delegate_targets
|
||||
if not probe_targets:
|
||||
return
|
||||
|
||||
probe_results = await asyncio.gather(
|
||||
*[_probe_upstream_auth(srv.url or "", forwarded_auth) for srv in passthrough_servers]
|
||||
*[_probe_upstream_auth(srv.url or "", auth_header) for srv, auth_header, _ in probe_targets]
|
||||
)
|
||||
for srv, (probe_status, _) in zip(passthrough_servers, probe_results):
|
||||
for (srv, _, challenge_server_name), (probe_status, _) in zip(probe_targets, probe_results):
|
||||
if probe_status == 401:
|
||||
# Token is missing or expired: keep pass-through clients on the
|
||||
# protected-resource discovery flow so they re-authorize against
|
||||
# the upstream IdP metadata proxied by LiteLLM.
|
||||
www_authenticate = _get_passthrough_www_authenticate(
|
||||
scope=scope,
|
||||
server_name=srv.name,
|
||||
server_name=challenge_server_name,
|
||||
invalid_token=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -772,7 +772,13 @@ class LassoGuardrail(CustomGuardrail):
|
|||
data: Request data (used for conversation_id generation and tools extraction)
|
||||
cache: Cache instance for storing conversation_id (optional for post-call)
|
||||
"""
|
||||
payload: Dict[str, Any] = {"messages": messages, "messageType": message_type}
|
||||
payload: Dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"messageType": message_type,
|
||||
# Drives the "Used By" badge on Lasso Application API Keys: every call from this
|
||||
# integration is attributed as "litellm" on the keys list.
|
||||
"source": {"type": "litellm"},
|
||||
}
|
||||
|
||||
# Add optional parameters if available
|
||||
if self.user_id:
|
||||
|
|
|
|||
|
|
@ -28,11 +28,20 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import (
|
|||
)
|
||||
from litellm.proxy.utils import ProxyUpdateSpend
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
StandardLoggingPayload,
|
||||
StandardLoggingPayloadErrorInformation,
|
||||
)
|
||||
from litellm.utils import get_end_user_id_for_cost_tracking
|
||||
|
||||
_PASS_THROUGH_CALL_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
CallTypes.pass_through.value,
|
||||
CallTypes.llm_passthrough_route.value,
|
||||
CallTypes.allm_passthrough_route.value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _ProxyDBLogger(CustomLogger):
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
|
|
@ -219,11 +228,13 @@ class _ProxyDBLogger(CustomLogger):
|
|||
verbose_proxy_logger.debug(
|
||||
f"user_api_key {user_api_key}, user_id {user_id}, team_id {team_id}, end_user_id {end_user_id}"
|
||||
)
|
||||
call_type: Optional[str] = kwargs.get("call_type")
|
||||
if _should_track_cost_callback(
|
||||
user_api_key=user_api_key,
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
end_user_id=end_user_id,
|
||||
call_type=call_type,
|
||||
):
|
||||
## UPDATE DATABASE
|
||||
await _update_database_and_spend_counters(
|
||||
|
|
@ -412,9 +423,15 @@ def _should_track_cost_callback(
|
|||
user_id: Optional[str],
|
||||
team_id: Optional[str],
|
||||
end_user_id: Optional[str],
|
||||
call_type: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if the cost callback should be tracked based on the kwargs
|
||||
|
||||
Pass-through endpoints can be configured with ``auth=false``, which leaves
|
||||
the request with no key/user/team/end-user to attribute spend to. Those
|
||||
requests still forward real provider traffic that operators expect to see
|
||||
in request/usage logs, so they are tracked even when unauthenticated.
|
||||
"""
|
||||
|
||||
# don't run track cost callback if user opted into disabling spend
|
||||
|
|
@ -423,7 +440,7 @@ def _should_track_cost_callback(
|
|||
|
||||
if user_api_key is not None or user_id is not None or team_id is not None or end_user_id is not None:
|
||||
return True
|
||||
return False
|
||||
return call_type in _PASS_THROUGH_CALL_TYPES
|
||||
|
||||
|
||||
def _get_budget_reservation_from_metadata(metadata: dict) -> Optional[dict]:
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ async def aresponses_api_with_mcp(
|
|||
pre_processed_mcp_tools=original_mcp_tools,
|
||||
)
|
||||
|
||||
return LiteLLM_Proxy_MCP_Handler._create_mcp_streaming_response(
|
||||
mcp_streaming_response = LiteLLM_Proxy_MCP_Handler._create_mcp_streaming_response(
|
||||
input=input,
|
||||
model=model,
|
||||
all_tools=all_tools,
|
||||
|
|
@ -272,6 +272,10 @@ async def aresponses_api_with_mcp(
|
|||
tool_server_map=tool_server_map,
|
||||
**kwargs,
|
||||
)
|
||||
await mcp_streaming_response._create_initial_response_iterator()
|
||||
if mcp_streaming_response._initial_creation_error is not None:
|
||||
raise mcp_streaming_response._initial_creation_error
|
||||
return mcp_streaming_response
|
||||
|
||||
# Determine if we should auto-execute tools
|
||||
should_auto_execute = bool(mcp_tools_with_litellm_proxy) and LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from litellm._uuid import uuid
|
|||
from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator
|
||||
from litellm.types.llms.openai import (
|
||||
BaseLiteLLMOpenAIResponseObject,
|
||||
ErrorEvent,
|
||||
ErrorEventError,
|
||||
MCPCallArgumentsDeltaEvent,
|
||||
MCPCallArgumentsDoneEvent,
|
||||
MCPCallCompletedEvent,
|
||||
|
|
@ -316,6 +318,11 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
# Cache the response ID to ensure consistency across all events
|
||||
self._cached_response_id: Optional[str] = None
|
||||
|
||||
self._initial_creation_error: Exception | None = None
|
||||
self._stream_error: Exception | None = None
|
||||
self._error_event_emitted = False
|
||||
self._last_sequence_number = 0
|
||||
|
||||
def _extract_mcp_headers_from_params(self) -> None:
|
||||
"""Extract MCP headers from original request params to pass to tool calls"""
|
||||
from typing import Dict, Optional
|
||||
|
|
@ -380,10 +387,31 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
|
||||
return LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools(self.mcp_tools_with_litellm_proxy)
|
||||
|
||||
def _make_stream_error_event(self) -> ResponsesAPIStreamingResponse:
|
||||
err = self._stream_error
|
||||
status_code = getattr(err, "status_code", None)
|
||||
return ErrorEvent(
|
||||
type=ResponsesAPIStreamEvents.ERROR,
|
||||
sequence_number=self._last_sequence_number + 1,
|
||||
error=ErrorEventError(
|
||||
type="mcp_gateway_error",
|
||||
code=str(status_code) if status_code is not None else "internal_error",
|
||||
message=str(err) if err is not None else "MCP gateway stream failed",
|
||||
param=None,
|
||||
),
|
||||
)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> ResponsesAPIStreamingResponse:
|
||||
chunk = await self._anext_impl()
|
||||
sequence_number = getattr(chunk, "sequence_number", None)
|
||||
if isinstance(sequence_number, int) and sequence_number > self._last_sequence_number:
|
||||
self._last_sequence_number = sequence_number
|
||||
return chunk
|
||||
|
||||
async def _anext_impl(self) -> ResponsesAPIStreamingResponse:
|
||||
"""
|
||||
Phase-based streaming:
|
||||
1. initial_response - Stream the first LLM response (includes response.created, response.in_progress, response.output_item.added)
|
||||
|
|
@ -438,10 +466,16 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
self.phase = "continue_initial_response"
|
||||
return await self.__anext__()
|
||||
self.phase = "finished"
|
||||
if self._stream_error is not None and not self._error_event_emitted:
|
||||
self._error_event_emitted = True
|
||||
return self._make_stream_error_event()
|
||||
raise StopAsyncIteration
|
||||
|
||||
# Phase 6: Finished
|
||||
if self.phase == "finished":
|
||||
if self._stream_error is not None and not self._error_event_emitted:
|
||||
self._error_event_emitted = True
|
||||
return self._make_stream_error_event()
|
||||
raise StopAsyncIteration
|
||||
|
||||
# Should not reach here
|
||||
|
|
@ -530,6 +564,12 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
|
||||
chunk = await cast(Any, self.base_iterator).__anext__() # type: ignore[attr-defined]
|
||||
|
||||
if self._cached_response_id is None and hasattr(chunk, "response"):
|
||||
new_response = getattr(chunk, "response", None)
|
||||
new_response_id = getattr(new_response, "id", None) if new_response is not None else None
|
||||
if new_response_id:
|
||||
self._cached_response_id = new_response_id
|
||||
|
||||
# Ensure response ID consistency - update chunk if needed
|
||||
if self._cached_response_id and hasattr(chunk, "response"):
|
||||
response_obj = getattr(chunk, "response", None)
|
||||
|
|
@ -589,6 +629,8 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
|
||||
traceback.print_exc()
|
||||
self.base_iterator = None
|
||||
self._initial_creation_error = e
|
||||
self._stream_error = e
|
||||
# Don't set phase to "finished" here — let __anext__ emit any
|
||||
# pre-generated MCP discovery events before ending the iteration.
|
||||
|
||||
|
|
@ -761,6 +803,7 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
if hasattr(follow_up_response, "__aiter__"):
|
||||
self.base_iterator = follow_up_response
|
||||
self.collected_response = None
|
||||
self._cached_response_id = None
|
||||
|
||||
except Exception as e:
|
||||
verbose_logger.error(f"Error creating follow-up iterator: {e}")
|
||||
|
|
@ -768,6 +811,7 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
|
||||
traceback.print_exc()
|
||||
self.base_iterator = None
|
||||
self._stream_error = e
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ ci = [
|
|||
# protobuf, Pillow is a compiled C extension).
|
||||
"tenacity==8.5.0",
|
||||
"google-generativeai==0.8.6",
|
||||
"Pillow==12.2.0",
|
||||
"Pillow==12.3.0",
|
||||
# Azure batch E2E tests still import psycopg2 directly.
|
||||
"psycopg2-binary==2.9.11",
|
||||
"pytest-codspeed==4.3.0",
|
||||
|
|
|
|||
|
|
@ -158,17 +158,14 @@ class TestClaudePlatformActionsCovered:
|
|||
)
|
||||
|
||||
|
||||
class TestMantleActionsCovered:
|
||||
"""#33094: the bedrock-mantle endpoint (``bedrock_mantle/<model>``
|
||||
route) is served under its own ``bedrock-mantle:*`` IAM action
|
||||
namespace, distinct from both ``bedrock:*`` and
|
||||
``aws-external-anthropic:*``. Without a matching statement every
|
||||
mantle request 403s on OIDC auth with::
|
||||
class TestBedrockMantleActionsCovered:
|
||||
"""LIT-3859: bedrock_mantle inference authorizes against the
|
||||
``bedrock-mantle`` action namespace, so the session-policy ceiling
|
||||
must include it or every Mantle request via OIDC/WIF auth denies
|
||||
with "no session policy allows the bedrock-mantle:CreateInference
|
||||
action" even when the role's identity policy grants it."""
|
||||
|
||||
is not authorized to perform: bedrock-mantle:CreateInference
|
||||
"""
|
||||
|
||||
def test_mantle_create_inference_present(self):
|
||||
def test_bedrock_mantle_create_inference_present(self):
|
||||
policy = _captured_policy()
|
||||
all_actions: set = set()
|
||||
for stmt in policy["Statement"]:
|
||||
|
|
@ -179,28 +176,18 @@ class TestMantleActionsCovered:
|
|||
all_actions.update(stmt_actions)
|
||||
assert "bedrock-mantle:CreateInference" in all_actions, (
|
||||
"bedrock-mantle:CreateInference missing from session policy — "
|
||||
"bedrock_mantle/* requests will 403 on OIDC auth"
|
||||
"bedrock_mantle/* requests will 403 on OIDC/WIF auth"
|
||||
)
|
||||
|
||||
def test_mantle_statement_allows(self):
|
||||
def test_bedrock_mantle_statement_allows(self):
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "MantleLiteLLM")
|
||||
stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM")
|
||||
assert stmt["Effect"] == "Allow"
|
||||
assert stmt["Resource"] == "*"
|
||||
|
||||
def test_mantle_statement_carries_secure_transport_condition(self):
|
||||
def test_no_bedrock_mantle_wildcard(self):
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "MantleLiteLLM")
|
||||
cond = stmt.get("Condition") or {}
|
||||
assert cond.get("Bool", {}).get("aws:SecureTransport") == "true", (
|
||||
"MantleLiteLLM must require aws:SecureTransport=true "
|
||||
"to keep parity with the bedrock statement"
|
||||
)
|
||||
|
||||
def test_mantle_statement_not_wildcard(self):
|
||||
"""Keep the ceiling tight — don't grant bedrock-mantle:* ."""
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "MantleLiteLLM")
|
||||
stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM")
|
||||
actions = stmt["Action"]
|
||||
if isinstance(actions, str):
|
||||
actions = [actions]
|
||||
|
|
@ -209,6 +196,15 @@ class TestMantleActionsCovered:
|
|||
"the ceiling should match the documented action set"
|
||||
)
|
||||
|
||||
def test_bedrock_mantle_statement_carries_secure_transport_condition(self):
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM")
|
||||
cond = stmt.get("Condition") or {}
|
||||
assert cond.get("Bool", {}).get("aws:SecureTransport") == "true", (
|
||||
"BedrockMantleLiteLLM must require aws:SecureTransport=true "
|
||||
"to keep parity with the bedrock statement"
|
||||
)
|
||||
|
||||
|
||||
def _make_jwt(payload: dict) -> str:
|
||||
def _segment(data: dict) -> str:
|
||||
|
|
|
|||
|
|
@ -4362,6 +4362,501 @@ async def test_register_bridge_relay_never_persists():
|
|||
mock_persist.assert_not_called()
|
||||
|
||||
|
||||
_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,
|
||||
)
|
||||
|
||||
fake_http_response = MagicMock()
|
||||
fake_http_response.json.return_value = upstream_body
|
||||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
# 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
|
||||
|
||||
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=key_resolver,
|
||||
),
|
||||
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",
|
||||
)
|
||||
if server.is_oauth_delegate and server.is_dcr_bridge:
|
||||
key_resolver.assert_awaited_once()
|
||||
else:
|
||||
key_resolver.assert_not_awaited()
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_delegate_bridge_token_exchange_mints_envelope_not_raw_token():
|
||||
"""A dcr_bridge oauth_delegate token exchange returns a gateway-bound envelope, not the raw
|
||||
upstream token: the response access_token opens (under the same master-key-derived keys and the
|
||||
server_id) to the caller's identity and the upstream Authorization, and the raw upstream token
|
||||
never appears in the bearer the client receives."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
|
||||
BridgeEnvelopeAdmitted,
|
||||
envelope_keys_from_master_key,
|
||||
resolve_bridge_envelope,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
||||
|
||||
body = json.loads(response.body)
|
||||
token = body["access_token"]
|
||||
assert body["token_type"] == "Bearer"
|
||||
assert body["expires_in"] > 0
|
||||
assert token.startswith("llm_env_")
|
||||
assert "UPSTREAM-SECRET-TOKEN" not in token
|
||||
assert "refresh_token" not in body
|
||||
|
||||
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
||||
opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), server.server_id)
|
||||
assert isinstance(opened, BridgeEnvelopeAdmitted)
|
||||
assert opened.identity.key_hash == "hashed-litellm-key-77"
|
||||
assert opened.upstream_authorization.get_secret_value() == "Bearer UPSTREAM-SECRET-TOKEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_delegate_bridge_token_exchange_fails_closed_without_litellm_identity():
|
||||
"""Without a resolvable litellm identity on the token request, the exchange must not mint an
|
||||
identity-less envelope. It returns an RFC 6749 §5.2-shaped invalid_request (error at the top
|
||||
level, not wrapped in detail) BEFORE exchanging the upstream code, so the single-use code is not
|
||||
burned and the client can retry."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
||||
captured: dict = {}
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash=None, fake_client_out=captured)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body)["error"] == "invalid_request"
|
||||
# identity resolution failed first, so the upstream single-use code was never exchanged (not burned)
|
||||
captured["client"].post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_envelope_too_large_upstream_token_is_502():
|
||||
"""An upstream token too large to seal into the envelope is an upstream-payload condition, so the
|
||||
mint surfaces a 502 (as an RFC 6749 §5.2 error body, not a raised HTTPException) rather than a 500:
|
||||
build_bridge_token_response returns EnvelopeTooLarge as a value, _finish_bridge_mint returns the
|
||||
"too_large" failure, and _bridge_mint_error_response maps it to a truthful status."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"access_token": "x" * 40000, "token_type": "Bearer", "expires_in": 3600}
|
||||
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_envelope_does_not_seal_upstream_refresh_token():
|
||||
"""The upstream refresh_token is never sealed into the client-held envelope: the edge never
|
||||
consumes it and a long-lived upstream credential should not live in the client bearer. The opened
|
||||
envelope's grant carries no refresh token even when the upstream returned one, and neither does
|
||||
the response body."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
|
||||
envelope_keys_from_master_key,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
|
||||
OpenedEnvelope,
|
||||
open_envelope,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {
|
||||
"access_token": "UP",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "UPSTREAM-REFRESH",
|
||||
}
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert "refresh_token" not in body
|
||||
assert "UPSTREAM-REFRESH" not in body["access_token"]
|
||||
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
||||
opened = open_envelope(body["access_token"], keys, datetime.now(timezone.utc))
|
||||
assert isinstance(opened, OpenedEnvelope)
|
||||
assert opened.grant.refresh_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
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.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
||||
):
|
||||
response = await exchange_token_with_server(
|
||||
request=_bridge_mock_request(),
|
||||
mcp_server=server,
|
||||
grant_type="refresh_token",
|
||||
code=None,
|
||||
redirect_uri=None,
|
||||
client_id="dcr-client-123",
|
||||
client_secret=None,
|
||||
code_verifier=None,
|
||||
refresh_token="client-refresh-token",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body)["error"] == "unsupported_grant_type"
|
||||
fake_http_client.post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_mint_fails_closed_before_upstream_when_master_key_unset():
|
||||
"""master_key is validated BEFORE the upstream exchange (in _prepare_bridge_mint), so a
|
||||
misconfigured gateway returns a 500 server_error without consuming the single-use code, avoiding
|
||||
the burn-then-fail the pre-exchange phase exists to prevent. The failure is returned as an RFC 6749
|
||||
error body, not raised."""
|
||||
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="no_active_key"),
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.master_key", None),
|
||||
):
|
||||
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",
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.body)["error"] == "server_error"
|
||||
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_positive_sub_second_lifetime_mints_not_502():
|
||||
"""A positive fractional expires_in in (0, 1) is a live token, not an elapsed one, so it mints a
|
||||
(1s-floored) envelope rather than being truncated to 0 and rejected with 502 after the single-use
|
||||
code was already consumed. Regression for classifying a sub-second remaining lifetime as expired."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0.5}
|
||||
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 body["expires_in"] >= 0
|
||||
|
||||
|
||||
@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
|
||||
elapsed portion up), so the client is never told the bearer lives past the point admission expires
|
||||
it. Regression for the sub-second overstatement of the raw (expires_at - now) delta."""
|
||||
import time
|
||||
|
||||
import jwt as _jwt
|
||||
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 300}
|
||||
before = int(time.time())
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
||||
body = json.loads(response.body)
|
||||
claims = _jwt.decode(body["access_token"].removeprefix("llm_env_"), options={"verify_signature": False})
|
||||
# projecting the reported lifetime from a time no later than the mint must not exceed the JWT exp
|
||||
assert before + body["expires_in"] <= claims["exp"]
|
||||
|
||||
|
||||
def test_bridge_reported_expires_in_can_be_zero_at_jwt_exp_boundary():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_BridgeMintReady,
|
||||
_finish_bridge_mint,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
|
||||
envelope_keys_from_master_key,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
ready = _BridgeMintReady(
|
||||
key_hash="hashed-litellm-key-77",
|
||||
keys=envelope_keys_from_master_key(_BRIDGE_MASTER_KEY),
|
||||
)
|
||||
response = _finish_bridge_mint(
|
||||
ready=ready,
|
||||
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
|
||||
token_response={"access_token": "UP", "expires_in": 1},
|
||||
now=datetime.fromtimestamp(100.25, tz=timezone.utc),
|
||||
)
|
||||
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert json.loads(response.body)["expires_in"] == 0
|
||||
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
assert _classify_upstream_lifetime(-0.5) == "expired"
|
||||
# a positive sub-second lifetime is alive, not elapsed; it clamps up to the envelope's 1s floor
|
||||
# rather than truncating to 0 and being misread as expired
|
||||
assert _classify_upstream_lifetime(0.5) == 1
|
||||
assert _classify_upstream_lifetime(0.001) == 1
|
||||
# a positive value >= 1 truncates toward zero (never overstating the stated lifetime)
|
||||
assert _classify_upstream_lifetime(1.9) == 1
|
||||
# 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"
|
||||
|
||||
|
||||
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
|
||||
async def test_bridge_token_exchange_honors_short_float_expires_in_ttl():
|
||||
"""A short float expires_in from the upstream caps the envelope TTL, so the client-held envelope
|
||||
does not outlive the upstream token. Before coercion a float was dropped and the envelope
|
||||
defaulted to the 1h cap (3600), which would forward a stale bearer after the upstream token
|
||||
expired."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 120.0}
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
||||
assert json.loads(response.body)["expires_in"] <= 120
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_delegate_bridge_token_exchange_missing_access_token_is_502_not_keyerror():
|
||||
"""When the upstream token response has no access_token, a dcr_bridge oauth_delegate exchange
|
||||
returns a clean 502 error body rather than raising a KeyError. _finish_bridge_mint asks
|
||||
_bridge_grant_from_token_response for a typed grant, gets None, and returns the "no_upstream_token"
|
||||
failure, which maps to 502; nothing indexes token_response["access_token"] on the bridge path."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {"token_type": "Bearer", "expires_in": 3600}
|
||||
|
||||
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_true_passthrough_bridge_token_exchange_returns_raw_upstream_token():
|
||||
"""Only oauth_delegate mints. A true_passthrough dcr_bridge server relays the raw upstream token
|
||||
to the client, since that mode has no litellm identity to bind and the caller owns the token."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.true_passthrough)
|
||||
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert body["access_token"] == "UPSTREAM-SECRET-TOKEN"
|
||||
assert not body["access_token"].startswith("llm_env_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_bridge_oauth_delegate_token_exchange_returns_raw_upstream_token():
|
||||
"""An oauth_delegate server without dcr_bridge keeps the pre-change contract: the raw upstream
|
||||
token is returned, so flag-off behavior is byte-identical."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, dcr_bridge=None)
|
||||
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
||||
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert body["access_token"] == "UPSTREAM-SECRET-TOKEN"
|
||||
|
||||
|
||||
async def _exchange_persistence_attempted_for_auth_type(auth_type) -> bool:
|
||||
"""Run exchange_token_with_server for a server of ``auth_type`` and report whether it attempted
|
||||
to persist the exchanged token server-side. The client-forwarded token modes must not persist:
|
||||
|
|
@ -4706,6 +5201,165 @@ async def test_extract_user_id_rejects_expired_key(proxy_globals):
|
|||
assert await _extract_user_id_from_request(request) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 (
|
||||
_resolve_active_litellm_key,
|
||||
_ResolvedKey,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
key = "sk-alice-key"
|
||||
cache = UserApiKeyCache()
|
||||
await cache.async_set_cache(
|
||||
hash_token(key),
|
||||
UserAPIKeyAuth(token=hash_token(key), user_id="alice"),
|
||||
model_type=UserAPIKeyAuth,
|
||||
)
|
||||
proxy_globals.user_api_key_cache = cache
|
||||
proxy_globals.prisma_client = object()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": f"Bearer {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_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_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
|
||||
|
||||
key = "sk-team-scoped-key"
|
||||
cache = UserApiKeyCache()
|
||||
await cache.async_set_cache(
|
||||
hash_token(key),
|
||||
UserAPIKeyAuth(token=hash_token(key), user_id=None, team_id="team-x"),
|
||||
model_type=UserAPIKeyAuth,
|
||||
)
|
||||
proxy_globals.user_api_key_cache = cache
|
||||
proxy_globals.prisma_client = object()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": f"Bearer {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_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 (
|
||||
_resolve_active_litellm_key,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
class _FakePrisma:
|
||||
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
||||
return UserAPIKeyAuth(token=token, user_id="blocked-user", blocked=True)
|
||||
|
||||
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
||||
proxy_globals.prisma_client = _FakePrisma()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": "sk-blocked-key"})
|
||||
assert await _resolve_active_litellm_key(request) == "no_active_key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 (
|
||||
_resolve_active_litellm_key,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
class _FakePrisma:
|
||||
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
||||
return UserAPIKeyAuth(token=token, user_id="u", expires="not-a-parseable-date")
|
||||
|
||||
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
||||
proxy_globals.prisma_client = _FakePrisma()
|
||||
|
||||
request = _token_request({"x-litellm-api-key": "sk-bad-expiry-key"})
|
||||
assert await _resolve_active_litellm_key(request) == "no_active_key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 (
|
||||
_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 = object()
|
||||
|
||||
request = _token_request({"content-type": "application/json"})
|
||||
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
|
||||
async def test_token_endpoint_uses_client_secret_basic_when_configured():
|
||||
"""LIT-4091: a server with token_endpoint_auth_method=client_secret_basic must send the
|
||||
|
|
|
|||
|
|
@ -5428,6 +5428,457 @@ def test_get_forwarded_auth_from_scope_skips_when_no_litellm_key_header():
|
|||
assert _get_forwarded_auth_from_scope(scope) is None
|
||||
|
||||
|
||||
def _delegate_auth_mcp_server(server_id: str = "delegate-1") -> MCPServer:
|
||||
return MCPServer(
|
||||
server_id=server_id,
|
||||
name="delegate_test",
|
||||
url="http://upstream:9401/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth2_flow="authorization_code",
|
||||
)
|
||||
|
||||
|
||||
def _delegate_scope(headers: list) -> dict:
|
||||
return {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/delegate_test",
|
||||
"scheme": "http",
|
||||
"server": ("localhost", 4000),
|
||||
"headers": headers,
|
||||
}
|
||||
|
||||
|
||||
def _patch_delegate_resolver(server: MCPServer, *resolvable_names: str):
|
||||
"""Patch the admission-parity resolver the delegate probe gates on. Returns
|
||||
``server`` only for names admission's ``get_mcp_server_by_name`` would match
|
||||
(alias / server_name / name); every other name (server_id, access group) yields
|
||||
None, exactly as the real resolver does."""
|
||||
|
||||
def _resolve(name, client_ip=None):
|
||||
return server if name in resolvable_names else None
|
||||
|
||||
return patch(
|
||||
"litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name",
|
||||
side_effect=_resolve,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_bad_token_gets_connect_time_401():
|
||||
"""Regression (LIT-4194): a rejected upstream token on a delegate-auth server
|
||||
must fail the connect with 401 + ``error="invalid_token"``, not be absorbed
|
||||
into HTTP 200 + an empty tool list by the tools/list handler.
|
||||
|
||||
Delegate-mode clients send only ``Authorization`` (no ``x-litellm-api-key``),
|
||||
so ``_get_forwarded_auth_from_scope`` returns None and, before the fix, the
|
||||
preflight returned early without probing.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
server = _delegate_auth_mcp_server()
|
||||
scope = _delegate_scope([(b"authorization", b"Bearer bogus-token")])
|
||||
|
||||
with _patch_delegate_resolver(server, "delegate_test"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, 'Bearer realm="upstream", error="invalid_token"')),
|
||||
) as probe:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["delegate_test"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
challenge = exc_info.value.headers["www-authenticate"]
|
||||
assert 'error="invalid_token"' in challenge
|
||||
assert 'resource_metadata="http://localhost:4000/.well-known/oauth-protected-resource/mcp/delegate_test"' in challenge
|
||||
probe.assert_awaited_once()
|
||||
probe_url, probe_auth = probe.call_args.args
|
||||
assert probe_url == "http://upstream:9401/mcp"
|
||||
assert probe_auth == "Bearer bogus-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_valid_token_passes_preflight():
|
||||
"""An upstream-accepted token must not be blocked by the delegate preflight."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
server = _delegate_auth_mcp_server()
|
||||
scope = _delegate_scope([(b"authorization", b"Bearer good-token")])
|
||||
|
||||
with _patch_delegate_resolver(server, "delegate_test"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(200, None)),
|
||||
) as probe:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["delegate_test"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
probe.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_valid_token_forbidden_returns_403():
|
||||
"""An upstream that accepts the token but forbids the caller (403) must surface
|
||||
as a bare 403 with no ``WWW-Authenticate`` re-auth hint (a fresh token with the
|
||||
same scopes would loop), not as an invalid_token challenge."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
server = _delegate_auth_mcp_server()
|
||||
scope = _delegate_scope([(b"authorization", b"Bearer scoped-out-token")])
|
||||
|
||||
with _patch_delegate_resolver(server, "delegate_test"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(403, None)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["delegate_test"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert not (exc_info.value.headers or {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_tokenless_request_not_probed():
|
||||
"""Tokenless delegate requests are the preemptive challenge's job; the
|
||||
preflight must not probe upstream with an empty credential."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
server = _delegate_auth_mcp_server()
|
||||
scope = _delegate_scope([(b"content-type", b"application/json")])
|
||||
|
||||
with _patch_delegate_resolver(server, "delegate_test"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, None)),
|
||||
) as probe:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["delegate_test"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
probe.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_preflight_skipped_on_multi_server_routes():
|
||||
"""The delegate probe is gated to single-server routes so one rejected token
|
||||
cannot 401 a multi-server aggregate connect (matching the OBO preflight)."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
servers = [_delegate_auth_mcp_server("delegate-1"), _delegate_auth_mcp_server("delegate-2")]
|
||||
scope = _delegate_scope([(b"authorization", b"Bearer bogus-token")])
|
||||
|
||||
with _patch_delegate_resolver(servers[0], "delegate_test", "other_server"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=servers),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, None)),
|
||||
) as probe:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["delegate_test", "other_server"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
probe.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_authorization_never_probes_passthrough_servers():
|
||||
"""A bare ``Authorization`` header may be a LiteLLM key (backward-compat), so
|
||||
only delegate servers (where admission classified it as an upstream token)
|
||||
may be probed with it; ``is_oauth_passthrough`` servers still require the
|
||||
unambiguous ``x-litellm-api-key`` + ``Authorization`` pair."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
passthrough_server = MCPServer(
|
||||
server_id="pt-1",
|
||||
name="pt_server",
|
||||
url="http://upstream:9402/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
oauth_passthrough=True,
|
||||
extra_headers=["Authorization"],
|
||||
)
|
||||
scope = _delegate_scope([(b"authorization", b"Bearer ambiguous-token")])
|
||||
|
||||
with _patch_delegate_resolver(passthrough_server, "pt_server"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[passthrough_server]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, None)),
|
||||
) as probe:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["pt_server"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
probe.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_not_probed_when_named_only_via_server_id():
|
||||
"""Security regression (LIT-4194): a delegate server reachable by the requested
|
||||
name only through its server_id (or an access group) is admitted as a real
|
||||
LiteLLM key by ``process_mcp_request`` (its ``get_mcp_server_by_name`` misses),
|
||||
so the bare ``Authorization`` header is that LiteLLM key. The probe must resolve
|
||||
the target through the SAME resolver and therefore skip it, never forwarding the
|
||||
key upstream, even though the widened allowed-server set still contains it."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
server = _delegate_auth_mcp_server(server_id="delegate-secret-id")
|
||||
# Admission's resolver matches alias/server_name/name only, never server_id: the
|
||||
# requested server_id resolves to None here, mirroring the real divergence.
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/delegate-secret-id",
|
||||
"scheme": "http",
|
||||
"server": ("localhost", 4000),
|
||||
"headers": [(b"authorization", b"Bearer sk-litellm-proxy-key")],
|
||||
}
|
||||
|
||||
with _patch_delegate_resolver(server, "delegate_test"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, None)),
|
||||
) as probe:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(user_id="u1", api_key="hashed-sk"),
|
||||
mcp_servers=["delegate-secret-id"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
probe.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_preflight_with_unpatched_probe():
|
||||
"""Integration across the preflight and the unpatched ``_probe_upstream_auth``,
|
||||
mocked only at the httpx-client boundary (tests/test_litellm is mocked-only; the
|
||||
real-network proof lives in the PR's live-proxy evidence). The mock honors the
|
||||
``AsyncHTTPHandler.post`` contract by raising ``httpx.HTTPStatusError`` on the
|
||||
upstream 401, so the production ``except httpx.HTTPStatusError`` branch is the one
|
||||
exercised. A rejected token surfaces as the connect-time 401 challenge; an
|
||||
accepted token passes untouched, and the caller's bearer reaches the delegate URL."""
|
||||
import httpx
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
accepted = MagicMock()
|
||||
accepted.status_code = 200
|
||||
accepted.headers = {}
|
||||
rejected = MagicMock()
|
||||
rejected.status_code = 401
|
||||
rejected.headers = {"www-authenticate": 'Bearer realm="stub-upstream", error="invalid_token"'}
|
||||
|
||||
async def respond_by_token(url=None, headers=None, json=None, timeout=None, **kwargs):
|
||||
if headers.get("Authorization") == "Bearer good-token":
|
||||
return accepted
|
||||
raise httpx.HTTPStatusError(
|
||||
"401 Unauthorized",
|
||||
request=httpx.Request("POST", url),
|
||||
response=rejected,
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.post = AsyncMock(side_effect=respond_by_token)
|
||||
|
||||
server = _delegate_auth_mcp_server()
|
||||
|
||||
with _patch_delegate_resolver(server, "delegate_test"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=_delegate_scope([(b"authorization", b"Bearer bogus-token")]),
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["delegate_test"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=_delegate_scope([(b"authorization", b"Bearer good-token")]),
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["delegate_test"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
challenge = exc_info.value.headers["www-authenticate"]
|
||||
assert 'error="invalid_token"' in challenge
|
||||
assert 'resource_metadata="http://localhost:4000/.well-known/oauth-protected-resource/mcp/delegate_test"' in challenge
|
||||
probed_urls = [call.kwargs["url"] for call in mock_client.post.await_args_list]
|
||||
assert probed_urls == ["http://upstream:9401/mcp", "http://upstream:9401/mcp"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_challenge_echoes_requested_alias():
|
||||
"""An alias-routed delegate request must be probed, and the challenge must echo
|
||||
the requested alias (not the canonical server name) so the resource_metadata
|
||||
URL matches what the tokenless preemptive challenge emits for the same route."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
server = _delegate_auth_mcp_server().model_copy(update={"alias": "dt-alias"})
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/dt-alias",
|
||||
"scheme": "http",
|
||||
"server": ("localhost", 4000),
|
||||
"headers": [(b"authorization", b"Bearer bogus-token")],
|
||||
}
|
||||
|
||||
with _patch_delegate_resolver(server, "dt-alias"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, 'Bearer error="invalid_token"')),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["dt-alias"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
challenge = exc_info.value.headers["www-authenticate"]
|
||||
assert 'error="invalid_token"' in challenge
|
||||
assert 'resource_metadata="http://localhost:4000/.well-known/oauth-protected-resource/mcp/dt-alias"' in challenge
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_probe_not_fanned_out_to_access_group_members():
|
||||
"""A single access-group name passes the one-target route gate but must not fan
|
||||
the delegate probe out to group-expanded member servers; the group name resolves
|
||||
to no server under admission's resolver, so no probe fires."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
group_member = _delegate_auth_mcp_server()
|
||||
|
||||
with _patch_delegate_resolver(group_member, "delegate_test"), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[group_member]),
|
||||
), patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, None)),
|
||||
) as probe:
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=_delegate_scope([(b"authorization", b"Bearer bogus-token")]),
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["prod_tools_group"],
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
probe.assert_not_awaited()
|
||||
|
||||
|
||||
def test_is_delegate_upstream_probe_target_fails_closed_on_m2m_shape():
|
||||
"""An unstamped M2M-shape row (null ``oauth2_flow`` + client credentials)
|
||||
resolves to ``client_credentials`` and must not be probed with the caller's
|
||||
bearer; its stored client credentials drive egress instead."""
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_is_delegate_upstream_probe_target,
|
||||
)
|
||||
|
||||
assert _is_delegate_upstream_probe_target(_delegate_auth_mcp_server()) is True
|
||||
|
||||
m2m_shape = MCPServer(
|
||||
server_id="delegate-m2m",
|
||||
name="delegate_m2m",
|
||||
url="http://upstream:9401/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth2_flow=None,
|
||||
token_url="http://idp:9000/token",
|
||||
client_id="client",
|
||||
client_secret="secret",
|
||||
)
|
||||
assert _is_delegate_upstream_probe_target(m2m_shape) is False
|
||||
|
||||
non_delegate = MCPServer(
|
||||
server_id="oauth2-plain",
|
||||
name="oauth2_plain",
|
||||
url="http://upstream:9401/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="authorization_code",
|
||||
)
|
||||
assert _is_delegate_upstream_probe_target(non_delegate) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mcp_client_sampling_disabled_by_default():
|
||||
"""Sampling callback must be None when allow_sampling is not set (default False)."""
|
||||
|
|
|
|||
|
|
@ -693,6 +693,8 @@ class TestLassoGuardrail:
|
|||
assert prompt_payload["messages"] == messages
|
||||
assert prompt_payload["userId"] == "test-user"
|
||||
assert prompt_payload["sessionId"] == "test-conversation"
|
||||
# Every call is attributed to the "litellm" integration for the "Used By" badge.
|
||||
assert prompt_payload["source"] == {"type": "litellm"}
|
||||
|
||||
# Test COMPLETION payload
|
||||
completion_messages = [{"role": "assistant", "content": "Test response"}]
|
||||
|
|
@ -703,6 +705,7 @@ class TestLassoGuardrail:
|
|||
assert completion_payload["messages"] == completion_messages
|
||||
assert completion_payload["userId"] == "test-user"
|
||||
assert completion_payload["sessionId"] == "test-conversation"
|
||||
assert completion_payload["source"] == {"type": "litellm"}
|
||||
|
||||
def test_header_preparation(self):
|
||||
"""Test header preparation."""
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from litellm.proxy._types import UserAPIKeyAuth
|
|||
from litellm.proxy.hooks.proxy_track_cost_callback import (
|
||||
_ProxyDBLogger,
|
||||
_get_budget_reservation_from_metadata,
|
||||
_should_track_cost_callback,
|
||||
_update_database_and_spend_counters,
|
||||
)
|
||||
|
||||
|
|
@ -1177,3 +1178,88 @@ async def test_track_cost_callback_enriches_user_id_for_mcp_style_metadata():
|
|||
kwargs["litellm_params"]["metadata"]["user_api_key_user_id"]
|
||||
== "mcp-user@example.com"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call_type, expected",
|
||||
[
|
||||
("pass_through_endpoint", True),
|
||||
("llm_passthrough_route", True),
|
||||
("allm_passthrough_route", True),
|
||||
("acompletion", False),
|
||||
("call_mcp_tool", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_should_track_cost_callback_pass_through_without_owner(call_type, expected):
|
||||
"""Regression for LIT-3782: unauthenticated pass-through requests (auth=false)
|
||||
carry no key/user/team/end-user, yet must still be tracked so they land in
|
||||
LiteLLM_SpendLogs. Other call types with no owner stay untracked."""
|
||||
assert (
|
||||
_should_track_cost_callback(
|
||||
user_api_key=None,
|
||||
user_id=None,
|
||||
team_id=None,
|
||||
end_user_id=None,
|
||||
call_type=call_type,
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call_type, expect_spend_log",
|
||||
[
|
||||
("pass_through_endpoint", True),
|
||||
("acompletion", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_cost_callback_logs_unauthenticated_pass_through_request(
|
||||
call_type, expect_spend_log
|
||||
):
|
||||
"""Regression for LIT-3782: a pass-through request with auth=false reaches the
|
||||
cost callback with no key/user/team/end-user. Before the fix the spend-log
|
||||
write was skipped and the request never appeared in request/usage logs. It
|
||||
must now be written for pass-through call types while other unauthenticated
|
||||
calls remain skipped."""
|
||||
logger = _ProxyDBLogger()
|
||||
|
||||
kwargs = {
|
||||
"call_type": call_type,
|
||||
"model": "unknown",
|
||||
"litellm_params": {"metadata": {}},
|
||||
"standard_logging_object": {
|
||||
"response_cost": 0.0,
|
||||
"request_tags": None,
|
||||
},
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.increment_spend_counters",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.update_cache",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj",
|
||||
) as mock_proxy_logging,
|
||||
):
|
||||
mock_proxy_logging.db_spend_update_writer.update_database = AsyncMock()
|
||||
mock_proxy_logging.slack_alerting_instance.customer_spend_alert = AsyncMock()
|
||||
|
||||
await logger._PROXY_track_cost_callback(
|
||||
kwargs=kwargs,
|
||||
completion_response=None,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
assert mock_proxy_logging.db_spend_update_writer.update_database.await_count == (
|
||||
1 if expect_spend_log else 0
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,8 +39,13 @@ def _output_item_added_chunk():
|
|||
return SimpleNamespace(type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED)
|
||||
|
||||
|
||||
def _completed_chunk(output):
|
||||
response = ResponsesAPIResponse(id="resp-1", created_at=0, output=output)
|
||||
def _created_chunk(response_id: str):
|
||||
response = ResponsesAPIResponse(id=response_id, created_at=0, output=[])
|
||||
return SimpleNamespace(type=ResponsesAPIStreamEvents.RESPONSE_CREATED, response=response)
|
||||
|
||||
|
||||
def _completed_chunk(output, response_id: str = "resp-1"):
|
||||
response = ResponsesAPIResponse(id=response_id, created_at=0, output=output)
|
||||
return SimpleNamespace(type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=response)
|
||||
|
||||
|
||||
|
|
@ -52,12 +57,12 @@ def _text_message(text: str):
|
|||
return {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]}
|
||||
|
||||
|
||||
def _tool_call_stream(call_id: str, tool_name: str) -> _FakeAsyncStream:
|
||||
return _FakeAsyncStream([_completed_chunk([_function_call(call_id, tool_name)])])
|
||||
def _tool_call_stream(call_id: str, tool_name: str, response_id: str = "resp-1") -> _FakeAsyncStream:
|
||||
return _FakeAsyncStream([_completed_chunk([_function_call(call_id, tool_name)], response_id=response_id)])
|
||||
|
||||
|
||||
def _text_only_stream(text: str) -> _FakeAsyncStream:
|
||||
return _FakeAsyncStream([_completed_chunk([_text_message(text)])])
|
||||
def _text_only_stream(text: str, response_id: str = "resp-1") -> _FakeAsyncStream:
|
||||
return _FakeAsyncStream([_completed_chunk([_text_message(text)], response_id=response_id)])
|
||||
|
||||
|
||||
def _mock_mcp_environment(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
|
||||
|
|
@ -170,3 +175,85 @@ async def test_tool_call_rounds_are_capped(monkeypatch):
|
|||
for call in aresponses_mock.call_args_list[:-1]:
|
||||
assert "tools" in call.kwargs
|
||||
assert "tools" not in aresponses_mock.call_args_list[-1].kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuation_id_is_final_round_not_interim_tool_call(monkeypatch):
|
||||
"""
|
||||
Regression test for the broken `previous_response_id` continuation after a
|
||||
gateway-executed MCP tool call. Each auto-execute round is a distinct
|
||||
upstream response: the interim round holds only the model's function_call
|
||||
(no tool output), the final round holds the answer. The client must be
|
||||
handed the FINAL round's response id, because that is the one whose stored
|
||||
chain includes the function_call_output. Pinning every event to the interim
|
||||
round's id made the next turn continue from a response with a dangling
|
||||
function_call, which the provider rejects with
|
||||
"No tool output found for function call ...".
|
||||
"""
|
||||
_mock_mcp_environment(monkeypatch)
|
||||
|
||||
aresponses_mock = AsyncMock(side_effect=[_text_only_stream("The first item is Alpha.", response_id="resp-final")])
|
||||
monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock)
|
||||
|
||||
iterator = _make_iterator(
|
||||
[
|
||||
_created_chunk("resp-interim"),
|
||||
_output_item_added_chunk(),
|
||||
_completed_chunk([_function_call("call_1", "read_wiki_contents")], response_id="resp-interim"),
|
||||
]
|
||||
)
|
||||
|
||||
chunks = [chunk async for chunk in iterator]
|
||||
completed = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED]
|
||||
|
||||
assert completed[-1].response.output[0]["content"][0]["text"] == "The first item is Alpha."
|
||||
assert completed[-1].response.id == "resp-final"
|
||||
assert completed[-1].response.id != "resp-interim"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_follow_up_call_failure_emits_terminal_error_event(monkeypatch):
|
||||
"""
|
||||
Regression test for the silent-swallow path: when the follow-up LLM call
|
||||
raises, the stream must emit a terminal `error` event instead of ending
|
||||
silently after the tool events (which surfaced to clients as a successful
|
||||
but empty completion).
|
||||
"""
|
||||
_mock_mcp_environment(monkeypatch)
|
||||
|
||||
aresponses_mock = AsyncMock(side_effect=RuntimeError("boom from provider"))
|
||||
monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock)
|
||||
|
||||
iterator = _make_iterator(
|
||||
[
|
||||
_output_item_added_chunk(),
|
||||
_completed_chunk([_function_call("call_1", "read_wiki_contents")]),
|
||||
]
|
||||
)
|
||||
|
||||
chunks = [chunk async for chunk in iterator]
|
||||
error_events = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.ERROR]
|
||||
|
||||
assert len(error_events) == 1
|
||||
assert error_events[0].error.type == "mcp_gateway_error"
|
||||
assert "boom from provider" in error_events[0].error.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_call_failure_is_stashed_for_eager_reraise(monkeypatch):
|
||||
"""
|
||||
Regression test: a failing initial LLM call must be stashed as
|
||||
`_initial_creation_error` so `aresponses_api_with_mcp` can re-raise it as a
|
||||
real 4xx before any SSE bytes are written, instead of returning HTTP 200
|
||||
with an empty stream.
|
||||
"""
|
||||
_mock_mcp_environment(monkeypatch)
|
||||
|
||||
aresponses_mock = AsyncMock(side_effect=RuntimeError("initial boom"))
|
||||
monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock)
|
||||
|
||||
iterator = _make_iterator([_output_item_added_chunk()])
|
||||
await iterator._create_initial_response_iterator()
|
||||
|
||||
assert iterator._initial_creation_error is not None
|
||||
assert "initial boom" in str(iterator._initial_creation_error)
|
||||
|
|
|
|||
|
|
@ -2081,3 +2081,79 @@ def test_stream_chunk_builder_text_completion_combines_text_and_usage():
|
|||
assert response.usage.prompt_tokens > 0
|
||||
assert response.usage.completion_tokens > 0
|
||||
assert response.usage.total_tokens == response.usage.prompt_tokens + response.usage.completion_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"aws_credential_kwargs",
|
||||
[
|
||||
{
|
||||
"aws_session_name": "litellm-gcp",
|
||||
"aws_role_name": "arn:aws:iam::123456789012:role/litellm-bedrock-role",
|
||||
"aws_web_identity_token": "oidc/google/108963886734710037768",
|
||||
},
|
||||
{
|
||||
"aws_access_key_id": "AKIASTATICKEYFORTEST",
|
||||
"aws_secret_access_key": "static-secret-key",
|
||||
"aws_session_token": "static-session-token",
|
||||
},
|
||||
],
|
||||
ids=["web_identity", "static_keys"],
|
||||
)
|
||||
async def test_acompletion_forwards_aws_credentials_through_responses_bridge(
|
||||
respx_mock: respx.MockRouter, monkeypatch, aws_credential_kwargs: dict
|
||||
):
|
||||
from botocore.credentials import Credentials
|
||||
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
||||
original_disable_aiohttp = litellm.disable_aiohttp_transport
|
||||
try:
|
||||
litellm.disable_aiohttp_transport = True
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.delenv("BEDROCK_MANTLE_API_KEY", raising=False)
|
||||
|
||||
get_credentials_mock = MagicMock(return_value=Credentials("fake-key", "fake-secret"))
|
||||
monkeypatch.setattr(BaseAWSLLM, "get_credentials", get_credentials_mock)
|
||||
|
||||
respx_mock.post("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses").respond(
|
||||
json={
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"created_at": 1760144904,
|
||||
"status": "completed",
|
||||
"model": "openai.gpt-5.4",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "ok", "annotations": []}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model="bedrock_mantle/openai.gpt-5.4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_base="https://bedrock-mantle.us-east-2.api.aws/v1",
|
||||
aws_region_name="us-east-2",
|
||||
num_retries=0,
|
||||
**aws_credential_kwargs,
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "ok"
|
||||
credential_kwargs = get_credentials_mock.call_args.kwargs
|
||||
assert credential_kwargs["aws_region_name"] == "us-east-2"
|
||||
for key, value in aws_credential_kwargs.items():
|
||||
assert credential_kwargs[key] == value
|
||||
authorization = respx_mock.calls.last.request.headers["Authorization"]
|
||||
assert authorization.startswith("AWS4-HMAC-SHA256")
|
||||
assert "fake-key" in authorization
|
||||
finally:
|
||||
litellm.disable_aiohttp_transport = original_disable_aiohttp
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
|
|
|||
117
uv.lock
generated
117
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-07-08T23:20:11.959202Z"
|
||||
exclude-newer = "2026-07-10T16:47:58.286372Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -3588,7 +3588,7 @@ ci = [
|
|||
{ name = "logfire", specifier = "==4.6.0" },
|
||||
{ name = "lunary", marker = "python_full_version == '3.10.*'", specifier = "==1.4.36" },
|
||||
{ name = "lunary", marker = "python_full_version >= '3.11'", specifier = "==1.4.37" },
|
||||
{ name = "pillow", specifier = "==12.2.0" },
|
||||
{ name = "pillow", specifier = "==12.3.0" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.11" },
|
||||
{ name = "pyarrow", specifier = "==23.0.1" },
|
||||
{ name = "pygithub", specifier = "==2.8.1" },
|
||||
|
|
@ -5279,75 +5279,54 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue