Merge remote-tracking branch 'origin/main' into litellm_settings_store_immutable

This commit is contained in:
Yuneng Jiang 2026-09-18 22:39:29 -07:00
commit 3a480a5d6c
No known key found for this signature in database
30 changed files with 2613 additions and 188 deletions

View file

@ -2223,6 +2223,7 @@ class BaseLLMHTTPHandler:
# Prepare headers
kwargs = kwargs or {}
kwargs_for_agentic: Final = self._agentic_hook_kwargs(kwargs=kwargs, api_key=api_key, api_base=api_base)
provider_specific_header: Final = cast(
litellm.types.utils.ProviderSpecificHeader | Sequence[litellm.types.utils.ProviderSpecificHeader] | None,
kwargs.get("provider_specific_header", None),
@ -2410,7 +2411,7 @@ class BaseLLMHTTPHandler:
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
kwargs={**kwargs, "api_key": api_key} if api_key else kwargs,
kwargs=kwargs_for_agentic,
hold_back=bool(held_back_tool_names),
server_fulfilled_tool_names=held_back_tool_names,
)
@ -2433,8 +2434,7 @@ class BaseLLMHTTPHandler:
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
api_key=api_key,
kwargs=kwargs,
kwargs=kwargs_for_agentic,
)
async def _finalize_anthropic_messages_response(
@ -2447,14 +2447,8 @@ class BaseLLMHTTPHandler:
anthropic_messages_optional_request_params: dict,
logging_obj: LiteLLMLoggingObj,
custom_llm_provider: str,
api_key: str | None,
kwargs: dict,
kwargs: dict[str, object],
) -> AnthropicMessagesResponse | AsyncIterator:
# Inject api_key into kwargs so follow-up calls in agentic hooks can
# authenticate. api_key is a named param here (not in kwargs), so
# _prepare_followup_kwargs would miss it otherwise.
kwargs_for_agentic: Final = {**kwargs, "api_key": api_key} if api_key else kwargs
# Call agentic completion hooks (non-streaming path only)
final_response: Final = await self._call_agentic_completion_hooks(
response=initial_response,
model=model,
@ -2464,7 +2458,7 @@ class BaseLLMHTTPHandler:
logging_obj=logging_obj,
stream=False,
custom_llm_provider=custom_llm_provider,
kwargs=kwargs_for_agentic,
kwargs=kwargs,
)
return self._maybe_wrap_in_fake_stream(
@ -5312,6 +5306,15 @@ class BaseLLMHTTPHandler:
fingerprints: Final = list(kwargs.get("_agentic_loop_fingerprints", []) or [])
return depth, max_loops, fingerprints
@staticmethod
def _agentic_hook_kwargs(
kwargs: Mapping[str, object], api_key: str | None, api_base: str | None
) -> dict[str, object]:
"""``api_key`` and ``api_base`` are named parameters of ``anthropic_messages`` rather than kwargs, so the
follow-up call an agentic hook makes only reaches the same deployment if they are re-added here."""
deployment_params: Final = {"api_key": api_key, "api_base": api_base}
return {**kwargs, **{key: value for key, value in deployment_params.items() if value}}
@staticmethod
def _has_agentic_completion_hook(logging_obj: LiteLLMLoggingObj) -> bool:
"""
@ -6604,7 +6607,7 @@ class BaseLLMHTTPHandler:
first_message: str | None = None,
request_defaults: ResponsesWebSocketRequestDefaults | None = None,
**kwargs: Any,
):
) -> Exception | None:
"""
Handles Responses API WebSocket mode.
@ -6638,7 +6641,7 @@ class BaseLLMHTTPHandler:
**kwargs,
)
await handler.run()
return
return None
import websockets
from websockets.asyncio.client import ClientConnection
@ -6757,9 +6760,10 @@ class BaseLLMHTTPHandler:
output_guardrail_callbacks=_ws_output_guardrail_callbacks,
quota_callbacks=_ws_quota_callbacks,
authorized_model=model,
custom_llm_provider=custom_llm_provider,
request_defaults=request_defaults,
)
await streaming.bidirectional_forward()
return await streaming.bidirectional_forward()
except websockets.exceptions.InvalidStatusCode as e:
verbose_logger.exception("Error connecting to responses WS backend: %s", e)
@ -6773,6 +6777,7 @@ class BaseLLMHTTPHandler:
pass
else:
raise Exception(f"Unexpected error while closing WebSocket: {close_error}")
return None
def image_edit_handler(
self,

View file

@ -262,7 +262,12 @@ async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | No
return loaded if isinstance(loaded, str) else None
async def load_active_user_by_id(user_id: str) -> "LiteLLM_UserTable | _KeyResolutionFailure":
UserRowSource = Literal["cache", "database"]
async def load_active_user_by_id(
user_id: str, source: UserRowSource = "cache"
) -> "LiteLLM_UserTable | _KeyResolutionFailure":
"""Load a live litellm user by id, returning the record when the user is active or a precise
failure otherwise. The interactive DCR client authenticates via SSO, so its refresh envelope seals a
user subject; renewing it must re-check the user is still live (present and not SCIM-deactivated) so a
@ -273,7 +278,11 @@ async def load_active_user_by_id(user_id: str) -> "LiteLLM_UserTable | _KeyResol
``HTTPException``, a SCIM-deactivated user, and, unlike the key path, a missing user. ``get_user_object``
catches every DB failure and re-raises a bare ``ValueError`` (a deleted user and a real outage look
identical, the original error surviving only as ``__context__``), so the outage check walks the cause
chain, and a missing user falls through to ``no_active_key`` rather than an opaque gateway fault."""
chain, and a missing user falls through to ``no_active_key`` rather than an opaque gateway fault.
``source="database"`` reads the row from the database, never the cache, so the credential mint refuses
a user that a writer deactivated or deleted without evicting the cached row, and it leaves the fresh
row in the cache for the requests the credential makes next. Every other caller keeps the cache read,
so introspection, which a resource server may call per request, stays off the database."""
from litellm.proxy._types import (
ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import
)
@ -296,6 +305,7 @@ async def load_active_user_by_id(user_id: str) -> "LiteLLM_UserTable | _KeyResol
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
check_db_only=source == "database",
)
except (ProxyException, HTTPException):
return "no_active_key"

View file

@ -59,6 +59,11 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
register_aggregate_client,
relative_request_url,
revoke_refresh_token,
supported_grant_types,
)
from litellm.proxy._experimental.mcp_server.idp_token_exchange import (
exchange_idp_subject_token,
token_exchange_available,
)
from litellm.proxy._experimental.mcp_server.oauth_identity_binding import (
RefreshOwnershipProven,
@ -1980,6 +1985,9 @@ async def token_endpoint(
refresh_token: str | None = Form(None),
scope: str | None = Form(None),
resource: str | None = Form(None),
subject_token: str | None = Form(None),
subject_token_type: str | None = Form(None),
requested_token_type: str | None = Form(None),
mcp_server_name: str | None = None,
):
"""
@ -2010,6 +2018,10 @@ async def token_endpoint(
cache=user_api_key_cache,
resource=resource,
mint_proxy_credential=mint_proxy_credential,
subject_token=subject_token,
subject_token_type=subject_token_type,
requested_token_type=requested_token_type,
exchange_subject_token=exchange_idp_subject_token,
)
lookup_name: Final = mcp_server_name or client_id
@ -2131,7 +2143,9 @@ async def introspect_endpoint(token: str = Form(...)) -> Response:
async def native_client_auth_discovery(request: Request) -> JSONResponse:
"""The versioned contract a native client (``lite login --pkce``, or a CLI in any other
language) reads to sign a user in through the browser and obtain a proxy credential."""
return JSONResponse(native_client_auth_contract(request), headers=TOKEN_NO_CACHE_HEADERS)
return JSONResponse(
native_client_auth_contract(request, token_exchange_available()), headers=TOKEN_NO_CACHE_HEADERS
)
# Per RFC 6749 §4.1.2.1, an IdP that rejects an OAuth authorization request
@ -2619,7 +2633,7 @@ def _build_aggregate_protected_resource_response(request: Request) -> dict:
}
def _build_aggregate_authorization_server_response(request: Request) -> dict:
def _build_aggregate_authorization_server_response(request: Request, token_exchange_available: bool) -> dict:
"""RFC 8414 metadata for the gateway as the aggregate authorization server.
The issuer is ``{base}/mcp`` and must stay equal to the value the
@ -2638,7 +2652,7 @@ def _build_aggregate_authorization_server_response(request: Request) -> dict:
"registration_endpoint": f"{request_base_url}/register",
"response_types_supported": ["code"],
"scopes_supported": [],
"grant_types_supported": ["authorization_code", "refresh_token"],
"grant_types_supported": supported_grant_types(token_exchange_available),
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
}
@ -2676,7 +2690,7 @@ async def oauth_authorization_server_aggregate(request: Request):
per-server row win here instead would serve an issuer of {base} against a resource that
advertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.
"""
return _build_aggregate_authorization_server_response(request)
return _build_aggregate_authorization_server_response(request, token_exchange_available())
# Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name}
@ -2902,7 +2916,9 @@ async def register_client(request: Request, mcp_server_name: str | None = None):
# advertises that), so this does not affect it. A request without redirect_uris is not
# a DCR request, so the legacy single-server-or-dummy fallback is kept for it.
if data.get("redirect_uris"):
return await register_aggregate_client(request=request, request_body=data)
return await register_aggregate_client(
request=request, request_body=data, token_exchange_available=token_exchange_available()
)
resolved: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)
if resolved:
return await register_client_with_server(

View file

@ -51,7 +51,7 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from typing_extensions import ReadOnly, TypedDict, assert_never
from typing_extensions import NotRequired, ReadOnly, TypedDict, assert_never
from litellm._logging import verbose_logger
from litellm.caching.caching import DualCache
@ -187,6 +187,52 @@ class MintProxyCredential(Protocol):
) -> Awaitable[MintedProxyCredential | ProxyCredentialMintFailure]: ...
TOKEN_EXCHANGE_GRANT_TYPE: Final = "urn:ietf:params:oauth:grant-type:token-exchange"
def supported_grant_types(token_exchange_available: bool) -> tuple[str, ...]:
"""The grants ``/token`` can serve on this deployment. The RFC 8693 exchange is listed
only where the JWT auth that proves a subject token is on, backed by a database, and
licensed, so a client never selects a grant the gateway would then refuse."""
if token_exchange_available:
return ("authorization_code", "refresh_token", TOKEN_EXCHANGE_GRANT_TYPE)
return ("authorization_code", "refresh_token")
"""RFC 8693: a native client that already holds a token from the customer's identity
provider trades it for the proxy-API credential without a browser round trip."""
_IssuedTokenType = Literal["urn:ietf:params:oauth:token-type:access_token"]
ACCESS_TOKEN_TOKEN_TYPE: Final[_IssuedTokenType] = "urn:ietf:params:oauth:token-type:access_token"
SUBJECT_TOKEN_TYPES: Final = frozenset(
{
"urn:ietf:params:oauth:token-type:jwt",
"urn:ietf:params:oauth:token-type:id_token",
ACCESS_TOKEN_TOKEN_TYPE,
}
)
class SubjectIdentity(BaseModel):
model_config = ConfigDict(frozen=True)
user_id: str = Field(min_length=1)
team_id: str | None = None
class SubjectTokenRefusal(BaseModel):
model_config = ConfigDict(frozen=True)
error: Literal["unsupported_grant_type", "invalid_request", "temporarily_unavailable"]
description: str = Field(min_length=1)
class ExchangeSubjectToken(Protocol):
"""Injected RFC 8693 subject-token verifier ``(subject_token, request)``: proves the
IdP token the way the proxy's own JWT auth does and names the litellm user and team it
stands for, or says why this gateway will not take it."""
def __call__(self, subject_token: str, request: Request, /) -> Awaitable[SubjectIdentity | SubjectTokenRefusal]: ...
class ConsentTeam(BaseModel):
model_config = ConfigDict(frozen=True)
team_id: str = Field(min_length=1)
@ -213,6 +259,12 @@ async def _refuse_proxy_credential(user_id: str, team_id: str | None) -> ProxyCr
return "unresolvable"
async def _refuse_subject_token(subject_token: str, request: Request) -> SubjectTokenRefusal:
return SubjectTokenRefusal(
error="unsupported_grant_type", description="this gateway is not configured to exchange IdP tokens"
)
async def _unavailable_vendor_credential(user_id: str, server_id: str) -> VendorCredentialState:
return "unavailable"
@ -318,7 +370,9 @@ def open_gateway_dcr_client(client_id: str) -> GatewayDcrClient | None:
return _open_sealed(client_id, GATEWAY_DCR_CLIENT_ID_PREFIX, GatewayDcrClient, _CLIENT_RECORD_DEBUG_KEY)
async def register_aggregate_client(request: Request, request_body: Mapping[str, object]) -> Response:
async def register_aggregate_client(
request: Request, request_body: Mapping[str, object], token_exchange_available: bool
) -> Response:
"""RFC 7591 dynamic registration against the gateway itself, statelessly.
Only ``redirect_uris`` is authoritative; every client is registered as a public
@ -382,7 +436,7 @@ async def register_aggregate_client(request: Request, request_body: Mapping[str,
"client_id_issued_at": int(now.timestamp()),
"redirect_uris": list(raw_uris),
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"grant_types": list(supported_grant_types(token_exchange_available)),
"response_types": ["code"],
},
)
@ -580,7 +634,7 @@ class NativeClientAuthContract(TypedDict):
revocation_endpoint_auth_methods_supported: ReadOnly[tuple[str, ...]]
def native_client_auth_contract(request: Request) -> NativeClientAuthContract:
def native_client_auth_contract(request: Request, token_exchange_available: bool) -> NativeClientAuthContract:
"""The versioned discovery document at ``/.well-known/litellm-cli-auth``: everything a
native client (in any language) needs to run the sign-in without reading LiteLLM
source. ``resource`` is the exact value to send as the RFC 8707 ``resource`` parameter
@ -595,7 +649,7 @@ def native_client_auth_contract(request: Request) -> NativeClientAuthContract:
"revocation_endpoint": f"{base_url}/revoke",
"resource": base_url,
"response_types_supported": ("code",),
"grant_types_supported": ("authorization_code", "refresh_token"),
"grant_types_supported": supported_grant_types(token_exchange_available),
"code_challenge_methods_supported": ("S256",),
"token_endpoint_auth_methods_supported": ("none",),
"revocation_endpoint_auth_methods_supported": ("none",),
@ -1033,20 +1087,26 @@ class _ProxyCredentialTokenResponse(TypedDict):
refresh_token: ReadOnly[str]
user_id: ReadOnly[str]
team_id: ReadOnly[str | None]
issued_token_type: NotRequired[ReadOnly[_IssuedTokenType]]
def _proxy_credential_response(
minted: MintedProxyCredential, principal: SessionPrincipal, keys: SessionSigningKeys, now: datetime
minted: MintedProxyCredential,
principal: SessionPrincipal,
keys: SessionSigningKeys,
now: datetime,
issued_token_type: _IssuedTokenType | None = None,
) -> Response:
"""The proxy-API token response: the access token is the very credential ``lite
login`` stores (accepted on every proxy route with user and team attribution), and
the refresh token is a gateway-sealed rotating token bound to the team the credential
was minted for, so a renewal keeps the team the user consented to."""
was minted for, so a renewal keeps the team the user consented to. A token exchange
also states ``issued_token_type``, which RFC 8693 section 2.2.1 requires."""
bound_principal: Final = principal.model_copy(update=MappingProxyType({"team_id": minted.team_id}))
refresh: Final = mint_session_refresh_token(bound_principal, keys, now)
if not isinstance(refresh, MintedSessionToken):
return _oauth_error(500, "server_error", "failed to mint the session credential")
body: Final[_ProxyCredentialTokenResponse] = {
credential: Final[_ProxyCredentialTokenResponse] = {
"access_token": minted.key,
"token_type": "Bearer",
"expires_in": minted.expires_in,
@ -1054,7 +1114,10 @@ def _proxy_credential_response(
"user_id": minted.user_id,
"team_id": minted.team_id,
}
return JSONResponse(status_code=200, content=body, headers=TOKEN_NO_CACHE_HEADERS)
if issued_token_type is None:
return JSONResponse(status_code=200, content=credential, headers=TOKEN_NO_CACHE_HEADERS)
exchanged: Final[_ProxyCredentialTokenResponse] = {**credential, "issued_token_type": issued_token_type}
return JSONResponse(status_code=200, content=exchanged, headers=TOKEN_NO_CACHE_HEADERS)
def _reload_failure_response(failure: ReloadUserFailure) -> Response:
@ -1073,6 +1136,16 @@ def _reload_failure_response(failure: ReloadUserFailure) -> Response:
assert_never(failure)
def _subject_token_refusal_response(refusal: SubjectTokenRefusal) -> Response:
match refusal.error:
case "temporarily_unavailable":
return _oauth_error(503, refusal.error, refusal.description)
case "unsupported_grant_type" | "invalid_request":
return _oauth_error(400, refusal.error, refusal.description)
case _:
assert_never(refusal.error)
def _mint_failure_response(failure: ProxyCredentialMintFailure) -> Response:
match failure:
case "not_a_member":
@ -1116,11 +1189,16 @@ async def aggregate_token(
cache: DualCache,
resource: str | None = None,
mint_proxy_credential: MintProxyCredential = _refuse_proxy_credential,
subject_token: str | None = None,
subject_token_type: str | None = None,
requested_token_type: str | None = None,
exchange_subject_token: ExchangeSubjectToken = _refuse_subject_token,
) -> Response:
"""The aggregate token verb: authorization_code and refresh_token grants for the
identity-only session pair, or for the proxy-API credential when the grant was issued
with that audience. Every path re-validates the litellm user live before minting, so a
deactivated user cannot obtain or renew a session."""
with that audience, and the RFC 8693 token exchange that turns an IdP token straight
into the proxy-API credential. Every path re-validates the litellm user live before
minting, so a deactivated user cannot obtain or renew a session."""
if master_key is None:
verbose_logger.error("mcp_gateway_dcr token grant rejected: no master_key configured")
return _oauth_error(500, "server_error", "the gateway has no master key configured")
@ -1159,7 +1237,20 @@ async def aggregate_token(
now=now,
issue=issue,
)
return _oauth_error(400, "unsupported_grant_type", "grant_type must be authorization_code or refresh_token")
if grant_type == TOKEN_EXCHANGE_GRANT_TYPE:
return await _token_exchange_grant(
subject_token=subject_token,
subject_token_type=subject_token_type,
requested_token_type=requested_token_type,
client_id=client_id,
exchange_subject_token=exchange_subject_token,
issue=issue,
)
return _oauth_error(
400,
"unsupported_grant_type",
f"grant_type must be authorization_code, refresh_token, or {TOKEN_EXCHANGE_GRANT_TYPE}",
)
class _GrantIssuer:
@ -1211,10 +1302,9 @@ class _GrantIssuer:
async def _issue_proxy_credential(
self, principal: SessionPrincipal, claim_key: str, claim_ttl_seconds: int, replayed: str
) -> Response:
if self._resource is not None and not is_proxy_api_resource(self._request, self._resource):
return _oauth_error(
400, "invalid_target", "resource does not match the proxy API this grant was issued for"
)
target_refusal: Final = self._proxy_api_target_refusal()
if target_refusal is not None:
return target_refusal
minted: Final = await self._mint_proxy_credential(principal.user_id, principal.team_id)
if not isinstance(minted, MintedProxyCredential):
return _mint_failure_response(minted)
@ -1223,6 +1313,33 @@ class _GrantIssuer:
return refusal
return _proxy_credential_response(minted, principal, self._keys, self._now)
async def exchange(
self, subject_token: str, client_id: str, exchange_subject_token: ExchangeSubjectToken
) -> Response:
"""The RFC 8693 tail: prove the IdP token, then mint. No single-use marker, because
the subject token stays a valid proof for as long as the IdP says it is and every
exchange mints a fresh credential and refresh token of its own."""
target_refusal: Final = self._proxy_api_target_refusal()
if target_refusal is not None:
return target_refusal
identity: Final = await exchange_subject_token(subject_token, self._request)
if isinstance(identity, SubjectTokenRefusal):
return _subject_token_refusal_response(identity)
principal: Final = SessionPrincipal(
user_id=identity.user_id, client_id=client_id, audience=PROXY_API_AUDIENCE, team_id=identity.team_id
)
minted: Final = await self._mint_proxy_credential(principal.user_id, principal.team_id)
if not isinstance(minted, MintedProxyCredential):
return _mint_failure_response(minted)
return _proxy_credential_response(
minted, principal, self._keys, self._now, issued_token_type=ACCESS_TOKEN_TOKEN_TYPE
)
def _proxy_api_target_refusal(self) -> Response | None:
if self._resource is None or is_proxy_api_resource(self._request, self._resource):
return None
return _oauth_error(400, "invalid_target", "resource does not match the proxy API this grant was issued for")
async def _claim_refusal(self, claim_key: str, claim_ttl_seconds: int, replayed: str) -> Response | None:
return _claim_refusal(
await self._guard.claim(claim_key, claim_ttl_seconds), replayed=_oauth_error(400, "invalid_grant", replayed)
@ -1297,6 +1414,32 @@ async def _refresh_token_grant(
)
async def _token_exchange_grant(
subject_token: str | None,
subject_token_type: str | None,
requested_token_type: str | None,
client_id: str,
exchange_subject_token: ExchangeSubjectToken,
issue: _GrantIssuer,
) -> Response:
"""RFC 8693 token exchange for a registered native client that already holds an IdP
token: the gateway proves the token the way its JWT auth does and answers with the
proxy-API credential, so a fresh laptop with only an IdP login gets a gateway key
without a browser round trip. The client must be registered because the refresh token
in the answer is bound to it."""
if not is_gateway_dcr_client_id(client_id) or open_gateway_dcr_client(client_id) is None:
return _oauth_error(401, "invalid_client", "unknown or malformed client_id")
if not subject_token or not subject_token_type:
return _oauth_error(400, "invalid_request", "subject_token and subject_token_type are required")
if subject_token_type not in SUBJECT_TOKEN_TYPES:
return _oauth_error(
400, "invalid_request", f"subject_token_type must be one of {', '.join(sorted(SUBJECT_TOKEN_TYPES))}"
)
if requested_token_type is not None and requested_token_type != ACCESS_TOKEN_TOKEN_TYPE:
return _oauth_error(400, "invalid_request", f"requested_token_type must be {ACCESS_TOKEN_TOKEN_TYPE}")
return await issue.exchange(subject_token, client_id, exchange_subject_token)
async def revoke_refresh_token(token: str, client_id: str, master_key: str | None, cache: DualCache) -> Response:
"""RFC 7009 revocation for the gateway's refresh tokens: burn the presented token's
``jti`` so neither the holder nor a thief can rotate it again. Access tokens are

View file

@ -0,0 +1,217 @@
"""The identity-provider side of the RFC 8693 token exchange on ``POST /token``: a native
client that already holds a JWT from the customer's IdP trades it for the same proxy-API
credential ``lite login`` stores, proven by the proxy's own JWT auth (signature, claims,
and the user and team sync it performs), so no browser round trip is needed."""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from typing import Final, Literal, Protocol
from fastapi import HTTPException, Request
from typing_extensions import assert_never
from litellm._logging import verbose_proxy_logger
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import SubjectIdentity, SubjectTokenRefusal
from litellm.proxy._types import JWTAuthBuilderResult, ProxyException
from litellm.proxy.auth.handle_jwt import JWTAuthManager, JWTHandler
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
EXCHANGE_ROUTE: Final = "/token"
REJECTED_SUBJECT_TOKEN: Final = "subject_token was rejected by the gateway's JWT auth"
SUBJECT_TOKEN_CHECK_UNAVAILABLE: Final = (
"the gateway could not verify subject_token because its identity provider or database is unavailable; retry"
)
SUBJECT_TOKEN_CHECK_FAULTED: Final = (
"the gateway could not verify subject_token because its database reported a fault that is not a transient "
"outage; retrying will not help until the gateway deployment is repaired"
)
GatewayOutage = Literal["retryable", "faulted"]
@dataclass(frozen=True, slots=True)
class TokenExchangePrerequisites:
"""The deployment-level gates ``user_api_key_auth`` applies before it verifies any JWT
bearer, plus the JWT-to-virtual-key mapping it consults first: a gateway that maps
tokens authenticates a JWT as its mapped key, with that key's models and budget, or
refuses an unmapped one, and the exchange proves the token through ``auth_builder``
alone, so it would mint the user's own credential past that policy. Discovery and
registration advertise the exchange grant only when every gate holds, and an exchange
attempt is refused naming the first one that does not."""
jwt_auth_enabled: bool
has_database: bool
licensed: bool
maps_jwts_to_virtual_keys: bool
@property
def available(self) -> bool:
return self.jwt_auth_enabled and self.has_database and self.licensed and not self.maps_jwts_to_virtual_keys
def refusal(self) -> SubjectTokenRefusal | None:
if not self.jwt_auth_enabled:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="JWT auth is not enabled on this gateway, so it cannot exchange IdP tokens",
)
if not self.has_database:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="this gateway has no database, so it cannot exchange IdP tokens",
)
if not self.licensed:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="JWT auth is an enterprise only feature; no license is set",
)
if self.maps_jwts_to_virtual_keys:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="this gateway maps IdP tokens to virtual keys, which the exchange does not serve",
)
return None
def read_token_exchange_prerequisites() -> TokenExchangePrerequisites:
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # rebound after startup, so read them per call
general_settings,
jwt_handler,
premium_user,
prisma_client,
)
return TokenExchangePrerequisites(
jwt_auth_enabled=general_settings.get("enable_jwt_auth", False) is True,
has_database=prisma_client is not None,
licensed=premium_user is True,
maps_jwts_to_virtual_keys=_maps_jwts_to_virtual_keys(jwt_handler),
)
def _maps_jwts_to_virtual_keys(jwt_handler: JWTHandler) -> bool:
if not hasattr(jwt_handler, "litellm_jwtauth"):
return False
return jwt_handler.litellm_jwtauth.is_virtual_key_mapping_configured()
def token_exchange_available() -> bool:
return read_token_exchange_prerequisites().available
class AuthorizeSubjectToken(Protocol):
"""Injected JWT authorization ``(subject_token, request_headers)``: the proxy's
``JWTAuthManager.auth_builder`` in production, which raises when the token is not
acceptable and otherwise names the user and team it resolved."""
def __call__(
self, subject_token: str, request_headers: Mapping[str, str], /
) -> Awaitable[JWTAuthBuilderResult]: ...
async def exchange_idp_subject_token(subject_token: str, request: Request) -> SubjectIdentity | SubjectTokenRefusal:
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # rebound after startup, so read them per call
general_settings,
jwt_handler,
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
async def authorize(token: str, request_headers: Mapping[str, str]) -> JWTAuthBuilderResult:
return await JWTAuthManager.auth_builder(
api_key=token,
jwt_handler=jwt_handler,
request_data={},
general_settings=general_settings,
route=EXCHANGE_ROUTE,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=None,
proxy_logging_obj=proxy_logging_obj,
request_headers=request_headers,
request_method="POST",
)
return await identity_from_subject_token(
subject_token,
request_headers=request.headers,
prerequisites=read_token_exchange_prerequisites(),
is_jwt=jwt_handler.is_jwt,
authorize=authorize,
)
async def identity_from_subject_token(
subject_token: str,
request_headers: Mapping[str, str],
prerequisites: TokenExchangePrerequisites,
is_jwt: Callable[[str], bool],
authorize: AuthorizeSubjectToken,
) -> SubjectIdentity | SubjectTokenRefusal:
"""Apply the same gates ``user_api_key_auth`` applies to a JWT bearer, then let the
proxy's JWT auth prove the token. A rejection comes back as ``invalid_request``, which
RFC 8693 section 2.2.2 prescribes for an invalid or unacceptable subject token, and a
check the gateway could not complete (the IdP's JWKS unreachable with no cached copy,
the auth database down) as ``temporarily_unavailable``, so the client retries instead
of treating a valid token as bad, worded by whether retrying can help. The reason stays
in the proxy log: this endpoint is public and JWT auth's own wording can name the JWKS
URL it fetched or quote the IdP's response."""
unmet: Final = prerequisites.refusal()
if unmet is not None:
return unmet
if not is_jwt(subject_token):
return SubjectTokenRefusal(error="invalid_request", description="subject_token is not a JWT")
try:
result: Final = await authorize(subject_token, request_headers)
except HTTPException as denied:
return _refusal_for(denied, denied.detail)
except ProxyException as denied:
return _refusal_for(denied, denied.message)
except Exception as denied: # noqa: BLE001 # auth_jwt raises a plain Exception on signature and claim failures
return _refusal_for(denied, denied)
user_id: Final = result["user_id"]
if user_id is None:
return SubjectTokenRefusal(error="invalid_request", description="subject_token names no user the gateway knows")
return SubjectIdentity(user_id=user_id, team_id=result["team_id"])
def _refusal_for(denied: Exception, reason: object) -> SubjectTokenRefusal:
outage: Final = _gateway_could_not_verify(denied)
if outage is None:
verbose_proxy_logger.warning("token exchange refused a subject_token: %s", reason)
return SubjectTokenRefusal(error="invalid_request", description=REJECTED_SUBJECT_TOKEN)
verbose_proxy_logger.error("token exchange could not verify a subject_token, %s: %s", outage, reason)
return SubjectTokenRefusal(error="temporarily_unavailable", description=_check_unavailable_description(outage))
def _check_unavailable_description(outage: GatewayOutage) -> str:
match outage:
case "retryable":
return SUBJECT_TOKEN_CHECK_UNAVAILABLE
case "faulted":
return SUBJECT_TOKEN_CHECK_FAULTED
case _:
assert_never(outage)
def _gateway_could_not_verify(denied: Exception) -> GatewayOutage | None:
"""A database fault anywhere in the chain (``get_user_object`` wraps prisma failures in a
bare ``ValueError``) or a 5xx from JWT auth (the IdP's JWKS unreachable with no cached
copy) is the gateway failing, not the token. A fault retrying cannot clear (a missing or
version-skewed query engine) is named as such, the way the mint path words it, so the
client is not told to wait on a deployment that needs repair."""
fault: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(denied)
if fault is not None:
return "faulted" if PrismaDBExceptionHandler.is_permanent_database_fault(fault) else "retryable"
return "retryable" if _is_server_error(denied) else None
def _is_server_error(denied: Exception) -> bool:
match denied:
case HTTPException(status_code=status_code):
return status_code >= 500
case ProxyException(code=code):
return code.isdigit() and int(code) >= 500
case _:
return False

