diff --git a/litellm/proxy/management/teams/dependencies.py b/litellm/proxy/management/teams/dependencies.py index cc2d548ed72..6d5dc1d1961 100644 --- a/litellm/proxy/management/teams/dependencies.py +++ b/litellm/proxy/management/teams/dependencies.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Annotated, Final, Protocol -from fastapi import Depends, Request +from fastapi import Depends, Query, Request from litellm.proxy._types import CommonProxyErrors, LiteLLM_TeamTable, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -10,6 +10,7 @@ from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem from litellm.proxy.list_api.list_framework import QueryPlan, plan_list_request from litellm.proxy.management.teams.authz import TeamAccess from litellm.proxy.management.teams.exceptions import members_not_readable, team_not_found +from litellm.proxy.management.teams.schemas import TeamMembersQuery from litellm.proxy.management.teams.service import TEAM_MEMBERS_LIST_SPEC from litellm.proxy.management.users.service import PrismaOrgRoles from litellm.repositories.team_repository import TeamRepository @@ -67,5 +68,10 @@ async def get_readable_team( return team -def get_team_members_plan(request: Request, caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)]) -> QueryPlan: +def get_team_members_plan( + query: Annotated[TeamMembersQuery, Query()], + request: Request, + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> QueryPlan: + """`query` is declared so FastAPI documents and validates the parameters; the plan is read from the same request.""" return plan_list_request(TEAM_MEMBERS_LIST_SPEC, request, caller) diff --git a/litellm/proxy/management/teams/endpoints.py b/litellm/proxy/management/teams/endpoints.py index b05dd5185dd..68c7afefeee 100644 --- a/litellm/proxy/management/teams/endpoints.py +++ b/litellm/proxy/management/teams/endpoints.py @@ -35,14 +35,6 @@ async def list_team_members( Anyone who can read `/team/info` for the team can call this: proxy admins, admin viewers, the team's keys, its members and its org admins. Anyone else gets a 403, and a team that does not exist is a 404. - Query parameters, all optional: - - `q`: search by `user_id` or `user_email`. Matches any part of the value and ignores case - - `filter[role]`: `admin` or `user`. Use `filter[role][in]=admin,user` to match several roles - - `sort`: `user_alias`, `user_email`, `user_id`, `role`, `spend`, `total_spend`, `max_budget_in_team` or - `budget_reset_at`. Put `-` in front to sort descending. Defaults to the order members were added - - `page`: the page to return, starting at 1 - - `page_size`: members per page. Defaults to 50, max 100 - `budget_source` says where a member's budget comes from: - `custom`: the member has a budget of their own - `team_default`: the member follows the team's member budget diff --git a/litellm/proxy/management/teams/schemas.py b/litellm/proxy/management/teams/schemas.py index 9ce5c2120b0..84161e7eb03 100644 --- a/litellm/proxy/management/teams/schemas.py +++ b/litellm/proxy/management/teams/schemas.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from litellm.proxy._types import TeamMemberBudgetSource @@ -29,3 +29,26 @@ class TeamMemberListItem(BaseModel): tpm_limit: int | None rpm_limit: int | None allowed_models: tuple[str, ...] + + +class TeamMembersQuery(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + q: str | None = Field( + None, description="Search by `user_id` or `user_email`. Matches any part of the value and ignores case" + ) + role: Literal["admin", "user"] | None = Field(None, alias="filter[role]", description="Only members with this role") + role_in: str | None = Field( + None, + alias="filter[role][in]", + description="Only members with one of these roles, comma-separated, as in `admin,user`", + ) + sort: str | None = Field( + None, + description=( + "`user_alias`, `user_email`, `user_id`, `role`, `spend`, `total_spend`, `max_budget_in_team` or " + "`budget_reset_at`. Put `-` in front to sort descending. Defaults to the order members were added" + ), + ) + page: int = Field(1, ge=1, description="The page to return, starting at 1") + page_size: int | None = Field(None, ge=1, description="Members per page. Defaults to 50, max 100") diff --git a/tests/proxy_behavior/management/test_team_members_list.py b/tests/proxy_behavior/management/test_team_members_list.py index d6f1e7b9641..d994c3839b5 100644 --- a/tests/proxy_behavior/management/test_team_members_list.py +++ b/tests/proxy_behavior/management/test_team_members_list.py @@ -270,6 +270,8 @@ async def test_members_list_sorts_by_spend_then_roster_order(proxy_client, prism pytest.param({"team_id": "other"}, id="unknown-param"), pytest.param({"sort": "budget_id"}, id="unsortable-field"), pytest.param({"filter[spend]": "1"}, id="unfilterable-field"), + pytest.param({"filter[role]": "owner"}, id="unknown-role"), + pytest.param({"page": "abc"}, id="non-integer-page"), ], ) async def test_members_list_rejects_unsupported_params(params, proxy_client, prisma, scratch, world): diff --git a/tests/unit/proxy/management/teams/test_dependencies.py b/tests/unit/proxy/management/teams/test_dependencies.py index 0e0b289df0b..d24172144d5 100644 --- a/tests/unit/proxy/management/teams/test_dependencies.py +++ b/tests/unit/proxy/management/teams/test_dependencies.py @@ -1,15 +1,20 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import Annotated, Final import pytest -from fastapi import Request +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.list_api.common import ManagementProblem +from litellm.proxy.list_api.list_framework import QueryPlan, build_query_plan from litellm.proxy.management.teams.authz import TeamAccess from litellm.proxy.management.teams.dependencies import get_readable_team, get_team_members_plan +from litellm.proxy.management.teams.service import TEAM_MEMBERS_LIST_SPEC +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail TEAM: Final = LiteLLM_TeamTable( team_id="team-1", organization_id=None, members_with_roles=[Member(user_id="member", role="user")] @@ -57,28 +62,45 @@ async def test_get_readable_team_stops_the_request_before_the_handler( assert refused.value.problem.status == status -def request(query: str) -> Request: - return Request( - { - "type": "http", - "method": "GET", - "scheme": "http", - "root_path": "", - "path": "/management/v1/teams/team-1/members", - "query_string": query.encode(), - "headers": [(b"host", b"testserver")], - } - ) +def plan_client(as_caller: UserAPIKeyAuth) -> TestClient: + app: Final = FastAPI() + app.dependency_overrides[user_api_key_auth] = lambda: as_caller + + @app.get("/plan") + def planned(plan: Annotated[QueryPlan, Depends(get_team_members_plan)]) -> tuple[int, int]: + return plan.skip, plan.take + + return TestClient(app) def test_get_team_members_plan_turns_paging_params_into_a_window_of_rows() -> None: - plan: Final = get_team_members_plan(request("page=3&page_size=10"), caller("member")) + planned: Final = plan_client(caller("member")).get("/plan", params={"page": "3", "page_size": "10"}) - assert (plan.skip, plan.take) == (20, 10) + assert planned.json() == [20, 10] def test_get_team_members_plan_refuses_a_field_members_cannot_be_sorted_by() -> None: with pytest.raises(ManagementProblem) as refused: - get_team_members_plan(request("sort=budget_id"), caller("member")) + plan_client(caller("member")).get("/plan", params={"sort": "budget_id"}) assert refused.value.problem.status == 400 + + +@pytest.mark.parametrize( + "params", + [ + pytest.param({"page": "abc"}, id="non-integer-page"), + pytest.param({"filter[role]": "owner"}, id="unknown-role"), + pytest.param({"team_id": "other"}, id="undeclared-param"), + ], +) +def test_get_team_members_plan_is_not_reached_by_params_the_query_model_rejects(params: dict[str, str]) -> None: + assert plan_client(caller("member")).get("/plan", params=params).is_client_error + + +def test_documented_query_params_are_exactly_the_ones_the_roster_can_be_planned_by() -> None: + refusal: Final = build_query_plan(spec=TEAM_MEMBERS_LIST_SPEC, params={"undeclared": "1"}, caller=caller("member")) + documented: Final = plan_client(caller("member")).app.openapi()["paths"]["/plan"]["get"]["parameters"] + + assert isinstance(refusal, ProblemDetail) + assert sorted(param["name"] for param in documented) == refusal.allowed diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 0f39845b4e1..f0d6a1dca9d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -9441,14 +9441,6 @@ export interface paths { * Anyone who can read `/team/info` for the team can call this: proxy admins, admin viewers, the team's keys, * its members and its org admins. Anyone else gets a 403, and a team that does not exist is a 404. * - * Query parameters, all optional: - * - `q`: search by `user_id` or `user_email`. Matches any part of the value and ignores case - * - `filter[role]`: `admin` or `user`. Use `filter[role][in]=admin,user` to match several roles - * - `sort`: `user_alias`, `user_email`, `user_id`, `role`, `spend`, `total_spend`, `max_budget_in_team` or - * `budget_reset_at`. Put `-` in front to sort descending. Defaults to the order members were added - * - `page`: the page to return, starting at 1 - * - `page_size`: members per page. Defaults to 50, max 100 - * * `budget_source` says where a member's budget comes from: * - `custom`: the member has a budget of their own * - `team_default`: the member follows the team's member budget @@ -62970,7 +62962,20 @@ export interface operations { }; list_team_members_management_v1_teams__team_id__members_get: { parameters: { - query?: never; + query?: { + /** @description Search by `user_id` or `user_email`. Matches any part of the value and ignores case */ + q?: string | null; + /** @description Only members with this role */ + "filter[role]"?: ("admin" | "user") | null; + /** @description Only members with one of these roles, comma-separated, as in `admin,user` */ + "filter[role][in]"?: string | null; + /** @description `user_alias`, `user_email`, `user_id`, `role`, `spend`, `total_spend`, `max_budget_in_team` or `budget_reset_at`. Put `-` in front to sort descending. Defaults to the order members were added */ + sort?: string | null; + /** @description The page to return, starting at 1 */ + page?: number; + /** @description Members per page. Defaults to 50, max 100 */ + page_size?: number | null; + }; header?: never; path: { team_id: string;