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();