diff --git a/litellm/types/proxy/management_endpoints/key_management_endpoints.py b/litellm/types/proxy/management_endpoints/key_management_endpoints.py index 23fcee1f26d..cc81742cbde 100644 --- a/litellm/types/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/key_management_endpoints.py @@ -132,6 +132,34 @@ BudgetComparison = Literal[">=", ">"] BudgetStatus = Literal["unlimited", "ok", "exceeded"] +BudgetNoteCode = Literal[ + "alert_only", + "custom_auth_may_override_end_user_cap", + "end_user_route_only", + "model_budget_fails_open", + "per_model_counters", + "project_spend_not_tracked", + "request_tags_add_budgets", + "reservation_blocks_at_limit", + "rolling_window", + "throttled_instead_of_blocked", + "user_budget_not_applied_to_team_key", +] + +BudgetNoteSeverity = Literal["info", "warning"] + +BudgetSpendState = Literal["live", "no_counter", "unavailable"] + + +class KeyBudgetNote(BaseModel): + """One caveat about a budget row. Branch on ``code``; ``text`` is free to be reworded.""" + + model_config = ConfigDict(frozen=True) + + code: BudgetNoteCode + severity: BudgetNoteSeverity + text: str + class KeyBudgetEntry(BaseModel): """One budget that can gate requests made with a key, with its live spend.""" @@ -143,6 +171,7 @@ class KeyBudgetEntry(BaseModel): enforcement: BudgetEnforcement max_budget: float | None = None spend: float | None = None + spend_state: BudgetSpendState remaining: float | None = None comparison: BudgetComparison budget_duration: str | None = None @@ -150,7 +179,7 @@ class KeyBudgetEntry(BaseModel): window_start: datetime | None = None source: str status: BudgetStatus - note: str | None = None + notes: tuple[KeyBudgetNote, ...] = () class KeyBudgetsResponse(BaseModel): diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx index a001fee7ca9..8491f7cc675 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx @@ -94,6 +94,30 @@ function EnforcementCell({ entry }: { entry: KeyBudgetEntry }) { ); } +/** + * A null spend means the live counter could not be read, which `SpendBudgetCell` coerces to 0 and + * draws as an empty meter. That renders a failed read as confident full headroom, so branch before + * reaching it: an unknown number must not look like a healthy one. + */ +function SpendCell({ entry }: { entry: KeyBudgetEntry }) { + const rule = budgetThresholdRule(entry); + return ( +
+ {entry.spend == null ? ( + + Unknown{" "} + + {entry.max_budget == null ? "· Unlimited" : `of $${formatNumberWithCommas(entry.max_budget, 2)}`} + + + ) : ( + + )} + {rule && {rule}} +
+ ); +} + function RemainingCell({ entry }: { entry: KeyBudgetEntry }) { const unlimited = entry.max_budget == null; return ( @@ -129,15 +153,7 @@ export const getKeyBudgetsTableColumns = (): ColumnDef[] => [ header: "Spend / Limit", size: 200, enableSorting: false, - cell: ({ row }) => { - const rule = budgetThresholdRule(row.original); - return ( -
- - {rule && {rule}} -
- ); - }, + cell: ({ row }) => , }, { id: "remaining", diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.budgets_tab.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.budgets_tab.test.tsx index 8325b55d278..4a978c62923 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.budgets_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.budgets_tab.test.tsx @@ -281,6 +281,31 @@ describe("KeyInfoView Budgets tab", () => { ); }); + it("does not render a spend the server could not read as a healthy $0.00", async () => { + const unreadable: KeyBudgetEntry = { + ...UNCONFIGURED_BUDGET, + scope: "tag", + entity_type: "tag", + entity_id: "prod", + entity_label: "prod", + max_budget: 1000, + spend: null, + remaining: null, + source: "budget_table:b-tag", + status: "ok", + note: "live spend could not be read", + }; + mockBudgets([unreadable]); + const panel = await renderAndOpenBudgetsTab(); + + const row = rowFor(panel, "prod"); + expect(row).toHaveTextContent("Unknown of $1,000.00"); + expect(row).not.toHaveTextContent("$0.00"); + // The meter is the part that lies loudest: drawn at 0% it reads as untouched headroom. + expect(within(row).queryByRole("meter")).not.toBeInTheDocument(); + expect(row).toHaveTextContent("Blocks at ≥ $1,000.00"); + }); + it("renders the longest note the endpoint can emit without dropping any of it", async () => { const endUser: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET,