From 2a2cb98f52fd5bd23e76d2042ffeb0587cf054de Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 6 Oct 2026 12:47:50 -0700 Subject: [PATCH] feat(proxy): declare the team members list query params The query params are a typed model on the plan dependency, so FastAPI validates them and they appear in the OpenAPI spec and the generated dashboard types. The list framework still checks sort fields, filter values and repeated params. A filter[role] value other than admin or user is now a 400. An unknown param is still a 400 problem document, with type invalid-query-parameter. --- .../proxy/management/teams/dependencies.py | 10 +++- litellm/proxy/management/teams/endpoints.py | 8 --- litellm/proxy/management/teams/schemas.py | 25 ++++++++- .../management/test_team_members_list.py | 2 + .../management/teams/test_dependencies.py | 56 +++++++++++++------ ui/litellm-dashboard/src/lib/http/schema.d.ts | 23 +++++--- 6 files changed, 87 insertions(+), 37 deletions(-) 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;