View file

@ -16,7 +16,7 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
ReloadUserFailure,
)
from litellm.proxy._types import LiteLLM_UserTable
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken, effective_user_role
from litellm.proxy.management_endpoints.ui_sso import (
CliSsoTeamDetail,
fetch_cli_sso_team_details,
@ -42,7 +42,7 @@ async def mint_proxy_credential(
user_id: str, team_id: str | None
) -> MintedProxyCredential | ProxyCredentialMintFailure:
"""Mint the ``lite login`` credential for a consented grant. Membership is checked
live, so a team the user left between consent and redemption (or between refreshes)
live against the database row, so a team the user left between consent and redemption (or between refreshes)
refuses the grant instead of minting a credential attributed to a team they are no
longer on. The team is exactly the one the consent page sealed into the grant; nothing
is picked on the user's behalf here, so a refresh can never move the credential, and a
@ -51,12 +51,12 @@ async def mint_proxy_credential(
posting the consent form without one. Memberships whose team rows are gone count as no
team at all, the way ``lite login`` treats them, so they can never lock a user out. The
user row handed to the minter carries no team list, exactly like ``lite login``'s, so
the minter's own first-team fallback stays inert."""
user: Final = await load_active_user_by_id(user_id)
the minter's own first-team fallback stays inert. The credential carries the role the
proxy already enforces for the user on every request, so a row with no role (JWT auth's
upsert writes none) mints as an internal user instead of being refused."""
user: Final = await load_active_user_by_id(user_id, source="database")
if isinstance(user, str):
return user
if user.user_role is None:
return "no_active_key"
if team_id is not None and team_id not in user.teams:
return "not_a_member"
details: Final = await _team_details(user.teams) if user.teams else ()
@ -68,7 +68,9 @@ async def mint_proxy_credential(
if selected is None:
return "not_a_member"
key: Final = ExperimentalUIJWTToken.get_cli_jwt_auth_token(
user_info=LiteLLM_UserTable(user_id=user.user_id, user_role=user.user_role, models=user.models),
user_info=LiteLLM_UserTable(
user_id=user.user_id, user_role=effective_user_role(user.user_role).value, models=user.models
),
team_id=team_id,
team_alias=selected.team_alias,
team_models=selected.team_models,

View file

@ -23684,6 +23684,17 @@
],
"title": "Refresh Token"
},
"requested_token_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Requested Token Type"
},
"resource": {
"anyOf": [
{
@ -23705,6 +23716,28 @@
}
],
"title": "Scope"
},
"subject_token": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Subject Token"
},
"subject_token_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Subject Token Type"
}
},
"required": [
@ -23758,6 +23791,17 @@
],
"title": "Refresh Token"
},
"requested_token_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Requested Token Type"
},
"resource": {
"anyOf": [
{
@ -23779,6 +23823,28 @@
}
],
"title": "Scope"
},
"subject_token": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Subject Token"
},
"subject_token_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Subject Token Type"
}
},
"required": [

View file

@ -534,6 +534,7 @@ class LiteLLMRoutes(enum.Enum):
"/mcp-rest/tools/call",
"/v1/mcp/tools",
"/introspect",
"/token",
]
# MCP server CRUD routes — control-plane. Gated by DISABLE_ADMIN_ENDPOINTS.

View file

@ -1216,21 +1216,19 @@ async def common_checks(
return True
def effective_user_role(user_role: str | None) -> LitellmUserRoles:
try:
return LitellmUserRoles(user_role)
except ValueError:
return LitellmUserRoles.INTERNAL_USER
def _get_user_role(
user_obj: LiteLLM_UserTable | None,
) -> LitellmUserRoles | None:
if user_obj is None:
return None
_user: Final = user_obj
_user_role: Final = _user.user_role
try:
role: Final = LitellmUserRoles(_user_role)
except ValueError:
return LitellmUserRoles.INTERNAL_USER
return role
return effective_user_role(user_obj.user_role)
def _is_api_route_allowed(
@ -2577,7 +2575,7 @@ async def get_user_object(
raise Exception("No db connected")
try:
db_access_time_key: Final = f"user_id:{user_id}"
should_check_db: Final = _should_check_db(
should_check_db: Final = bool(check_db_only) or _should_check_db(
key=db_access_time_key,
last_db_access_time=last_db_access_time,
db_cache_expiry=db_cache_expiry,

View file

@ -1867,7 +1867,7 @@ class JWTAuthManager:
@staticmethod
def get_team_id_from_header(
request_headers: dict | None,
request_headers: Mapping[str, str] | None,
allowed_team_ids: set[str],
fallback_to_db_teams: bool = False,
) -> str | None:
@ -2037,7 +2037,7 @@ class JWTAuthManager:
async def _attach_team_from_header_for_admin(
admin_result: JWTAuthBuilderResult,
route: str,
request_headers: dict | None,
request_headers: Mapping[str, str] | None,
jwt_handler: JWTHandler,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
@ -2293,7 +2293,7 @@ class JWTAuthManager:
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None,
proxy_logging_obj: ProxyLogging,
request_headers: dict | None = None,
request_headers: Mapping[str, str] | None = None,
request_method: str | None = None,
) -> JWTAuthBuilderResult:
return await JWTAuthManager.authorize_jwt(
@ -2390,7 +2390,7 @@ class JWTAuthManager:
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None,
proxy_logging_obj: ProxyLogging,
request_headers: dict[str, str] | None = None,
request_headers: Mapping[str, str] | None = None,
request_method: str | None = None,
provisioning: _JWTProvisioning | None = None,
) -> JWTAuthBuilderResult:

View file

@ -3335,6 +3335,7 @@ async def team_member_add(
```
"""
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.proxy_server import (
litellm_proxy_admin_name,
premium_user,
@ -3429,6 +3430,10 @@ async def team_member_add(
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
await evict_and_broadcast(
cache_keys=tuple(sorted(user.user_id for user in updated_users)),
user_api_key_cache=user_api_key_cache,
)
await _evict_created_membership_caches(
user_ids=(tm.user_id for tm in updated_team_memberships),
team_id=data.team_id,

View file

@ -1,7 +1,8 @@
import asyncio
import contextlib
import json
import time
from collections.abc import AsyncIterator, Awaitable, Mapping
from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
from enum import Enum
from functools import partial
from types import MappingProxyType
@ -12,10 +13,12 @@ import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from openai.types.responses.response_create_params import ResponseInputParam
from pydantic import BaseModel, ConfigDict, ValidationError
from starlette.websockets import WebSocket, WebSocketDisconnect
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.constants import EMPTY_MAPPING
from litellm.integrations.custom_guardrail import ModifyResponseException
from litellm.llms.base_llm.guardrail_translation.utils import (
blocked_responses_api_usage as _blocked_responses_api_usage,
@ -1291,7 +1294,8 @@ async def cancel_response(
async def _read_ws_model_from_first_frame(
websocket: WebSocket,
) -> tuple | None:
query_model: str | None = None,
) -> tuple[str, str] | None:
"""Read the first WS frame and return (model, raw_message), or None on error.
Sends an appropriate error frame and closes the socket before returning None.
@ -1340,7 +1344,7 @@ async def _read_ws_model_from_first_frame(
await websocket.close(code=1008, reason="Invalid first message")
return None
model: Final = _extract_model_from_first_ws_event(first_event)
model: Final = query_model or _extract_model_from_first_ws_event(first_event)
if not model:
await websocket.send_text(
json.dumps(
@ -1371,6 +1375,38 @@ def _extract_model_from_first_ws_event(first_event: Any) -> str | None:
return (nested.get("model") if isinstance(nested, dict) else None) or first_event.get("model")
class _ResponseCreateRoutingHints(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
input: str | Sequence[object] | None = None
previous_response_id: str | None = None
response: "_ResponseCreateRoutingHints | None" = None
def _routing_hints_from_first_ws_frame(first_message: str) -> Mapping[str, object]:
try:
frame: Final = _ResponseCreateRoutingHints.model_validate_json(first_message)
except ValidationError:
return EMPTY_MAPPING
nested: Final = frame.response or frame
hints: Final = {
"input": frame.input if nested.input is None else nested.input,
"previous_response_id": (
frame.previous_response_id if nested.previous_response_id is None else nested.previous_response_id
),
}
return MappingProxyType({key: value for key, value in hints.items() if value is not None})
def _responses_ws_failure_frame(failure: Exception) -> str:
raw_status: Final = getattr(failure, "status_code", None)
status: Final = raw_status if isinstance(raw_status, int) and not isinstance(raw_status, bool) else 500
error_type: Final = (
"rate_limit_exceeded" if status == 429 else "invalid_request_error" if 400 <= status < 500 else "server_error"
)
return json.dumps({"type": "error", "status": status, "error": {"type": error_type, "message": str(failure)}})
async def _enforce_responses_ws_first_frame_model_auth(
request: Request,
model: str,
@ -1457,19 +1493,16 @@ async def responses_websocket_endpoint(
accept_kwargs["subprotocol"] = requested_protocols[0]
await websocket.accept(**accept_kwargs)
first_message: str | None = None
if not model:
result: Final = await _read_ws_model_from_first_frame(websocket)
if result is None:
return
model, first_message = result
result: Final = await _read_ws_model_from_first_frame(websocket, query_model=model)
if result is None:
return
resolved_model, first_message = result
data: dict[str, object] = {
"model": model,
"model": resolved_model,
"websocket": websocket,
"first_message": first_message,
}
if first_message is not None:
data["first_message"] = first_message
# Construct a synthetic Request for pre-call processing
headers_list: Final = list(websocket.scope.get("headers") or [])
@ -1482,7 +1515,7 @@ async def responses_websocket_endpoint(
request: Final = Request(scope=scope)
request._url = websocket.url
_body_bytes: Final = json.dumps({"model": model}).encode()
_body_bytes: Final = json.dumps({"model": resolved_model}).encode()
async def return_body():
return _body_bytes
@ -1492,10 +1525,10 @@ async def responses_websocket_endpoint(
# Phase 1: pre-call processing (auth, guardrails, rate limits)
base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
try:
if first_message is not None:
if not model:
await _enforce_responses_ws_first_frame_model_auth(
request=request,
model=model,
model=resolved_model,
user_api_key_dict=user_api_key_dict,
llm_router=llm_router,
)
@ -1514,7 +1547,7 @@ async def responses_websocket_endpoint(
user_request_timeout=user_request_timeout,
user_max_tokens=user_max_tokens,
user_api_base=user_api_base,
model=model,
model=resolved_model,
route_type="_aresponses_websocket",
)
except Exception as e:
@ -1536,16 +1569,31 @@ async def responses_websocket_endpoint(
await websocket.close(code=1008, reason="Pre-call error")
return
routed_data: Final = dict(
data, user_api_key_dict=user_api_key_dict, **_routing_hints_from_first_ws_frame(first_message)
)
# Phase 2: route to upstream provider
try:
data["user_api_key_dict"] = user_api_key_dict
llm_call: Final = await route_request(
data=data,
data=routed_data,
route_type="_aresponses_websocket",
llm_router=llm_router,
user_model=user_model,
)
await llm_call
except Exception:
failure: Final = await llm_call
if isinstance(failure, Exception):
await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=failure,
request_data=routed_data,
)
except Exception as e:
verbose_proxy_logger.exception("Responses WebSocket error")
with contextlib.suppress(Exception):
await websocket.send_text(_responses_ws_failure_frame(e))
await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=e,
request_data=routed_data,
)
await websocket.close(code=1011, reason="Internal server error")

View file

@ -22,7 +22,6 @@ from litellm.types.llms.openai import (
ContentPartAddedEvent,
ContentPartDoneEvent,
ContentPartDonePartOutputText,
ContentPartDonePartReasoningText,
FunctionCallArgumentsDeltaEvent,
FunctionCallArgumentsDoneEvent,
OutputItemAddedEvent,
@ -102,6 +101,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self.sent_response_created_event: bool = False
self.sent_response_in_progress_event: bool = False
self.sent_output_item_added_event: bool = False
self.sent_message_item_added_event: bool = False
self.sent_content_part_added_event: bool = False
self.sent_output_text_done_event: bool = False
self.sent_output_content_part_done_event: bool = False
@ -111,6 +111,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self.completed_response = None
self.final_text: str = ""
self._cached_item_id: str | None = None
self._message_output_index: int = 0
self._cached_response_id: str | None = None
self._buffered_chunk: ModelResponseStream | None = None
self._upstream_exhausted: bool = False
@ -563,7 +564,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sequence_number += 1
event: Final = OutputItemAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
output_index=0,
output_index=self._message_output_index,
item=BaseLiteLLMOpenAIResponseObject(
**{
"id": self._cached_item_id,
@ -585,13 +586,41 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
event: Final = ContentPartAddedEvent(
type=ResponsesAPIStreamEvents.CONTENT_PART_ADDED,
item_id=self._cached_item_id,
output_index=0,
output_index=self._message_output_index,
content_index=0,
part=BaseLiteLLMOpenAIResponseObject(**{"type": "output_text", "text": "", "annotations": []}),
)
event.__dict__["sequence_number"] = self._sequence_number
return event
def _queue_message_item_added_events(self) -> None:
if self._cached_item_id is None:
self._cached_item_id = f"msg_{uuid.uuid4()}"
self.sent_message_item_added_event = True
self.sent_content_part_added_event = True
if self._cached_reasoning_item_id is not None:
self._message_output_index = self._next_tool_output_index
self._next_tool_output_index += 1
else:
self._message_output_index = 0
self._sequence_number += 1
event: Final = OutputItemAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
output_index=self._message_output_index,
item=BaseLiteLLMOpenAIResponseObject(
**{
"id": self._cached_item_id,
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": [],
}
),
)
event.__dict__["sequence_number"] = self._sequence_number
self._pending_response_events.append(event)
self._pending_response_events.append(self.create_content_part_added_event())
def _merge_provider_specific_fields(self, src: dict) -> None:
"""Merge provider_specific_fields using last-value-wins for lists.
@ -711,7 +740,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
return OutputTextDoneEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE,
item_id=self._cached_item_id,
output_index=0,
output_index=self._message_output_index,
content_index=0,
text=getattr(litellm_complete_object.choices[0].message, "content", "") or "",
)
@ -721,33 +750,24 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._cached_item_id = f"msg_{uuid.uuid4()}"
text: Final = getattr(litellm_complete_object.choices[0].message, "content", "") or ""
reasoning_content = getattr(litellm_complete_object.choices[0].message, "reasoning_content", "") or ""
annotations: Final = getattr(litellm_complete_object.choices[0].message, "annotations", None)
part: PART_UNION_TYPES | None = None
if reasoning_content:
part = ContentPartDonePartReasoningText(
type="reasoning_text",
reasoning=reasoning_content,
)
else:
response_annotations: Final = (
LiteLLMCompletionResponsesConfig._transform_chat_completion_annotations_to_response_output_annotations(
annotations=annotations
)
)
part = ContentPartDonePartOutputText(
type="output_text",
text=text,
annotations=response_annotations,
logprobs=None,
response_annotations: Final = (
LiteLLMCompletionResponsesConfig._transform_chat_completion_annotations_to_response_output_annotations(
annotations=annotations
)
)
part: Final[PART_UNION_TYPES] = ContentPartDonePartOutputText(
type="output_text",
text=text,
annotations=response_annotations,
logprobs=None,
)
return ContentPartDoneEvent(
type=ResponsesAPIStreamEvents.CONTENT_PART_DONE,
item_id=self._cached_item_id,
output_index=0,
output_index=self._message_output_index,
content_index=0,
part=part,
)
@ -766,7 +786,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
)
return OutputItemDoneEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
output_index=0,
output_index=self._message_output_index,
sequence_number=1,
item=BaseLiteLLMOpenAIResponseObject(
**{
@ -832,6 +852,15 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
def return_default_done_events(
self, litellm_complete_object: ModelResponse
) -> BaseLiteLLMOpenAIResponseObject | None:
if self.sent_message_item_added_event is False:
final_content: Final = litellm_complete_object.choices[0].message.content or ""
if not final_content:
self.sent_output_text_done_event = True
self.sent_output_content_part_done_event = True
self.sent_output_item_done_event = True
return None
self._queue_message_item_added_events()
return self._pending_response_events.pop(0)
if self.sent_output_text_done_event is False:
self.sent_output_text_done_event = True
return self.create_output_text_done_event(litellm_complete_object)
@ -936,31 +965,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
return
# Default: message
self._cached_item_id = self._cached_item_id or f"msg_{uuid.uuid4()}"
event = OutputItemAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
output_index=0,
item=BaseLiteLLMOpenAIResponseObject(
**{
"id": self._cached_item_id,
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": [],
}
),
)
event.__dict__["sequence_number"] = self._sequence_number
self._pending_response_events.append(event)
# Emit content_part.added immediately after output_item.added for message
# items. The OpenAI Responses spec requires this event before any
# output_text.delta events so downstream parsers can initialize the
# text part structure.
if not self.sent_content_part_added_event:
self.sent_content_part_added_event = True
content_part_event: Final = self.create_content_part_added_event()
self._pending_response_events.append(content_part_event)
self._queue_message_item_added_events()
return
async def __anext__(
@ -1115,12 +1120,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self.collected_chat_completion_chunks.append(
self._snapshot_chunk_for_stream_chunk_builder(cast(ModelResponseStream, chunk))
)
# Emit any just-queued output_item event
if self._pending_response_events:
return self._pending_response_events.pop(0)
response_api_chunk = self._transform_chat_completion_chunk_to_response_api_chunk(chunk)
if response_api_chunk:
return response_api_chunk
self._pending_response_events.append(response_api_chunk)
if self._pending_response_events:
return self._pending_response_events.pop(0)
# Otherwise, loop to next chunk
except StopIteration:
return self.common_done_event_logic(sync_mode=True)
@ -1162,7 +1166,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
event = OutputTextAnnotationAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_ANNOTATION_ADDED,
item_id=item_id,
output_index=0,
output_index=self._message_output_index,
content_index=0,
annotation_index=idx,
annotation=annotation_dict,
@ -1189,11 +1193,13 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
# Priority 2: Handle text deltas
delta_content: Final = self._get_delta_string_from_streaming_choices(chunk.choices)
if delta_content:
if not self.sent_message_item_added_event:
self._queue_message_item_added_events()
self._sequence_number += 1
text_delta_event: Final = OutputTextDeltaEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA,
item_id=item_id,
output_index=0,
output_index=self._message_output_index,
content_index=0,
delta=delta_content,
)

View file

@ -1,5 +1,6 @@
import asyncio
import contextvars
import json
from collections.abc import Coroutine, Generator, Iterable, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass
@ -8,7 +9,7 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, Optional, TypeAlias, cast
import httpx
from pydantic import BaseModel, TypeAdapter
from pydantic import BaseModel, TypeAdapter, ValidationError
from typing_extensions import assert_never
import litellm
@ -2274,6 +2275,27 @@ def _deployment_reasoning_default(kwargs: Mapping[str, object]) -> Reasoning | d
return _JSON_OBJECT_ADAPTER.validate_python(reasoning_effort) if isinstance(reasoning_effort, Mapping) else None
_RESPONSES_WS_ROUTING_HINT_KEYS: Final = frozenset({"input", "previous_response_id"})
def _first_ws_frame_with_routed_input(first_message: str, routed_input: object) -> str:
try:
frame: Final = _JSON_OBJECT_ADAPTER.validate_json(first_message)
except ValidationError:
return first_message
if frame is None or routed_input is None:
return first_message
raw_nested: Final = frame.get("response")
nested: Final = _JSON_OBJECT_ADAPTER.validate_python(raw_nested) if isinstance(raw_nested, Mapping) else None
if nested is not None and nested.get("input") is not None:
if nested["input"] == routed_input:
return first_message
return json.dumps({**frame, "response": {**nested, "input": routed_input}})
if frame.get("input") == routed_input:
return first_message
return json.dumps({**frame, "input": routed_input})
def _build_responses_websocket_request_defaults(kwargs: Mapping[str, object]) -> ResponsesWebSocketRequestDefaults:
default_reasoning: Final = _deployment_reasoning_default(kwargs)
candidate_params: Final[dict[str, object]] = {
@ -2295,11 +2317,11 @@ async def _aresponses_websocket(
api_key: str | None = None,
timeout: float | None = None,
**kwargs,
):
) -> Exception | None:
"""
Private function to handle the Responses API WebSocket mode.
For PROXY use only.
For PROXY use only. Returns the provider failure that ended the connection, if any.
Resolves the LLM provider from ``model``, looks up the matching
``BaseResponsesAPIConfig``, and hands off to
@ -2364,10 +2386,14 @@ async def _aresponses_websocket(
"api_base",
"api_key",
"timeout",
"first_message",
*_RESPONSES_WS_ROUTING_HINT_KEYS,
}
remaining_kwargs: Final = {k: v for k, v in kwargs.items() if k not in _explicit_keys}
deployment_kwargs: Final = {k: v for k, v in kwargs.items() if k not in _RESPONSES_WS_ROUTING_HINT_KEYS}
first_message: Final = kwargs.get("first_message")
await base_llm_http_handler.async_responses_websocket(
return await base_llm_http_handler.async_responses_websocket(
model=resolved_model,
websocket=websocket,
logging_obj=litellm_logging_obj,
@ -2375,9 +2401,14 @@ async def _aresponses_websocket(
api_base=resolved_api_base,
api_key=resolved_api_key,
timeout=timeout,
first_message=(
_first_ws_frame_with_routed_input(first_message, kwargs.get("input"))
if isinstance(first_message, str)
else None
),
user_api_key_dict=kwargs.get("user_api_key_dict"),
litellm_metadata=_build_litellm_metadata_for_ws(kwargs),
custom_llm_provider=_custom_llm_provider,
request_defaults=_build_responses_websocket_request_defaults(kwargs),
request_defaults=_build_responses_websocket_request_defaults(deployment_kwargs),
**remaining_kwargs,
)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import copy
import json
import time
import traceback
@ -154,7 +155,7 @@ def _load_json_value(payload: str | bytes) -> object:
return json.loads(payload)
def _model_id_from_metadata(litellm_metadata: dict[str, object] | None) -> str | None:
def _model_id_from_metadata(litellm_metadata: Mapping[str, object] | None) -> str | None:
model_info: Final = litellm_metadata.get("model_info") if litellm_metadata else None
model_id: Final = model_info.get("id") if _is_json_object(model_info) else None
return model_id if isinstance(model_id, str) else None
@ -229,6 +230,29 @@ def _status_code_for_error_fields(error_type: str | None, error_code: str | None
return next((status for status in map(_status_code_for_error_field, fields) if status is not None), 500)
def _map_stream_error_to_exception(error_obj: object, model: str, custom_llm_provider: str) -> Exception:
from litellm.llms.base_llm.chat.transformation import BaseLLMException
error_message, error_type, error_code = _error_event_fields(error_obj)
status_code: Final = _status_code_for_error_fields(error_type, error_code)
error_body: Final = {"message": error_message, "type": error_type, "code": error_code}
provider_exception: Final = BaseLLMException(
status_code=status_code,
message=f"Error code: {status_code} - {{'error': {error_body}}}",
body=error_body,
)
try:
return litellm.exception_type(
model=model,
custom_llm_provider=custom_llm_provider,
original_exception=provider_exception,
completion_kwargs={},
extra_kwargs={},
)
except Exception as mapped_exception:
return mapped_exception
def _mid_stream_fallback_eligible(mapped_exception: Exception) -> bool:
if isinstance(mapped_exception, litellm.ContentPolicyViolationError):
return True
@ -592,26 +616,7 @@ class BaseResponsesAPIStreamingIterator:
)
def _map_error_event_exception(self, error_obj: object) -> Exception:
from litellm.llms.base_llm.chat.transformation import BaseLLMException
error_message, error_type, error_code = _error_event_fields(error_obj)
status_code: Final = _status_code_for_error_fields(error_type, error_code)
error_body: Final = {"message": error_message, "type": error_type, "code": error_code}
provider_exception: Final = BaseLLMException(
status_code=status_code,
message=f"Error code: {status_code} - {{'error': {error_body}}}",
body=error_body,
)
try:
return litellm.exception_type(
model=self.model or "",
custom_llm_provider=self.custom_llm_provider or "",
original_exception=provider_exception,
completion_kwargs={},
extra_kwargs={},
)
except Exception as mapped_exception:
return mapped_exception
return _map_stream_error_to_exception(error_obj, self.model or "", self.custom_llm_provider or "")
def _maybe_raise_for_error_event(self, result: object) -> None:
chunk_type: Final = getattr(result, "type", None)
@ -1695,6 +1700,65 @@ RESPONSES_WS_LOGGED_EVENT_TYPES: Final = [
RESPONSES_WS_MASKABLE_TEXT_BLOCK_TYPES: Final = frozenset({"input_text", "output_text", "text"})
_RESPONSES_WS_FAILURE_EVENT_TYPES: Final = frozenset({"error", "response.failed"})
_RESPONSES_WS_OUTPUT_ITEM_EVENT_TYPES: Final = frozenset({"response.output_item.added", "response.output_item.done"})
def _ws_event_error(event: Mapping[str, object]) -> object:
if event.get("type") == "error":
return event.get("error")
response: Final = event.get("response")
return response.get("error") if _is_json_object(response) else None
def _restore_input_item_ids(items: Sequence[object]) -> Sequence[object]:
return ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input(copy.deepcopy(list(items))) # pyright: ignore[reportPrivateUsage] # same restore the HTTP responses path runs
def _restored_container_fields(container: Mapping[str, object]) -> Mapping[str, object]:
input_items: Final = container.get("input")
previous_response_id: Final = container.get("previous_response_id")
restored: Final = {
"input": _restore_input_item_ids(input_items) if _is_json_array(input_items) else input_items,
"previous_response_id": (
ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(previous_response_id)
if isinstance(previous_response_id, str)
else previous_response_id
),
}
return MappingProxyType({key: value for key, value in restored.items() if value != container.get(key)})
def _restore_wrapped_ids_in_response_create(msg_obj: Mapping[str, object]) -> dict[str, object] | None:
nested: Final = msg_obj.get("response")
nested_fields: Final = _restored_container_fields(nested) if _is_json_object(nested) else EMPTY_MAPPING
top_fields: Final = _restored_container_fields(msg_obj)
if not nested_fields and not top_fields:
return None
restored_nested: Final = (
{"response": {**nested, **nested_fields}} if _is_json_object(nested) and nested_fields else EMPTY_MAPPING
)
return {**msg_obj, **top_fields, **restored_nested}
def _wrap_output_item_encrypted_content(
event_obj: Mapping[str, object], litellm_metadata: Mapping[str, object]
) -> dict[str, object] | None:
if not litellm_metadata.get("encrypted_content_affinity_enabled"):
return None
model_id: Final = _model_id_from_metadata(litellm_metadata)
item: Final = event_obj.get("item")
if model_id is None or not _is_json_object(item):
return None
encrypted_content: Final = item.get("encrypted_content")
if not isinstance(encrypted_content, str) or not encrypted_content:
return None
wrapped_content: Final = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( # pyright: ignore[reportPrivateUsage] # same wrap the HTTP streaming path applies
encrypted_content=encrypted_content, model_id=model_id
)
return {**event_obj, "item": {**item, "encrypted_content": wrapped_content}}
class ResponsesWebSocketStreaming:
"""
@ -1721,6 +1785,7 @@ class ResponsesWebSocketStreaming:
output_guardrail_callbacks: list[PresidioGuardrailCallback] | None = None,
quota_callbacks: Sequence[ProjectQuotaCallback] | None = None,
authorized_model: str | None = None,
custom_llm_provider: str | None = None,
request_defaults: ResponsesWebSocketRequestDefaults | None = None,
):
self.websocket = websocket
@ -1728,6 +1793,9 @@ class ResponsesWebSocketStreaming:
self.logging_obj = logging_obj
self.user_api_key_dict = user_api_key_dict
self.request_data: dict[str, object] = request_data or {}
litellm_metadata: Final = self.request_data.get("litellm_metadata")
self.litellm_metadata: dict[str, object] = litellm_metadata if _is_json_object(litellm_metadata) else {}
self.custom_llm_provider: str | None = custom_llm_provider
self.messages: list[_MutableJsonObject] = []
self.input_messages: list[dict[str, object]] = []
self.first_message = first_message
@ -1796,13 +1864,65 @@ class ResponsesWebSocketStreaming:
if self.logging_obj:
self.logging_obj.pre_call(input=message, api_key="")
def _failure_exception(self) -> Exception | None:
failed_event: Final = next(
(event for event in self.messages if event.get("type") in _RESPONSES_WS_FAILURE_EVENT_TYPES), None
)
if failed_event is None:
return None
return _map_stream_error_to_exception(
_ws_event_error(failed_event), self.authorized_model or "", self.custom_llm_provider or ""
)
async def _log_messages(self) -> None:
if not self.logging_obj:
return
if self.input_messages:
self.logging_obj.model_call_details["messages"] = self.input_messages
if self.messages:
if not self.messages:
return
exception: Final = self._failure_exception()
if exception is None:
asyncio.create_task(self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True))
return
self._record_usage_for_failure()
traceback_exception: Final = "".join(traceback.format_exception(exception))
asyncio.create_task(
self.logging_obj.dispatch_failure_handlers(exception, traceback_exception, prefer_async_handlers=True)
)
def _record_usage_for_failure(self) -> None:
from litellm.cost_calculator import ResponsesWebSocketTokenUsageProcessor
from litellm.types.utils import LiteLLMRealtimeStreamLoggingObject
usage: Final = ResponsesWebSocketTokenUsageProcessor.collect_and_combine_usage_from_responses_ws_results(
self.messages
)
tier_partition: Final = ResponsesWebSocketTokenUsageProcessor.partition_results_by_service_tier(self.messages)
service_tier: Final = next(iter(tier_partition)) if len(tier_partition) == 1 else None
logging_result: Final = LiteLLMRealtimeStreamLoggingObject(
usage=usage, results=self.messages, service_tier=service_tier
)
response_cost: Final = self.logging_obj._response_cost_calculator(result=logging_result) or 0.0 # pyright: ignore[reportPrivateUsage] # as the HTTP streaming iterator does
self.logging_obj.record_partial_usage_for_failure(usage, response_cost)
def _wrap_response_event(self, response_str: str) -> str:
try:
event_obj: Final = _load_json_object(response_str)
except (json.JSONDecodeError, TypeError):
return response_str
response: Final = event_obj.get("response")
if _is_json_object(response):
wrapped_response: Final = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id( # pyright: ignore[reportPrivateUsage] # same wrap the HTTP streaming path applies
responses_api_response=response,
custom_llm_provider=self.custom_llm_provider,
litellm_metadata=self.litellm_metadata,
)
return json.dumps({**event_obj, "response": wrapped_response})
if event_obj.get("type") not in _RESPONSES_WS_OUTPUT_ITEM_EVENT_TYPES:
return response_str
wrapped_event: Final = _wrap_output_item_encrypted_content(event_obj, self.litellm_metadata)
return response_str if wrapped_event is None else json.dumps(wrapped_event)
async def backend_to_client(self) -> None:
"""Forward events from backend WebSocket to the client."""
@ -1839,12 +1959,13 @@ class ResponsesWebSocketStreaming:
unmasked_str = self._unmask_response_event(response_str)
output_masked_str = await self._mask_response_completed(unmasked_str)
wrapped_str = self._wrap_response_event(output_masked_str)
# Log the output-masked form so PII redacted by apply_to_output
# guardrails does not appear in success logs.
self._store_event(output_masked_str)
self._store_event(wrapped_str)
await self.websocket.send_text(output_masked_str)
await self.websocket.send_text(wrapped_str)
except websockets.exceptions.ConnectionClosed as e:
verbose_logger.debug("Responses WS backend connection closed: %s", e)
@ -1913,19 +2034,22 @@ class ResponsesWebSocketStreaming:
if parsed.get("type") != "response.create":
return message
msg_obj: Final = self._with_request_defaults(parsed)
defaults_applied: Final = msg_obj != parsed
authorized_obj: Final = self._with_request_defaults(parsed)
defaults_applied: Final = authorized_obj != parsed
# Always enforce the authorized model, even when PII masking is off.
model_modified: Final = self._enforce_authorized_model(msg_obj)
model_modified: Final = self._enforce_authorized_model(authorized_obj)
restored_obj: Final = _restore_wrapped_ids_in_response_create(authorized_obj)
msg_obj: Final = authorized_obj if restored_obj is None else restored_obj
frame_modified: Final = model_modified or restored_obj is not None or defaults_applied
if not self.guardrail_callbacks:
return json.dumps(msg_obj) if model_modified or defaults_applied else message
return json.dumps(msg_obj) if frame_modified else message
if "metadata" not in self.request_data:
self.request_data["metadata"] = {}
modified = model_modified or defaults_applied
modified = frame_modified
guardrail_cbs: Final[tuple[PresidioGuardrailCallback, ...]] = tuple(self.guardrail_callbacks)
for cb in guardrail_cbs:
presidio_config = cb.get_presidio_settings_from_request_data(self.request_data)
@ -2209,8 +2333,7 @@ class ResponsesWebSocketStreaming:
except Exception as e:
verbose_logger.debug("Responses WS client_to_backend ended: %s", e)
async def bidirectional_forward(self) -> None:
"""Run both forwarding directions concurrently."""
async def bidirectional_forward(self) -> Exception | None:
forward_task: Final = asyncio.create_task(self.backend_to_client())
try:
await self.client_to_backend()
@ -2227,6 +2350,7 @@ class ResponsesWebSocketStreaming:
await self.backend_ws.close()
except Exception:
pass
return self._failure_exception()
# ---------------------------------------------------------------------------

View file

@ -2008,7 +2008,7 @@ def client(original_function):
result=result,
call_type=call_type,
)
elif call_type == CallTypes.arealtime.value:
elif call_type in (CallTypes.arealtime.value, CallTypes.aresponses_websocket.value):
return result
### POST-CALL RULES ###
post_call_processing(

View file

@ -1068,6 +1068,35 @@ async def test_arealtime_marks_litellm_params_async(monkeypatch):
assert LitellmLogging._is_sync_litellm_request(captured["litellm_params"]) is False
@pytest.mark.asyncio
async def test_aresponses_websocket_hands_back_the_provider_failure_without_a_success_log(monkeypatch: pytest.MonkeyPatch):
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.responses.main import base_llm_http_handler
success_events = []
class CaptureLogger(CustomLogger):
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
success_events.append(response_obj)
monkeypatch.setattr(litellm, "callbacks", [CaptureLogger()])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
failure = litellm.BadRequestError(message="invalid_encrypted_content", model="gpt-4o", llm_provider="openai")
with patch.object( # test-quality-ok: the provider socket is the seam; how the wrapper treats the relay's outcome is under test
base_llm_http_handler, "async_responses_websocket", AsyncMock(return_value=failure)
):
outcome = await litellm._aresponses_websocket(model="openai/gpt-4o", websocket=MagicMock(), api_key="sk-test")
await asyncio.sleep(0)
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10.0)
assert outcome is failure
assert success_events == []
@pytest.mark.asyncio
async def test_agenerate_content_marks_litellm_params_async():
"""LIT-4475: the async ``agenerate_content`` entrypoint must plant

View file

@ -1957,6 +1957,96 @@ async def test_async_anthropic_messages_handler_passes_api_key_to_agentic_hooks(
)
_FOUNDRY_API_BASE: Final = "https://lit5418.services.ai.azure.com/anthropic"
_FOUNDRY_SSE_BODY: Final = (
b'event: message_start\ndata: {"type": "message_start", "message": {"id": "msg_1", "type": "message", '
b'"role": "assistant", "model": "claude-fable-5-1", "content": [], "stop_reason": null, '
b'"usage": {"input_tokens": 1, "output_tokens": 0}}}\n\n'
b'event: content_block_start\ndata: {"type": "content_block_start", "index": 0, '
b'"content_block": {"type": "text", "text": ""}}\n\n'
b'event: content_block_delta\ndata: {"type": "content_block_delta", "index": 0, '
b'"delta": {"type": "text_delta", "text": "ready"}}\n\n'
b'event: content_block_stop\ndata: {"type": "content_block_stop", "index": 0}\n\n'
b'event: message_delta\ndata: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, '
b'"usage": {"output_tokens": 1}}\n\n'
b'event: message_stop\ndata: {"type": "message_stop"}\n\n'
)
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.asyncio
async def test_async_anthropic_messages_handler_passes_deployment_api_base_to_agentic_hooks(stream, monkeypatch):
"""
Regression for LIT-5418: an azure_ai deployment carries its Foundry endpoint as
``api_base``, a named parameter that never lands in kwargs. The agentic hooks
(websearch interception's follow-up call after the search) must receive it on
both the non-streaming and the streaming path, or the follow-up fails with
"Missing Azure API Base" and the client gets the dangling tool_use back.
"""
from litellm.integrations.custom_logger import CustomLogger
from litellm.llms.azure_ai.anthropic.messages_transformation import AzureAnthropicMessagesConfig
monkeypatch.delenv("AZURE_API_BASE", raising=False)
class CapturingAgenticCallback(CustomLogger):
def __init__(self):
super().__init__()
self.hook_kwargs: dict | None = None
async def async_should_run_agentic_loop(self, response, model, messages, tools, stream, custom_llm_provider, kwargs):
self.hook_kwargs = dict(kwargs)
return False, {}
callback = CapturingAgenticCallback()
handler = BaseLLMHTTPHandler()
upstream_request = httpx.Request("POST", f"{_FOUNDRY_API_BASE}/v1/messages")
upstream_response = (
httpx.Response(200, content=_FOUNDRY_SSE_BODY, request=upstream_request)
if stream
else httpx.Response(
200,
json={
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-fable-5-1",
"content": [{"type": "text", "text": "ready"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1},
},
request=upstream_request,
)
)
mock_client = AsyncMock(spec=AsyncHTTPHandler)
mock_client.post = AsyncMock(return_value=upstream_response)
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {}
mock_logging_obj.dynamic_success_callbacks = [callback]
result = await handler.async_anthropic_messages_handler(
model="claude-fable-5-1",
messages=[{"role": "user", "content": "Say ready"}],
anthropic_messages_provider_config=AzureAnthropicMessagesConfig(),
anthropic_messages_optional_request_params={"max_tokens": 32},
custom_llm_provider="azure_ai",
litellm_params=GenericLiteLLMParams(api_key="foundry-key", api_base=_FOUNDRY_API_BASE),
logging_obj=mock_logging_obj,
client=mock_client,
api_key="foundry-key",
api_base=_FOUNDRY_API_BASE,
stream=stream,
kwargs={},
)
if stream:
_ = [chunk async for chunk in result]
assert mock_client.post.call_args.kwargs["url"] == f"{_FOUNDRY_API_BASE}/v1/messages"
assert callback.hook_kwargs is not None, "agentic hook never ran"
assert callback.hook_kwargs.get("api_base") == _FOUNDRY_API_BASE
assert callback.hook_kwargs.get("api_key") == "foundry-key"
class _FakeWSExceptions:
class WebSocketException(Exception):
pass

View file

@ -7568,6 +7568,69 @@ async def test_reload_active_user_by_id_permanent_engine_fault_is_faulted(proxy_
assert await _reload_active_user_by_id("sso-user-7") == "faulted"
@pytest.mark.asyncio
async def test_load_active_user_by_id_reads_the_row_from_the_database_not_the_cache(proxy_globals):
"""JWT auth caches the user it creates before it adds that user to the JWT's team, and adding a
member never evicts the cached row, so a credential minted off the cached row refused the very first
token exchange as not a member. The database source has to read the row from the database and leave
the fresh row in the cache for the requests the credential makes next."""
from litellm.proxy._experimental.mcp_server.bridge_token_flow import load_active_user_by_id
from litellm.proxy._types import LiteLLM_UserTable
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
await cache.async_set_cache(
key="fresh-jwt-user", value=LiteLLM_UserTable(user_id="fresh-jwt-user", teams=[]), model_type=LiteLLM_UserTable
)
prisma = MagicMock()
prisma.db.litellm_usertable.find_unique = AsyncMock(
return_value=LiteLLM_UserTable(user_id="fresh-jwt-user", teams=["team-a"])
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = prisma
loaded = await load_active_user_by_id("fresh-jwt-user", source="database")
assert not isinstance(loaded, str)
assert loaded.teams == ["team-a"]
cached = await cache.async_get_cache(key="fresh-jwt-user", model_type=LiteLLM_UserTable)
assert cached is not None
assert cached.teams == ["team-a"]
@pytest.mark.asyncio
async def test_load_active_user_by_id_serves_a_cached_row_without_a_database_read(proxy_globals):
"""Introspection and refresh revalidation run per call, so the loader's default source is the cache: a
cached row answers without a database read, and only a caller that asks for the database row pays for
one."""
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
_reload_active_user_by_id,
load_active_user_by_id,
)
from litellm.proxy._types import LiteLLM_UserTable
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
await cache.async_set_cache(
key="cached-jwt-user",
value=LiteLLM_UserTable(user_id="cached-jwt-user", teams=["team-a"]),
model_type=LiteLLM_UserTable,
)
prisma = MagicMock()
prisma.db.litellm_usertable.find_unique = AsyncMock(
return_value=LiteLLM_UserTable(user_id="cached-jwt-user", teams=[])
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = prisma
loaded = await load_active_user_by_id("cached-jwt-user")
assert not isinstance(loaded, str)
assert loaded.teams == ["team-a"]
assert await _reload_active_user_by_id("cached-jwt-user") is None
prisma.db.litellm_usertable.find_unique.assert_not_awaited()
@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
@ -11048,6 +11111,43 @@ def test_native_client_login_walks_discovery_consent_token_refresh_and_revoke(mo
assert stranger.json()["error"] == "invalid_client"
@pytest.mark.parametrize(
"jwt_auth_enabled, virtual_key_claim_field, exchange_servable",
[(True, None, True), (False, None, False), (True, "client_id", False)],
ids=["jwt auth on", "jwt auth off", "jwts mapped to virtual keys"],
)
def test_discovery_advertises_the_exchange_grant_only_where_the_gateway_can_serve_it(
monkeypatch, jwt_auth_enabled, virtual_key_claim_field, exchange_servable
):
"""Every document a native client reads before it picks a grant (the versioned contract, the
aggregate authorization-server metadata, and the registration response) lists the RFC 8693
exchange exactly when the running proxy can serve it: JWT auth on, a database, a license, and
no JWT-to-virtual-key mapping, since the exchange would mint past the mapped key's policy."""
from litellm.caching.caching import DualCache
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.handle_jwt import JWTHandler
client, _session_cookie, _minted = _native_client_app(monkeypatch)
handler: Final = JWTHandler()
handler.update_environment(
prisma_client=None,
user_api_key_cache=DualCache(),
litellm_jwtauth=LiteLLM_JWTAuth(virtual_key_claim_field=virtual_key_claim_field),
)
monkeypatch.setattr("litellm.proxy.proxy_server.jwt_handler", handler)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": jwt_auth_enabled})
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object())
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
exchange_grant = ["urn:ietf:params:oauth:grant-type:token-exchange"] if exchange_servable else []
expected = ["authorization_code", "refresh_token", *exchange_grant]
assert client.get("/.well-known/litellm-cli-auth").json()["grant_types_supported"] == expected
assert client.get("/.well-known/oauth-authorization-server/mcp").json()["grant_types_supported"] == expected
registered = client.post("/register", json={"redirect_uris": ["http://127.0.0.1:51234/callback"]})
assert registered.status_code == 201
assert registered.json()["grant_types"] == expected
def test_native_client_authorize_without_the_proxy_resource_keeps_the_mcp_flow(monkeypatch):
"""A registered client asking for the MCP resource (or no resource) never sees the consent
page, so existing MCP clients are untouched by the native-client arm."""
@ -11847,13 +11947,17 @@ async def test_oauth_refresh_revalidates_the_same_active_user_rule(
from litellm.proxy._experimental.mcp_server.bridge_token_flow import _reload_active_user_by_id
handler, _ = jwt_oauth_identity
handler.user_api_key_cache.set_cache(
"jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", metadata={"scim_active": state != "inactive"})
)
user_id: Final = f"jwt-owner-{state}"
row: Final = LiteLLM_UserTable(user_id=user_id, metadata={"scim_active": state != "inactive"})
proxy_server.prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=row)
if state == "missing_database":
monkeypatch.setattr(proxy_server, "prisma_client", None)
expected: Final = None if state == "active" else "no_active_key" if state == "inactive" else "unresolvable"
assert await _reload_active_user_by_id("jwt-owner") == expected
assert await _reload_active_user_by_id(user_id) == expected
if state != "missing_database":
cached: Final = handler.user_api_key_cache.get_cache(user_id, model_type=LiteLLM_UserTable)
assert cached is not None
assert cached.metadata == row.metadata
@pytest.mark.asyncio

View file

@ -15,13 +15,18 @@ from starlette.requests import Request
from litellm.caching.caching import DualCache
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
_AUTH_CODE_DEBUG_KEY,
ACCESS_TOKEN_TOKEN_TYPE,
CONNECT_FLOW_COOKIE_PREFIX,
GATEWAY_AUTH_CODE_PREFIX,
GATEWAY_AUTH_CODE_TTL_SECONDS,
MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS,
MAX_CLIENT_ID_LENGTH,
SUBJECT_TOKEN_TYPES,
TOKEN_EXCHANGE_GRANT_TYPE,
ConsentTeam,
MintedProxyCredential,
SubjectIdentity,
SubjectTokenRefusal,
_GatewayAuthCode,
_open_sealed,
_seal,
@ -90,9 +95,11 @@ def _request(path="/authorize", query="", cookies=None, method="GET"):
)
async def _register(redirect_uris) -> dict:
async def _register(redirect_uris, token_exchange_available=True) -> dict:
response = await register_aggregate_client(
request=_request(path="/register", method="POST"), request_body={"redirect_uris": redirect_uris}
request=_request(path="/register", method="POST"),
request_body={"redirect_uris": redirect_uris},
token_exchange_available=token_exchange_available,
)
return json.loads(response.body)
@ -105,6 +112,7 @@ async def _reload_user_active(user_id: str):
async def test_register_mints_stateless_public_client():
body = await _register([REDIRECT_URI])
assert body["token_endpoint_auth_method"] == "none"
assert body["grant_types"] == ["authorization_code", "refresh_token", TOKEN_EXCHANGE_GRANT_TYPE]
assert "client_secret" not in body
assert body["redirect_uris"] == [REDIRECT_URI]
assert is_gateway_dcr_client_id(body["client_id"])
@ -113,11 +121,18 @@ async def test_register_mints_stateless_public_client():
assert record.redirect_uris == (REDIRECT_URI,)
@pytest.mark.asyncio
async def test_register_omits_the_exchange_grant_where_the_gateway_cannot_serve_it():
body = await _register([REDIRECT_URI], token_exchange_available=False)
assert body["grant_types"] == ["authorization_code", "refresh_token"]
@pytest.mark.asyncio
@pytest.mark.parametrize("redirect_uris", [VSCODE_REDIRECT_URIS, MAX_LENGTH_REDIRECT_URIS])
async def test_register_four_callbacks_preserves_metadata(redirect_uris: tuple[str, ...]) -> None:
response: Final = await register_aggregate_client(
request=_request(path="/register", method="POST"),
token_exchange_available=True,
request_body={
"client_name": "Visual Studio Code",
"client_uri": "https://code.visualstudio.com",
@ -143,6 +158,7 @@ async def test_register_four_callbacks_preserves_metadata(redirect_uris: tuple[s
async def test_register_rejects_five_valid_callbacks() -> None:
response: Final = await register_aggregate_client(
request=_request(path="/register", method="POST"),
token_exchange_available=True,
request_body={"redirect_uris": [*VSCODE_REDIRECT_URIS, "http://127.0.0.1:33419/"]},
)
assert response.status_code == 400
@ -156,6 +172,7 @@ async def test_register_rejects_five_valid_callbacks() -> None:
async def test_register_four_callbacks_preserves_encoded_size_guard() -> None:
response: Final = await register_aggregate_client(
request=_request(path="/register", method="POST"),
token_exchange_available=True,
request_body={"redirect_uris": [f"https://client.example/{index}/".ljust(256, "é") for index in range(4)]},
)
assert response.status_code == 400
@ -208,6 +225,7 @@ async def test_register_rejects_userinfo_spoofed_origin():
response = await register_aggregate_client(
request=_request(path="/register", method="POST"),
request_body={"redirect_uris": ["https://claude.ai@attacker.example/callback"]},
token_exchange_available=True,
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_redirect_uri"
@ -228,7 +246,9 @@ async def test_register_rejects_userinfo_spoofed_origin():
)
async def test_register_rejects_bad_redirect_uris(redirect_uris):
response = await register_aggregate_client(
request=_request(path="/register", method="POST"), request_body={"redirect_uris": redirect_uris}
request=_request(path="/register", method="POST"),
request_body={"redirect_uris": redirect_uris},
token_exchange_available=True,
)
assert response.status_code == 400
assert json.loads(response.body)["error"] in ("invalid_redirect_uri", "invalid_client_metadata")
@ -1948,7 +1968,7 @@ async def test_revoke_refuses_unknown_clients_and_a_missing_master_key():
def test_native_client_auth_contract_points_every_endpoint_at_this_proxy():
assert json.loads(json.dumps(native_client_auth_contract(_request("/.well-known/litellm-cli-auth")))) == {
assert json.loads(json.dumps(native_client_auth_contract(_request("/.well-known/litellm-cli-auth"), True))) == {
"contract_version": 1,
"issuer": "https://llm.example.com",
"authorization_endpoint": "https://llm.example.com/authorize",
@ -1957,13 +1977,22 @@ def test_native_client_auth_contract_points_every_endpoint_at_this_proxy():
"revocation_endpoint": "https://llm.example.com/revoke",
"resource": "https://llm.example.com",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:token-exchange",
],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"revocation_endpoint_auth_methods_supported": ["none"],
}
def test_native_client_auth_contract_omits_the_exchange_grant_where_the_gateway_cannot_serve_it():
contract = native_client_auth_contract(_request("/.well-known/litellm-cli-auth"), False)
assert list(contract["grant_types_supported"]) == ["authorization_code", "refresh_token"]
@pytest.mark.parametrize(
"resource, expected",
[
@ -2148,3 +2177,180 @@ async def test_gateway_owned_resource_stays_scoped_through_consent_and_refresh(a
)
assert renewed.status_code == 200
assert _opened_principal(json.loads(renewed.body)).resource_server_id == "github-id"
JWT_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
IDP_TOKEN = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1MSJ9.idp-signature"
class _Exchanger:
def __init__(self, result=None):
self.calls = []
self.result = result
async def __call__(self, subject_token, request):
self.calls.append((subject_token, request.url.path))
if self.result is not None:
return self.result
return SubjectIdentity(user_id="u1", team_id="team-b")
async def _exchange_native(client_id, minter, exchanger, cache=None, **overrides):
arguments = {
"grant_type": TOKEN_EXCHANGE_GRANT_TYPE,
"subject_token": IDP_TOKEN,
"subject_token_type": JWT_SUBJECT_TOKEN_TYPE,
"exchange_subject_token": exchanger,
}
return await _redeem_native(None, client_id, minter, cache=cache, **{**arguments, **overrides})
@pytest.mark.asyncio
async def test_token_exchange_mints_the_proxy_credential_for_the_idp_subject():
"""RFC 8693: a registered native client trades the IdP token it already holds for the
same credential the consent flow mints, attributed to the user and team the gateway's
JWT auth resolved, with a rotating refresh token bound to that team and the client.
The exchange can be repeated while the IdP token lives; nothing is burned."""
client_id = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
minter, exchanger, cache = _Minter(), _Exchanger(), DualCache()
response = await _exchange_native(client_id, minter, exchanger, cache=cache)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
body = json.loads(response.body)
assert exchanger.calls == [(IDP_TOKEN, "/token")]
assert minter.calls == [("u1", "team-b")]
assert body["issued_token_type"] == ACCESS_TOKEN_TOKEN_TYPE
assert body["access_token"] == "sk-cli-u1"
assert body["token_type"] == "Bearer"
assert body["expires_in"] == 3600
assert (body["user_id"], body["team_id"]) == ("u1", "team-b")
principal = _opened_refresh(body["refresh_token"], client_id)
assert (principal.user_id, principal.client_id, principal.audience, principal.team_id) == (
"u1",
client_id,
"proxy_api",
"team-b",
)
again = await _exchange_native(client_id, minter, exchanger, cache=cache)
assert again.status_code == 200
assert json.loads(again.body)["refresh_token"] != body["refresh_token"]
assert minter.calls == [("u1", "team-b"), ("u1", "team-b")]
@pytest.mark.asyncio
async def test_exchanged_credential_refreshes_and_rotates_like_a_consented_one():
client_id = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
minter, cache = _Minter(), DualCache()
exchanged = json.loads((await _exchange_native(client_id, minter, _Exchanger(), cache=cache)).body)
refreshed = await _refresh_native(exchanged["refresh_token"], client_id, minter, cache)
assert refreshed.status_code == 200
body = json.loads(refreshed.body)
assert "issued_token_type" not in body
assert (body["access_token"], body["user_id"], body["team_id"]) == ("sk-cli-u1", "u1", "team-b")
assert body["refresh_token"] != exchanged["refresh_token"]
assert minter.calls == [("u1", "team-b"), ("u1", "team-b")]
replay = await _refresh_native(exchanged["refresh_token"], client_id, minter, cache)
assert replay.status_code == 400
assert json.loads(replay.body)["error"] == "invalid_grant"
@pytest.mark.asyncio
async def test_token_exchange_for_a_teamless_subject_mints_a_teamless_credential():
client_id = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
minter = _Minter()
response = await _exchange_native(client_id, minter, _Exchanger(SubjectIdentity(user_id="u2")))
assert response.status_code == 200
body = json.loads(response.body)
assert minter.calls == [("u2", None)]
assert (body["user_id"], body["team_id"]) == ("u2", None)
assert _opened_refresh(body["refresh_token"], client_id).team_id is None
@pytest.mark.asyncio
@pytest.mark.parametrize("subject_token_type", sorted(SUBJECT_TOKEN_TYPES))
async def test_token_exchange_accepts_every_advertised_subject_token_type(subject_token_type):
client_id = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
response = await _exchange_native(client_id, _Minter(), _Exchanger(), subject_token_type=subject_token_type)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_token_exchange_without_an_idp_exchanger_is_unsupported():
"""A gateway that wires no IdP verifier into the endpoint answers the way it always
answered an unknown grant, and never reaches the minter."""
client_id = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
minter = _Minter()
response = await _redeem_native(
None,
client_id,
minter,
grant_type=TOKEN_EXCHANGE_GRANT_TYPE,
subject_token=IDP_TOKEN,
subject_token_type=JWT_SUBJECT_TOKEN_TYPE,
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "unsupported_grant_type"
assert minter.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"overrides, status, error",
[
({"subject_token": None}, 400, "invalid_request"),
({"subject_token": ""}, 400, "invalid_request"),
({"subject_token_type": None}, 400, "invalid_request"),
({"subject_token_type": "urn:ietf:params:oauth:token-type:saml2"}, 400, "invalid_request"),
({"requested_token_type": "urn:ietf:params:oauth:token-type:refresh_token"}, 400, "invalid_request"),
({"resource": "https://other.example.com"}, 400, "invalid_target"),
({"resource": "https://llm.example.com/mcp"}, 400, "invalid_target"),
({"client_id": "llm_dcrc_forged"}, 401, "invalid_client"),
({"client_id": "not-a-gateway-client"}, 401, "invalid_client"),
],
)
async def test_token_exchange_refuses_a_malformed_request_before_touching_the_idp_token(overrides, status, error):
registered = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
minter, exchanger = _Minter(), _Exchanger()
response = await _exchange_native(
overrides.get("client_id", registered),
minter,
exchanger,
**{name: value for name, value in overrides.items() if name != "client_id"},
)
assert response.status_code == status
assert json.loads(response.body)["error"] == error
assert exchanger.calls == []
assert minter.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error, status",
[("unsupported_grant_type", 400), ("invalid_request", 400), ("temporarily_unavailable", 503)],
)
async def test_token_exchange_relays_the_idp_refusal_and_never_mints(error, status):
client_id = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
minter = _Minter()
exchanger = _Exchanger(SubjectTokenRefusal(error=error, description="subject_token was rejected: bad signature"))
response = await _exchange_native(client_id, minter, exchanger)
assert response.status_code == status
body = json.loads(response.body)
assert (body["error"], body["error_description"]) == (error, "subject_token was rejected: bad signature")
assert minter.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failure, status, error",
[
("not_a_member", 400, "invalid_grant"),
("team_required", 400, "invalid_grant"),
("no_active_key", 400, "invalid_grant"),
("unavailable", 503, "temporarily_unavailable"),
],
)
async def test_token_exchange_relays_a_mint_refusal(failure, status, error):
client_id = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
response = await _exchange_native(client_id, _Minter(failure), _Exchanger())
assert response.status_code == status
assert json.loads(response.body)["error"] == error

View file

@ -0,0 +1,237 @@
import logging
import pytest
from fastapi import HTTPException
from prisma.engine.errors import BinaryNotFoundError
from prisma.errors import DataError
from litellm.caching.caching import DualCache
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import SubjectIdentity, SubjectTokenRefusal
from litellm.proxy._experimental.mcp_server.idp_token_exchange import (
REJECTED_SUBJECT_TOKEN,
SUBJECT_TOKEN_CHECK_FAULTED,
SUBJECT_TOKEN_CHECK_UNAVAILABLE,
TokenExchangePrerequisites,
identity_from_subject_token,
token_exchange_available,
)
from litellm.proxy._types import JWTIssuerConfig, LiteLLM_JWTAuth, ProxyException
from litellm.proxy.auth.handle_jwt import JWKSUnreachableError, JWTHandler, jwks_unavailable_exception
IDP_JWT = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1MSJ9.idp-signature"
REQUEST_HEADERS = {"x-litellm-team-id": "team-b", "user-agent": "lite/0.1"}
EVERY_GATE_HOLDS = {
"jwt_auth_enabled": True,
"has_database": True,
"licensed": True,
"maps_jwts_to_virtual_keys": False,
}
JWKS_URL = "https://idp.example.com/.well-known/jwks.json"
JWKS_DOWN = jwks_unavailable_exception(JWKSUnreachableError(f"ConnectError fetching {JWKS_URL} after 3 attempts"))
def _authorized(user_id="u1", team_id="team-b"):
return {
"is_proxy_admin": False,
"team_object": None,
"user_object": None,
"end_user_object": None,
"org_object": None,
"token": IDP_JWT,
"team_id": team_id,
"user_id": user_id,
"user_email": None,
"end_user_id": None,
"org_id": None,
"team_membership": None,
"jwt_claims": {"sub": user_id},
"agent_id": None,
}
class _Authorizer:
def __init__(self, result=None, raises=None):
self.calls = []
self.result = result if result is not None else _authorized()
self.raises = raises
async def __call__(self, subject_token, request_headers):
self.calls.append((subject_token, dict(request_headers)))
if self.raises is not None:
raise self.raises
return self.result
async def _identity(authorizer, subject_token=IDP_JWT, **unmet):
return await identity_from_subject_token(
subject_token,
request_headers=REQUEST_HEADERS,
prerequisites=TokenExchangePrerequisites(**{**EVERY_GATE_HOLDS, **unmet}),
is_jwt=JWTHandler.is_jwt,
authorize=authorizer,
)
@pytest.mark.asyncio
async def test_a_jwt_the_proxy_accepts_names_its_user_and_team():
"""The subject token goes to the proxy's own JWT auth with the caller's headers (that is
where the team header is read), and the identity it resolved is what gets minted."""
authorizer = _Authorizer()
assert await _identity(authorizer) == SubjectIdentity(user_id="u1", team_id="team-b")
assert authorizer.calls == [(IDP_JWT, REQUEST_HEADERS)]
@pytest.mark.asyncio
async def test_a_jwt_that_resolves_no_team_names_a_teamless_identity():
assert await _identity(_Authorizer(_authorized(team_id=None))) == SubjectIdentity(user_id="u1", team_id=None)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"unmet, subject_token, error, mentions",
[
({"jwt_auth_enabled": False}, IDP_JWT, "unsupported_grant_type", "JWT auth is not enabled"),
({"has_database": False}, IDP_JWT, "unsupported_grant_type", "no database"),
({"licensed": False}, IDP_JWT, "unsupported_grant_type", "enterprise"),
({"maps_jwts_to_virtual_keys": True}, IDP_JWT, "unsupported_grant_type", "virtual keys"),
({}, "sk-litellm-virtual-key", "invalid_request", "not a JWT"),
],
)
async def test_the_gates_user_api_key_auth_applies_refuse_before_any_verification(
unmet, subject_token, error, mentions
):
authorizer = _Authorizer()
refusal = await _identity(authorizer, subject_token=subject_token, **unmet)
assert isinstance(refusal, SubjectTokenRefusal)
assert refusal.error == error
assert mentions in refusal.description
assert authorizer.calls == []
@pytest.mark.parametrize(
"unmet",
[
{},
{"jwt_auth_enabled": False},
{"has_database": False},
{"licensed": False},
{"maps_jwts_to_virtual_keys": True},
],
)
def test_the_grant_is_available_exactly_when_every_gate_holds(unmet):
prerequisites = TokenExchangePrerequisites(**{**EVERY_GATE_HOLDS, **unmet})
assert prerequisites.available is (unmet == {})
assert (prerequisites.refusal() is None) is prerequisites.available
MAPPED_ISSUER = JWTIssuerConfig(
issuer="https://idp.example.test", audience="litellm-gateway", virtual_key_claim_field="client_id"
)
def _running_jwt_handler(litellm_jwtauth):
handler = JWTHandler()
if litellm_jwtauth is not None:
handler.update_environment(prisma_client=None, user_api_key_cache=DualCache(), litellm_jwtauth=litellm_jwtauth)
return handler
@pytest.mark.parametrize(
"general_settings, prisma_client, premium_user, litellm_jwtauth, expected",
[
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(), True),
({"enable_jwt_auth": True}, object(), True, None, True),
({}, object(), True, LiteLLM_JWTAuth(), False),
({"enable_jwt_auth": True}, None, True, LiteLLM_JWTAuth(), False),
({"enable_jwt_auth": True}, object(), False, LiteLLM_JWTAuth(), False),
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(virtual_key_claim_field="client_id"), False),
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(issuers=[MAPPED_ISSUER]), False),
],
)
def test_availability_is_read_from_the_running_proxy(
monkeypatch, general_settings, prisma_client, premium_user, litellm_jwtauth, expected
):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user)
monkeypatch.setattr("litellm.proxy.proxy_server.jwt_handler", _running_jwt_handler(litellm_jwtauth))
assert token_exchange_available() is expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"raised, reason",
[
(HTTPException(status_code=403, detail="User not allowed to access this route"), "not allowed"),
(ProxyException(message="Token expired", type="auth_error", param="token", code=401), "Token expired"),
(Exception("Validation fails: signature verification failed"), "signature verification failed"),
(Exception("Invalid JWT Submitted"), "Invalid JWT"),
(Exception(f"Failed to fetch keys from {JWKS_URL}: 502 Bad Gateway from the IdP"), JWKS_URL),
(ValueError("User doesn't exist in db. 'user_id'=u1. Got error - not found"), "not found"),
],
)
async def test_a_jwt_the_proxy_rejects_is_refused_with_the_reason_kept_in_the_log(raised, reason, caplog):
"""The endpoint is public, so the response never quotes JWT auth's wording (it can name
the JWKS URL or relay the IdP's reply); the operator reads the reason in the proxy log."""
caplog.set_level(logging.WARNING, logger="LiteLLM Proxy")
refusal = await _identity(_Authorizer(raises=raised))
assert refusal == SubjectTokenRefusal(error="invalid_request", description=REJECTED_SUBJECT_TOKEN)
assert reason in caplog.text
@pytest.mark.asyncio
async def test_a_jwt_that_resolves_no_user_cannot_be_exchanged():
refusal = await _identity(_Authorizer(_authorized(user_id=None)))
assert refusal == SubjectTokenRefusal(
error="invalid_request", description="subject_token names no user the gateway knows"
)
def _user_lookup_wrapping_a_database_outage():
p1001 = DataError(
data={"user_facing_error": {"message": "Can't reach database server at `127.0.0.1`:`5432`", "meta": {}}}
)
try:
raise p1001
except DataError as outage:
try:
raise ValueError(f"User doesn't exist in db. 'user_id'=u1. Got error - {outage}")
except ValueError as wrapped:
return wrapped
@pytest.mark.asyncio
@pytest.mark.parametrize(
"raised, reason",
[
(JWKS_DOWN, JWKS_URL),
(HTTPException(status_code=503, detail="the auth database is not reachable"), "not reachable"),
(_user_lookup_wrapping_a_database_outage(), "Can't reach database server"),
],
)
async def test_an_idp_or_gateway_outage_is_reported_as_retryable_not_as_a_bad_token(raised, reason, caplog):
caplog.set_level(logging.ERROR, logger="LiteLLM Proxy")
refusal = await _identity(_Authorizer(raises=raised))
assert refusal == SubjectTokenRefusal(error="temporarily_unavailable", description=SUBJECT_TOKEN_CHECK_UNAVAILABLE)
assert reason in caplog.text
def _user_lookup_wrapping_a_fault_retrying_cannot_clear():
try:
raise BinaryNotFoundError("query engine binary not found")
except BinaryNotFoundError as fault:
try:
raise ValueError(f"User doesn't exist in db. 'user_id'=u1. Got error - {fault}")
except ValueError as wrapped:
return wrapped
@pytest.mark.asyncio
async def test_a_database_fault_retrying_cannot_clear_is_not_reported_as_a_transient_outage(caplog):
"""The status stays 503 (the only OAuth error a client reads as the server's fault, and what
the mint path answers to the same fault) but the wording must not tell the client to wait."""
caplog.set_level(logging.ERROR, logger="LiteLLM Proxy")
refusal = await _identity(_Authorizer(raises=_user_lookup_wrapping_a_fault_retrying_cannot_clear()))
assert refusal == SubjectTokenRefusal(error="temporarily_unavailable", description=SUBJECT_TOKEN_CHECK_FAULTED)
assert "retrying will not help" in refusal.description
assert "faulted: " in caplog.text and "query engine binary not found" in caplog.text

