Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/management_v1/budgets.py: 87%
67 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""`GET /management/v1/budgets`."""
3from collections.abc import Mapping, Sequence
4from dataclasses import dataclass
5from datetime import datetime
6from types import MappingProxyType
7from typing import Annotated, Final
9from fastapi import APIRouter, Depends, Request
10from pydantic import BaseModel, TypeAdapter
12from litellm._logging import verbose_proxy_logger
13from litellm.proxy._types import (
14 CommonProxyErrors,
15 UserAPIKeyAuth,
16 user_api_key_has_admin_view,
17)
18from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
19from litellm.proxy.list_api.common import (
20 PROBLEM_TYPE_BASE,
21 ManagementProblem,
22)
23from litellm.proxy.list_api.list_framework import (
24 FilterSpec,
25 ListSpec,
26 Predicate,
27 QueryPlan,
28 Scope,
29 ScopeAll,
30 ScopeDenied,
31 SortKey,
32 handle_list,
33 order_by_sql,
34 where_sql,
35)
36from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
37from litellm.proxy.utils import PrismaClient
38from litellm.types.proxy.management_endpoints.management_v1 import (
39 ListResponse,
40 ProblemDetail,
41)
43router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
45BUDGET_TABLE: Final = '"LiteLLM_BudgetTable"'
48class BudgetListItem(BaseModel):
49 """One budget as the Budgets page reads it, and as it comes back off the table.
51 Validating the raw row through here is what makes `tpm_limit` / `rpm_limit`
52 numbers: they are `BigInt?` in the schema, which the query engine hands back as
53 decimal strings, and a quoted "60000" breaks arithmetic in the dashboard.
54 """
56 budget_id: str
57 max_budget: float | None = None
58 soft_budget: float | None = None
59 tpm_limit: int | None = None
60 rpm_limit: int | None = None
61 tpd_limit: int | None = None
62 budget_duration: str | None = None
63 budget_reset_at: datetime | None = None
64 created_at: datetime
65 updated_at: datetime
68class _RowCount(BaseModel):
69 count: int
72_BUDGET_ROWS: Final = TypeAdapter(tuple[BudgetListItem, ...])
73_ROW_COUNTS: Final = TypeAdapter(tuple[_RowCount, ...])
75SELECTED_COLUMNS: Final = ", ".join(f'"{name}"' for name in BudgetListItem.model_fields)
78@dataclass(frozen=True, slots=True)
79class PrismaBudgetListExecutor:
80 """The database half of the budgets list. Every caller-supplied value is bound to a
81 placeholder by `where_sql`; only the spec's own column names reach the SQL text."""
83 prisma_client: PrismaClient
85 async def count(self, where: tuple[Predicate, ...]) -> int:
86 clauses, params = where_sql(where)
87 sql: Final = f"SELECT COUNT(*) AS count FROM {BUDGET_TABLE}" + (f" WHERE {clauses}" if clauses else "")
88 rows: Final = await self.prisma_client.db.query_raw(sql, *params)
89 counted: Final = _ROW_COUNTS.validate_python(rows)
90 return counted[0].count if counted else 0
92 async def find_many(self, plan: QueryPlan) -> Sequence[BudgetListItem]:
93 clauses, params = where_sql(plan.where)
94 sql: Final = (
95 f"SELECT {SELECTED_COLUMNS} FROM {BUDGET_TABLE}"
96 + (f" WHERE {clauses}" if clauses else "")
97 + f" ORDER BY {order_by_sql(plan.order)}"
98 + f" LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}"
99 )
100 rows: Final = await self.prisma_client.db.query_raw(sql, *params, plan.take, plan.skip)
101 return _BUDGET_ROWS.validate_python(rows)
104def _serialize(row: BudgetListItem) -> BudgetListItem:
105 """The row shape is the wire shape: the query selects exactly the columns served."""
106 return row
109def _scope(caller: UserAPIKeyAuth) -> Scope:
110 if user_api_key_has_admin_view(caller): 110 ↛ 112line 110 didn't jump to line 112 because the condition on line 110 was always true
111 return ScopeAll()
112 return ScopeDenied(reason=f"Only proxy admins can list budgets, your role={caller.user_role}")
115# budget_duration is deliberately absent from `sortable`: the column holds strings
116# like "7d" and "30d", so a lexicographic ORDER BY puts "30d" ahead of "7d".
117BUDGET_FILTERS: Final[Mapping[str, FilterSpec]] = MappingProxyType(
118 {
119 "budget_duration": FilterSpec(type=str, ops=frozenset(("in", "is_null"))),
120 "max_budget": FilterSpec(type=float, ops=frozenset(("gte", "lte", "is_null"))),
121 "created_at": FilterSpec(type=datetime, ops=frozenset(("gte", "lte"))),
122 }
123)
125BUDGETS_LIST_SPEC: Final[ListSpec[BudgetListItem, BudgetListItem]] = ListSpec(
126 resource="budgets",
127 sortable=frozenset(("budget_id", "max_budget", "tpm_limit", "rpm_limit", "tpd_limit", "created_at")),
128 searchable=frozenset(("budget_id",)),
129 filters=BUDGET_FILTERS,
130 default_sort=(SortKey(field="created_at", descending=True),),
131 default_page_size=50,
132 max_page_size=100,
133 scope=_scope,
134 serialize=_serialize,
135 tiebreaker="budget_id",
136)
139@router.get(
140 "/budgets",
141 tags=("budget management",),
142 dependencies=(Depends(user_api_key_auth),),
143 response_model=ListResponse[BudgetListItem],
144)
145async def list_budgets(
146 request: Request,
147 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
148) -> ListResponse[BudgetListItem]:
149 """
150 The budgets defined on this proxy, paged, sortable and filterable, for the
151 Budgets page.
153 Readable by a proxy admin or an admin viewer; anyone else is refused 403. The
154 older `/budget/list` answers with the whole table as a bare array and has no
155 way to page, sort or filter it.
157 `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`,
158 `rpm_limit`, `tpd_limit` or `created_at`, each optionally prefixed with `-` for descending,
159 and defaults to `-created_at`. `budget_id` is appended to every sort as the
160 tiebreaker. `q` is a case-insensitive substring match on `budget_id`.
161 `page_size` defaults to 50 and is capped at 100. Filters are
162 `filter[budget_duration][in|is_null]`, `filter[max_budget][gte|lte|is_null]`
163 and `filter[created_at][gte|lte]`.
165 Example curl:
166 ```
167 curl --location --globoff 'http://0.0.0.0:4000/management/v1/budgets?sort=-max_budget&filter[budget_duration][in]=7d,30d&page_size=25' \
168 --header 'Authorization: Bearer sk-1234'
169 ```
170 """
171 try:
172 from litellm.proxy.proxy_server import prisma_client
174 if prisma_client is None: 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 raise ManagementProblem(
176 ProblemDetail(
177 type=f"{PROBLEM_TYPE_BASE}database-not-connected",
178 title="Database not connected",
179 status=503,
180 detail=CommonProxyErrors.db_not_connected_error.value,
181 )
182 )
184 return await handle_list(
185 spec=BUDGETS_LIST_SPEC,
186 executor=PrismaBudgetListExecutor(prisma_client=prisma_client),
187 request=request,
188 caller=user_api_key_dict,
189 )
191 except ManagementProblem:
192 raise
193 except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape
194 verbose_proxy_logger.exception(
195 "litellm.proxy.management_endpoints.management_v1.budgets.list_budgets(): Exception occured - %s", e
196 )
197 raise ManagementProblem(
198 ProblemDetail(
199 type=f"{PROBLEM_TYPE_BASE}internal-server-error",
200 title="Internal server error",
201 status=500,
202 detail="Failed to list budgets.",
203 )
204 )