diff --git a/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py b/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py index effdd9725c2..5980663ecc1 100644 --- a/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py +++ b/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py @@ -10750,139 +10750,6 @@ async def test_key_budget_error_keeps_the_masked_key_name(key_name): assert f"Key=prod-key ({key_name}) Current cost" in message -class _UntouchedPrisma: - def __getattr__(self, name: str) -> object: - raise AssertionError(f"database reached through {name}") - - -class _MissingUserPrisma: - class db: - class litellm_usertable: - @staticmethod - async def find_unique(where: dict[str, str], include: dict[str, bool]) -> None: - return None - - -@pytest.mark.asyncio -async def test_enforced_model_allowlists_treats_a_missing_user_row_as_unrestricted(): - from litellm.proxy.auth.auth_checks import enforced_model_allowlists - from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache - from litellm.proxy.utils import ProxyLogging - - cache = UserApiKeyCache() - scopes = await enforced_model_allowlists( - valid_token=UserAPIKeyAuth(token="hashed-fake", user_id="default_user_id"), - prisma_client=_MissingUserPrisma(), - user_api_key_cache=cache, - proxy_logging_obj=ProxyLogging(user_api_key_cache=cache), - ) - - assert [list(scope) for scope in scopes] == [[], [], [], [], []] - - -class _UnreachableUserPrisma: - class db: - class litellm_usertable: - @staticmethod - async def find_unique(where: dict[str, str], include: dict[str, bool]) -> None: - raise RuntimeError("database gone") - - -@pytest.mark.asyncio -async def test_enforced_model_allowlists_surfaces_a_failed_user_lookup(): - from litellm.proxy.auth.auth_checks import enforced_model_allowlists - from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache - from litellm.proxy.utils import ProxyLogging - - cache = UserApiKeyCache() - with pytest.raises(ValueError, match="database gone"): - await enforced_model_allowlists( - valid_token=UserAPIKeyAuth(token="hashed-fake", user_id="user-fake"), - prisma_client=_UnreachableUserPrisma(), - user_api_key_cache=cache, - proxy_logging_obj=ProxyLogging(user_api_key_cache=cache), - ) - - -@pytest.mark.asyncio -async def test_enforced_model_allowlists_reads_every_level_from_cache(): - from litellm.proxy._types import ( - LiteLLM_BudgetTable, - LiteLLM_ProjectTableCachedObj, - LiteLLM_TeamMembership, - LiteLLM_TeamTableCachedObj, - LiteLLM_UserTable, - ) - from litellm.proxy.auth.auth_checks import enforced_model_allowlists - from litellm.proxy.common_utils.user_api_key_cache import ( - UserApiKeyCache, - team_membership_reservation_cache_key, - ) - from litellm.proxy.utils import ProxyLogging - - cache = UserApiKeyCache() - proxy_logging_obj = ProxyLogging(user_api_key_cache=cache) - await cache.async_set_cache( - key="team_id:team-fake", value=LiteLLM_TeamTableCachedObj(team_id="team-fake", models=["gpt-4o"]) - ) - await cache.async_set_cache( - key=team_membership_reservation_cache_key(user_id="user-fake", team_id="team-fake"), - value=LiteLLM_TeamMembership( - user_id="user-fake", - team_id="team-fake", - litellm_budget_table=LiteLLM_BudgetTable(allowed_models=["gpt-4o-mini"]), - ), - ) - await cache.async_set_cache( - key="project_id:project-fake", - value=LiteLLM_ProjectTableCachedObj(project_id="project-fake", models=["gpt-4.1"]), - ) - await cache.async_set_cache(key="user-fake", value=LiteLLM_UserTable(user_id="user-fake", models=["o3"])) - prisma_client = _UntouchedPrisma() - - team_scoped = await enforced_model_allowlists( - valid_token=UserAPIKeyAuth( - token="hashed-fake", - models=["all-team-models"], - team_models=["gpt-4o", "gpt-4o-mini"], - user_id="user-fake", - team_id="team-fake", - project_id="project-fake", - ), - prisma_client=prisma_client, - user_api_key_cache=cache, - proxy_logging_obj=proxy_logging_obj, - ) - personal = await enforced_model_allowlists( - valid_token=UserAPIKeyAuth(token="hashed-fake", user_id="user-fake"), - prisma_client=prisma_client, - user_api_key_cache=cache, - proxy_logging_obj=proxy_logging_obj, - ) - without_database = await enforced_model_allowlists( - valid_token=UserAPIKeyAuth( - token="hashed-fake", - models=["gpt-4o"], - team_models=["gpt-4o-mini"], - user_id="user-fake", - team_id="team-fake", - ), - prisma_client=None, - user_api_key_cache=cache, - proxy_logging_obj=proxy_logging_obj, - ) - - assert [list(scope) for scope in team_scoped] == [ - ["gpt-4o", "gpt-4o-mini"], - ["gpt-4o"], - ["gpt-4o-mini"], - [], - ["gpt-4.1"], - ] - assert [list(scope) for scope in personal] == [[], [], [], ["o3"], []] - assert [list(scope) for scope in without_database] == [["gpt-4o"], ["gpt-4o-mini"]] - - @pytest.mark.asyncio @pytest.mark.parametrize("channel", ["team", "key"]) async def test_access_group_model_fallback_uses_the_injected_database(channel: str) -> None: diff --git a/tests/unit/proxy/auth/test_model_access_group_budgets.py b/tests/unit/proxy/auth/test_model_access_group_budgets.py index 7506bd031d9..7b0c4d9996e 100644 --- a/tests/unit/proxy/auth/test_model_access_group_budgets.py +++ b/tests/unit/proxy/auth/test_model_access_group_budgets.py @@ -27,6 +27,7 @@ from litellm.proxy.auth.auth_checks import ( _model_access_group_max_budget_check, collect_matched_model_access_groups, common_checks, + enforce_model_access_group_budget, stamp_matched_model_access_groups, ) from litellm.proxy.common_utils.reset_budget_job import _model_access_group_counter_key @@ -551,3 +552,65 @@ async def test_common_checks_blocks_a_request_whose_group_is_over_budget(): async def test_free_model_routes_skip_the_model_access_group_budget_check(): """skip_budget_checks is how free models stay free; it has to cover this budget too.""" assert await _common_checks_with_over_budget_group(skip_budget_checks=True) is True + + +async def _enforce_for_a_route_without_a_request_body( + *rows: _MagBudgetRow, + key_models: list[str], + spend_by_counter_key: dict[str, float], +) -> UserAPIKeyAuth: + """Run the pair the websocket frame gate runs, which reaches the proxy for what it needs.""" + valid_token = UserAPIKeyAuth(api_key="hashed", models=key_models, user_id=USER_ID) + cache = await _cache() + read, _ = _spend_reader(spend_by_counter_key) + with ( + patch("litellm.proxy.proxy_server.prisma_client", _RecordingPrismaClient(*rows)), # test-quality-ok: read lazily inside the helper, which takes no client + patch("litellm.proxy.proxy_server.user_api_key_cache", cache), # test-quality-ok: same lazy import + patch("litellm.proxy.proxy_server.proxy_logging_obj", ProxyLogging(user_api_key_cache=cache)), # test-quality-ok: same lazy import + patch("litellm.proxy.proxy_server.get_current_spend", read), # test-quality-ok: get_current_spend is lazily imported inside the budget check and has no injection point + ): + await enforce_model_access_group_budget( + model="gpt-4o", + valid_token=valid_token, + llm_router=Router(model_list=MODEL_LIST), + ) + return valid_token + + +@pytest.mark.asyncio +async def test_caller_granted_the_model_only_through_an_exhausted_group_is_refused(): + """Routes with no request body, the websocket passthrough among them, run this instead of + ``common_checks``, so the group budget has to stop them the same way.""" + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await _enforce_for_a_route_without_a_request_body( + _MagBudgetRow("tier-a", max_budget=1.0), + key_models=["tier-a"], + spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 1.5}, + ) + + assert exc_info.value.entity_id == "tier-a" + assert exc_info.value.current_cost == 1.5 + + +@pytest.mark.asyncio +async def test_group_that_authorized_the_call_is_stamped_for_the_spend_writer(): + """Without the stamp the call runs on the group's budget and never pays into it, so the pool + can never catch up with what it authorized.""" + valid_token = await _enforce_for_a_route_without_a_request_body( + _MagBudgetRow("tier-a", max_budget=10.0), + key_models=["tier-a"], + spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 1.0}, + ) + + assert valid_token.matched_model_access_groups == ["tier-a"] + + +@pytest.mark.asyncio +async def test_model_granted_directly_charges_no_group(): + valid_token = await _enforce_for_a_route_without_a_request_body( + _MagBudgetRow("tier-a", max_budget=1.0), + key_models=["gpt-4o"], + spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 1.5}, + ) + + assert valid_token.matched_model_access_groups is None diff --git a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_websocket_passthrough_logging_handler.py b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_websocket_passthrough_logging_handler.py new file mode 100644 index 00000000000..91a01f02d94 --- /dev/null +++ b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_websocket_passthrough_logging_handler.py @@ -0,0 +1,290 @@ +from datetime import datetime +from types import MappingProxyType +from typing import Final +from unittest.mock import MagicMock + +import pytest + +import litellm +from litellm.proxy.pass_through_endpoints.llm_provider_handlers.openai_websocket_passthrough_logging_handler import ( + OpenAIWebsocketPassthroughLoggingHandler, +) + +REALTIME_MODEL: Final = "gpt-realtime-2.1" +RESPONSES_MODEL: Final = "gpt-5.4-mini" +REASONING_MODEL: Final = "gpt-5.4" + + +def _logging_obj() -> MagicMock: + logging_obj = MagicMock() + logging_obj.model_call_details = {} + return logging_obj + + +def _session_created(model: str) -> dict: + return {"type": "session.created", "session": {"id": "sess_fake", "model": model}} + + +def _realtime_response_done(text_in: int, audio_in: int, text_out: int, audio_out: int) -> dict: + return { + "type": "response.done", + "response": { + "id": "resp_fake", + "status": "completed", + "usage": { + "input_tokens": text_in + audio_in, + "output_tokens": text_out + audio_out, + "total_tokens": text_in + audio_in + text_out + audio_out, + "input_token_details": {"text_tokens": text_in, "audio_tokens": audio_in, "cached_tokens": 0}, + "output_token_details": {"text_tokens": text_out, "audio_tokens": audio_out}, + }, + }, + } + + +def _responses_completed(model: str, input_tokens: int, output_tokens: int) -> dict: + return { + "type": "response.completed", + "response": { + "id": "resp_fake", + "model": model, + "status": "completed", + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}, + }, + }, + } + + +def _handle(messages, kwargs=MappingProxyType({}), logging_obj=None, url_route="/openai/v1/realtime"): + return OpenAIWebsocketPassthroughLoggingHandler().openai_websocket_passthrough_handler( + websocket_messages=messages, + logging_obj=logging_obj if logging_obj is not None else _logging_obj(), + url_route=url_route, + start_time=datetime.now(), + kwargs=kwargs, + ) + + +def _realtime_cost(model: str, text_in: int, audio_in: int, text_out: int, audio_out: int) -> float: + prices = litellm.model_cost[model] + return ( + text_in * prices["input_cost_per_token"] + + audio_in * prices["input_cost_per_audio_token"] + + text_out * prices["output_cost_per_token"] + + audio_out * prices["output_cost_per_audio_token"] + ) + + +def test_realtime_session_is_priced_on_the_model_the_session_reported(): + logging_obj = _logging_obj() + messages = [ + _session_created(REALTIME_MODEL), + {"type": "response.created", "response": {"id": "resp_fake"}}, + _realtime_response_done(text_in=33, audio_in=120, text_out=16, audio_out=240), + ] + + handled = _handle(messages, logging_obj=logging_obj) + + expected_cost = _realtime_cost(REALTIME_MODEL, 33, 120, 16, 240) + assert handled["kwargs"]["response_cost"] == pytest.approx(expected_cost) + assert handled["kwargs"]["model"] == REALTIME_MODEL + assert handled["kwargs"]["custom_llm_provider"] == "openai" + assert handled["result"].model == REALTIME_MODEL + assert handled["result"].usage.total_tokens == 409 + assert logging_obj.model_call_details["response_cost"] == pytest.approx(expected_cost) + assert logging_obj.model_call_details["model"] == REALTIME_MODEL + + +def test_realtime_cost_covers_every_turn_of_the_session(): + one_turn = _handle([_session_created(REALTIME_MODEL), _realtime_response_done(10, 0, 5, 0)]) + two_turns = _handle( + [ + _session_created(REALTIME_MODEL), + _realtime_response_done(10, 0, 5, 0), + _realtime_response_done(10, 0, 5, 0), + ] + ) + + assert two_turns["kwargs"]["response_cost"] == pytest.approx(2 * one_turn["kwargs"]["response_cost"]) + assert two_turns["result"].usage.total_tokens == 30 + + +def test_responses_turn_is_priced_on_the_model_the_response_reported(): + handled = _handle( + [_responses_completed(RESPONSES_MODEL, input_tokens=13, output_tokens=5)], + url_route="/openai_passthrough/v1/responses", + ) + + prices = litellm.model_cost[RESPONSES_MODEL] + expected_cost = 13 * prices["input_cost_per_token"] + 5 * prices["output_cost_per_token"] + assert handled["kwargs"]["response_cost"] == pytest.approx(expected_cost) + assert handled["kwargs"]["model"] == RESPONSES_MODEL + assert handled["result"].usage.prompt_tokens == 13 + assert handled["result"].usage.completion_tokens == 5 + + +def test_responses_cost_covers_every_turn_of_the_connection(): + handled = _handle( + [ + _responses_completed(RESPONSES_MODEL, input_tokens=13, output_tokens=5), + _responses_completed(RESPONSES_MODEL, input_tokens=20, output_tokens=7), + ], + url_route="/openai_passthrough/v1/responses", + ) + + prices = litellm.model_cost[RESPONSES_MODEL] + expected_cost = 33 * prices["input_cost_per_token"] + 12 * prices["output_cost_per_token"] + assert handled["kwargs"]["response_cost"] == pytest.approx(expected_cost) + assert handled["result"].usage.total_tokens == 45 + + +def test_a_model_the_caller_asked_for_never_prices_a_session_the_provider_never_named(): + """ + The caller picks what to ask for, and the provider picks what to run. Only + the provider's own frames may set the price of a session, so a model the + caller put in the request cannot stand in for one the provider never named. + """ + logging_obj = _logging_obj() + + handled = _handle( + [_realtime_response_done(text_in=10, audio_in=0, text_out=5, audio_out=0)], + kwargs=MappingProxyType({"model": REALTIME_MODEL}), + logging_obj=logging_obj, + ) + + assert handled["result"] is None + assert handled["kwargs"] == {"model": REALTIME_MODEL} + assert logging_obj.model_call_details == {} + + +def test_each_responses_turn_is_priced_on_the_model_that_turn_ran(): + """ + One socket can run several models one after another, so a connection that + starts on a big model and finishes on a small one has to pay the big + model's price for the turn that used it. + """ + handled = _handle( + [ + _responses_completed(REASONING_MODEL, input_tokens=13, output_tokens=5), + _responses_completed(RESPONSES_MODEL, input_tokens=20, output_tokens=7), + ], + url_route="/openai_passthrough/v1/responses", + ) + + first = litellm.model_cost[REASONING_MODEL] + second = litellm.model_cost[RESPONSES_MODEL] + expected_cost = ( + 13 * first["input_cost_per_token"] + + 5 * first["output_cost_per_token"] + + 20 * second["input_cost_per_token"] + + 7 * second["output_cost_per_token"] + ) + assert handled["kwargs"]["response_cost"] == pytest.approx(expected_cost) + assert handled["result"].usage.total_tokens == 45 + + +def test_a_responses_turn_that_names_no_model_is_priced_on_the_session_model(): + handled = _handle( + [ + _session_created(RESPONSES_MODEL), + { + "type": "response.completed", + "response": {"usage": {"input_tokens": 13, "output_tokens": 5, "total_tokens": 18}}, + }, + ], + url_route="/openai_passthrough/v1/responses", + ) + + prices = litellm.model_cost[RESPONSES_MODEL] + expected_cost = 13 * prices["input_cost_per_token"] + 5 * prices["output_cost_per_token"] + assert handled["kwargs"]["response_cost"] == pytest.approx(expected_cost) + + +def test_a_session_nobody_named_a_model_for_is_left_unpriced(): + logging_obj = _logging_obj() + handled = _handle( + [{"type": "response.done", "response": {"usage": {"input_tokens": 10, "output_tokens": 5}}}], + kwargs=MappingProxyType({"litellm_call_id": "call-fake"}), + logging_obj=logging_obj, + ) + + assert handled["result"] is None + assert handled["kwargs"] == {"litellm_call_id": "call-fake"} + assert logging_obj.model_call_details == {} + + +def test_a_session_that_reported_no_usage_still_records_its_model(): + logging_obj = _logging_obj() + + handled = _handle([_session_created(REALTIME_MODEL)], logging_obj=logging_obj) + + assert handled["result"] is None + assert handled["kwargs"] == {"model": REALTIME_MODEL, "custom_llm_provider": "openai"} + assert logging_obj.model_call_details["model"] == REALTIME_MODEL + assert logging_obj.model_call_details["custom_llm_provider"] == "openai" + + +def test_a_responses_turn_without_readable_usage_is_not_billed(): + handled = _handle( + [{"type": "response.completed", "response": {"model": RESPONSES_MODEL, "status": "completed"}}], + url_route="/openai_passthrough/v1/responses", + ) + + assert handled["result"] is None + assert handled["kwargs"] == {"model": RESPONSES_MODEL, "custom_llm_provider": "openai"} + + +def test_the_price_of_a_multi_model_connection_reaches_the_spend_row(): + """ + The logging layer prices whatever result it is handed, and one result can + only name one model, so a connection that ran two models has to carry the + price it already worked out or the cheaper model pays for both turns. The + row it lands in names the model the connection opened on. + """ + handled = _handle( + [ + _responses_completed(RESPONSES_MODEL, input_tokens=13, output_tokens=5), + _responses_completed(REASONING_MODEL, input_tokens=20, output_tokens=7), + ], + url_route="/openai_passthrough/v1/responses", + ) + + logged_cost = litellm.response_cost_calculator( + response_object=handled["result"], + model=handled["kwargs"]["model"], + custom_llm_provider="openai", + call_type="completion", + optional_params={}, + ) + + session_prices = litellm.model_cost[RESPONSES_MODEL] + priced_on_the_session_model = ( + 33 * session_prices["input_cost_per_token"] + 12 * session_prices["output_cost_per_token"] + ) + assert logged_cost == pytest.approx(handled["kwargs"]["response_cost"]) + assert logged_cost > priced_on_the_session_model + assert handled["kwargs"]["model"] == RESPONSES_MODEL + + +def test_a_turn_the_caller_walked_out_of_still_names_the_model_it_ran(): + """ + A client that closes before response.completed leaves no usage behind to + bill, but response.created already named the model, so the row says which + model answered instead of falling back to unknown. + """ + logging_obj = _logging_obj() + + handled = _handle( + [{"type": "response.created", "response": {"id": "resp_fake", "model": REASONING_MODEL}}], + logging_obj=logging_obj, + url_route="/openai_passthrough/v1/responses", + ) + + assert handled["result"] is None + assert handled["kwargs"] == {"model": REASONING_MODEL, "custom_llm_provider": "openai"} + assert logging_obj.model_call_details["model"] == REASONING_MODEL diff --git a/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py index db0a74d360a..6f35ebbc161 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -7,20 +7,20 @@ import os import sys import uuid import zlib -from collections.abc import AsyncIterator, Callable, Mapping +from collections.abc import AsyncIterator, Callable, Mapping, Sequence from contextlib import ExitStack, contextmanager from dataclasses import dataclass from datetime import datetime from io import BytesIO from types import MappingProxyType, ModuleType, SimpleNamespace from typing import Final, Optional, cast -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from uuid import UUID import httpx import pytest import respx -from fastapi import APIRouter, FastAPI, HTTPException, Request, Response, UploadFile +from fastapi import APIRouter, FastAPI, HTTPException, Request, Response, UploadFile, WebSocketDisconnect from fastapi.responses import StreamingResponse from fastapi.routing import APIRoute from fastapi.testclient import TestClient @@ -33,6 +33,7 @@ from litellm._logging import verbose_proxy_logger from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._lazy_features import LazyFeature, attach_lazy_features from litellm.proxy._types import ProxyException, UserAPIKeyAuth @@ -7515,6 +7516,281 @@ async def test_websocket_passthrough_does_not_close_twice_when_success_logging_f websocket.close.assert_awaited_once_with(code=1008, reason=upstream_reason) +SUCCESS_HANDLER: Final = ( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints." + "pass_through_endpoint_logging.pass_through_async_success_handler" +) + + +class RecordingFrameModelGate: + def __init__(self, refusals: Mapping[str, str] = MappingProxyType({})): + self.refusals = refusals + self.checked: list[str] = [] + + async def __call__(self, model: str, valid_token: UserAPIKeyAuth, /) -> Optional[str]: + self.checked.append(model) + return self.refusals.get(model) + + +def _received_frame(frame): + if isinstance(frame, bytes): + return {"type": "websocket.receive", "bytes": frame} + return {"type": "websocket.receive", "text": frame} + + +async def _relay_client_frames(frames, gate, user_api_key_dict=None): + upstream_ws = RecordingUpstreamWebSocket() + websocket = _client_websocket( + AsyncMock(side_effect=[_received_frame(frame) for frame in frames] + [{"type": "websocket.disconnect"}]) + ) + + with ( + _patched_websocket_passthrough_environment(upstream_ws), + patch(SUCCESS_HANDLER, new_callable=AsyncMock) as success_handler, + ): + await websocket_passthrough_request( + websocket=websocket, + target="wss://api.openai.com/v1/realtime?model=gpt-realtime-2.1", + custom_headers={"Authorization": "Bearer sk-provider"}, + user_api_key_dict=user_api_key_dict or UserAPIKeyAuth(), + forward_headers=False, + endpoint="/openai/v1/realtime", + accept_websocket=False, + client_frame_model_gate=gate, + ) + + return SimpleNamespace(upstream=upstream_ws, client=websocket, success_handler=success_handler) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "frame, expected_model", + [ + ({"type": "response.create", "model": "gpt-5.4-mini"}, "gpt-5.4-mini"), + ({"type": "response.create", "response": {"model": "gpt-5.4"}}, "gpt-5.4"), + ({"type": "session.update", "session": {"model": "gpt-realtime-2.1"}}, "gpt-realtime-2.1"), + ( + {"type": "session.update", "session": {"input_audio_transcription": {"model": "gpt-4o-transcribe"}}}, + "gpt-4o-transcribe", + ), + ( + { + "type": "session.update", + "session": {"audio": {"input": {"transcription": {"model": "gpt-transcribe"}}}}, + }, + "gpt-transcribe", + ), + ], +) +async def test_websocket_passthrough_checks_the_model_a_client_frame_asks_for(frame, expected_model): + gate = RecordingFrameModelGate() + + relayed = await _relay_client_frames([json.dumps(frame)], gate) + + assert gate.checked == [expected_model] + assert [call.args[0] for call in relayed.upstream.send.await_args_list] == [json.dumps(frame)] + assert relayed.client.close.await_args_list == [call()] + + +@pytest.mark.asyncio +async def test_websocket_passthrough_refuses_a_frame_naming_a_model_the_key_cannot_call(): + refusal = "key not allowed to access model. This key can only access models=['gpt-5.4-mini']." + gate = RecordingFrameModelGate(MappingProxyType({"gpt-realtime-2.1": refusal})) + frame = json.dumps({"type": "session.update", "session": {"model": "gpt-realtime-2.1"}}) + + relayed = await _relay_client_frames([frame], gate) + + assert relayed.upstream.send.await_args_list == [] + assert json.loads(relayed.client.send_text.await_args.args[0]) == { + "type": "error", + "error": {"type": "invalid_request_error", "message": refusal}, + } + assert relayed.client.close.await_args_list == [call(code=1008, reason=refusal)] + relayed.upstream.close.assert_awaited() + + +@pytest.mark.asyncio +async def test_websocket_passthrough_refuses_the_whole_frame_when_any_named_model_is_denied(): + refusal = "key not allowed to access model. This key can only access models=['gpt-realtime-2.1']." + gate = RecordingFrameModelGate(MappingProxyType({"gpt-4o-transcribe": refusal})) + frame = json.dumps( + { + "type": "session.update", + "session": {"model": "gpt-realtime-2.1", "input_audio_transcription": {"model": "gpt-4o-transcribe"}}, + } + ) + + relayed = await _relay_client_frames([frame], gate) + + assert gate.checked == ["gpt-realtime-2.1", "gpt-4o-transcribe"] + assert relayed.upstream.send.await_args_list == [] + assert relayed.client.close.await_args_list == [call(code=1008, reason=refusal)] + + +@pytest.mark.asyncio +async def test_websocket_passthrough_stops_relaying_after_a_refused_frame(): + refusal = "key not allowed to access model. This key can only access models=['gpt-5.4-mini']." + gate = RecordingFrameModelGate(MappingProxyType({"gpt-realtime-2.1": refusal})) + frames = [ + json.dumps({"type": "session.update", "session": {"model": "gpt-realtime-2.1"}}), + json.dumps({"type": "response.create"}), + ] + + relayed = await _relay_client_frames(frames, gate) + + assert relayed.upstream.send.await_args_list == [] + assert gate.checked == ["gpt-realtime-2.1"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "frame", + [ + '{"type": "input_audio_buffer.append", "audio": "AAAA"}', + '{"type": "response.create", "response": {"modalities": ["audio"]}}', + '{"type": "session.update", "session": {"model": 42}}', + 'not json at all, but it does say "model"', + '{"type": "session.update", "session": "model"}', + ], +) +async def test_websocket_passthrough_forwards_frames_that_name_no_model(frame): + gate = RecordingFrameModelGate(MappingProxyType({"gpt-realtime-2.1": "denied"})) + + relayed = await _relay_client_frames([frame], gate) + + assert gate.checked == [] + assert [call_.args[0] for call_ in relayed.upstream.send.await_args_list] == [frame] + assert relayed.client.close.await_args_list == [call()] + + +@pytest.mark.asyncio +async def test_websocket_passthrough_never_bills_the_model_a_client_frame_named(): + """ + A frame is what the caller asked for, not what the provider ran. A caller + who names a cheap model in a frame the provider ignores must not move the + session onto that model's price, so only the provider's own frames decide + what a session is billed as. + """ + gate = RecordingFrameModelGate() + frames = [ + json.dumps({"type": "session.update", "session": {"model": "gpt-realtime-2.1"}}), + json.dumps({"type": "response.create", "response": {"model": "gpt-realtime-2.1-mini"}}), + ] + + relayed = await _relay_client_frames(frames, gate) + + assert gate.checked == ["gpt-realtime-2.1", "gpt-realtime-2.1-mini"] + assert "model" not in relayed.success_handler.call_args.kwargs + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "frame, expected_model", + [ + ('{"type": "response.create", "\\u006dodel": "gpt-5.4"}', "gpt-5.4"), + ('{"type": "session.update", "session": {"\\u006d\\u006fdel": "gpt-realtime-2.1"}}', "gpt-realtime-2.1"), + ], +) +async def test_websocket_passthrough_checks_a_model_named_with_escaped_json(frame, expected_model): + """ + JSON lets a caller spell the same key several ways, so a frame carrying + "\\u006dodel" asks for a model just as plainly as one carrying "model" and + has to face the same check. + """ + refusal = "key not allowed to access model. This key can only access models=['gpt-5.4-mini']." + gate = RecordingFrameModelGate(MappingProxyType({expected_model: refusal})) + + relayed = await _relay_client_frames([frame], gate) + + assert gate.checked == [expected_model] + assert relayed.upstream.send.await_args_list == [] + assert relayed.client.close.await_args_list == [call(code=1008, reason=refusal)] + + +@pytest.mark.asyncio +async def test_websocket_passthrough_checks_the_model_a_binary_frame_asks_for(): + refusal = "key not allowed to access model. This key can only access models=['gpt-5.4-mini']." + gate = RecordingFrameModelGate(MappingProxyType({"gpt-realtime-2.1": refusal})) + frame = json.dumps({"type": "session.update", "session": {"model": "gpt-realtime-2.1"}}).encode() + + relayed = await _relay_client_frames([frame], gate) + + assert gate.checked == ["gpt-realtime-2.1"] + assert relayed.upstream.send.await_args_list == [] + assert relayed.client.close.await_args_list == [call(code=1008, reason=refusal)] + + +@pytest.mark.asyncio +async def test_websocket_passthrough_forwards_binary_audio_untouched(): + gate = RecordingFrameModelGate(MappingProxyType({"gpt-realtime-2.1": "denied"})) + audio = bytes(range(256)) * 4 + + relayed = await _relay_client_frames([audio], gate) + + assert gate.checked == [] + assert [call_.args[0] for call_ in relayed.upstream.send.await_args_list] == [audio] + + +@pytest.mark.asyncio +async def test_websocket_passthrough_without_a_frame_gate_forwards_every_frame(): + frame = json.dumps({"type": "session.update", "session": {"model": "gpt-realtime-2.1"}}) + + relayed = await _relay_client_frames([frame], None) + + assert [call_.args[0] for call_ in relayed.upstream.send.await_args_list] == [frame] + assert "model" not in relayed.success_handler.call_args.kwargs + + +REALTIME_SESSION_CREATED: Final = json.dumps( + {"type": "session.created", "session": {"id": "sess_fake", "model": "gpt-realtime-2.1"}} +) +REALTIME_RESPONSE_DONE: Final = json.dumps( + { + "type": "response.done", + "response": {"status": "completed", "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}}, + } +) + + +@pytest.mark.asyncio +async def test_websocket_passthrough_still_bills_a_session_the_client_walked_out_of(): + """ + A realtime client closes the moment it has the answer it asked for, so the + next frame the provider sends lands on a socket that is already gone. That + is how a session ends, not a gateway failure, and the turns the caller + already used still have to reach spend, including the last frame the client + never collected. + """ + upstream_ws = FakeUpstreamWebSocket( + REALTIME_SESSION_CREATED, + REALTIME_RESPONSE_DONE, + json.dumps({"type": "response.output_text.delta", "delta": "too late"}), + ) + websocket = _client_websocket(_pending_receive) + websocket.send_text = AsyncMock(side_effect=[None, None, WebSocketDisconnect(code=1006)]) + + with ( + _patched_websocket_passthrough_environment(upstream_ws), + patch(SUCCESS_HANDLER, new_callable=AsyncMock) as success_handler, + ): + await websocket_passthrough_request( + websocket=websocket, + target="wss://api.openai.com/v1/realtime?model=gpt-realtime-2.1", + custom_headers={"Authorization": "Bearer sk-provider"}, + user_api_key_dict=UserAPIKeyAuth(), + forward_headers=False, + endpoint="/openai_passthrough/v1/realtime", + accept_websocket=False, + ) + + relayed_frames: Final = success_handler.call_args.kwargs["response_body"] + assert [frame["type"] for frame in relayed_frames] == [ + "session.created", + "response.done", + "response.output_text.delta", + ] + + DEEPGRAM_LISTEN_TARGET = "wss://api.deepgram.com/v1/listen?model=nova-3&encoding=linear16&sample_rate=16000" DEEPGRAM_INTERIM_FRAME = json.dumps( { @@ -8442,6 +8718,41 @@ def test_passthrough_client_cannot_forge_session_id_omission(client_metadata_key ) +class StampingFrameModelGate: + def __init__(self, groups: list[str]): + self.groups = groups + + async def __call__(self, model: str, valid_token: UserAPIKeyAuth, /) -> Optional[str]: + valid_token.matched_model_access_groups = self.groups + return None + + +async def _billed_metadata(gate) -> dict: + served = await _relay_client_frames( + [json.dumps({"type": "response.create", "model": "gpt-5.4-mini"})], + gate, + UserAPIKeyAuth(api_key="hashed-key"), + ) + return served.success_handler.call_args.kwargs["litellm_params"]["metadata"] + + +@pytest.mark.asyncio +async def test_websocket_session_is_charged_to_the_group_its_frames_were_authorized_through(): + """A websocket names its model in a frame, long after the relay built its logging metadata, so the + groups the gate matched have to be picked up at the end or the group pays for nothing it allowed.""" + metadata = await _billed_metadata(StampingFrameModelGate(["tier-a"])) + + assert metadata[MODEL_ACCESS_GROUP_METADATA_KEY] == ["tier-a"] + assert metadata["user_api_key"] == "hashed-key" + + +@pytest.mark.asyncio +async def test_websocket_session_that_matched_no_group_is_charged_to_none(): + metadata = await _billed_metadata(RecordingFrameModelGate()) + + assert not metadata[MODEL_ACCESS_GROUP_METADATA_KEY] + + @pytest.mark.parametrize("client_metadata_key", ["litellm_metadata", "metadata"]) def test_passthrough_logs_the_resolved_deployment_model_info_over_the_request_body(client_metadata_key: str): """A provider route that resolved a router deployment stashes its model_info on request.state. That diff --git a/tests/unit/proxy/pass_through_endpoints/test_success_handler.py b/tests/unit/proxy/pass_through_endpoints/test_success_handler.py index 2cc03949ff1..5b3f337fb5e 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_success_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/test_success_handler.py @@ -648,3 +648,57 @@ def test_vertex_ai_live_route_without_usage_frames_yields_no_logging_response( assert normalized["standard_logging_response_object"] is None assert normalized["kwargs"] == {"model": _LIVE_MODEL} + + +REALTIME_FRAMES: Final = [ + {"type": "session.created", "session": {"id": "sess_fake", "model": "gpt-realtime-2.1"}}, + { + "type": "response.done", + "response": { + "usage": { + "input_tokens": 33, + "output_tokens": 16, + "total_tokens": 49, + "input_token_details": {"text_tokens": 33, "audio_tokens": 0, "cached_tokens": 0}, + "output_token_details": {"text_tokens": 16, "audio_tokens": 0}, + } + }, + }, +] + + +def _normalize(url_route: str, response_body): + logging_obj = MagicMock() + logging_obj.model_call_details = {} + return PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload( + httpx_response=MagicMock(spec=httpx.Response), + response_body=response_body, + request_body={}, + logging_obj=logging_obj, + url_route=url_route, + result="websocket_connection_successful", + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + litellm_call_id="call-fake", + ) + + +@pytest.mark.parametrize("url_route", ["/openai/v1/realtime", "/openai_passthrough/v1/realtime"]) +def test_openai_websocket_frames_reach_the_websocket_cost_handler(url_route): + normalized = _normalize(url_route, REALTIME_FRAMES) + + assert normalized["kwargs"]["model"] == "gpt-realtime-2.1" + assert normalized["kwargs"]["custom_llm_provider"] == "openai" + prices = litellm.model_cost["gpt-realtime-2.1"] + assert normalized["kwargs"]["response_cost"] == pytest.approx( + 33 * prices["input_cost_per_token"] + 16 * prices["output_cost_per_token"] + ) + assert normalized["standard_logging_response_object"].usage.total_tokens == 49 + + +def test_openai_http_passthrough_bodies_are_left_to_the_http_handlers(): + normalized = _normalize("/openai/v1/responses", {"model": "gpt-5.4-mini", "usage": {"input_tokens": 13}}) + + assert normalized["standard_logging_response_object"] is None + assert normalized["kwargs"] == {"litellm_call_id": "call-fake"} diff --git a/tests/unit/proxy/test_openai_ws_passthrough_routes.py b/tests/unit/proxy/test_openai_ws_passthrough_routes.py index 6a9cd972dc2..bd65001111a 100644 --- a/tests/unit/proxy/test_openai_ws_passthrough_routes.py +++ b/tests/unit/proxy/test_openai_ws_passthrough_routes.py @@ -1,11 +1,12 @@ -"""OpenAI passthrough WebSocket route: registration, opt-in gating, and refusals.""" +"""OpenAI passthrough WebSocket route: registration, opt-in gating, refusals, and per-frame model checks.""" import json -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping +from contextlib import contextmanager from dataclasses import dataclass from types import MappingProxyType, SimpleNamespace from typing import Final -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest @@ -14,18 +15,16 @@ from starlette.routing import WebSocketRoute import litellm from litellm.llms.openai.workload_identity import _workload_identity_auth -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import Litellm_EntityType, LiteLLM_UserTable, UserAPIKeyAuth from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( _OPENAI_WS_DISABLED_REFUSAL, - _OPENAI_WS_MODEL_RESTRICTED_REFUSAL, - _has_model_restrictions, + _OPENAI_WS_UNAVAILABLE_AUTH_REFUSAL, _openai_websocket_refusal, - _proxy_model_allowlists, + _proxy_frame_model_gate, openai_websocket_proxy_route, router, ) - -Scopes = tuple[Sequence[str], ...] +from litellm.proxy.pass_through_endpoints.pass_through_endpoints import WebsocketFrameModelGate ENABLED: Final = MappingProxyType({"enable_openai_websocket_passthrough": True}) DISABLED_SETTINGS: Final = ( @@ -76,6 +75,7 @@ class _RelayCall: forward_headers: bool endpoint: str accept_websocket: bool + client_frame_model_gate: WebsocketFrameModelGate | None class _FakeRelay: @@ -92,6 +92,7 @@ class _FakeRelay: forward_headers: bool, endpoint: str, accept_websocket: bool, + client_frame_model_gate: WebsocketFrameModelGate | None = None, ) -> None: self.calls.append( _RelayCall( @@ -100,24 +101,25 @@ class _FakeRelay: forward_headers=forward_headers, endpoint=endpoint, accept_websocket=accept_websocket, + client_frame_model_gate=client_frame_model_gate, ) ) -class _FakeModelAllowlists: - def __init__(self, scopes: Scopes) -> None: - self.scopes = scopes - self.calls: list[UserAPIKeyAuth] = [] +class _FakeFrameModelGate: + def __init__(self, refusal: str | None = None) -> None: + self.refusal = refusal + self.checked: list[str] = [] - async def __call__(self, valid_token: UserAPIKeyAuth, /) -> Scopes: - self.calls.append(valid_token) - return self.scopes + async def __call__(self, model: str, valid_token: UserAPIKeyAuth, /) -> str | None: + self.checked.append(model) + return self.refusal @dataclass(frozen=True, slots=True) class _Served: relay: _FakeRelay - allowlists: _FakeModelAllowlists + frame_model_gate: _FakeFrameModelGate async def _serve( @@ -125,16 +127,15 @@ async def _serve( endpoint: str, user_api_key_dict: UserAPIKeyAuth, general_settings: Mapping[str, object], - scopes: Scopes = (), ) -> _Served: - served = _Served(relay=_FakeRelay(), allowlists=_FakeModelAllowlists(scopes)) + served = _Served(relay=_FakeRelay(), frame_model_gate=_FakeFrameModelGate()) await openai_websocket_proxy_route( websocket=websocket, endpoint=endpoint, user_api_key_dict=user_api_key_dict, general_settings=general_settings, relay=served.relay, - model_allowlists=served.allowlists, + frame_model_gate=served.frame_model_gate, ) return served @@ -143,18 +144,19 @@ async def _serve( @pytest.mark.parametrize("prefix", ["openai", "openai_passthrough"]) async def test_openai_websocket_forwards_query_and_keeps_provider_auth(prefix, monkeypatch): monkeypatch.delenv("OPENAI_API_BASE", raising=False) - websocket = _FakeWebSocket(f"/{prefix}/v1/realtime", "model=gpt-4o-realtime-preview") + websocket = _FakeWebSocket(f"/{prefix}/v1/realtime", "model=gpt-realtime-2.1") with patch(GET_CREDENTIALS, return_value="sk-provider"): served = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) assert served.relay.calls == [ _RelayCall( - target="wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview", + target="wss://api.openai.com/v1/realtime?model=gpt-realtime-2.1", custom_headers=MappingProxyType({"Authorization": "Bearer sk-provider"}), forward_headers=False, endpoint=f"/{prefix}/v1/realtime", accept_websocket=False, + client_frame_model_gate=served.frame_model_gate, ) ] assert websocket.accepts == [None] @@ -166,7 +168,7 @@ async def test_openai_websocket_forwards_query_and_keeps_provider_auth(prefix, m async def test_openai_websocket_accepts_first_client_subprotocol(): websocket = _FakeWebSocket( "/openai/v1/realtime", - "model=gpt-4o-realtime-preview", + "model=gpt-realtime-2.1", subprotocols="realtime, openai-insecure-api-key.sk-abc, openai-beta.realtime-v1", ) @@ -239,7 +241,7 @@ async def test_openai_websocket_closes_cleanly_when_workload_identity_exchange_f @pytest.mark.asyncio async def test_openai_websocket_closes_cleanly_when_provider_credentials_missing(): - websocket = _FakeWebSocket("/openai/v1/realtime", "model=gpt-4o-realtime-preview") + websocket = _FakeWebSocket("/openai/v1/realtime", "model=gpt-realtime-2.1") with patch(GET_CREDENTIALS, return_value=None): served = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) @@ -255,7 +257,7 @@ async def test_openai_websocket_closes_cleanly_when_provider_credentials_missing @pytest.mark.parametrize("prefix", ["openai", "openai_passthrough"]) @pytest.mark.parametrize("general_settings", DISABLED_SETTINGS) async def test_openai_websocket_refused_unless_explicitly_enabled(prefix, general_settings): - websocket = _FakeWebSocket(f"/{prefix}/v1/realtime", "model=gpt-4o-realtime-preview") + websocket = _FakeWebSocket(f"/{prefix}/v1/realtime", "model=gpt-realtime-2.1") served = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), general_settings) @@ -265,25 +267,21 @@ async def test_openai_websocket_refused_unless_explicitly_enabled(prefix, genera assert served.relay.calls == [] -@pytest.mark.asyncio @pytest.mark.parametrize("general_settings", DISABLED_SETTINGS) -async def test_openai_websocket_refusal_is_disabled_for_falsy_settings(general_settings): - refusal = await _openai_websocket_refusal(UserAPIKeyAuth(), general_settings, _FakeModelAllowlists(())) - assert refusal is _OPENAI_WS_DISABLED_REFUSAL +def test_openai_websocket_refusal_is_disabled_for_falsy_settings(general_settings): + assert _openai_websocket_refusal(general_settings) is _OPENAI_WS_DISABLED_REFUSAL -@pytest.mark.asyncio @pytest.mark.parametrize("value", [True, "true", "True"]) -async def test_openai_websocket_refusal_is_none_for_truthy_settings(value): - settings = MappingProxyType({"enable_openai_websocket_passthrough": value}) - assert await _openai_websocket_refusal(UserAPIKeyAuth(), settings, _FakeModelAllowlists(())) is None +def test_openai_websocket_refusal_is_none_for_truthy_settings(value): + assert _openai_websocket_refusal(MappingProxyType({"enable_openai_websocket_passthrough": value})) is None @pytest.mark.asyncio async def test_openai_websocket_refusal_echoes_requested_subprotocol(): websocket = _FakeWebSocket( "/openai_passthrough/v1/realtime", - "model=gpt-4o-realtime-preview", + "model=gpt-realtime-2.1", subprotocols="realtime, openai-beta.realtime-v1", ) @@ -294,68 +292,190 @@ async def test_openai_websocket_refusal_echoes_requested_subprotocol(): assert served.relay.calls == [] -RESTRICTED_SCOPES: Final[tuple[Scopes, ...]] = ( - (("gpt-4o",),), - ((), ("gpt-4o-realtime-preview",)), - (("all-team-models",), ("gpt-4o",)), - ((), ("all-proxy-models",), ("gpt-4o",)), - ((), (), (), ("gpt-4o",)), - (("*",), (), (), (), ("gpt-4o",)), +@pytest.mark.asyncio +@pytest.mark.parametrize( + "key_models", + [["gpt-5.4-mini"], ["gpt-realtime-2.1", "gpt-5.4"], []], ) -UNRESTRICTED_SCOPES: Final[tuple[Scopes, ...]] = ( - (), - ((),), - (("all-proxy-models",),), - (("*",),), - (("all-team-models",), ("all-proxy-models",)), - ((), (), (), (), ()), - (("*",), ("all-proxy-models",), ("all-team-models",), (), ()), -) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("scopes", RESTRICTED_SCOPES) -async def test_openai_websocket_rejects_model_restricted_identities(scopes): - websocket = _FakeWebSocket("/openai/v1/realtime", "model=gpt-4o-realtime-preview") - user_api_key_dict = UserAPIKeyAuth(token="hashed-fake", user_id="user-fake", team_id="team-fake") - - served = await _serve(websocket, "v1/realtime", user_api_key_dict, ENABLED, scopes) - - assert "model restrictions" in websocket.error_message() - assert websocket.closed == (1008, _OPENAI_WS_MODEL_RESTRICTED_REFUSAL.close_reason) - assert served.relay.calls == [] - assert served.allowlists.calls == [user_api_key_dict] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("scopes", RESTRICTED_SCOPES) -async def test_openai_websocket_disabled_refusal_skips_allowlist_lookups(scopes): - allowlists = _FakeModelAllowlists(scopes) - - refusal = await _openai_websocket_refusal(UserAPIKeyAuth(), MappingProxyType({}), allowlists) - - assert refusal is _OPENAI_WS_DISABLED_REFUSAL - assert allowlists.calls == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("scopes", UNRESTRICTED_SCOPES) -async def test_openai_websocket_allows_unrestricted_identities(scopes): +async def test_openai_websocket_connects_model_restricted_keys(key_models): websocket = _FakeWebSocket("/openai/v1/responses", "") + user_api_key_dict = UserAPIKeyAuth(models=key_models, token="hashed-fake", user_id="user-fake") with patch(GET_CREDENTIALS, return_value="sk-provider"): - served = await _serve(websocket, "v1/responses", UserAPIKeyAuth(), ENABLED, scopes) + served = await _serve(websocket, "v1/responses", user_api_key_dict, ENABLED) assert len(served.relay.calls) == 1 + assert served.relay.calls[0].client_frame_model_gate is served.frame_model_gate assert websocket.sent == [] assert websocket.closed is None @pytest.mark.asyncio -async def test_proxy_model_allowlists_reads_the_token_scopes_without_a_database(): - token: Final = UserAPIKeyAuth(models=[], team_id="team-fake", team_models=["gpt-4o"]) - with patch("litellm.proxy.proxy_server.prisma_client", None): - scopes = await _proxy_model_allowlists()(token) +async def test_proxy_frame_model_gate_allows_a_model_the_key_owns(): + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=["gpt-realtime-2.1", "gpt-5.4-mini"]) - assert tuple(tuple(scope) for scope in scopes) == ((), ("gpt-4o",)) - assert _has_model_restrictions(scopes) + with patch("litellm.proxy.proxy_server.prisma_client", None): # test-quality-ok: the gate needs no database + assert await gate("gpt-realtime-2.1", token) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("requested_model", ["gpt-5.4", "gpt-realtime-2.1-mini"]) +async def test_proxy_frame_model_gate_refuses_a_model_the_key_lacks(requested_model): + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=["gpt-realtime-2.1", "gpt-5.4-mini"]) + + with patch("litellm.proxy.proxy_server.prisma_client", None): # test-quality-ok: the gate needs no database + refusal = await gate(requested_model, token) + + assert refusal is not None + assert requested_model in refusal + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "granted_models, refusal_names", + [(["gpt-5.4-mini"], "gpt-5.4"), ([], "no model grants")], + ids=["model_outside_the_grant", "no_grants"], +) +async def test_proxy_frame_model_gate_refuses_a_model_the_agent_was_not_granted(granted_models, refusal_names): + from litellm.types.agents import AgentResponse + + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=[], agent_id="agent-fake") + token.managed_agent_policy = AgentResponse( + agent_id="agent-fake", + agent_name="Agent", + agent_card_params={}, + object_permission={"object_permission_id": "grant-fake", "models": granted_models}, + ) + + with patch("litellm.proxy.proxy_server.prisma_client", None): # test-quality-ok: the gate needs no database + refusal = await gate("gpt-5.4", token) + + assert refusal is not None + assert refusal_names in refusal + + +GET_USER_OBJECT: Final = "litellm.proxy.auth.auth_checks.get_user_object" + + +def _personal_user(models: list[str]) -> LiteLLM_UserTable: + return LiteLLM_UserTable(user_id="user-fake", models=models, max_budget=None, spend=0.0) + + +@contextmanager +def _personal_user_allowlist(models: list[str]) -> Iterator[AsyncMock]: + """Stand up the database-backed owner lookup the allowlist check runs, and report every call it made.""" + lookup: Final = AsyncMock(return_value=_personal_user(models)) + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: read at call time + patch(GET_USER_OBJECT, lookup), + ): + yield lookup + + +@pytest.mark.asyncio +async def test_proxy_frame_model_gate_refuses_a_model_the_person_behind_the_key_lacks(): + """ + A key with no team of its own carries its owner's model allowlist, the same + one every HTTP route applies, so a frame naming a model that person cannot + use is refused even when the key itself names no models at all. + """ + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=[], user_id="user-fake") + + with _personal_user_allowlist(["gpt-5.4-mini"]): + refusal = await gate("gpt-5.4", token) + + assert refusal is not None + assert "gpt-5.4" in refusal + + +@pytest.mark.asyncio +async def test_proxy_frame_model_gate_allows_a_model_the_person_behind_the_key_owns(): + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=[], user_id="user-fake") + + with _personal_user_allowlist(["gpt-5.4-mini", "gpt-realtime-2.1"]): + assert await gate("gpt-realtime-2.1", token) is None + + +@pytest.mark.asyncio +async def test_proxy_frame_model_gate_leaves_a_team_key_to_the_teams_allowlist(): + """ + A key that belongs to a team is governed by the team's models, not by the + personal allowlist of whoever created it, so the owner's own restrictions + must not close a session the team is entitled to. + """ + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=[], user_id="user-fake", team_id="team-fake") + + with _personal_user_allowlist(["gpt-5.4-mini"]) as owner_lookup: + assert await gate("gpt-5.4", token) is None + + assert owner_lookup.await_args_list == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("unrestricted_models", [[], ["all-proxy-models"], ["*"]]) +async def test_proxy_frame_model_gate_allows_every_model_for_unrestricted_keys(unrestricted_models): + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=unrestricted_models) + + with patch("litellm.proxy.proxy_server.prisma_client", None): # test-quality-ok: the gate needs no database + assert await gate("gpt-realtime-2.1", token) is None + + +ENFORCE_GROUP_BUDGET: Final = ( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.enforce_model_access_group_budget" +) + + +@pytest.mark.asyncio +async def test_proxy_frame_model_gate_refuses_a_model_whose_access_group_is_out_of_budget(): + """ + A model that sits in a budgeted access group is refused on every other route once + that group has spent its budget, so a frame naming it closes the session instead + of quietly running on an empty pool. The key is allowed the model outright here, + so nothing but the group's budget can produce the refusal. + """ + exhausted: Final = litellm.BudgetExceededError( + current_cost=2.0, + max_budget=1.0, + message="Budget has been exceeded! Model access group=tier-a Current cost: 2.0, Max budget: 1.0", + entity_type=Litellm_EntityType.MODEL_ACCESS_GROUP.value, + entity_id="tier-a", + ) + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=["gpt-5.4-mini"]) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: the model check needs no database + patch(ENFORCE_GROUP_BUDGET, AsyncMock(side_effect=exhausted)), + ): + refusal = await gate("gpt-5.4-mini", token) + + assert refusal is not None + assert "Budget has been exceeded" in refusal + assert "Model access group=tier-a" in refusal + + +@pytest.mark.asyncio +async def test_proxy_frame_model_gate_refuses_out_loud_when_it_cannot_reach_the_budgets(): + """ + A gate that cannot answer has to close the session with a reason the caller can read. + Letting the failure escape drops the socket with no frame at all, which the caller + cannot tell apart from the network going away. + """ + gate: Final = _proxy_frame_model_gate() + token: Final = UserAPIKeyAuth(models=["gpt-5.4-mini"]) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: the model check needs no database + patch(ENFORCE_GROUP_BUDGET, AsyncMock(side_effect=ConnectionError("connection pool is exhausted"))), + ): + refusal = await gate("gpt-5.4-mini", token) + + assert refusal == _OPENAI_WS_UNAVAILABLE_AUTH_REFUSAL + assert "connection pool" not in refusal