From 3b5ab06a95aafb77fe3adf9facd298f3ddaef80b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 19 Aug 2026 15:58:16 -0700 Subject: [PATCH] fix(ui): state each key budget's real threshold in the Budgets tab Scopes disagree on whether hitting the limit exactly is over it. team_member enforces >= so 50 of 50 is already denied, while team enforces > so 300 of 300 still passes. The table rendered the numbers and the server's status but not the operator, so two rows could show identical spend and limit with opposite statuses and nothing on screen explaining why. Each row with a limit now states its own threshold, "Blocks at >= $300.00" against "Blocks at > $300.00", and a soft row says "Alerts at" so the wording never promises a block it cannot make. --- .../templates/KeyBudgetsTableColumns.test.ts | 112 ++++++++++++++++++ .../templates/KeyBudgetsTableColumns.tsx | 27 ++++- .../key_info_view.budgets_tab.test.tsx | 46 +++++++ 3 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts new file mode 100644 index 00000000000..ba850325956 --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { KeyBudgetEntry } from "@/app/(dashboard)/hooks/keys/useKeyBudgets"; +import { budgetThresholdRule, isBlockingRow, severityRank } from "./KeyBudgetsTableColumns"; + +const UNCONFIGURED_BUDGET = { + scope: "key", + entity_type: "key", + entity_id: null, + entity_label: null, + enforcement: "hard", + max_budget: null, + spend: 0, + remaining: null, + comparison: ">=", + budget_duration: null, + budget_reset_at: null, + window_start: null, + source: "key.max_budget", + status: "unlimited", + note: null, +} as KeyBudgetEntry; + +const TEAM_MEMBER_AT_LIMIT: KeyBudgetEntry = { + ...UNCONFIGURED_BUDGET, + scope: "team_member", + comparison: ">=", + max_budget: 50, + spend: 50, + remaining: 0, + status: "exceeded", +}; + +const TEAM_UNDER_LIMIT: KeyBudgetEntry = { + ...UNCONFIGURED_BUDGET, + scope: "team", + comparison: ">", + max_budget: 300, + spend: 120, + remaining: 180, + status: "ok", +}; + +const INCLUSIVE_AT_300: KeyBudgetEntry = { + ...UNCONFIGURED_BUDGET, + comparison: ">=", + max_budget: 300, + spend: 300, + remaining: 0, + status: "exceeded", +}; + +const EXCLUSIVE_AT_300: KeyBudgetEntry = { ...INCLUSIVE_AT_300, comparison: ">", status: "ok" }; + +const SOFT_OVER: KeyBudgetEntry = { + ...UNCONFIGURED_BUDGET, + enforcement: "soft", + comparison: ">=", + max_budget: 500, + spend: 900, + remaining: -400, + status: "exceeded", +}; + +const SUB_DOLLAR_LIMIT: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET, comparison: ">", max_budget: 0.1 }; + +const BLOCKING: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET, status: "exceeded", enforcement: "hard" }; +const ALERT_ONLY: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET, status: "exceeded", enforcement: "soft" }; +const HEALTHY: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET, status: "ok", enforcement: "hard" }; + +describe("budgetThresholdRule", () => { + it("marks an inclusive scope as blocking at the limit, so spend equal to it is already denied", () => { + expect(budgetThresholdRule(TEAM_MEMBER_AT_LIMIT)).toBe("Blocks at ≥ $50.00"); + }); + + it("marks an exclusive scope as blocking only above the limit", () => { + expect(budgetThresholdRule(TEAM_UNDER_LIMIT)).toBe("Blocks at > $300.00"); + }); + + it("distinguishes two scopes sitting on identical numbers by their operator alone", () => { + expect(budgetThresholdRule(INCLUSIVE_AT_300)).toBe("Blocks at ≥ $300.00"); + expect(budgetThresholdRule(EXCLUSIVE_AT_300)).toBe("Blocks at > $300.00"); + }); + + it("never promises a soft budget will block", () => { + expect(budgetThresholdRule(SOFT_OVER)).toBe("Alerts at ≥ $500.00"); + }); + + it("states no threshold for a scope with nothing configured", () => { + expect(budgetThresholdRule(UNCONFIGURED_BUDGET)).toBeNull(); + }); + + it("keeps sub-dollar limits legible rather than rounding them to zero", () => { + expect(budgetThresholdRule(SUB_DOLLAR_LIMIT)).toBe("Blocks at > $0.10"); + }); +}); + +describe("isBlockingRow", () => { + it("counts only a hard budget that is over as blocking", () => { + expect(isBlockingRow(BLOCKING)).toBe(true); + expect(isBlockingRow(ALERT_ONLY)).toBe(false); + expect(isBlockingRow(HEALTHY)).toBe(false); + expect(isBlockingRow(UNCONFIGURED_BUDGET)).toBe(false); + }); +}); + +describe("severityRank", () => { + it("ranks a blocking budget above an alert-only one, and both above healthy and unlimited", () => { + expect(severityRank(BLOCKING)).toBeLessThan(severityRank(ALERT_ONLY)); + expect(severityRank(ALERT_ONLY)).toBeLessThan(severityRank(HEALTHY)); + expect(severityRank(HEALTHY)).toBeLessThan(severityRank(UNCONFIGURED_BUDGET)); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx index e52d3c6d01f..e25088e383c 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx @@ -11,6 +11,7 @@ import { StatusBadge, type StatusTone, } from "@/components/shared/table_cells"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; const SCOPE_LABELS: Record = { proxy: "Proxy", @@ -37,6 +38,19 @@ const STATUS_ORDER: Record = { exceeded: 0, ok: 1, unlimited: 2 export const severityRank = (entry: KeyBudgetEntry): number => (STATUS_ORDER[entry.status] ?? STATUS_ORDER.unlimited) * 2 + (isAlertOnly(entry) ? 1 : 0); +const COMPARISON_GLYPH: Record = { ">=": "≥", ">": ">" }; + +/** + * Scopes disagree on whether hitting the limit exactly is over it: team_member enforces `>=` + * so 50 of 50 is already denied, while team enforces `>` so 300 of 300 still passes. Two rows + * can therefore show identical numbers and opposite statuses, so state each row's real threshold. + */ +export const budgetThresholdRule = (entry: KeyBudgetEntry): string | null => { + if (entry.max_budget == null) return null; + const threshold = `${COMPARISON_GLYPH[entry.comparison] ?? entry.comparison} $${formatNumberWithCommas(entry.max_budget, 2)}`; + return isAlertOnly(entry) ? `Alerts at ${threshold}` : `Blocks at ${threshold}`; +}; + const statusPresentation = (entry: KeyBudgetEntry): { tone: StatusTone; label: string } => { if (entry.status === "unlimited") return { tone: "neutral", label: "Unlimited" }; if (entry.status !== "exceeded") return { tone: "success", label: "Within budget" }; @@ -114,9 +128,15 @@ export const getKeyBudgetsTableColumns = (): ColumnDef[] => [ header: "Spend / Limit", size: 200, enableSorting: false, - cell: ({ row }) => ( - - ), + cell: ({ row }) => { + const rule = budgetThresholdRule(row.original); + return ( +
+ + {rule && {rule}} +
+ ); + }, }, { id: "remaining", @@ -138,6 +158,7 @@ export const getKeyBudgetsTableColumns = (): ColumnDef[] => [ ); 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 91d812bbf24..72ec022f257 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 @@ -271,6 +271,52 @@ describe("KeyInfoView Budgets tab", () => { expect(softRow).toHaveTextContent("alert only"); }); + it("explains why two rows on identical numbers get opposite statuses", async () => { + const inclusive: KeyBudgetEntry = { + ...UNCONFIGURED_BUDGET, + scope: "team_member", + entity_type: "team_member", + entity_label: "alice @ Platform", + comparison: ">=", + max_budget: 300, + spend: 300, + remaining: 0, + status: "exceeded", + }; + const exclusive: KeyBudgetEntry = { + ...UNCONFIGURED_BUDGET, + scope: "team", + entity_type: "team", + entity_label: "Platform", + comparison: ">", + max_budget: 300, + spend: 300, + remaining: 0, + status: "ok", + }; + mockBudgets([inclusive, exclusive]); + const panel = await renderAndOpenBudgetsTab(); + + const blockedRow = rowFor(panel, "alice @ Platform"); + const allowedRow = rowFor(panel, "Platform"); + + expect(blockedRow).toHaveTextContent("Blocks at ≥ $300.00"); + expect(allowedRow).toHaveTextContent("Blocks at > $300.00"); + + expect(within(blockedRow).getByTestId("key-budget-blocking")).toBeInTheDocument(); + expect(within(allowedRow).queryByTestId("key-budget-blocking")).not.toBeInTheDocument(); + expect(within(allowedRow).getByText("Within budget")).toBeInTheDocument(); + + expect(blockedRow).toHaveTextContent("$300.0000 of $300.00"); + expect(allowedRow).toHaveTextContent("$300.0000 of $300.00"); + }); + + it("states no threshold for a scope with nothing configured", async () => { + const panel = await renderAndOpenBudgetsTab(); + + expect(rowFor(panel, "Acme Org")).not.toHaveTextContent("Blocks at"); + }); + it("renders a scope with nothing configured as Unlimited rather than $0", async () => { const panel = await renderAndOpenBudgetsTab();