From a685cc1511387149285dca3ea623fa6db4de7873 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 30 Jul 2026 19:38:24 -0700 Subject: [PATCH 1/4] feat(proxy): add a generic list contract for management/v1 entity lists Paging, sorting, filtering and search for an entity collection, declared once as a ListSpec and served by handle_list. The route injects a ListExecutor that owns its table, so this module never imports Prisma. The caller's scope is derived from the caller alone and ANDed with whatever they filtered on, so a query parameter can only narrow what they may read. This is the shared half of the budgets list; it lands here so the endpoint has something to register against, and drops out when the framework arrives on its own branch. --- .../management_v1/list_framework.py | 308 ++++++++++++++++++ .../management_endpoints/management_v1.py | 33 +- 2 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/list_framework.py diff --git a/litellm/proxy/management_endpoints/management_v1/list_framework.py b/litellm/proxy/management_endpoints/management_v1/list_framework.py new file mode 100644 index 00000000000..6e800d295f9 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/list_framework.py @@ -0,0 +1,308 @@ +"""Generic paging/sorting/filtering contract for `/management/v1` entity lists. + +Prisma-free by construction: a route declares a `ListSpec` and injects a +`ListExecutor` that owns the table, so the parsing, scoping and envelope rules +stay in one place and every entity list answers the same way. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Generic, Literal, Protocol, TypeAlias, TypeVar +from urllib.parse import urlencode + +from fastapi import Request +from pydantic import JsonValue + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.management_endpoints.management_v1.common import ( + PROBLEM_TYPE_BASE, + ManagementProblem, +) +from litellm.types.proxy.management_endpoints.management_v1 import ( + ListLinks, + ListMeta, + ListResponse, + ProblemDetail, +) + +FilterOp: TypeAlias = Literal["eq", "in", "gte", "lte", "contains", "is_null"] +FilterType: TypeAlias = Literal["string", "number", "datetime"] + +# Quoted so the recursive alias parses under the repo's 3.10 floor, where neither +# the `type` statement nor a forward reference inside a `|` expression exists. +WhereLeaf: TypeAlias = "str | int | float | bool | datetime | None" +WhereValue: TypeAlias = "WhereLeaf | Sequence[WhereLeaf] | Where | Sequence[Where]" +Where: TypeAlias = "Mapping[str, WhereValue]" +OrderBy: TypeAlias = "Sequence[Mapping[str, Literal['asc', 'desc']]]" + +RowT = TypeVar("RowT") + +PAGINATION_PARAMS = frozenset({"page", "page_size", "sort", "q"}) + + +@dataclass(frozen=True, slots=True) +class FilterSpec: + type: FilterType + ops: frozenset[FilterOp] + + +@dataclass(frozen=True, slots=True) +class SortKey: + field: str + descending: bool + + +@dataclass(frozen=True, slots=True) +class ScopeAll: + """The caller may read every row.""" + + +@dataclass(frozen=True, slots=True) +class ScopeWhere: + """The caller may read only rows matching `where`.""" + + where: Where + + +@dataclass(frozen=True, slots=True) +class ScopeDenied: + """The caller may not read the collection at all.""" + + detail: str + + +Scope: TypeAlias = "ScopeAll | ScopeWhere | ScopeDenied" + + +class ListExecutor(Protocol, Generic[RowT]): + """The table half of a list, injected so the framework never imports Prisma.""" + + async def count(self, where: Where) -> int: ... + + async def find_many(self, where: Where, order: OrderBy, skip: int, take: int) -> Sequence[RowT]: ... + + +@dataclass(frozen=True, slots=True) +class ListSpec(Generic[RowT]): + resource: str + sortable: frozenset[str] + searchable: frozenset[str] + filters: Mapping[str, FilterSpec] + default_sort: tuple[SortKey, ...] + default_page_size: int + max_page_size: int + scope: Callable[[UserAPIKeyAuth], Scope] + serialize: Callable[[RowT], Mapping[str, JsonValue]] + tiebreaker: str + + +@dataclass(frozen=True, slots=True) +class QueryPlan: + where: Where + order: OrderBy + skip: int + take: int + page: int + page_size: int + + +def _problem(slug: str, title: str, detail: str, allowed: Sequence[str] | None = None) -> ManagementProblem: + return ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}{slug}", + title=title, + status=400, + detail=detail, + allowed=list(allowed) if allowed is not None else None, + ) + ) + + +def _allowed_params(filters: Mapping[str, FilterSpec]) -> frozenset[str]: + return PAGINATION_PARAMS | frozenset( + f"filter[{field}][{op}]" for field, filter_spec in filters.items() for op in filter_spec.ops + ) + + +def _reject_unknown_params(request: Request, filters: Mapping[str, FilterSpec]) -> None: + allowed = _allowed_params(filters) + unknown = tuple(sorted(name for name in request.query_params if name not in allowed)) + if not unknown: + return + raise _problem( + "unknown-query-parameter", + "Unknown query parameter", + f"Unrecognized query parameter(s): {', '.join(unknown)}.", + sorted(allowed), + ) + + +def _positive_int(raw: str | None, default: int, name: str) -> int: + if raw is None: + return default + try: + value = int(raw) + except ValueError: + raise _problem("invalid-query-parameter", "Invalid query parameter", f"{name} must be an integer.") + if value < 1: + raise _problem("invalid-query-parameter", "Invalid query parameter", f"{name} must be at least 1.") + return value + + +def _parse_sort(raw: str | None, sortable: frozenset[str], default_sort: tuple[SortKey, ...]) -> tuple[SortKey, ...]: + if raw is None: + return default_sort + keys = tuple( + SortKey(field=token.removeprefix("-"), descending=token.startswith("-")) + for token in (part.strip() for part in raw.split(",")) + if token + ) + unknown = tuple(key.field for key in keys if key.field not in sortable) + if unknown: + raise _problem( + "invalid-sort-field", + "Invalid sort field", + f"Cannot sort on: {', '.join(unknown)}.", + sorted(sortable), + ) + return keys or default_sort + + +def _order_by(keys: Sequence[SortKey], tiebreaker: str) -> OrderBy: + tail = () if any(key.field == tiebreaker for key in keys) else (SortKey(field=tiebreaker, descending=False),) + return tuple({key.field: ("desc" if key.descending else "asc")} for key in (*keys, *tail)) + + +def _coerce(value: str, filter_type: FilterType, param: str) -> WhereLeaf: + if filter_type == "number": + try: + return float(value) + except ValueError: + raise _problem("invalid-filter-value", "Invalid filter value", f"{param} must be a number.") + if filter_type == "datetime": + try: + return datetime.fromisoformat(value) + except ValueError: + raise _problem("invalid-filter-value", "Invalid filter value", f"{param} must be an ISO-8601 timestamp.") + return value + + +def _bool(value: str, param: str) -> bool: + if value.lower() in ("true", "1"): + return True + if value.lower() in ("false", "0"): + return False + raise _problem("invalid-filter-value", "Invalid filter value", f"{param} must be true or false.") + + +def _condition(field: str, op: FilterOp, raw: str, filter_type: FilterType, param: str) -> Where: + if op == "is_null": + return {field: None} if _bool(raw, param) else {field: {"not": None}} + if op == "in": + return {field: {"in": tuple(_coerce(part, filter_type, param) for part in raw.split(",") if part)}} + if op == "contains": + return {field: {"contains": raw, "mode": "insensitive"}} + if op == "eq": + return {field: _coerce(raw, filter_type, param)} + return {field: {op: _coerce(raw, filter_type, param)}} + + +def _filter_conditions(request: Request, filters: Mapping[str, FilterSpec]) -> tuple[Where, ...]: + return tuple( + _condition(field, op, request.query_params[f"filter[{field}][{op}]"], spec.type, f"filter[{field}][{op}]") + for field, spec in filters.items() + for op in sorted(spec.ops) + if f"filter[{field}][{op}]" in request.query_params + ) + + +def _search_condition(raw: str | None, searchable: frozenset[str]) -> tuple[Where, ...]: + if not raw or not searchable: + return () + return ({"OR": tuple({field: {"contains": raw, "mode": "insensitive"}} for field in sorted(searchable))},) + + +def build_query_plan(request: Request, spec: ListSpec[RowT], scope: Scope) -> QueryPlan: + """Turn the query string into the executor's arguments, or raise a 400 problem. + + `scope` is derived from the caller, never from the query string, and is ANDed + with the caller's filters so a filter can only ever narrow what they may read. + """ + _reject_unknown_params(request, spec.filters) + + page = _positive_int(request.query_params.get("page"), 1, "page") + page_size = min( + _positive_int(request.query_params.get("page_size"), spec.default_page_size, "page_size"), + spec.max_page_size, + ) + keys = _parse_sort(request.query_params.get("sort"), spec.sortable, spec.default_sort) + + scope_conditions: tuple[Where, ...] = (scope.where,) if isinstance(scope, ScopeWhere) else () + conditions = ( + scope_conditions + + _filter_conditions(request, spec.filters) + + _search_condition(request.query_params.get("q"), spec.searchable) + ) + + return QueryPlan( + where={"AND": conditions} if conditions else {}, + order=_order_by(keys, spec.tiebreaker), + skip=(page - 1) * page_size, + take=page_size, + page=page, + page_size=page_size, + ) + + +def _page_url(request: Request, page: int) -> str: + others = tuple((key, value) for key, value in request.query_params.multi_items() if key != "page") + return f"{request.url.path}?{urlencode((*others, ('page', page)))}" + + +def _links(request: Request, page: int, last_page: int) -> ListLinks: + return ListLinks( + self_link=_page_url(request, page), + first=_page_url(request, 1), + prev=_page_url(request, page - 1) if page > 1 else None, + next=_page_url(request, page + 1) if page < last_page else None, + last=_page_url(request, last_page), + ) + + +async def handle_list( + request: Request, + spec: ListSpec[RowT], + executor: ListExecutor[RowT], + caller: UserAPIKeyAuth, +) -> ListResponse: + """Serve one page of `spec.resource` under the caller's scope.""" + scope = spec.scope(caller) + if isinstance(scope, ScopeDenied): + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}forbidden", + title="Forbidden", + status=403, + detail=scope.detail, + ) + ) + + plan = build_query_plan(request, spec, scope) + total_count = await executor.count(plan.where) + rows = await executor.find_many(where=plan.where, order=plan.order, skip=plan.skip, take=plan.take) + total_pages = math.ceil(total_count / plan.page_size) + + return ListResponse( + data=tuple(spec.serialize(row) for row in rows), + meta=ListMeta( + page=plan.page, + page_size=plan.page_size, + total_count=total_count, + total_pages=total_pages, + ), + links=_links(request, plan.page, max(total_pages, 1)), + ) diff --git a/litellm/types/proxy/management_endpoints/management_v1.py b/litellm/types/proxy/management_endpoints/management_v1.py index 2aecc54f114..a7427bc7590 100644 --- a/litellm/types/proxy/management_endpoints/management_v1.py +++ b/litellm/types/proxy/management_endpoints/management_v1.py @@ -1,6 +1,8 @@ """Shared response shapes for the `/management/v1` control-plane surface.""" -from pydantic import BaseModel, ConfigDict, Field +from collections.abc import Mapping + +from pydantic import BaseModel, ConfigDict, Field, JsonValue class ProblemDetail(BaseModel): @@ -37,3 +39,32 @@ class FacetListResponse(BaseModel): data: list[str] meta: PageMeta links: PageLinks + + +class ListMeta(BaseModel): + """An entity list can afford the COUNT(*) a facet cannot, so it reports a real total.""" + + page: int + page_size: int + total_count: int + total_pages: int + + +class ListLinks(BaseModel): + """Hypermedia for an entity list. `first`/`last` exist here because `total_pages` is known.""" + + model_config = ConfigDict(populate_by_name=True) + + self_link: str = Field(alias="self") + first: str + prev: str | None = None + next: str | None = None + last: str + + +class ListResponse(BaseModel): + """One page of an entity collection. Rows are flat: no `{type, id, attributes}` wrapper.""" + + data: tuple[Mapping[str, JsonValue], ...] + meta: ListMeta + links: ListLinks From f0866d0446a76ee84bda688b93881f95d3ade9a8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 30 Jul 2026 19:38:32 -0700 Subject: [PATCH 2/4] feat(proxy): add GET /management/v1/budgets The Budgets page reads /budget/list, which returns the whole table as a bare array with no way to page, sort or filter it. A customer with enough budgets to fill the page has no way to find one. Registers LiteLLM_BudgetTable against the management/v1 list contract: sortable on budget_id, max_budget, tpm_limit, rpm_limit and created_at, default order newest-first with budget_id breaking ties, search on budget_id, and filters for budget_duration, max_budget and created_at. budget_duration is deliberately not sortable; the column holds "7d"/"30d" strings, so a lexicographic ORDER BY puts "30d" ahead of "7d". tpm_limit and rpm_limit are BigInt? in Prisma, so rows validate through a pydantic model on the way out and serialize as JSON numbers. A caller without admin view is refused 403 as a problem document rather than served an empty page. /budget/list is untouched. --- litellm/proxy/_types.py | 1 + .../management_v1/__init__.py | 4 + .../management_v1/budgets.py | 195 ++++++++ tests/e2e/coverage_registry/mgmt.yaml | 2 + .../test_budget_customer_user_org_e2e.py | 166 +++++- .../auth/test_admin_viewer_handler_access.py | 9 + .../proxy/auth/test_route_checks.py | 1 + .../management_v1/test_budgets.py | 471 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 100 ++++ 9 files changed, 946 insertions(+), 3 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/budgets.py create mode 100644 tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9e98cb46b9a..9f3b32328c5 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -840,6 +840,7 @@ class LiteLLMRoutes(enum.Enum): "/config/list", "/config/field/info", "/budget/list", + "/management/v1/budgets", "/budget/settings", # Invitation viewing (admin viewer cannot create/delete; can read). "/invitation/info", diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index 257de66130b..a06c6b2591c 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -2,11 +2,15 @@ from fastapi import APIRouter +from litellm.proxy.management_endpoints.management_v1.budgets import ( + router as budgets_router, +) from litellm.proxy.management_endpoints.management_v1.spend_logs import ( router as spend_logs_router, ) router = APIRouter() +router.include_router(budgets_router) router.include_router(spend_logs_router) __all__ = ["router"] diff --git a/litellm/proxy/management_endpoints/management_v1/budgets.py b/litellm/proxy/management_endpoints/management_v1/budgets.py new file mode 100644 index 00000000000..79c3876c7ab --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/budgets.py @@ -0,0 +1,195 @@ +"""`GET /management/v1/budgets`.""" + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import ( + CommonProxyErrors, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, +) +from litellm.proxy.management_endpoints.management_v1.list_framework import ( + FilterSpec, + ListSpec, + OrderBy, + Scope, + ScopeAll, + ScopeDenied, + SortKey, + Where, + handle_list, +) +from litellm.proxy.utils import PrismaClient +from litellm.types.proxy.management_endpoints.management_v1 import ( + ListResponse, + ProblemDetail, +) + +router = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + + +class BudgetRow(BaseModel): + """The `LiteLLM_BudgetTable` columns this list serves. + + Validating the untyped Prisma row through here is what makes `tpm_limit` / + `rpm_limit` ints: they are `BigInt?` in the schema, which the query engine can + hand back as a decimal string. + """ + + model_config = ConfigDict(from_attributes=True) + + budget_id: str + max_budget: float | None = None + soft_budget: float | None = None + tpm_limit: int | None = None + rpm_limit: int | None = None + budget_duration: str | None = None + budget_reset_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +_BUDGET_ROWS = TypeAdapter(tuple[BudgetRow, ...]) + + +@dataclass(frozen=True, slots=True) +class PrismaBudgetListExecutor: + """The `ListExecutor` half of the budgets list: everything Prisma-shaped lives here.""" + + prisma_client: PrismaClient + + async def count(self, where: Where) -> int: + return int(await self.prisma_client.db.litellm_budgettable.count(where=dict(where))) + + async def find_many(self, where: Where, order: OrderBy, skip: int, take: int) -> Sequence[BudgetRow]: + rows = await self.prisma_client.db.litellm_budgettable.find_many( + where=dict(where), order=list(order), skip=skip, take=take + ) + return _BUDGET_ROWS.validate_python(rows) + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + +def _serialize(row: BudgetRow) -> Mapping[str, JsonValue]: + return { + "budget_id": row.budget_id, + "max_budget": row.max_budget, + "soft_budget": row.soft_budget, + "tpm_limit": row.tpm_limit, + "rpm_limit": row.rpm_limit, + "budget_duration": row.budget_duration, + "budget_reset_at": _iso(row.budget_reset_at), + "created_at": _iso(row.created_at), + "updated_at": _iso(row.updated_at), + } + + +def _scope(caller: UserAPIKeyAuth) -> Scope: + if user_api_key_has_admin_view(caller): + return ScopeAll() + return ScopeDenied( + detail="Only proxy admins can list budgets, your role={}".format(caller.user_role), + ) + + +# budget_duration is deliberately absent from `sortable`: the column holds strings +# like "7d" and "30d", so a lexicographic ORDER BY puts "30d" ahead of "7d". +BUDGETS_LIST_SPEC: ListSpec[BudgetRow] = ListSpec( + resource="budgets", + sortable=frozenset({"budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at"}), + searchable=frozenset({"budget_id"}), + filters={ + "budget_duration": FilterSpec(type="string", ops=frozenset({"in", "is_null"})), + "max_budget": FilterSpec(type="number", ops=frozenset({"gte", "lte", "is_null"})), + "created_at": FilterSpec(type="datetime", ops=frozenset({"gte", "lte"})), + }, + default_sort=(SortKey(field="created_at", descending=True), SortKey(field="budget_id", descending=False)), + default_page_size=50, + max_page_size=100, + scope=_scope, + serialize=_serialize, + tiebreaker="budget_id", +) + + +@router.get( + "/budgets", + tags=["budget management"], + dependencies=[Depends(user_api_key_auth)], + response_model=ListResponse, +) +async def list_budgets( + request: Request, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> ListResponse: + """ + The budgets defined on this proxy, paged, sortable and filterable, for the + Budgets page. + + Readable by a proxy admin or an admin viewer; anyone else is refused 403. The + older `/budget/list` answers with the whole table as a bare array and has no + way to page, sort or filter it. + + `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`, + `rpm_limit` or `created_at`, each optionally prefixed with `-` for descending, + and defaults to `-created_at,budget_id`. `q` is a case-insensitive substring + match on `budget_id`. `page_size` defaults to 50 and is capped at 100. + Filters are `filter[budget_duration][in|is_null]`, + `filter[max_budget][gte|lte|is_null]` and `filter[created_at][gte|lte]`. + + Example curl: + ``` + curl --location --globoff 'http://0.0.0.0:4000/management/v1/budgets?sort=-max_budget&filter[budget_duration][in]=7d,30d&page_size=25' \ + --header 'Authorization: Bearer sk-1234' + ``` + """ + try: + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}database-not-connected", + title="Database not connected", + status=503, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + ) + + return await handle_list( + request=request, + spec=BUDGETS_LIST_SPEC, + executor=PrismaBudgetListExecutor(prisma_client=prisma_client), + caller=user_api_key_dict, + ) + + except ManagementProblem: + raise + except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape + verbose_proxy_logger.exception( + "litellm.proxy.management_endpoints.management_v1.budgets.list_budgets(): Exception occured - {}".format( + str(e) + ) + ) + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}internal-server-error", + title="Internal server error", + status=500, + detail="Failed to list budgets.", + ) + ) diff --git a/tests/e2e/coverage_registry/mgmt.yaml b/tests/e2e/coverage_registry/mgmt.yaml index 68c5ef6b31d..2a0fc5c9f29 100644 --- a/tests/e2e/coverage_registry/mgmt.yaml +++ b/tests/e2e/coverage_registry/mgmt.yaml @@ -57,6 +57,8 @@ - {id: mgmt.budget.update.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "budget_management_endpoints.py:155", rationale: "Limit changes apply"} - {id: mgmt.budget.delete.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "budget_management_endpoints.py:280", rationale: "Clears limits"} - {id: mgmt.budget.list.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "budget_management_endpoints.py:215", rationale: "Budget enumeration"} +- {id: mgmt.budget.list_v1.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "management_v1/budgets.py:129", rationale: "Budget enumeration the Budgets page can page, sort and filter"} +- {id: mgmt.budget.list_v1.admin_only, module: mgmt, tier: P1, surface: api, assertions: [admin_only], source: "management_v1/budgets.py:129", rationale: "A caller without admin view is refused, not served an empty page"} - {id: mgmt.callback.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "callback_management_endpoints.py", rationale: "Callback config (smoke)"} - {id: mgmt.cache_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cache_settings_endpoints.py", rationale: "Cache config (smoke). Deliberately uncovered: the previous test read the live settings and wrote them back, which proves nothing (identical values in, so a no-op POST still passes) while being able to break the deployment. /cache/settings persists what it receives and that row outranks YAML cache_params, re-applied on a timer, so a write that omits ssl or redis_startup_nodes turns a TLS cluster into a plaintext standalone node and every later Redis call hangs. That took out 60 of 72 tests on 2026-07-25. GET cannot round-trip it either: it resolves the stored row overlaid with REDIS_* env and never reads YAML, so on a fresh deploy it cannot see YAML ssl to echo back. A safe test needs an isolated proxy, or LIT-4816 fixed so a partial write cannot downgrade transport. Do not re-add a read-then-write-back test against a shared proxy."} - {id: mgmt.cost_tracking.estimate.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cost_tracking_settings.py", rationale: "Cost estimate (smoke)"} diff --git a/tests/e2e/management/test_budget_customer_user_org_e2e.py b/tests/e2e/management/test_budget_customer_user_org_e2e.py index 54cc18b228b..12372bb7cc1 100644 --- a/tests/e2e/management/test_budget_customer_user_org_e2e.py +++ b/tests/e2e/management/test_budget_customer_user_org_e2e.py @@ -19,10 +19,10 @@ import time from collections.abc import Callable import pytest -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, Field, RootModel from e2e_config import unique_marker -from e2e_http import NoBody, unwrap +from e2e_http import NoBody, Success, UnauthorizedError, UnknownApiError, unwrap from lifecycle import ResourceManager from management_client import ManagementClient from models import KeyGenerateBody, OrgInfoParams, OrgNewBody, UserNewBody @@ -44,9 +44,11 @@ def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: class BudgetNewBody(BaseModel): - max_budget: float + max_budget: float | None = None soft_budget: float | None = None budget_duration: str | None = None + budget_id: str | None = None + tpm_limit: int | None = None class BudgetNewResponse(BaseModel): @@ -204,6 +206,164 @@ class TestBudgetManagement: ) +# ---------- /management/v1/budgets ---------- + +_BUDGETS_V1 = "/management/v1/budgets" + + +class BudgetPageParams(BaseModel): + """Query for GET /management/v1/budgets. The filter fields serialize to the + bracketed keys the route reads them under, so nothing here is a raw dict.""" + + q: str | None = None + sort: str | None = None + page: int | None = None + page_size: int | None = None + duration_in: str | None = Field(default=None, serialization_alias="filter[budget_duration][in]") + max_budget_is_null: bool | None = Field(default=None, serialization_alias="filter[max_budget][is_null]") + not_a_parameter: str | None = Field(default=None, serialization_alias="filter[budget_id][eq]") + + +class BudgetPageMeta(BaseModel): + page: int + page_size: int + total_count: int + total_pages: int + + +class BudgetPageLinks(BaseModel): + first: str + prev: str | None = None + next: str | None = None + last: str + + +class BudgetPageRow(BaseModel): + budget_id: str + max_budget: float | None = None + tpm_limit: int | None = None + budget_duration: str | None = None + + +class BudgetPageResponse(BaseModel): + data: list[BudgetPageRow] + meta: BudgetPageMeta + links: BudgetPageLinks + + +def _list_budgets(client: ManagementClient, params: BudgetPageParams) -> BudgetPageResponse: + return unwrap( + client.proxy.transport.get( + _BUDGETS_V1, + headers=client.proxy.transport.master, + params=params, + response_type=BudgetPageResponse, + ) + ) + + +def _list_budget_ids(client: ManagementClient, params: BudgetPageParams) -> tuple[str, ...]: + return tuple(row.budget_id for row in _list_budgets(client, params).data) + + +def _list_status(client: ManagementClient, params: BudgetPageParams, key: str | None = None) -> int: + headers = client.proxy.transport.master if key is None else client.proxy.transport.bearer(key) + outcome = client.proxy.transport.get( + _BUDGETS_V1, headers=headers, params=params, response_type=BudgetPageResponse + ) + match outcome: + case Success(status_code=status_code): + return status_code + case UnauthorizedError(): + return 401 + case UnknownApiError(status_code=status_code): + return status_code + case _: + raise AssertionError(outcome) + + +class TestBudgetListV1: + """The paged, sorted, filtered budget list the Budgets page reads. + + Every test tags its own budgets with a marker in the budget_id and searches on + it, so budgets left behind by other suites cannot move the assertions. + """ + + @pytest.mark.covers("mgmt.budget.list_v1.happy_path") + def test_sorts_pages_and_filters_the_budgets_it_created( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + marker = unique_marker() + small, medium, large = (f"{marker}-small", f"{marker}-medium", f"{marker}-large") + for budget_id, max_budget, duration in ( + (small, 1.0, "7d"), + (medium, 2.0, "30d"), + (large, 3.0, "30d"), + ): + _create_budget( + client, + resources, + BudgetNewBody( + budget_id=budget_id, max_budget=max_budget, budget_duration=duration, tpm_limit=60000 + ), + ) + + mine = BudgetPageParams(q=marker, sort="-max_budget") + _ = _poll( + client, + lambda: mine if len(_list_budget_ids(client, mine)) == 3 else None, + f"{_BUDGETS_V1} never listed all three budgets tagged {marker}", + ) + + assert _list_budget_ids(client, mine) == (large, medium, small) + + page_two = _list_budgets(client, BudgetPageParams(q=marker, sort="-max_budget", page=2, page_size=1)) + assert [row.budget_id for row in page_two.data] == [medium] + assert page_two.meta.total_count == 3 + assert page_two.meta.total_pages == 3 + assert page_two.meta.page_size == 1 + assert page_two.links.prev is not None and page_two.links.next is not None + + assert set(_list_budget_ids(client, BudgetPageParams(q=marker, duration_in="30d"))) == {medium, large} + + limits = _list_budgets(client, BudgetPageParams(q=marker, sort="budget_id")).data + assert [row.tpm_limit for row in limits] == [60000, 60000, 60000] + + @pytest.mark.covers("mgmt.budget.list_v1.happy_path") + def test_is_null_finds_the_budget_left_uncapped( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + marker = unique_marker() + uncapped = _create_budget(client, resources, BudgetNewBody(budget_id=f"{marker}-uncapped")) + _ = _create_budget(client, resources, BudgetNewBody(budget_id=f"{marker}-capped", max_budget=4.0)) + + params = BudgetPageParams(q=marker, max_budget_is_null=True) + found = _poll( + client, + lambda: params if _list_budget_ids(client, params) == (uncapped,) else None, + f"{_BUDGETS_V1} never isolated the uncapped budget {uncapped}", + ) + + assert [row.max_budget for row in _list_budgets(client, found).data] == [None] + + @pytest.mark.covers("mgmt.budget.list_v1.happy_path") + def test_refuses_a_sort_field_and_a_parameter_it_does_not_support(self, client: ManagementClient) -> None: + assert _list_status(client, BudgetPageParams(sort="budget_duration")) == 400 + assert _list_status(client, BudgetPageParams(not_a_parameter="b-1")) == 400 + + @pytest.mark.covers("mgmt.budget.list_v1.admin_only") + def test_is_refused_for_a_non_admin_key(self, client: ManagementClient, resources: ResourceManager) -> None: + key = client.proxy.generate_key(KeyGenerateBody()) + resources.defer(lambda: client.proxy.delete_key(key)) + + status = _list_status(client, BudgetPageParams(), key=key) + + assert status in (401, 403), ( + f"a non-admin key listing budgets must be refused 401/403, got {status}. Serving 200 with an " + f"empty page would read as 'this proxy has no budgets'" + ) + + # ---------- customer / end-user ---------- diff --git a/tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py b/tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py index b0d2595e48c..9f4a801eb83 100644 --- a/tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py +++ b/tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py @@ -51,6 +51,7 @@ def admin_viewer_client(monkeypatch): mock_budget_table = MagicMock() mock_budget_table.find_many = AsyncMock(return_value=[]) mock_budget_table.find_first = AsyncMock(return_value=None) + mock_budget_table.count = AsyncMock(return_value=0) mock_invitation_table = MagicMock() mock_invitation_table.find_unique = AsyncMock(return_value=None) @@ -106,6 +107,14 @@ def test_budget_list_allows_admin_viewer(admin_viewer_client): assert resp.status_code == 200, resp.text +def test_management_v1_budgets_allows_admin_viewer(admin_viewer_client): + """`/management/v1/budgets` is the paged/sortable budget list; same read tier as + `/budget/list`, and it answers 403 rather than an empty page when it refuses.""" + resp = admin_viewer_client.get("/management/v1/budgets") + _assert_not_role_blocked(resp) + assert resp.status_code == 200, resp.text + + def test_budget_settings_allows_admin_viewer(admin_viewer_client): """`/budget/settings` describes a budget's fields; read-only.""" resp = admin_viewer_client.get("/budget/settings", params={"budget_id": "b1"}) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index a6d4dc63697..06764139eda 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1960,6 +1960,7 @@ ADMIN_VIEWER_SETTINGS_ROUTES = [ "/config/field/info", # Budgets page "/budget/list", + "/management/v1/budgets", "/budget/settings", # Invitation viewing (admin viewer cannot create/delete; can read) "/invitation/info", diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py new file mode 100644 index 00000000000..500c072bc7d --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py @@ -0,0 +1,471 @@ +from datetime import datetime, timezone +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient + +from litellm.proxy._types import LiteLLMRoutes, LitellmUserRoles +from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth +from litellm.proxy.management_endpoints.management_v1 import router +from litellm.proxy.management_endpoints.management_v1.budgets import BUDGETS_LIST_SPEC +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, + problem_response, +) +from litellm.proxy.management_endpoints.management_v1.list_framework import ( + ScopeWhere, + build_query_plan, +) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail + +app = FastAPI() + + +@app.exception_handler(ManagementProblem) +async def management_problem_exception_handler(request: Request, exc: ManagementProblem): + return problem_response(exc.problem) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return problem_response( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", + title="Invalid query parameter", + status=400, + detail="The request query parameters are invalid.", + ) + ) + + +app.include_router(router) +client = TestClient(app) + +BUDGETS_PATH = f"{MANAGEMENT_V1_PREFIX}/budgets" +SORTABLE = ["budget_id", "created_at", "max_budget", "rpm_limit", "tpm_limit"] + + +def _row(budget_id: str, **overrides: Any) -> dict[str, Any]: + return { + "budget_id": budget_id, + "max_budget": 10.0, + "soft_budget": None, + "tpm_limit": None, + "rpm_limit": None, + "budget_duration": "30d", + "budget_reset_at": None, + "created_at": datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 7, 21, 12, 0, tzinfo=timezone.utc), + **overrides, + } + + +@pytest.fixture +def budget_table(monkeypatch): + table = MagicMock() + table.count = AsyncMock(return_value=0) + table.find_many = AsyncMock(return_value=[]) + prisma_client = MagicMock() + prisma_client.db.litellm_budgettable = table + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client) + return table + + +@pytest.fixture +def as_proxy_admin(): + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + yield + app.dependency_overrides.clear() + + +def _serve(budget_table, rows: list[dict[str, Any]], total: int | None = None) -> None: + budget_table.find_many = AsyncMock(return_value=rows) + budget_table.count = AsyncMock(return_value=len(rows) if total is None else total) + + +def _as_role(role: LitellmUserRoles): + original = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="u", user_role=role) + return original + + +def _get(query: str = ""): + suffix = f"?{query}" if query else "" + return client.get(f"{BUDGETS_PATH}{suffix}", headers={"Authorization": "Bearer k"}) + + +def test_returns_flat_rows_in_the_control_plane_envelope(budget_table, as_proxy_admin): + """`{data, meta, links}` with flat rows; no JSON:API `{type, id, attributes}` wrapper.""" + _serve(budget_table, [_row("b-1")]) + + response = _get() + + assert response.status_code == 200 + body = response.json() + assert set(body) == {"data", "meta", "links"} + assert body["data"][0]["budget_id"] == "b-1" + assert "attributes" not in body["data"][0] + + +def test_serves_the_columns_the_budgets_page_renders(budget_table, as_proxy_admin): + _serve(budget_table, [_row("b-1", soft_budget=5.0, budget_reset_at=datetime(2026, 8, 1, tzinfo=timezone.utc))]) + + row = _get().json()["data"][0] + + assert set(row) == { + "budget_id", + "max_budget", + "soft_budget", + "tpm_limit", + "rpm_limit", + "budget_duration", + "budget_reset_at", + "created_at", + "updated_at", + } + assert row["soft_budget"] == 5.0 + assert row["budget_reset_at"].startswith("2026-08-01T00:00:00") + + +def test_defaults_to_newest_first_with_budget_id_breaking_ties(budget_table, as_proxy_admin): + """Two budgets created in the same transaction share a created_at; without the + tiebreaker their relative order is undefined and pages can repeat or drop rows.""" + _serve(budget_table, []) + + _get() + + assert budget_table.find_many.call_args.kwargs["order"] == [ + {"created_at": "desc"}, + {"budget_id": "asc"}, + ] + + +def test_appends_the_tiebreaker_to_an_explicit_sort(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("sort=-max_budget") + + assert budget_table.find_many.call_args.kwargs["order"] == [ + {"max_budget": "desc"}, + {"budget_id": "asc"}, + ] + + +def test_does_not_duplicate_the_tiebreaker_when_it_is_sorted_on(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("sort=-budget_id") + + assert budget_table.find_many.call_args.kwargs["order"] == [{"budget_id": "desc"}] + + +def test_refuses_to_sort_on_budget_duration(budget_table, as_proxy_admin): + """The column holds "7d"/"30d", so a lexicographic ORDER BY would put "30d" + before "7d" and silently mis-order the page.""" + _serve(budget_table, []) + + response = _get("sort=budget_duration") + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert "budget_duration" in body["detail"] + assert body["allowed"] == SORTABLE + budget_table.find_many.assert_not_called() + + +def test_the_advertised_sort_fields_are_the_ones_that_work(budget_table, as_proxy_admin): + """Guards the rejection above against drifting from what the spec actually accepts.""" + _serve(budget_table, []) + + for field in SORTABLE: + assert _get(f"sort={field}").status_code == 200, field + assert sorted(BUDGETS_LIST_SPEC.sortable) == SORTABLE + + +def test_rejects_an_unknown_query_parameter(budget_table, as_proxy_admin): + """A silently ignored filter over-returns budgets, which is worse than a rejected request.""" + _serve(budget_table, []) + + response = _get("filtre[max_budget][gte]=5") + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert "filtre[max_budget][gte]" in body["detail"] + assert "filter[max_budget][gte]" in body["allowed"] + budget_table.count.assert_not_called() + budget_table.find_many.assert_not_called() + + +def test_rejects_an_operator_the_filter_does_not_declare(budget_table, as_proxy_admin): + """`max_budget` takes ranges, not `in`; accepting an undeclared operator is how a + filter starts meaning something the query planner never checked.""" + _serve(budget_table, []) + + assert _get("filter[max_budget][in]=5,10").status_code == 400 + assert _get("filter[created_at][is_null]=true").status_code == 400 + + +def test_omitted_page_size_serves_fifty(budget_table, as_proxy_admin): + _serve(budget_table, []) + + body = _get().json() + + assert body["meta"]["page_size"] == 50 + assert budget_table.find_many.call_args.kwargs["take"] == 50 + + +def test_clamps_an_oversized_page_size_to_a_hundred(budget_table, as_proxy_admin): + """Unclamped, one request can ask the proxy to serialize the whole budget table.""" + _serve(budget_table, []) + + body = _get("page_size=500").json() + + assert body["meta"]["page_size"] == 100 + assert budget_table.find_many.call_args.kwargs["take"] == 100 + + +def test_offsets_by_page(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("page=3&page_size=25") + + assert budget_table.find_many.call_args.kwargs["skip"] == 50 + assert budget_table.find_many.call_args.kwargs["take"] == 25 + + +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.TEAM, + ], +) +def test_refuses_a_caller_without_admin_view(budget_table, role): + """Budgets are proxy-wide, so a caller who cannot read all of them must be told + so. Answering 200 with an empty list would read as "there are no budgets".""" + _serve(budget_table, [_row("b-1")]) + original = _as_role(role) + try: + response = _get() + finally: + app.dependency_overrides = original + + assert response.status_code == 403 + assert response.headers["content-type"].startswith("application/problem+json") + assert response.json()["status"] == 403 + budget_table.count.assert_not_called() + budget_table.find_many.assert_not_called() + + +@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY]) +def test_admins_and_admin_viewers_may_read_every_budget(budget_table, role): + _serve(budget_table, [_row("b-1")]) + original = _as_role(role) + try: + response = _get() + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + assert [row["budget_id"] for row in response.json()["data"]] == ["b-1"] + + +def test_a_denied_caller_stays_denied_whatever_they_filter_on(budget_table): + """The scope decision reads the caller, never the query string.""" + _serve(budget_table, [_row("b-1")]) + original = _as_role(LitellmUserRoles.INTERNAL_USER) + try: + response = _get("filter[max_budget][gte]=0&q=b-") + finally: + app.dependency_overrides = original + + assert response.status_code == 403 + + +def test_a_filter_narrows_the_scope_predicate_instead_of_replacing_it(budget_table, as_proxy_admin): + """A filter is ANDed in. Assigning it over the scope clause is what would let a + caller widen their own read.""" + _serve(budget_table, []) + + _get("filter[max_budget][gte]=5") + + where = budget_table.find_many.call_args.kwargs["where"] + assert {"max_budget": {"gte": 5.0}} in where["AND"] + + +def test_a_scoped_caller_keeps_their_scope_clause_alongside_their_filter(): + """Same spec, driven through the planner with a row-scoped caller: the scope + clause has to survive next to whatever the caller filtered on.""" + request = Request( + { + "type": "http", + "method": "GET", + "path": BUDGETS_PATH, + "headers": [], + "query_string": b"filter[max_budget][gte]=5", + } + ) + + plan = build_query_plan(request, BUDGETS_LIST_SPEC, ScopeWhere(where={"budget_id": {"in": ("b-1",)}})) + + assert {"budget_id": {"in": ("b-1",)}} in plan.where["AND"] + assert {"max_budget": {"gte": 5.0}} in plan.where["AND"] + + +def test_q_matches_budget_id_case_insensitively(budget_table, as_proxy_admin): + """budget_id is the only text identity on the row; matching anything else would + return budgets whose ids do not contain what the user typed.""" + _serve(budget_table, []) + + _get("q=Prod") + + where = budget_table.find_many.call_args.kwargs["where"] + assert {"OR": ({"budget_id": {"contains": "Prod", "mode": "insensitive"}},)} in where["AND"] + + +def test_q_does_not_search_any_other_column(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("q=30d") + + searched = budget_table.find_many.call_args.kwargs["where"]["AND"][0]["OR"] + assert [next(iter(clause)) for clause in searched] == ["budget_id"] + assert BUDGETS_LIST_SPEC.searchable == frozenset({"budget_id"}) + + +def test_is_null_selects_the_unlimited_budgets(budget_table, as_proxy_admin): + """"Unlimited" is max_budget IS NULL; `max_budget = 0` would be a hard zero cap.""" + _serve(budget_table, [_row("b-unlimited", max_budget=None)]) + + body = _get("filter[max_budget][is_null]=true").json() + + assert {"max_budget": None} in budget_table.find_many.call_args.kwargs["where"]["AND"] + assert body["data"][0]["max_budget"] is None + + +def test_is_null_false_selects_the_capped_budgets(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("filter[max_budget][is_null]=false") + + assert {"max_budget": {"not": None}} in budget_table.find_many.call_args.kwargs["where"]["AND"] + + +def test_in_filter_splits_the_requested_durations(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("filter[budget_duration][in]=7d,30d") + + assert {"budget_duration": {"in": ("7d", "30d")}} in budget_table.find_many.call_args.kwargs["where"]["AND"] + + +def test_created_at_range_is_read_as_a_timestamp(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("filter[created_at][gte]=2026-07-01T00:00:00%2B00:00") + + assert { + "created_at": {"gte": datetime(2026, 7, 1, tzinfo=timezone.utc)} + } in budget_table.find_many.call_args.kwargs["where"]["AND"] + + +def test_rejects_a_filter_value_that_is_not_of_the_declared_type(budget_table, as_proxy_admin): + _serve(budget_table, []) + + assert _get("filter[max_budget][gte]=lots").status_code == 400 + assert _get("filter[created_at][gte]=yesterday").status_code == 400 + + +def test_reports_the_total_and_links_every_page_on_a_middle_page(budget_table, as_proxy_admin): + """The Budgets page renders a page count, so the total has to be the match total, + not the length of the page it just received.""" + _serve(budget_table, [_row("b-3"), _row("b-4")], total=7) + + body = _get("page=2&page_size=2").json() + + assert body["meta"] == {"page": 2, "page_size": 2, "total_count": 7, "total_pages": 4} + links = body["links"] + assert "page=1" in links["first"] and "page_size=2" in links["first"] + assert "page=1" in links["prev"] + assert "page=3" in links["next"] + assert "page=4" in links["last"] + assert "page=2" in links["self"] + + +def test_the_last_page_has_no_next(budget_table, as_proxy_admin): + _serve(budget_table, [_row("b-5")], total=5) + + links = _get("page=3&page_size=2").json()["links"] + + assert links["next"] is None + assert "page=2" in links["prev"] + + +def test_the_first_page_has_no_prev(budget_table, as_proxy_admin): + _serve(budget_table, [_row("b-1")], total=5) + + links = _get("page_size=2").json()["links"] + + assert links["prev"] is None + assert "page=2" in links["next"] + + +def test_an_empty_table_still_links_a_first_and_last_page(budget_table, as_proxy_admin): + _serve(budget_table, [], total=0) + + body = _get().json() + + assert body["meta"]["total_count"] == 0 + assert body["meta"]["total_pages"] == 0 + assert "page=1" in body["links"]["first"] and "page=1" in body["links"]["last"] + + +def test_counts_over_the_same_predicate_it_pages(budget_table, as_proxy_admin): + """A total counted without the caller's filter would page through rows the + filter excluded.""" + _serve(budget_table, [], total=0) + + _get("filter[budget_duration][in]=30d") + + assert budget_table.count.call_args.kwargs["where"] == budget_table.find_many.call_args.kwargs["where"] + + +def test_bigint_limits_serialize_as_json_numbers(budget_table, as_proxy_admin): + """tpm_limit/rpm_limit are BigInt? in Prisma; the query engine can hand them back + as decimal strings, and a quoted "60000" breaks arithmetic in the dashboard.""" + _serve(budget_table, [_row("b-1", tpm_limit="60000", rpm_limit=1200)]) + + row = _get().json()["data"][0] + + assert row["tpm_limit"] == 60000 + assert row["rpm_limit"] == 1200 + assert isinstance(row["tpm_limit"], int) and not isinstance(row["tpm_limit"], bool) + assert '"tpm_limit": "60000"' not in _get().text + + +def test_reports_a_missing_database_as_a_problem_document(monkeypatch, as_proxy_admin): + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + + response = _get() + + assert response.status_code == 503 + assert response.headers["content-type"].startswith("application/problem+json") + + +def test_is_reachable_by_the_roles_that_can_open_the_budgets_page(): + """Route-level auth gate, which the dependency_overrides above bypass. The handler's + admin-view check is dead code if RouteChecks rejects the role first.""" + assert BUDGETS_PATH in LiteLLMRoutes.admin_viewer_routes.value + assert ("/budget/list" in LiteLLMRoutes.admin_viewer_routes.value) == ( + BUDGETS_PATH in LiteLLMRoutes.admin_viewer_routes.value + ) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 380b6545da8..752b762e773 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7169,6 +7169,43 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/budgets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Budgets + * @description The budgets defined on this proxy, paged, sortable and filterable, for the + * Budgets page. + * + * Readable by a proxy admin or an admin viewer; anyone else is refused 403. The + * older `/budget/list` answers with the whole table as a bare array and has no + * way to page, sort or filter it. + * + * `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`, + * `rpm_limit` or `created_at`, each optionally prefixed with `-` for descending, + * and defaults to `-created_at,budget_id`. `q` is a case-insensitive substring + * match on `budget_id`. `page_size` defaults to 50 and is capped at 100. + * Filters are `filter[budget_duration][in|is_null]`, + * `filter[max_budget][gte|lte|is_null]` and `filter[created_at][gte|lte]`. + * + * Example curl: + * ``` + * curl --location --globoff 'http://0.0.0.0:4000/management/v1/budgets?sort=-max_budget&filter[budget_duration][in]=7d,30d&page_size=25' --header 'Authorization: Bearer sk-1234' + * ``` + */ + get: operations["list_budgets_management_v1_budgets_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/management/v1/spend_logs/end_users": { parameters: { query?: never; @@ -24771,6 +24808,7 @@ export interface components { /** Updated By */ updated_by?: string | null; }; + JsonValue: unknown; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -24922,6 +24960,36 @@ export interface components { /** Guardrails */ guardrails: components["schemas"]["GuardrailInfoResponse"][]; }; + /** + * ListLinks + * @description Hypermedia for an entity list. `first`/`last` exist here because `total_pages` is known. + */ + ListLinks: { + /** First */ + first: string; + /** Last */ + last: string; + /** Next */ + next?: string | null; + /** Prev */ + prev?: string | null; + /** Self */ + self: string; + }; + /** + * ListMeta + * @description An entity list can afford the COUNT(*) a facet cannot, so it reports a real total. + */ + ListMeta: { + /** Page */ + page: number; + /** Page Size */ + page_size: number; + /** Total Count */ + total_count: number; + /** Total Pages */ + total_pages: number; + }; /** * ListPluginsResponse * @description Response from listing plugins. @@ -24937,6 +25005,18 @@ export interface components { /** Prompts */ prompts: components["schemas"]["PromptSpec"][]; }; + /** + * ListResponse + * @description One page of an entity collection. Rows are flat: no `{type, id, attributes}` wrapper. + */ + ListResponse: { + /** Data */ + data: { + [key: string]: components["schemas"]["JsonValue"]; + }[]; + links: components["schemas"]["ListLinks"]; + meta: components["schemas"]["ListMeta"]; + }; /** * ListRunsResponse * @description Response from listing runs @@ -43416,6 +43496,26 @@ export interface operations { }; }; }; + list_budgets_management_v1_budgets_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListResponse"]; + }; + }; + }; + }; list_spend_log_end_users_management_v1_spend_logs_end_users_get: { parameters: { query: { From 78c756dff94554435b2912cd416022ebb9c10291 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 31 Jul 2026 10:19:24 -0700 Subject: [PATCH 3/4] fix(proxy): rework the budgets list onto the merged list contract PR #35308 landed a different shape than this branch was written against: `where` is a tuple of frozen predicates rather than a Prisma-shaped mapping, `ListSpec` carries both the row and the wire type, and `where_sql` / `order_by_sql` render for a raw-SQL executor. The budgets executor now queries through `query_raw` the way the spend logs facet does, selecting only the columns it serves. Also casts datetime binds in `where_sql`. They cross into the query engine as JSON, so an uncast placeholder arrives as text and Postgres refuses `timestamp >= text` outright; every `filter[created_at][gte|lte]` was answering 500. The cast reads the bind as an instant and drops it to naive UTC to match Prisma's TIMESTAMP(3) column, the same one /spend/logs/ui applies. --- .../management_v1/budgets.py | 25 +++++--- .../management_v1/list_framework.py | 15 ++++- .../management_v1/test_budgets.py | 15 ++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 61 ++++++++++++++----- 4 files changed, 87 insertions(+), 29 deletions(-) diff --git a/litellm/proxy/management_endpoints/management_v1/budgets.py b/litellm/proxy/management_endpoints/management_v1/budgets.py index d9104eb5da2..bc1521caf0b 100644 --- a/litellm/proxy/management_endpoints/management_v1/budgets.py +++ b/litellm/proxy/management_endpoints/management_v1/budgets.py @@ -1,8 +1,9 @@ """`GET /management/v1/budgets`.""" -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime +from types import MappingProxyType from typing import Annotated from fastapi import APIRouter, Depends, Request @@ -112,15 +113,19 @@ def _scope(caller: UserAPIKeyAuth) -> Scope: # budget_duration is deliberately absent from `sortable`: the column holds strings # like "7d" and "30d", so a lexicographic ORDER BY puts "30d" ahead of "7d". +BUDGET_FILTERS: Mapping[str, FilterSpec] = MappingProxyType( + { # mutable-ok: an immutable mapping has no literal form; MappingProxyType freezes this one and it never escapes + "budget_duration": FilterSpec(type=str, ops=frozenset(("in", "is_null"))), + "max_budget": FilterSpec(type=float, ops=frozenset(("gte", "lte", "is_null"))), + "created_at": FilterSpec(type=datetime, ops=frozenset(("gte", "lte"))), + } +) + BUDGETS_LIST_SPEC: ListSpec[BudgetListItem, BudgetListItem] = ListSpec( resource="budgets", - sortable=frozenset({"budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at"}), - searchable=frozenset({"budget_id"}), - filters={ - "budget_duration": FilterSpec(type=str, ops=frozenset({"in", "is_null"})), - "max_budget": FilterSpec(type=float, ops=frozenset({"gte", "lte", "is_null"})), - "created_at": FilterSpec(type=datetime, ops=frozenset({"gte", "lte"})), - }, + sortable=frozenset(("budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at")), + searchable=frozenset(("budget_id",)), + filters=BUDGET_FILTERS, default_sort=(SortKey(field="created_at", descending=True),), default_page_size=50, max_page_size=100, @@ -132,8 +137,8 @@ BUDGETS_LIST_SPEC: ListSpec[BudgetListItem, BudgetListItem] = ListSpec( @router.get( "/budgets", - tags=["budget management"], - dependencies=[Depends(user_api_key_auth)], + tags=("budget management",), + dependencies=(Depends(user_api_key_auth),), response_model=ListResponse[BudgetListItem], ) async def list_budgets( diff --git a/litellm/proxy/management_endpoints/management_v1/list_framework.py b/litellm/proxy/management_endpoints/management_v1/list_framework.py index 3e4b9131d1e..e2bddefab83 100644 --- a/litellm/proxy/management_endpoints/management_v1/list_framework.py +++ b/litellm/proxy/management_endpoints/management_v1/list_framework.py @@ -213,12 +213,23 @@ def _sql_operator(op: ComparisonOp) -> str: assert_never(op) +def _placeholder(index: int, value: FilterValue) -> str: + """`$n`, cast when the bind is a datetime. + + Binds cross into the query engine as JSON, so a datetime arrives as text and + Postgres refuses `timestamp >= text` outright. Prisma stores DateTime as a naive + `TIMESTAMP(3)` holding UTC, so the bind is read as an instant and then dropped to + naive UTC to match the column, the same cast `/spend/logs/ui` applies. + """ + return f"${index}::timestamptz AT TIME ZONE 'UTC'" if isinstance(value, datetime) else f"${index}" + + def _render(predicate: Predicate, index: int) -> tuple[str, tuple[object, ...]]: match predicate: case IsNull(field=field, negated=negated): return f'"{field}" IS {"NOT NULL" if negated else "NULL"}', () case Within(field=field, values=values): - placeholders = ", ".join(f"${index + offset}" for offset in range(len(values))) + placeholders = ", ".join(_placeholder(index + offset, value) for offset, value in enumerate(values)) return f'"{field}" IN ({placeholders})', values case AnyOf(clauses=clauses): rendered, params = _render_all(clauses, index) @@ -226,7 +237,7 @@ def _render(predicate: Predicate, index: int) -> tuple[str, tuple[object, ...]]: case Compare(field=field, op="contains", value=value): return f"\"{field}\" ILIKE ${index} ESCAPE '\\'", (f"%{escape_like(str(value))}%",) case Compare(field=field, op=op, value=value): - return f'"{field}" {_sql_operator(op)} ${index}', (value,) + return f'"{field}" {_sql_operator(op)} {_placeholder(index, value)}', (value,) case _: assert_never(predicate) diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py index fe6d0289e53..f98286985b7 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py @@ -371,15 +371,28 @@ def test_in_filter_binds_each_requested_duration(query_raw, as_proxy_admin): def test_created_at_range_is_bound_as_a_timestamp(query_raw, as_proxy_admin): + """The bind crosses into the query engine as JSON, so an uncast placeholder reaches + Postgres as text and `timestamp >= text` is a hard error, not a wrong answer.""" _serve(query_raw, []) _get("filter[created_at][gte]=2026-07-01T00:00:00Z") sql, *params = _select_call(query_raw) - assert '"created_at" >= $1' in sql + assert "\"created_at\" >= $1::timestamptz AT TIME ZONE 'UTC'" in sql assert params[0] == datetime(2026, 7, 1, tzinfo=timezone.utc) +def test_a_non_datetime_bind_is_not_cast(query_raw, as_proxy_admin): + """Guards the cast above from being applied to every placeholder.""" + _serve(query_raw, []) + + _get("filter[max_budget][gte]=5") + + sql = _select_call(query_raw)[0] + assert '"max_budget" >= $1' in sql + assert "timestamptz" not in sql + + def test_an_offsetless_created_at_bound_is_read_as_utc(query_raw, as_proxy_admin): """The dashboard sends 'YYYY-MM-DDTHH:MM:SS' with no offset. Left naive, Postgres would compare it in the session timezone and shift the window off the rows shown.""" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 46a158bbac7..9a301f1c474 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7187,10 +7187,11 @@ export interface paths { * * `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`, * `rpm_limit` or `created_at`, each optionally prefixed with `-` for descending, - * and defaults to `-created_at,budget_id`. `q` is a case-insensitive substring - * match on `budget_id`. `page_size` defaults to 50 and is capped at 100. - * Filters are `filter[budget_duration][in|is_null]`, - * `filter[max_budget][gte|lte|is_null]` and `filter[created_at][gte|lte]`. + * and defaults to `-created_at`. `budget_id` is appended to every sort as the + * tiebreaker. `q` is a case-insensitive substring match on `budget_id`. + * `page_size` defaults to 50 and is capped at 100. Filters are + * `filter[budget_duration][in|is_null]`, `filter[max_budget][gte|lte|is_null]` + * and `filter[created_at][gte|lte]`. * * Example curl: * ``` @@ -21570,6 +21571,40 @@ export interface components { /** Reset At */ reset_at?: string | null; }; + /** + * BudgetListItem + * @description One budget as the Budgets page reads it, and as it comes back off the table. + * + * Validating the raw row through here is what makes `tpm_limit` / `rpm_limit` + * numbers: they are `BigInt?` in the schema, which the query engine hands back as + * decimal strings, and a quoted "60000" breaks arithmetic in the dashboard. + */ + BudgetListItem: { + /** Budget Duration */ + budget_duration?: string | null; + /** Budget Id */ + budget_id: string; + /** Budget Reset At */ + budget_reset_at?: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Max Budget */ + max_budget?: number | null; + /** Rpm Limit */ + rpm_limit?: number | null; + /** Soft Budget */ + soft_budget?: number | null; + /** Tpm Limit */ + tpm_limit?: number | null; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; /** BudgetNewRequest */ BudgetNewRequest: { /** @@ -24814,7 +24849,6 @@ export interface components { /** Updated By */ updated_by?: string | null; }; - JsonValue: unknown; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -24968,7 +25002,7 @@ export interface components { }; /** * ListLinks - * @description Hypermedia for an entity list. `first`/`last` exist here because `total_pages` is known. + * @description Page-mode counterpart to `PageLinks`. `first`/`last` are knowable here because the total count is. */ ListLinks: { /** First */ @@ -24984,7 +25018,7 @@ export interface components { }; /** * ListMeta - * @description An entity list can afford the COUNT(*) a facet cannot, so it reports a real total. + * @description Page-mode counterpart to `PageMeta`: an entity list pays for the COUNT(*) so the table can show a page count. */ ListMeta: { /** Page */ @@ -25011,15 +25045,10 @@ export interface components { /** Prompts */ prompts: components["schemas"]["PromptSpec"][]; }; - /** - * ListResponse - * @description One page of an entity collection. Rows are flat: no `{type, id, attributes}` wrapper. - */ - ListResponse: { + /** ListResponse[BudgetListItem] */ + ListResponse_BudgetListItem_: { /** Data */ - data: { - [key: string]: components["schemas"]["JsonValue"]; - }[]; + data: components["schemas"]["BudgetListItem"][]; links: components["schemas"]["ListLinks"]; meta: components["schemas"]["ListMeta"]; }; @@ -43574,7 +43603,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListResponse"]; + "application/json": components["schemas"]["ListResponse_BudgetListItem_"]; }; }; }; From 858ba174308c0ccfd290640ea7cfb46014084e59 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 31 Jul 2026 10:43:28 -0700 Subject: [PATCH 4/4] refactor(proxy): fold the predicate renderer instead of recursing recursive_detector flags `_render_all`, and the flag is fair: it recursed once per predicate, so the stack grew with the number of filters on the request for no reason. Walking a predicate list is a running bind index, which is a fold. `_render` still re-enters for `AnyOf`, but its clauses are plain comparisons built by `?q=`, so that nesting is one level deep and no caller can drive it deeper. --- .../management_v1/list_framework.py | 25 +++++++++++++++---- .../management_v1/test_budgets.py | 14 +++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/management_v1/list_framework.py b/litellm/proxy/management_endpoints/management_v1/list_framework.py index e2bddefab83..8f25c45016b 100644 --- a/litellm/proxy/management_endpoints/management_v1/list_framework.py +++ b/litellm/proxy/management_endpoints/management_v1/list_framework.py @@ -15,6 +15,7 @@ raw-SQL executor with every caller-supplied value bound to a placeholder. from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone +from functools import partial, reduce from math import ceil from typing import Generic, Literal, Protocol, TypeVar @@ -242,12 +243,26 @@ def _render(predicate: Predicate, index: int) -> tuple[str, tuple[object, ...]]: assert_never(predicate) +def _render_one( + rendered: tuple[tuple[str, ...], tuple[object, ...]], + predicate: Predicate, + first_index: int, +) -> tuple[tuple[str, ...], tuple[object, ...]]: + """Append one predicate, numbering it after the binds already consumed.""" + clauses, params = rendered + clause, clause_params = _render(predicate, first_index + len(params)) + return (*clauses, clause), (*params, *clause_params) + + def _render_all(predicates: tuple[Predicate, ...], index: int) -> tuple[tuple[str, ...], tuple[object, ...]]: - if not predicates: - return (), () - head, head_params = _render(predicates[0], index) - tail, tail_params = _render_all(predicates[1:], index + len(head_params)) - return (head, *tail), head_params + tail_params + """Render every predicate, numbering placeholders continuously across them. + + Folded rather than self-recursive: walking a predicate list is a running index, and + recursing per predicate grew the stack with the filter count for nothing. `_render` + still re-enters here for `AnyOf`, whose clauses are plain `Compare`s from `?q=`, so + that nesting is one level deep and cannot be driven deeper by a caller. + """ + return reduce(partial(_render_one, first_index=index), predicates, ((), ())) def where_sql(where: tuple[Predicate, ...], first_index: int = 1) -> tuple[str, tuple[object, ...]]: diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py index f98286985b7..40473f1a25a 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py @@ -382,6 +382,20 @@ def test_created_at_range_is_bound_as_a_timestamp(query_raw, as_proxy_admin): assert params[0] == datetime(2026, 7, 1, tzinfo=timezone.utc) +def test_numbers_placeholders_continuously_across_predicates(query_raw, as_proxy_admin): + """Each predicate is numbered after the binds the ones before it consumed. Restart + the count and `$1` gets read as the duration while the search string goes unbound.""" + _serve(query_raw, []) + + _get("filter[budget_duration][in]=7d,30d&filter[max_budget][gte]=5&q=prod") + + sql, *params = _select_call(query_raw) + assert '"budget_duration" IN ($1, $2)' in sql + assert '"max_budget" >= $3' in sql + assert '"budget_id" ILIKE $4' in sql + assert params[:4] == ["7d", "30d", 5.0, "%prod%"] + + def test_a_non_datetime_bind_is_not_cast(query_raw, as_proxy_admin): """Guards the cast above from being applied to every placeholder.""" _serve(query_raw, [])