Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,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",
Expand Down
4 changes: 4 additions & 0 deletions litellm/proxy/management_endpoints/management_v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
205 changes: 205 additions & 0 deletions litellm/proxy/management_endpoints/management_v1/budgets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""`GET /management/v1/budgets`."""

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
from pydantic import BaseModel, 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,
Predicate,
QueryPlan,
Scope,
ScopeAll,
ScopeDenied,
SortKey,
handle_list,
order_by_sql,
where_sql,
)
from litellm.proxy.utils import PrismaClient
from litellm.types.proxy.management_endpoints.management_v1 import (
ListResponse,
ProblemDetail,
)

router = APIRouter(prefix=MANAGEMENT_V1_PREFIX)

BUDGET_TABLE = '"LiteLLM_BudgetTable"'


class BudgetListItem(BaseModel):
"""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.
"""

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


class _RowCount(BaseModel):
count: int


_BUDGET_ROWS = TypeAdapter(tuple[BudgetListItem, ...])
_ROW_COUNTS = TypeAdapter(tuple[_RowCount, ...])

SELECTED_COLUMNS = ", ".join(f'"{name}"' for name in BudgetListItem.model_fields)


@dataclass(frozen=True, slots=True)
class PrismaBudgetListExecutor:
"""The database half of the budgets list. Every caller-supplied value is bound to a
placeholder by `where_sql`; only the spec's own column names reach the SQL text."""

prisma_client: PrismaClient

async def count(self, where: tuple[Predicate, ...]) -> int:
clauses, params = where_sql(where)
sql = f"SELECT COUNT(*) AS count FROM {BUDGET_TABLE}" + (f" WHERE {clauses}" if clauses else "")
rows = await self.prisma_client.db.query_raw(sql, *params)
counted = _ROW_COUNTS.validate_python(rows)
return counted[0].count if counted else 0

async def find_many(self, plan: QueryPlan) -> Sequence[BudgetListItem]:
clauses, params = where_sql(plan.where)
sql = (
f"SELECT {SELECTED_COLUMNS} FROM {BUDGET_TABLE}"
+ (f" WHERE {clauses}" if clauses else "")
+ f" ORDER BY {order_by_sql(plan.order)}"
+ f" LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}"
)
rows = await self.prisma_client.db.query_raw(sql, *params, plan.take, plan.skip)
return _BUDGET_ROWS.validate_python(rows)


def _serialize(row: BudgetListItem) -> BudgetListItem:
"""The row shape is the wire shape: the query selects exactly the columns served."""
return row


def _scope(caller: UserAPIKeyAuth) -> Scope:
if user_api_key_has_admin_view(caller):
return ScopeAll()
return ScopeDenied(reason="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".
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_FILTERS,
default_sort=(SortKey(field="created_at", descending=True),),
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[BudgetListItem],
)
async def list_budgets(
request: Request,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> ListResponse[BudgetListItem]:
"""
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` 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:
```
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(
spec=BUDGETS_LIST_SPEC,
executor=PrismaBudgetListExecutor(prisma_client=prisma_client),
request=request,
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.",
)
)
40 changes: 33 additions & 7 deletions litellm/proxy/management_endpoints/management_v1/list_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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

Expand Down Expand Up @@ -213,30 +214,55 @@ 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)
return f"({' OR '.join(rendered)})", params
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)


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, ...]]:
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/coverage_registry/mgmt.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"}
Expand Down
Loading
Loading