mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
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:
parent
8c61c80faf
commit
2a2cb98f52
6 changed files with 87 additions and 37 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
23
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue