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_"]; }; }; };