diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 2ab76a7a101..fcb6ffdae84 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -284,7 +284,16 @@ class RouteChecks: jwt_team_allowed_routes=RouteChecks._jwt_team_allowed_routes(valid_token=valid_token), ) elif RouteChecks.is_llm_api_route(route=route): - pass + # View-only admins must not spend provider budget on inference. + # _check_proxy_admin_viewer_access already 403s LLM routes, but that + # branch sits below this early pass — so call it here first (#43478). + if _user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value: + RouteChecks._check_proxy_admin_viewer_access( + route=route, + _user_role=_user_role, + request_data=request_data, + request=request, + ) elif RouteChecks.is_info_route(route=route): # check if user allowed to call an info route if route == "/key/info": diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index d55316ca429..590a6a47863 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -206,6 +206,51 @@ def test_proxy_admin_viewer_config_update_route_rejected(): assert "role= proxy_admin_viewer" in str(exc_info.value.detail) +@pytest.mark.parametrize( + "inference_route", + [ + "/v1/chat/completions", + "/chat/completions", + "/v1/embeddings", + "/v1/responses", + "/v1/images/generations", + ], +) +def test_proxy_admin_viewer_inference_routes_rejected(inference_route): + """proxy_admin_viewer must not call cost-incurring LLM routes (#43478). + + `non_proxy_admin_allowed_routes_check` used to early-return on + `is_llm_api_route`, so `_check_proxy_admin_viewer_access` never ran for + /v1/chat/completions and friends — a view-only key could spend budget. + """ + user_obj = LiteLLM_UserTable( + user_id="viewer_user", + user_email="viewer@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + valid_token = UserAPIKeyAuth( + user_id="viewer_user", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + request = MagicMock(spec=Request) + request.query_params = {} + request.method = "POST" + + with pytest.raises(HTTPException) as exc_info: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + route=inference_route, + request=request, + valid_token=valid_token, + request_data={}, + ) + + assert exc_info.value.status_code == 403 + assert "OpenAI routes" in str(exc_info.value.detail) or "not allowed" in str(exc_info.value.detail) + assert "proxy_admin_viewer" in str(exc_info.value.detail) + + @pytest.mark.parametrize( "blocked_route", [