View file

@ -1,6 +1,6 @@
"""Tests for minting the ``lite login`` credential from a consented native-client grant."""
from unittest.mock import ANY, AsyncMock
from unittest.mock import ANY, AsyncMock, MagicMock
import pytest
@ -8,7 +8,9 @@ from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import ConsentTeam, MintedProxyCredential
from litellm.proxy._experimental.mcp_server.proxy_api_credentials import lookup_consent_teams, mint_proxy_credential
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.ui_sso import CliSsoTeamDetail
_LOAD_USER = "litellm.proxy._experimental.mcp_server.proxy_api_credentials.load_active_user_by_id"
@ -67,10 +69,21 @@ async def test_mint_passes_user_lookup_failures_through(failure, load_user, fetc
@pytest.mark.asyncio
async def test_mint_refuses_a_user_without_a_role(load_user, fetch_teams):
load_user.return_value = _user(user_role=None)
assert await mint_proxy_credential("u1", None) == "no_active_key"
fetch_teams.assert_not_awaited()
@pytest.mark.parametrize(
"stored_role, minted_role",
[
(None, LitellmUserRoles.INTERNAL_USER),
("made_up_role", LitellmUserRoles.INTERNAL_USER),
("proxy_admin", LitellmUserRoles.PROXY_ADMIN),
],
)
async def test_mint_carries_the_role_the_proxy_enforces_for_the_user(load_user, fetch_teams, stored_role, minted_role):
"""A user JWT auth upserted has no role in the database, and the proxy already treats
such a user as an internal user on every request, so the credential says the same."""
load_user.return_value = _user(user_role=stored_role)
minted = await mint_proxy_credential("u1", "team-a")
assert isinstance(minted, MintedProxyCredential)
assert _decoded(minted).user_role == minted_role
@pytest.mark.asyncio
@ -79,7 +92,7 @@ async def test_mint_refuses_a_teamless_grant_for_a_team_member(load_user, fetch_
is refused for a user with teams instead of minting an unscoped credential or drifting
onto the first team, on redemption and on every refresh alike."""
assert await mint_proxy_credential("u1", None) == "team_required"
load_user.assert_awaited_once_with("u1")
load_user.assert_awaited_once_with("u1", source="database")
fetch_teams.assert_awaited_once_with(ANY, ["team-a", "team-b"])
@ -114,6 +127,53 @@ async def test_mint_honors_the_consented_team(load_user, fetch_teams):
assert decoded.team_model_aliases == {"fast": "gpt-5.4-mini"}
@pytest.mark.asyncio
async def test_mint_reads_the_users_teams_from_the_database_not_a_stale_cached_row(fetch_teams, monkeypatch):
"""JWT auth caches the user it creates before it adds that user to the JWT's team, and adding a member
never evicts the cached row, so a mint off the cached row refused the very first token exchange as not
a member. The mint has to read the database row, whatever the cache holds."""
from litellm.proxy import proxy_server
cache = UserApiKeyCache()
await cache.async_set_cache(
key="stale-cache-user", value=_user(user_id="stale-cache-user", teams=[]), model_type=LiteLLM_UserTable
)
prisma = MagicMock()
prisma.db.litellm_usertable.find_unique = AsyncMock(
return_value=_user(user_id="stale-cache-user", teams=["team-a"])
)
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
minted = await mint_proxy_credential("stale-cache-user", "team-a")
assert isinstance(minted, MintedProxyCredential)
assert minted.team_id == "team-a"
assert _decoded(minted).team_id == "team-a"
@pytest.mark.asyncio
async def test_mint_refuses_a_user_scim_deactivated_after_the_cache_last_saw_them_active(fetch_teams, monkeypatch):
"""SCIM deactivation writes the user row without evicting the cached copy, so a mint off the cache would
keep issuing credentials for the management-object TTL. The mint reads the database row, so the
deactivated user is refused on the first refresh after the deactivation."""
from litellm.proxy import proxy_server
cache = UserApiKeyCache()
await cache.async_set_cache(
key="deactivated-user", value=_user(user_id="deactivated-user", teams=["team-a"]), model_type=LiteLLM_UserTable
)
prisma = MagicMock()
prisma.db.litellm_usertable.find_unique = AsyncMock(
return_value=_user(user_id="deactivated-user", teams=["team-a"], metadata={"scim_active": False})
)
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
assert await mint_proxy_credential("deactivated-user", "team-a") == "no_active_key"
fetch_teams.assert_not_awaited()
@pytest.mark.asyncio
async def test_mint_refuses_a_team_the_user_is_not_on(load_user, fetch_teams):
assert await mint_proxy_credential("u1", "team-c") == "not_a_member"

View file

@ -1,5 +1,6 @@
import asyncio
import json
import time
from collections.abc import Mapping
from types import SimpleNamespace
from typing import TYPE_CHECKING, Final, Literal, Optional
@ -916,6 +917,32 @@ async def test_get_user_object_wraps_db_outage_as_valueerror_preserving_context(
assert isinstance(exc_info.value.__context__, ConnectionError)
@pytest.mark.asyncio
async def test_get_user_object_check_db_only_ignores_recent_miss(monkeypatch):
"""A database-only read is never answered by the per-worker negative memo: a row created after a miss on
this worker is returned within db_cache_expiry seconds instead of raising UserNotFoundError, so the token
exchange mints for a user JWT auth just accepted."""
from litellm.proxy.auth import auth_checks
user_id = "memo-probe-user"
monkeypatch.setitem(auth_checks.last_db_access_time, f"user_id:{user_id}", (None, time.time()))
db_row = LiteLLM_UserTable(user_id=user_id, user_email=None, user_role="internal_user")
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=db_row)
result = await get_user_object(
user_id=user_id,
prisma_client=mock_prisma_client,
user_api_key_cache=UserApiKeyCache(),
user_id_upsert=False,
check_db_only=True,
)
assert result is not None
assert result.user_id == user_id
mock_prisma_client.db.litellm_usertable.find_unique.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_user_object_upsert_includes_user_email():
"""Test that user_email is included when creating a new user via get_user_object upsert"""
@ -8740,6 +8767,23 @@ async def test_access_group_model_fallback_uses_the_injected_database(channel: s
reader.assert_awaited_once_with(where={"access_group_id": "group-a"})
def test_jwt_team_role_reaches_the_gateway_token_endpoint_by_default():
"""The RFC 8693 token exchange authorizes the IdP JWT against ``POST /token`` itself, and JWT
auth only binds a team from a multi-team claim when that team may call the route, so the
default team allowlist has to cover the gateway's token endpoint or the exchange would mint
teamless credentials for every ``team_ids_jwt_field`` deployment."""
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.auth_checks import allowed_routes_check
assert allowed_routes_check(
user_role=LitellmUserRoles.TEAM, user_route="/token", litellm_proxy_roles=LiteLLM_JWTAuth()
)
assert not allowed_routes_check(
user_role=LitellmUserRoles.TEAM,
user_route="/token",
litellm_proxy_roles=LiteLLM_JWTAuth(team_allowed_routes=[]),
)
def test_route_skips_budget_checks_marks_only_spend_free_routes() -> None:
assert route_skips_budget_checks(route="/v1/models") is True
assert route_skips_budget_checks(route="/spend/logs") is True

View file

@ -627,6 +627,7 @@ def test_virtual_key_llm_api_routes_denies_spend_logs_v2():
"/mcp/tools/call",
"/mcp-rest/tools/call",
"/mcp/tools/list",
"/token",
],
)
def test_mcp_inference_routes_classified_as_llm_api(route):

View file

@ -13316,6 +13316,77 @@ async def test_team_member_add_audits_a_user_created_from_a_list_payload(monkeyp
assert created_user_id not in mock_audit.call_args.kwargs["existing_user_ids"]
@pytest.mark.asyncio
async def test_team_member_add_evicts_the_new_members_cached_user_row_on_every_worker(monkeypatch):
"""Auth admits a team-bound credential off the teams list of the cached user row. The add wrote the
new team to the database row only, so a worker still holding the old row refused the member's
credential with 403 until the management-object TTL expired. The add now evicts the row here and
broadcasts the eviction to the other workers, the way /team/member_delete already does."""
from litellm.proxy._types import TeamMemberAddRequest
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.team_endpoints import team_member_add
team_id = "team-b"
user_id = "dev-1"
cache = UserApiKeyCache()
await cache.async_set_cache(
key=user_id, value=LiteLLM_UserTable(user_id=user_id, teams=["team-a"]), model_type=LiteLLM_UserTable
)
broadcast = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", AsyncMock())
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
monkeypatch.setattr("litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id")
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", cache)
monkeypatch.setattr(
"litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation", broadcast
)
updated_team = MagicMock()
updated_team.model_dump.return_value = {
"team_id": team_id,
"members_with_roles": [{"user_id": user_id, "role": "user"}],
}
async def fake_add_team_members_to_team(**kwargs):
return updated_team, [LiteLLM_UserTable(user_id=user_id, teams=["team-a", team_id])], []
with (
patch( # test-quality-ok: team_member_add has no injection seam for its prisma-backed helpers
"litellm.proxy.management_endpoints.team_endpoints.get_team_object",
new_callable=AsyncMock,
return_value=LiteLLM_TeamTable(team_id=team_id, members_with_roles=[]),
),
patch( # test-quality-ok: team_member_add has no injection seam for its prisma-backed helpers
"litellm.proxy.management_endpoints.team_endpoints._validate_team_member_add_permissions",
new_callable=AsyncMock,
),
patch( # test-quality-ok: team_member_add has no injection seam for its prisma-backed helpers
"litellm.proxy.management_endpoints.team_endpoints._validate_and_populate_member_user_info",
new_callable=AsyncMock,
),
patch( # test-quality-ok: team_member_add has no injection seam for its prisma-backed helpers
"litellm.proxy.management_endpoints.team_endpoints._resolve_existing_member_user_ids",
new_callable=AsyncMock,
return_value=frozenset({user_id}),
),
patch( # test-quality-ok: team_member_add has no injection seam for its prisma-backed helpers
"litellm.proxy.management_endpoints.team_endpoints._add_team_members_to_team",
side_effect=fake_add_team_members_to_team,
),
patch( # test-quality-ok: team_member_add has no injection seam for its prisma-backed helpers
"litellm.proxy.management_endpoints.team_endpoints._create_team_member_add_audit_logs",
new_callable=AsyncMock,
),
):
await team_member_add(
data=TeamMemberAddRequest(team_id=team_id, member=Member(user_id=user_id, role="user")),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"),
)
assert await cache.async_get_cache(key=user_id, model_type=LiteLLM_UserTable) is None
broadcast.assert_awaited_once_with(cache_key=user_id)
def test_validate_member_user_id_provisioning_caps_the_ids_it_echoes_back():
"""A large member list must not echo every id back in the error body."""
from litellm.proxy.management_endpoints.team_endpoints import (

View file

@ -617,6 +617,201 @@ class TestResponsesWSFirstFrameModelAuth:
mock_model_auth.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("nested", [False, True])
@pytest.mark.parametrize("query_model", [None, "gpt-4o-mini"])
async def test_endpoint_routes_on_first_frame_input_and_previous_response_id(
self, nested: bool, query_model: str | None
):
from litellm.proxy.response_api_endpoints.endpoints import (
responses_websocket_endpoint,
)
replayed_input = [{"type": "reasoning", "id": "encitem_abc", "encrypted_content": "litellm_enc:abc;blob"}]
payload = {"model": "gpt-4o-mini", "input": replayed_input, "previous_response_id": "resp_prev"}
first_frame = {"type": "response.create", "response": payload} if nested else {"type": "response.create", **payload}
raw_first_frame = json.dumps(first_frame)
ws = MagicMock()
ws.headers = {}
ws.query_params = {}
ws.scope = {"headers": []}
ws.url = "ws://testserver/v1/responses"
ws.accept = AsyncMock()
ws.receive_text = AsyncMock(return_value=raw_first_frame)
ws.close = AsyncMock()
processor = MagicMock()
processor.common_processing_pre_call_logic = AsyncMock(
return_value=({"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock())
)
async def fake_llm_call():
return None
with (
patch( # test-quality-ok: first-frame model auth needs a live router and key table and has its own tests below
"litellm.proxy.response_api_endpoints.endpoints._enforce_responses_ws_first_frame_model_auth",
new_callable=AsyncMock,
),
patch( # test-quality-ok: the pre-call processor needs a live proxy; the payload it hands to routing is what is under test
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
return_value=processor,
),
patch( # test-quality-ok: routing is the seam where the first frame's input and previous_response_id become observable
"litellm.proxy.route_llm_request.route_request",
new_callable=AsyncMock,
return_value=fake_llm_call(),
) as mock_route_request,
):
await responses_websocket_endpoint(
websocket=ws,
model=query_model,
user_api_key_dict=MagicMock(),
)
ws.receive_text.assert_awaited_once()
routed = mock_route_request.await_args.kwargs["data"]
assert routed["model"] == "gpt-4o-mini"
assert routed["input"] == replayed_input
assert routed["previous_response_id"] == "resp_prev"
assert processor.common_processing_pre_call_logic.await_args.kwargs["model"] == "gpt-4o-mini"
assert mock_route_request.await_args.kwargs["route_type"] == "_aresponses_websocket"
ws.close.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("provider_rejected", [True, False])
async def test_endpoint_books_a_provider_rejected_connection_as_a_failed_request(self, provider_rejected: bool):
from litellm.proxy.response_api_endpoints.endpoints import (
responses_websocket_endpoint,
)
ws = MagicMock()
ws.headers = {}
ws.query_params = {}
ws.scope = {"headers": []}
ws.url = "ws://testserver/v1/responses"
ws.accept = AsyncMock()
ws.receive_text = AsyncMock(
return_value=json.dumps({"type": "response.create", "model": "gpt-4o-mini", "input": []})
)
ws.close = AsyncMock()
processor = MagicMock()
processor.common_processing_pre_call_logic = AsyncMock(
return_value=({"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock())
)
failure = litellm.BadRequestError(
message="invalid_encrypted_content", model="gpt-4o-mini", llm_provider="openai"
)
async def fake_llm_call():
return failure if provider_rejected else None
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
user_api_key_dict = MagicMock()
with (
patch( # test-quality-ok: first-frame model auth needs a live router and key table and has its own tests above
"litellm.proxy.response_api_endpoints.endpoints._enforce_responses_ws_first_frame_model_auth",
new_callable=AsyncMock,
),
patch( # test-quality-ok: the pre-call processor needs a live proxy; what the endpoint does with the relay's outcome is under test
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
return_value=processor,
),
patch( # test-quality-ok: routing is the seam that hands back the relay's outcome
"litellm.proxy.route_llm_request.route_request",
new_callable=AsyncMock,
return_value=fake_llm_call(),
),
patch( # test-quality-ok: the failure hook is the proxy's only path to a failed spend log row
"litellm.proxy.proxy_server.proxy_logging_obj",
proxy_logging_obj,
),
):
await responses_websocket_endpoint(
websocket=ws,
model=None,
user_api_key_dict=user_api_key_dict,
)
ws.close.assert_not_awaited()
if not provider_rejected:
proxy_logging_obj.post_call_failure_hook.assert_not_awaited()
return
proxy_logging_obj.post_call_failure_hook.assert_awaited_once()
booked = proxy_logging_obj.post_call_failure_hook.await_args.kwargs
assert booked["original_exception"] is failure
assert booked["user_api_key_dict"] is user_api_key_dict
assert booked["request_data"]["model"] == "gpt-4o-mini"
@pytest.mark.asyncio
async def test_endpoint_sends_an_error_frame_when_routing_rejects_the_connection(self):
from litellm.proxy.response_api_endpoints.endpoints import (
responses_websocket_endpoint,
)
ws = MagicMock()
ws.headers = {}
ws.query_params = {}
ws.scope = {"headers": []}
ws.url = "ws://testserver/v1/responses"
ws.accept = AsyncMock()
ws.receive_text = AsyncMock(
return_value=json.dumps({"type": "response.create", "model": "gpt-4o-mini", "input": []})
)
ws.send_text = AsyncMock()
ws.close = AsyncMock()
processor = MagicMock()
processor.common_processing_pre_call_logic = AsyncMock(
return_value=({"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock())
)
rejection = litellm.RateLimitError(
message="origin deployment is cooling down", model="gpt-4o-mini", llm_provider="openai"
)
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
user_api_key_dict = MagicMock()
with (
patch( # test-quality-ok: first-frame model auth needs a live router and key table and has its own tests above
"litellm.proxy.response_api_endpoints.endpoints._enforce_responses_ws_first_frame_model_auth",
new_callable=AsyncMock,
),
patch( # test-quality-ok: the pre-call processor needs a live proxy; what the endpoint tells the client is under test
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
return_value=processor,
),
patch( # test-quality-ok: routing is the seam that raises the affinity rejection
"litellm.proxy.route_llm_request.route_request",
new_callable=AsyncMock,
side_effect=rejection,
),
patch( # test-quality-ok: the failure hook is the proxy's only path to a failed spend log row
"litellm.proxy.proxy_server.proxy_logging_obj",
proxy_logging_obj,
),
):
await responses_websocket_endpoint(
websocket=ws,
model=None,
user_api_key_dict=user_api_key_dict,
)
frame = json.loads(ws.send_text.await_args.args[0])
assert frame["type"] == "error"
assert frame["status"] == 429
assert frame["error"]["type"] == "rate_limit_exceeded"
assert "cooling down" in frame["error"]["message"]
ws.close.assert_awaited_once_with(code=1011, reason="Internal server error")
booked = proxy_logging_obj.post_call_failure_hook.await_args.kwargs
assert booked["original_exception"] is rejection
assert booked["user_api_key_dict"] is user_api_key_dict
assert booked["request_data"]["model"] == "gpt-4o-mini"
@pytest.mark.asyncio
async def test_reruns_model_auth_for_first_frame_model(self):
from starlette.requests import Request
@ -743,6 +938,41 @@ class TestReadWSModelFromFirstFrameErrors:
ws.send_text.assert_not_awaited()
ws.close.assert_not_awaited()
@pytest.mark.asyncio
async def test_query_model_wins_over_first_frame_model(self):
from litellm.proxy.response_api_endpoints.endpoints import (
_read_ws_model_from_first_frame,
)
raw = json.dumps({"type": "response.create", "model": "gpt-4o", "input": []})
ws = MagicMock()
ws.receive_text = AsyncMock(return_value=raw)
ws.send_text = AsyncMock()
ws.close = AsyncMock()
result = await _read_ws_model_from_first_frame(ws, query_model="reasoning-group")
assert result == ("reasoning-group", raw)
ws.close.assert_not_awaited()
@pytest.mark.asyncio
async def test_query_model_satisfies_a_first_frame_without_model(self):
from litellm.proxy.response_api_endpoints.endpoints import (
_read_ws_model_from_first_frame,
)
raw = json.dumps({"type": "response.create", "input": []})
ws = MagicMock()
ws.receive_text = AsyncMock(return_value=raw)
ws.send_text = AsyncMock()
ws.close = AsyncMock()
result = await _read_ws_model_from_first_frame(ws, query_model="reasoning-group")
assert result == ("reasoning-group", raw)
ws.send_text.assert_not_awaited()
ws.close.assert_not_awaited()
class TestManagedResponsesSameProvider:
def _handler(self, model, custom_llm_provider=None):

View file

@ -20,7 +20,10 @@ from litellm.responses.litellm_completion_transformation.streaming_iterator impo
LiteLLMCompletionStreamingIterator,
)
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.llms.openai import ResponsesAPIStreamEvents
from litellm.types.llms.openai import (
BaseLiteLLMOpenAIResponseObject,
ResponsesAPIStreamEvents,
)
from litellm.types.responses.main import build_web_search_call
from litellm.types.utils import (
Delta,
@ -957,3 +960,174 @@ def test_streamed_unrecognized_tool_choice_is_echoed_as_auto() -> None:
]
assert [event.response.tool_choice for event in response_events] == ["auto", "auto", "auto"]
def _reasoning_chunk(reasoning: str, finish_reason: str | None = None) -> ModelResponseStream:
return ModelResponseStream(
id=CHAT_COMPLETION_ID,
created=1748575031,
model="claude-haiku-4-5",
object="chat.completion.chunk",
choices=[
StreamingChoices(
index=0,
delta=Delta(role="assistant", reasoning_content=reasoning),
finish_reason=finish_reason,
)
],
)
async def _collect_events(
iterator: LiteLLMCompletionStreamingIterator, sync_mode: bool
) -> list[BaseLiteLLMOpenAIResponseObject]:
if sync_mode:
return list(iterator)
return [event async for event in iterator]
def _is_message_item(event: BaseLiteLLMOpenAIResponseObject) -> bool:
return getattr(getattr(event, "item", None), "type", None) == "message"
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
async def test_tool_only_stream_emits_no_message_item_events(sync_mode: bool):
iterator: Final = _build_iterator([_tool_call_chunk(), _chunk("", finish_reason="tool_calls")])
events: Final = await _collect_events(iterator, sync_mode)
message_item_events = [
event
for event in events
if getattr(event, "type", None)
in (ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE)
and _is_message_item(event)
]
assert message_item_events == []
assert [
event
for event in events
if str(getattr(event, "type", "")).startswith("response.output_text")
or getattr(event, "type", None)
in (ResponsesAPIStreamEvents.CONTENT_PART_ADDED, ResponsesAPIStreamEvents.CONTENT_PART_DONE)
] == []
assert any(getattr(event, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED for event in events)
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
async def test_reasoning_then_text_announces_message_item_before_text_events(sync_mode: bool):
iterator: Final = _build_iterator(
[
_reasoning_chunk("let me think"),
_chunk("Hello"),
_chunk("!", finish_reason="stop"),
]
)
events: Final = await _collect_events(iterator, sync_mode)
announced_message_ids: set[str] = set()
announced_indexes_by_item_type: dict[str, int] = {}
content_part_added_seen = False
saw_text_delta = False
for event in events:
event_type = getattr(event, "type", None)
if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED:
announced_indexes_by_item_type[event.item.type] = event.output_index
if _is_message_item(event):
announced_message_ids.add(event.item.id)
elif event_type == ResponsesAPIStreamEvents.CONTENT_PART_ADDED:
content_part_added_seen = True
elif event_type in (
ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA,
ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE,
ResponsesAPIStreamEvents.CONTENT_PART_DONE,
):
assert event.item_id in announced_message_ids
if event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA:
assert content_part_added_seen
saw_text_delta = True
elif event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE and _is_message_item(event):
assert event.item.id in announced_message_ids
assert saw_text_delta
assert "".join(
event.delta for event in events if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA
) == "Hello!"
assert announced_indexes_by_item_type["message"] != announced_indexes_by_item_type["reasoning"]
@pytest.mark.asyncio
async def test_reasoning_item_closes_before_message_item_opens():
iterator: Final = _build_iterator(
[
_reasoning_chunk("let me think"),
_chunk("Hello"),
_chunk("!", finish_reason="stop"),
]
)
events: Final = await _collect_events(iterator, sync_mode=False)
item_lifecycle: Final = [
(event.type, event.item.type)
for event in events
if getattr(event, "type", None)
in (ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE)
]
assert item_lifecycle == [
(ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, "reasoning"),
(ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, "reasoning"),
(ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, "message"),
(ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, "message"),
]
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
async def test_tool_then_reasoning_then_text_gives_message_its_own_output_index(sync_mode: bool):
iterator: Final = _build_iterator(
[
_tool_call_chunk(),
_reasoning_chunk("thinking"),
_chunk("Hello"),
_chunk("!", finish_reason="stop"),
]
)
events: Final = await _collect_events(iterator, sync_mode)
output_item_added_events: Final = [
event for event in events if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED
]
message_item_adds: Final = [event for event in output_item_added_events if _is_message_item(event)]
function_call_adds: Final = [
event for event in output_item_added_events if getattr(event.item, "type", None) == "function_call"
]
assert len(message_item_adds) == 1
assert all(message_item_adds[0].output_index != event.output_index for event in function_call_adds)
output_indexes_by_item_id: Final = {event.item.id: event.output_index for event in output_item_added_events}
assert len(output_indexes_by_item_id) == len(set(output_indexes_by_item_id.values()))
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
async def test_plain_text_stream_announces_exactly_one_message_item(sync_mode: bool):
iterator: Final = _build_iterator([_chunk("Hel"), _chunk("lo", finish_reason="stop")])
events: Final = await _collect_events(iterator, sync_mode)
message_item_adds = [
event
for event in events
if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED and _is_message_item(event)
]
assert len(message_item_adds) == 1
for event in events:
if getattr(event, "type", None) in (
ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA,
ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE,
):
assert event.item_id == message_item_adds[0].item.id

View file

@ -424,6 +424,94 @@ async def test_aresponses_websocket_strips_responses_routing_prefix_from_openai_
assert mock_ws.call_args.kwargs["custom_llm_provider"] == "openai"
@pytest.mark.asyncio
async def test_aresponses_websocket_keeps_routing_hints_out_of_the_relay_kwargs(): # test-quality-ok: the relay kwargs are the only place a dropped key is observable; the provider socket behind them is the boundary
from unittest.mock import MagicMock
from litellm.responses.main import _aresponses_websocket
with patch.object(
import_module("litellm.responses.main").base_llm_http_handler, "async_responses_websocket",
new_callable=AsyncMock,
) as mock_ws:
await _aresponses_websocket(
model="openai/gpt-5.6",
websocket=MagicMock(),
api_key="sk-test",
litellm_logging_obj=MagicMock(),
input=[{"type": "message", "role": "user", "content": "hi"}],
previous_response_id="resp_prev",
)
mock_ws.assert_awaited_once()
assert "input" not in mock_ws.call_args.kwargs
assert "previous_response_id" not in mock_ws.call_args.kwargs
_STRIPPED_WS_INPUT = [{"role": "user", "content": "hi"}]
_ORIGINAL_WS_INPUT = [
{"type": "reasoning", "id": "rs_1", "encrypted_content": "blob-from-a-removed-deployment", "summary": []},
*_STRIPPED_WS_INPUT,
]
@pytest.mark.asyncio
@pytest.mark.parametrize("nested", [False, True])
async def test_aresponses_websocket_forwards_the_routed_input_in_the_first_frame(nested: bool): # test-quality-ok: the first frame handed to the relay is the only place the routed input is observable before the provider socket
from unittest.mock import MagicMock
from litellm.responses.main import _aresponses_websocket
body = {"model": "gpt-5.6", "input": _ORIGINAL_WS_INPUT, "store": False}
first_message = json.dumps(
{"type": "response.create", "response": body} if nested else {"type": "response.create", **body}
)
with patch.object(
import_module("litellm.responses.main").base_llm_http_handler, "async_responses_websocket",
new_callable=AsyncMock,
) as mock_ws:
await _aresponses_websocket(
model="openai/gpt-5.6",
websocket=MagicMock(),
api_key="sk-test",
litellm_logging_obj=MagicMock(),
input=list(_STRIPPED_WS_INPUT),
first_message=first_message,
)
forwarded = json.loads(mock_ws.call_args.kwargs["first_message"])
container = forwarded["response"] if nested else forwarded
assert container["input"] == _STRIPPED_WS_INPUT
assert container["store"] is False
assert container["model"] == "gpt-5.6"
assert forwarded["type"] == "response.create"
@pytest.mark.asyncio
async def test_aresponses_websocket_forwards_the_first_frame_verbatim_when_routing_left_the_input_alone(): # test-quality-ok: the relay kwargs are the boundary; byte-identical passthrough is only observable there
from unittest.mock import MagicMock
from litellm.responses.main import _aresponses_websocket
first_message = '{"type": "response.create", "model": "gpt-5.6", "input": [{"role": "user", "content": "hi"}]}'
with patch.object(
import_module("litellm.responses.main").base_llm_http_handler, "async_responses_websocket",
new_callable=AsyncMock,
) as mock_ws:
await _aresponses_websocket(
model="openai/gpt-5.6",
websocket=MagicMock(),
api_key="sk-test",
litellm_logging_obj=MagicMock(),
input=list(_STRIPPED_WS_INPUT),
first_message=first_message,
)
assert mock_ws.call_args.kwargs["first_message"] == first_message
_INJECTION_POINT_INPUT = [{"role": "system", "content": "You are terse."}, {"role": "user", "content": "hi"}]
_SYSTEM_POINT = {"location": "message", "role": "system"}
_USER_POINT = {"location": "message", "role": "user"}

View file

@ -1502,6 +1502,34 @@ class TestNativeWebSocketDeploymentDefaults:
assert dict(request_defaults.fill_missing) == {"reasoning": {"effort": "high"}, "service_tier": "priority"}
assert dict(request_defaults.overrides) == {"provider_default": "configured"}
@pytest.mark.asyncio
async def test_aresponses_websocket_keeps_first_frame_routing_hints_out_of_the_defaults(
self, monkeypatch: pytest.MonkeyPatch
):
import importlib
from unittest.mock import AsyncMock
responses_main = importlib.import_module("litellm.responses.main")
stub = MagicMock()
stub.async_responses_websocket = AsyncMock()
monkeypatch.setattr(responses_main, "base_llm_http_handler", stub)
await responses_main._aresponses_websocket.__wrapped__(
model="openai/gpt-5-pro",
websocket=MagicMock(),
api_key="sk-test",
litellm_logging_obj=MagicMock(),
reasoning_effort="high",
input=[{"id": "encitem_abc", "type": "reasoning", "encrypted_content": "litellm_enc:abc"}],
previous_response_id="resp_first_turn",
)
call_kwargs = stub.async_responses_websocket.call_args.kwargs
assert dict(call_kwargs["request_defaults"].fill_missing) == {"reasoning": {"effort": "high"}}
assert "input" not in call_kwargs
assert "previous_response_id" not in call_kwargs
class TestNativeWebSocketGuardrails:
@pytest.mark.asyncio
@ -2927,3 +2955,382 @@ class TestNativeWebSocketUrlConstruction:
mock_config.get_websocket_url.assert_called_once()
_, call_kwargs = mock_config.get_websocket_url.call_args
assert call_kwargs["litellm_params"]["api_version"] == "2025-04-01-preview"
_AFFINITY_METADATA = {
"model_info": {"id": "dep-1"},
"encrypted_content_affinity_enabled": True,
}
def _wrapped_reasoning_item():
from litellm.responses.utils import ResponsesAPIRequestUtils
return {
"type": "reasoning",
"id": ResponsesAPIRequestUtils._build_encrypted_item_id("dep-1", "rs_orig"),
"encrypted_content": ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id("gAAAA-blob", "dep-1"),
"summary": [],
}
class TestNativeWebSocketEncryptedContentAffinity:
@pytest.mark.asyncio
@pytest.mark.parametrize("nested", [False, True])
async def test_client_to_backend_restores_wrapped_ids(self, nested: bool):
from unittest.mock import AsyncMock
from litellm.responses.utils import ResponsesAPIRequestUtils
wrapped_previous = ResponsesAPIRequestUtils._build_responses_api_response_id(
custom_llm_provider="openai", model_id="dep-1", response_id="resp_orig"
)
payload = {
"input": [_wrapped_reasoning_item(), {"type": "message", "role": "user", "content": "hi"}],
"previous_response_id": wrapped_previous,
}
frame = {"type": "response.create", "response": payload} if nested else {"type": "response.create", **payload}
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
websocket = MagicMock()
websocket.receive_text = AsyncMock(side_effect=[json.dumps(frame), Exception("stop")])
handler = _make_streaming(websocket=websocket, backend_ws=backend_ws, request_data={})
await handler.client_to_backend()
sent = json.loads(backend_ws.send.await_args_list[0][0][0])
body = sent["response"] if nested else sent
assert body["input"][0]["id"] == "rs_orig"
assert body["input"][0]["encrypted_content"] == "gAAAA-blob"
assert body["input"][1] == {"type": "message", "role": "user", "content": "hi"}
assert body["previous_response_id"] == "resp_orig"
@pytest.mark.asyncio
async def test_client_to_backend_leaves_unwrapped_frames_untouched(self):
from unittest.mock import AsyncMock
frame = json.dumps({"type": "response.create", "input": "hello", "previous_response_id": "resp_raw"})
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
websocket = MagicMock()
websocket.receive_text = AsyncMock(side_effect=[frame, Exception("stop")])
handler = _make_streaming(websocket=websocket, backend_ws=backend_ws, request_data={})
await handler.client_to_backend()
assert backend_ws.send.await_args_list[0][0][0] == frame
@pytest.mark.asyncio
async def test_backend_to_client_wraps_ids_when_affinity_is_enabled(self):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
from litellm.responses.utils import ResponsesAPIRequestUtils
reasoning_item = {"type": "reasoning", "id": "rs_1", "encrypted_content": "gAAAA-blob", "summary": []}
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps({"type": "response.output_item.done", "output_index": 0, "item": dict(reasoning_item)}),
json.dumps(
{
"type": "response.completed",
"response": {"id": "resp_1", "output": [dict(reasoning_item)], "usage": {"total_tokens": 3}},
}
),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={"litellm_metadata": dict(_AFFINITY_METADATA)},
custom_llm_provider="openai",
)
await handler.backend_to_client()
wrapped_content = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id("gAAAA-blob", "dep-1")
item_done = json.loads(websocket.send_text.await_args_list[0][0][0])
assert item_done["item"]["encrypted_content"] == wrapped_content
completed = json.loads(websocket.send_text.await_args_list[1][0][0])
assert completed["response"]["id"] == ResponsesAPIRequestUtils._build_responses_api_response_id(
custom_llm_provider="openai", model_id="dep-1", response_id="resp_1"
)
assert completed["response"]["output"][0]["id"] == ResponsesAPIRequestUtils._build_encrypted_item_id(
"dep-1", "rs_1"
)
assert completed["response"]["output"][0]["encrypted_content"] == wrapped_content
await asyncio.sleep(0)
logged = logging_obj.dispatch_success_handlers.await_args[0][0]
assert logged[0]["response"]["id"] == completed["response"]["id"]
@pytest.mark.asyncio
async def test_backend_to_client_wraps_only_response_id_without_affinity(self):
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
from litellm.responses.utils import ResponsesAPIRequestUtils
reasoning_item = {"type": "reasoning", "id": "rs_1", "encrypted_content": "gAAAA-blob", "summary": []}
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps({"type": "response.output_item.done", "output_index": 0, "item": dict(reasoning_item)}),
json.dumps({"type": "response.completed", "response": {"id": "resp_1", "output": [dict(reasoning_item)]}}),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={"litellm_metadata": {"model_info": {"id": "dep-1"}}},
custom_llm_provider="openai",
)
await handler.backend_to_client()
item_done = json.loads(websocket.send_text.await_args_list[0][0][0])
assert item_done["item"] == reasoning_item
completed = json.loads(websocket.send_text.await_args_list[1][0][0])
assert completed["response"]["id"] == ResponsesAPIRequestUtils._build_responses_api_response_id(
custom_llm_provider="openai", model_id="dep-1", response_id="resp_1"
)
assert completed["response"]["output"][0] == reasoning_item
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failure_frame, expected_status",
[
(
{
"type": "error",
"error": {
"type": "invalid_request_error",
"code": "invalid_encrypted_content",
"message": "The encrypted content for item rs_1 could not be verified.",
},
},
400,
),
(
{
"type": "response.failed",
"response": {
"id": "resp_1",
"status": "failed",
"error": {"code": "server_error", "message": "upstream blew up"},
},
},
500,
),
],
)
async def test_backend_to_client_books_failure_frames_as_failures(
self, failure_frame: dict[str, object], expected_status: int
):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps({"type": "response.created", "response": {"id": "resp_1", "status": "in_progress"}}),
json.dumps(failure_frame),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
logging_obj._response_cost_calculator = MagicMock(return_value=0.0)
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
authorized_model="gpt-5.6",
custom_llm_provider="openai",
)
await handler.backend_to_client()
await asyncio.sleep(0)
logging_obj.dispatch_success_handlers.assert_not_awaited()
logging_obj.dispatch_failure_handlers.assert_awaited_once()
exception = logging_obj.dispatch_failure_handlers.await_args[0][0]
assert exception.status_code == expected_status
assert failure_frame.get("error", failure_frame.get("response", {}).get("error"))["message"] in str(exception)
@pytest.mark.asyncio
async def test_backend_to_client_bills_completed_turns_before_a_failure(self):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps(
{
"type": "response.completed",
"response": {
"id": "resp_1",
"status": "completed",
"output": [],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
},
}
),
json.dumps({"type": "error", "error": {"type": "invalid_request_error", "message": "bad turn"}}),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
logging_obj._response_cost_calculator = MagicMock(return_value=0.01)
handler = _make_streaming(websocket=websocket, backend_ws=backend_ws, logging_obj=logging_obj, request_data={})
await handler.backend_to_client()
await asyncio.sleep(0)
logging_obj.record_partial_usage_for_failure.assert_called_once()
usage, response_cost = logging_obj.record_partial_usage_for_failure.call_args[0]
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (10, 5, 15)
assert response_cost == 0.01
logging_obj.dispatch_success_handlers.assert_not_awaited()
logging_obj.dispatch_failure_handlers.assert_awaited_once()
@pytest.mark.asyncio
async def test_bidirectional_forward_returns_the_provider_failure(self):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
backend_drained = asyncio.Event()
backend_events = [
json.dumps({"type": "response.created", "response": {"id": "resp_1", "status": "in_progress"}}),
json.dumps(
{
"type": "error",
"status": 400,
"error": {
"type": "invalid_request_error",
"code": "invalid_encrypted_content",
"message": "could not be verified",
},
}
),
]
async def recv(decode=False):
if backend_events:
return backend_events.pop(0)
backend_drained.set()
raise Exception("stop")
async def receive_text():
await backend_drained.wait()
raise Exception("client gone")
websocket = MagicMock()
websocket.send_text = AsyncMock()
websocket.receive_text = receive_text
backend_ws = MagicMock()
backend_ws.recv = recv
backend_ws.send = AsyncMock()
backend_ws.close = AsyncMock()
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
logging_obj._response_cost_calculator = MagicMock(return_value=0.0)
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
authorized_model="gpt-5.6",
custom_llm_provider="openai",
)
failure = await handler.bidirectional_forward()
assert isinstance(failure, Exception)
assert failure.status_code == 400
assert "could not be verified" in str(failure)
@pytest.mark.asyncio
async def test_bidirectional_forward_returns_none_after_a_completed_turn(self):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
backend_drained = asyncio.Event()
backend_events = [
json.dumps(
{
"type": "response.completed",
"response": {
"id": "resp_1",
"status": "completed",
"output": [],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
},
}
),
]
async def recv(decode=False):
if backend_events:
return backend_events.pop(0)
backend_drained.set()
raise Exception("stop")
async def receive_text():
await backend_drained.wait()
raise Exception("client gone")
websocket = MagicMock()
websocket.send_text = AsyncMock()
websocket.receive_text = receive_text
backend_ws = MagicMock()
backend_ws.recv = recv
backend_ws.send = AsyncMock()
backend_ws.close = AsyncMock()
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
authorized_model="gpt-5.6",
custom_llm_provider="openai",
)
assert await handler.bidirectional_forward() is None

View file

@ -24767,10 +24767,16 @@ export interface components {
redirect_uri?: string;
/** Refresh Token */
refresh_token?: string | null;
/** Requested Token Type */
requested_token_type?: string | null;
/** Resource */
resource?: string | null;
/** Scope */
scope?: string | null;
/** Subject Token */
subject_token?: string | null;
/** Subject Token Type */
subject_token_type?: string | null;
};
/** Body_token_endpoint_token_post */
Body_token_endpoint_token_post: {
@ -24788,10 +24794,16 @@ export interface components {
redirect_uri?: string;
/** Refresh Token */
refresh_token?: string | null;
/** Requested Token Type */
requested_token_type?: string | null;
/** Resource */
resource?: string | null;
/** Scope */
scope?: string | null;
/** Subject Token */
subject_token?: string | null;
/** Subject Token Type */
subject_token_type?: string | null;
};
/** Body_upload_logo_upload_logo_post */
Body_upload_logo_upload_logo_post: {