From 06c622ec501dbe0954386c105d0788912333f50a Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 19 Aug 2026 18:32:00 -0700 Subject: [PATCH] fix(ui): keep a cold per-model budget live instead of rendering it as dead A cold counter is transient, not a property of the budget. A key created minutes ago with a per-model cap is fully live and blocks on the next request over it, but the row read "Cannot trip", greyed, sorted below unlimited scopes, telling someone to ignore the cap that stops them a minute later. Only a permanent property counts as dead now, which leaves the project budget whose spend is never incremented and the personal budget a team key never applies. The fixture hid it: no_counter was built with no notes and a computed remaining, neither of which the resolver can emit, so the row under test was not the row users see. Fixtures now run through the invariants _to_entry guarantees, which immediately caught a second one. --- .../templates/KeyBudgetsTableColumns.test.ts | 24 +++++- .../templates/KeyBudgetsTableColumns.tsx | 6 +- .../key_info_view.budgets_tab.test.tsx | 73 +++++++++++++++---- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts index 8ad6e39c5e1..2fb92a43d8a 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts @@ -55,6 +55,14 @@ const END_USER_ROUTE_NOTE = { text: noteText("end_user_route_only"), } as const; +// Permanently inert for this key: the personal budget is not applied to team keys at all. +const USER_ON_TEAM_KEY_NOTE = { + code: "user_budget_not_applied_to_team_key", + severity: "info", + text: noteText("user_budget_not_applied_to_team_key"), +} as const; + +// Transient, not dead: the counter is merely cold and the cap blocks once one request warms it. const COLD_MODEL_NOTE = { code: "model_budget_fails_open", severity: "info", @@ -169,7 +177,7 @@ describe("cannotTrip", () => { // Severity answers whether the row already carries the fact in a field, which is orthogonal to // whether the row is dead. All four corners occur, so severity can never stand in for deadness. it.each([ - ["info", "dead", COLD_MODEL_NOTE, true], + ["info", "dead", USER_ON_TEAM_KEY_NOTE, true], ["info", "live", ROLLING_NOTE, false], ["warning", "dead", PROJECT_DEAD_NOTE, true], ["warning", "live", END_USER_ROUTE_NOTE, false], @@ -194,6 +202,20 @@ describe("cannotTrip", () => { }); }); +describe("cold per-model counters", () => { + it("stays live, because the cap blocks as soon as one request warms the counter", () => { + expect(COLD_MODEL_NOTE.severity).toBe("info"); + expect(cannotTrip({ ...HEALTHY, scope: "key_model", notes: [COLD_MODEL_NOTE] })).toBe(false); + }); + + it("is not lumped in with a budget that is permanently inert", () => { + expect(cannotTrip({ ...HEALTHY, notes: [USER_ON_TEAM_KEY_NOTE] })).toBe(true); + expect(rowRank({ ...HEALTHY, scope: "key_model", notes: [COLD_MODEL_NOTE] })).toBeLessThan( + rowRank({ ...HEALTHY, notes: [USER_ON_TEAM_KEY_NOTE] }), + ); + }); +}); + describe("isThrottled", () => { it("never blames a throttled budget for a denial, because going over slows rather than rejects", () => { const throttledOver: KeyBudgetEntry = { diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx index d5a83f7a5cb..ceb2bf31bf2 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx @@ -33,6 +33,10 @@ const isAlertOnly = (entry: KeyBudgetEntry): boolean => entry.enforcement === "s /** * Whether a note means the row is dead: it cannot reject a request no matter what the numbers say. + * Only a permanent property counts. `model_budget_fails_open` is deliberately not dead, because a + * cold counter is transient: the budget is live and blocks as soon as one request warms it, so + * calling it dead tells someone to ignore a cap that stops them a minute later. + * * Deadness is a property of the code alone. Severity does not imply it in either direction, since * dead codes appear under both values, so an unclassified code is assumed live: calling a row dead * when it is not invites dismissing the budget that actually stopped the request, which is the one @@ -43,7 +47,7 @@ const CODE_KILLS_ROW: Readonly> = { alert_only: false, custom_auth_may_override_end_user_cap: false, end_user_route_only: false, - model_budget_fails_open: true, + model_budget_fails_open: false, per_model_counters: false, project_spend_not_tracked: true, request_tags_add_budgets: false, 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 ed34bf4e193..b84e38f9064 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 @@ -225,7 +225,30 @@ const OVERLONG_NOTE_TEXT = `${"a caveat clause that keeps going ".repeat(16)}end const ALL_BUDGETS = [KEY_UNLIMITED, USER_WITHIN_BUDGET, TEAM_SOFT_OVER, TEAM_MEMBER_BLOCKING, ORG_UNCONFIGURED]; +// A fixture the resolver cannot produce is how a rendering bug hides: a cold per-model row built +// with no notes and a computed `remaining` looked healthy while the real one rendered as dead. +// Hold every fixture to the invariants _to_entry guarantees. +const assertServerCouldEmit = (budgets: readonly KeyBudgetEntry[]): void => { + for (const budget of budgets) { + // Scoped to the states the resolver defines today; a fixture simulating a newer server is + // deliberately outside them and only has to satisfy the invariants that are not state-specific. + if (["live", "no_counter", "unavailable"].includes(budget.spend_state)) { + expect(budget.spend_state === "live").toBe(budget.spend !== null); + } + expect(budget.status === "unlimited").toBe(budget.max_budget === null); + if (budget.spend == null || budget.max_budget == null) { + expect(budget.remaining).toBeNull(); + } else { + expect(budget.remaining).toBeCloseTo(budget.max_budget - budget.spend, 6); + } + if (budget.spend_state === "no_counter") { + expect(budget.notes.map((note) => note.code)).toContain("model_budget_fails_open"); + } + } +}; + const mockBudgets = (budgets: KeyBudgetEntry[]) => { + assertServerCouldEmit(budgets); const loaded = { data: { key: "test-token-123", budgets }, isLoading: false, isError: false, error: null }; apiMocks.useQuery.mockReturnValue(loaded); }; @@ -329,20 +352,29 @@ describe("KeyInfoView Budgets tab", () => { expect(row).toHaveTextContent("Blocks at ≥ $1,000.00"); }); + // The resolver nulls `remaining` whenever spend is null and always attaches the cold note to a + // `no_counter` reading, so a fixture without both is one the server cannot produce. + const COLD_PER_MODEL: KeyBudgetEntry = { + ...UNCONFIGURED_BUDGET, + scope: "key_model", + entity_type: "key", + entity_id: "claude-opus-5", + entity_label: "claude-opus-5", + max_budget: 40, + spend: null, + spend_state: "no_counter", + remaining: null, + comparison: ">", + source: "key.model_max_budget[claude-opus-5]", + status: "ok", + notes: [ + { code: "per_model_counters", severity: "warning", text: noteText("per_model_counters") }, + { code: "model_budget_fails_open", severity: "info", text: noteText("model_budget_fails_open") }, + ], + }; + it("shows a genuine no-counter-yet zero as $0.00 with its meter, not as unknown", async () => { - const cold: KeyBudgetEntry = { - ...UNCONFIGURED_BUDGET, - scope: "key_model", - entity_type: "key", - entity_label: "claude-opus-5", - max_budget: 40, - spend: null, - spend_state: "no_counter", - remaining: 40, - source: "key.model_max_budget", - status: "ok", - }; - mockBudgets([cold]); + mockBudgets([COLD_PER_MODEL]); const panel = await renderAndOpenBudgetsTab(); const row = rowFor(panel, "claude-opus-5"); @@ -351,6 +383,21 @@ describe("KeyInfoView Budgets tab", () => { expect(within(row).getByRole("meter")).toBeInTheDocument(); }); + it("keeps a cold per-model budget live, since one request warms the counter and it starts blocking", async () => { + mockBudgets([COLD_PER_MODEL, KEY_UNLIMITED]); + const panel = await renderAndOpenBudgetsTab(); + + const row = rowFor(panel, "claude-opus-5"); + expect(within(row).getByText("Within budget")).toBeInTheDocument(); + expect(within(row).queryByText("Cannot trip")).not.toBeInTheDocument(); + expect(within(row).getByText("Blocks requests")).toBeInTheDocument(); + expect(row).toHaveTextContent("Blocks at > $40.00"); + + // A live budget must outrank a scope with nothing configured, never sink below it. + const [, ...dataRows] = panel.getAllByRole("row"); + expect(dataRows[0]).toHaveTextContent("claude-opus-5"); + }); + it("treats a spend state this build predates as unreadable rather than as a confident number", async () => { const future: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET,