diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 51050e62494..3d8fed18423 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2119,23 +2119,6 @@ async def _delete_cache_key_object( await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key) -class TeamNotFoundError(HTTPException): - """The team row is provably absent, as opposed to merely unreadable. - - ``get_team_object`` reports every failure as a 404, so a deleted team and a - database that would not answer are indistinguishable to its callers. Callers - that must not treat a degraded read as a definitive answer, such as the - authorization fallback in ``user_api_key_auth``, key on this subclass. It - stays a 404 carrying the same detail, so every other caller is unaffected. - """ - - def __init__(self, team_id: str) -> None: - super().__init__( - status_code=404, - detail={"error": f"Team doesn't exist in db. Team={team_id}. Create team via `/team/new` call."}, - ) - - async def delete_cache_key_objects( hashed_tokens: Sequence[str], user_api_key_cache: UserApiKeyCache, @@ -2219,10 +2202,6 @@ async def _get_team_object_from_user_api_key_cache( ) if should_check_db: response = await _get_team_db_check(team_id=team_id, prisma_client=prisma_client, team_id_upsert=team_id_upsert) - # The database answered and the row is not there. Distinct from every - # other failure here, which leaves the team's grant unknown. - if response is None: - raise TeamNotFoundError(team_id=team_id) else: response = None @@ -2344,8 +2323,6 @@ async def get_team_object( key=key, team_id_upsert=team_id_upsert, ) - except TeamNotFoundError: - raise except Exception: raise HTTPException( status_code=404, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 39e1c14a6e6..f7a04ba79e7 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -34,7 +34,6 @@ from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import ( ExperimentalUIJWTToken, - TeamNotFoundError, _cache_key_object, _can_object_call_model, _check_end_user_budget, @@ -86,7 +85,6 @@ from litellm.proxy.common_utils.http_parsing_utils import ( ) from litellm.proxy.common_utils.realtime_utils import _realtime_request_body from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache -from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.proxy.utils import ( PrismaClient, @@ -2163,28 +2161,6 @@ def _team_obj_from_token(valid_token: UserAPIKeyAuth) -> LiteLLM_TeamTableCached ) -def _token_can_vouch_for_team(valid_token: UserAPIKeyAuth, lookup_error: BaseException) -> bool: - """Whether the token's own team fields may stand in for a team that failed to - resolve, without widening access. - - A team that is provably gone is a definitive answer, not a degraded read, so - nothing may stand in for it and no setting may override that. - - Otherwise the team's grant is merely unknown. A token carrying one may vouch, - since replaying a recorded grant cannot widen it and denying every team key - while the row is briefly unreadable would trade the widening for an outage. A - token carrying none may not: ``team_models=[]`` reads as every model and - ``team_blocked=False`` as unblocked. ``allow_requests_on_db_unavailable`` opts - back out, and is only consulted here because the failure is known by this - point to be a degraded read. - """ - if isinstance(lookup_error, TeamNotFoundError): - return False - if valid_token.team_models: - return True - return PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() - - @tracer.wrap() async def _run_centralized_common_checks( user_api_key_auth_obj: UserAPIKeyAuth, @@ -2388,12 +2364,7 @@ async def _run_centralized_common_checks( if isinstance(team_result, BaseException): # Token-derived fallback only valid when a team_id is set; # _team_obj_from_token asserts that precondition. - if user_api_key_auth_obj.team_id is None: - team_object = None - elif _token_can_vouch_for_team(user_api_key_auth_obj, team_result): - team_object = _team_obj_from_token(user_api_key_auth_obj) - else: - raise team_result + team_object = _team_obj_from_token(user_api_key_auth_obj) if user_api_key_auth_obj.team_id is not None else None else: team_object = team_result diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index 93c6cfc42d0..ccf710c5708 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -163,7 +163,6 @@ async def test_team_object_has_object_permission_id(): token=hashed_key, last_refreshed_at=time.time(), team_object_permission_id=permission_id, - team_models=["gpt-4o"], ) user_api_key_cache.set_cache(key=hashed_key, value=valid_token) @@ -256,7 +255,6 @@ async def test_aaauser_personal_budgets(key_ownership): user_id=_user_id, team_id="my-special-team", team_max_budget=100, - team_models=["gpt-4o"], spend=20, ) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 3ed4c9e9a6d..28eda6633e8 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -2122,53 +2122,6 @@ async def test_get_team_object_raises_404_when_not_found(): assert "Team doesn't exist in db" in str(exc_info.value.detail) -def _mock_prisma_for_team_lookup(find_unique): - from unittest.mock import MagicMock - - mock_prisma_client = MagicMock() - mock_prisma_client.db.litellm_teamtable.find_unique = find_unique - return mock_prisma_client - - -@pytest.mark.asyncio -async def test_get_team_object_distinguishes_absent_team_from_unreadable_row(): - """A deleted team and a database that would not answer both surface as a 404, - which leaves callers unable to tell a definitive answer from a degraded read. - Only the row being positively absent raises the subclass; anything else keeps - the plain 404 so every existing caller is unaffected.""" - from unittest.mock import AsyncMock, MagicMock - - from fastapi import HTTPException - - from litellm.proxy.auth.auth_checks import TeamNotFoundError, get_team_object - - mock_cache = MagicMock() - mock_cache.async_get_cache = AsyncMock(return_value=None) - - # The database answered, and the row is not there. - with pytest.raises(TeamNotFoundError) as absent_info: - await get_team_object( - team_id="absent-team-lit5522", - prisma_client=_mock_prisma_for_team_lookup(AsyncMock(return_value=None)), - user_api_key_cache=mock_cache, - check_db_only=True, - ) - assert absent_info.value.status_code == 404 - assert "Team doesn't exist in db" in str(absent_info.value.detail) - - # The database did not answer. Same status and detail, but not the subclass, - # so a caller keying on it does not read this as proof the team is gone. - with pytest.raises(HTTPException) as unreadable_info: - await get_team_object( - team_id="unreadable-team-lit5522", - prisma_client=_mock_prisma_for_team_lookup(AsyncMock(side_effect=ConnectionError("db unreachable"))), - user_api_key_cache=mock_cache, - check_db_only=True, - ) - assert unreadable_info.value.status_code == 404 - assert not isinstance(unreadable_info.value, TeamNotFoundError) - - # Reject Client-Side Metadata Tags Tests diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index eea556b9a0d..129813d806c 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -4368,212 +4368,6 @@ async def test_centralized_common_checks_team_404_does_not_zero_other_contexts() setattr(_proxy_server_mod, k, v) -@pytest.mark.asyncio -async def test_centralized_common_checks_unresolvable_team_without_grant_is_refused(): - """The store restricts the team to gpt-4o-mini and the read of it fails, so the - only surviving team record is the token's own, which carries ``team_models=[]`` - and reads as every model. The request must be refused with the original lookup - error. Pre-fix it was served.""" - import litellm.proxy.proxy_server as _proxy_server_mod - from fastapi import HTTPException, Request - from starlette.datastructures import URL - - # The key inherits its models from the team (models=[]), so the team object - # is the only gate on model access. - token = UserAPIKeyAuth( - api_key="sk-test", - team_id="restricted-team", - models=[], - team_models=[], - ) - request = Request(scope={"type": "http"}) - request._url = URL(url="/chat/completions") - request._body = json.dumps({"model": "gpt-4.1"}).encode() - - team_read_failure = HTTPException( - status_code=404, - detail={"error": "Team doesn't exist in db. Team=restricted-team."}, - ) - - attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) - originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs} - try: - for k, v in attrs.items(): - setattr(_proxy_server_mod, k, v) - with patch( - "litellm.proxy.auth.user_api_key_auth.get_team_object", - new_callable=AsyncMock, - side_effect=team_read_failure, - ): - with pytest.raises(HTTPException) as exc_info: - await _run_centralized_common_checks( - user_api_key_auth_obj=token, - request=request, - request_data={"model": "gpt-4.1"}, - route="/chat/completions", - ) - assert exc_info.value is team_read_failure - finally: - for k, v in originals.items(): - setattr(_proxy_server_mod, k, v) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("token_team_models", [[], ["gpt-4.1"]]) -async def test_centralized_common_checks_absent_team_refused_despite_db_unavailable_optout(token_team_models): - """A team that is provably gone is a definitive answer, not a degraded read. - ``allow_requests_on_db_unavailable`` is a static settings read, so without the - absent-versus-unreadable distinction it would hand a deleted team's key the - old permissive fallback while the database is perfectly healthy. Refused in - both token shapes, including the one whose grant would otherwise vouch. - - Imported from the module under test rather than from ``auth_checks``: other - tests in this suite ``importlib.reload`` that module, which rebinds the class - and would leave this raising a type the guard has never seen.""" - import litellm.proxy.proxy_server as _proxy_server_mod - from fastapi import HTTPException, Request - from starlette.datastructures import URL - - from litellm.proxy.auth.user_api_key_auth import TeamNotFoundError - - token = UserAPIKeyAuth( - api_key="sk-test", - team_id="deleted-team", - models=[], - team_models=token_team_models, - ) - request = Request(scope={"type": "http"}) - request._url = URL(url="/chat/completions") - request._body = json.dumps({"model": "gpt-4.1"}).encode() - - team_absent = TeamNotFoundError(team_id="deleted-team") - - attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) - attrs["general_settings"] = {"allow_requests_on_db_unavailable": True} - originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs} - try: - for k, v in attrs.items(): - setattr(_proxy_server_mod, k, v) - with patch( - "litellm.proxy.auth.user_api_key_auth.get_team_object", - new_callable=AsyncMock, - side_effect=team_absent, - ): - with pytest.raises(HTTPException) as exc_info: - await _run_centralized_common_checks( - user_api_key_auth_obj=token, - request=request, - request_data={"model": "gpt-4.1"}, - route="/chat/completions", - ) - assert exc_info.value is team_absent - finally: - for k, v in originals.items(): - setattr(_proxy_server_mod, k, v) - - -@pytest.mark.asyncio -async def test_centralized_common_checks_unreadable_team_keeps_db_unavailable_optout(): - """The counterpart: an unreadable team leaves the grant unknown rather than - answered, so an operator who has accepted degraded authorization during a - database fault still gets the fallback. Without this the fix would trade the - widening for a lockout with no way out.""" - import litellm.proxy.proxy_server as _proxy_server_mod - from fastapi import HTTPException as _HTTPException - from fastapi import Request - from starlette.datastructures import URL - - token = UserAPIKeyAuth(api_key="sk-test", team_id="unreadable-team", models=[], team_models=[]) - request = Request(scope={"type": "http"}) - request._url = URL(url="/chat/completions") - request._body = json.dumps({"model": "gpt-4.1"}).encode() - - attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) - attrs["general_settings"] = {"allow_requests_on_db_unavailable": True} - originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs} - try: - for k, v in attrs.items(): - setattr(_proxy_server_mod, k, v) - with ( - patch( - "litellm.proxy.auth.user_api_key_auth.get_team_object", - new_callable=AsyncMock, - side_effect=_HTTPException(status_code=404, detail={"error": "team unreadable"}), - ), - patch( - "litellm.proxy.auth.user_api_key_auth.common_checks", - new_callable=AsyncMock, - ) as mock_checks, - ): - await _run_centralized_common_checks( - user_api_key_auth_obj=token, - request=request, - request_data={"model": "gpt-4.1"}, - route="/chat/completions", - ) - mock_checks.assert_awaited_once() - assert mock_checks.call_args.kwargs["team_object"].team_id == "unreadable-team" - finally: - for k, v in originals.items(): - setattr(_proxy_server_mod, k, v) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "requested_model, is_granted", - [("gpt-4o-mini", True), ("gpt-4.1", False)], -) -async def test_centralized_common_checks_unresolvable_team_with_grant_enforces_it(requested_model, is_granted): - """Mirror of the refusal above: a token that does carry a team model grant keeps - the fallback, and the reconstructed team must still enforce that grant rather - than wave the request through.""" - import litellm.proxy.proxy_server as _proxy_server_mod - from fastapi import HTTPException, Request - from starlette.datastructures import URL - - from litellm.proxy._types import ProxyErrorTypes, ProxyException - - token = UserAPIKeyAuth( - api_key="sk-test", - team_id="restricted-team", - models=[], - team_models=["gpt-4o-mini"], - ) - request = Request(scope={"type": "http"}) - request._url = URL(url="/chat/completions") - request._body = json.dumps({"model": requested_model}).encode() - - attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) - originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs} - try: - for k, v in attrs.items(): - setattr(_proxy_server_mod, k, v) - with patch( - "litellm.proxy.auth.user_api_key_auth.get_team_object", - new_callable=AsyncMock, - side_effect=HTTPException(status_code=404, detail={"error": "team unreadable"}), - ): - if is_granted: - await _run_centralized_common_checks( - user_api_key_auth_obj=token, - request=request, - request_data={"model": requested_model}, - route="/chat/completions", - ) - else: - with pytest.raises(ProxyException) as exc_info: - await _run_centralized_common_checks( - user_api_key_auth_obj=token, - request=request, - request_data={"model": requested_model}, - route="/chat/completions", - ) - assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied - finally: - for k, v in originals.items(): - setattr(_proxy_server_mod, k, v) - - @pytest.mark.asyncio async def test_centralized_common_checks_user_http_exception_isolates_to_user_only(): """Per-fetch isolation, mirror of the team case: an HTTPException