diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts index ba850325956..25f8c3864d3 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.test.ts @@ -30,9 +30,9 @@ const TEAM_MEMBER_AT_LIMIT: KeyBudgetEntry = { status: "exceeded", }; -const TEAM_UNDER_LIMIT: KeyBudgetEntry = { +const PROJECT_UNDER_LIMIT: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET, - scope: "team", + scope: "project", comparison: ">", max_budget: 300, spend: 120, @@ -40,8 +40,11 @@ const TEAM_UNDER_LIMIT: KeyBudgetEntry = { status: "ok", }; -const INCLUSIVE_AT_300: KeyBudgetEntry = { +// team is ">=" while budget reservation is on and ">" once an operator disables it, so the same +// scope must render either operator off the response rather than a value baked in per scope. +const RESERVED_TEAM_AT_300: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET, + scope: "team", comparison: ">=", max_budget: 300, spend: 300, @@ -49,7 +52,7 @@ const INCLUSIVE_AT_300: KeyBudgetEntry = { status: "exceeded", }; -const EXCLUSIVE_AT_300: KeyBudgetEntry = { ...INCLUSIVE_AT_300, comparison: ">", status: "ok" }; +const UNRESERVED_TEAM_AT_300: KeyBudgetEntry = { ...RESERVED_TEAM_AT_300, comparison: ">", status: "ok" }; const SOFT_OVER: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET, @@ -73,12 +76,12 @@ describe("budgetThresholdRule", () => { }); it("marks an exclusive scope as blocking only above the limit", () => { - expect(budgetThresholdRule(TEAM_UNDER_LIMIT)).toBe("Blocks at > $300.00"); + expect(budgetThresholdRule(PROJECT_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("reads the operator off each response, so one scope can render either threshold", () => { + expect(budgetThresholdRule(RESERVED_TEAM_AT_300)).toBe("Blocks at ≥ $300.00"); + expect(budgetThresholdRule(UNRESERVED_TEAM_AT_300)).toBe("Blocks at > $300.00"); }); it("never promises a soft budget will block", () => { diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx index e25088e383c..a001fee7ca9 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx @@ -41,9 +41,10 @@ export const severityRank = (entry: KeyBudgetEntry): number => 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. + * Scopes disagree on whether hitting the limit exactly is already over it, and a scope's operator + * is not fixed: budget reservation tightens some scopes to `>=`, and disabling it relaxes them back + * to `>`. So this reads `comparison` off each row rather than assuming a constant per scope. Two + * rows can show identical numbers and opposite statuses, so state the threshold each one enforces. */ export const budgetThresholdRule = (entry: KeyBudgetEntry): string | null => { if (entry.max_budget == null) return null; @@ -71,12 +72,12 @@ function ScopeCell({ entry }: { entry: KeyBudgetEntry }) { } /> - {entity && {entity}} - {entry.note && ( - - {entry.note} + {entity && ( + + {entity} )} + {entry.note && {entry.note}} ); } @@ -110,7 +111,7 @@ export const getKeyBudgetsTableColumns = (): ColumnDef[] => [ id: "scope", meta: { title: "Scope" }, header: "Scope", - size: 240, + size: 300, enableSorting: false, cell: ({ row }) => , }, 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 72ec022f257..aa346488ac4 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 @@ -162,7 +162,7 @@ const TEAM_SOFT_OVER: KeyBudgetEntry = { remaining: -400, source: "budget_table:b-soft", status: "exceeded", - note: "alert only", + note: "alert only, never blocks; compared against recorded spend rather than the live counter", }; const TEAM_MEMBER_BLOCKING: KeyBudgetEntry = { @@ -268,7 +268,9 @@ describe("KeyInfoView Budgets tab", () => { expect(within(softRow).getByText("Exceeded (alert only)")).toBeInTheDocument(); expect(within(softRow).queryByTestId("key-budget-blocking")).not.toBeInTheDocument(); expect(within(softRow).queryByText("Blocks requests")).not.toBeInTheDocument(); - expect(softRow).toHaveTextContent("alert only"); + expect(softRow).toHaveTextContent( + "alert only, never blocks; compared against recorded spend rather than the live counter", + ); }); it("explains why two rows on identical numbers get opposite statuses", async () => { @@ -285,8 +287,8 @@ describe("KeyInfoView Budgets tab", () => { }; const exclusive: KeyBudgetEntry = { ...UNCONFIGURED_BUDGET, - scope: "team", - entity_type: "team", + scope: "project", + entity_type: "project", entity_label: "Platform", comparison: ">", max_budget: 300, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bd5b70c25b6..77fbb1cb4f7 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -6822,7 +6822,8 @@ export interface paths { * hash. Defaults to the key in the Authorization header when omitted (`GET /key/budgets`). * - end_user_id: str | None (query parameter) - Also report the budgets that would apply to this * end user. Omitted end users produce no `end_user` rows, because nothing binds an end user to - * a key outside a request. + * a key outside a request. Proxy admins only, since end users are a proxy-global namespace with + * no key, team or organization scoping to check a caller against. * * Returns: * - key: str - The key that was looked up, echoed back as it was passed in @@ -7512,7 +7513,8 @@ export interface paths { * hash. Defaults to the key in the Authorization header when omitted (`GET /key/budgets`). * - end_user_id: str | None (query parameter) - Also report the budgets that would apply to this * end user. Omitted end users produce no `end_user` rows, because nothing binds an end user to - * a key outside a request. + * a key outside a request. Proxy admins only, since end users are a proxy-global namespace with + * no key, team or organization scoping to check a caller against. * * Returns: * - key: str - The key that was looked up, echoed back as it was passed in