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,