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.
This commit is contained in:
ryan-crabbe-berri 2026-10-06 12:47:50 -07:00
parent 8c61c80faf
commit 2a2cb98f52
6 changed files with 87 additions and 37 deletions

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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):

View file

@ -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

View file

@ -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;