From eef4553e56c7bb72948fc6117c70c7a43acb3f0a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:30:01 +0000 Subject: [PATCH] fix(ui): surface the owner's user budget on keys without their own budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../VirtualKeysPage/keyTableColumns.tsx | 6 +- .../components/key_team_helpers/key_list.tsx | 2 + .../shared/InheritedBudgetHint.test.tsx | 37 +++++++++++ .../components/shared/InheritedBudgetHint.tsx | 24 ++++++- .../key_info_view.budget_display.test.tsx | 65 +++++++++++++++++++ .../components/templates/key_info_view.tsx | 3 +- 6 files changed, 133 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx index 51b8b734b4c..138b14d4176 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx @@ -320,7 +320,11 @@ export const getKeyTableColumns = ({ ); }, diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 7f79260843e..b7a5172e0c4 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -108,6 +108,8 @@ export interface KeyResponse { user_id: string; user_email: string; user_alias: string | null; + max_budget?: number | null; + budget_duration?: string | null; }; created_by_user?: { user_id: string; diff --git a/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.test.tsx b/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.test.tsx index 657e859aef9..a2c44c79e96 100644 --- a/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.test.tsx @@ -10,6 +10,13 @@ const organization = { organization_alias: "Acme", litellm_budget_table: { max_budget: 5000, budget_duration: null }, }; +const user = { + user_id: "user-1", + user_email: "owner@example.com", + user_alias: "Key Owner", + max_budget: 1500, + budget_duration: "1mo", +}; describe("inheritedBudgetGates", () => { it("returns team then org gates when both have budgets", () => { @@ -42,6 +49,30 @@ describe("inheritedBudgetGates", () => { ), ).toEqual(["team-1", "org-1"]); }); + + it("returns the owner's user budget as a gate", () => { + expect(inheritedBudgetGates(null, null, user)).toEqual([ + { scope: "User", alias: "Key Owner", maxBudget: 1500, budgetDuration: "1mo" }, + ]); + }); + + it("skips the user gate when the owner has no budget", () => { + expect(inheritedBudgetGates(null, null, { ...user, max_budget: null })).toEqual([]); + expect(inheritedBudgetGates(null, null, null)).toEqual([]); + }); + + it("falls back to email then id for the user alias", () => { + expect(inheritedBudgetGates(null, null, { ...user, user_alias: null })[0].alias).toBe("owner@example.com"); + expect(inheritedBudgetGates(null, null, { ...user, user_alias: null, user_email: null })[0].alias).toBe("user-1"); + }); + + it("lists team, org, and user gates together", () => { + expect(inheritedBudgetGates(team, organization, user).map((g) => g.scope)).toEqual([ + "Team", + "Organization", + "User", + ]); + }); }); describe("InheritedBudgetHint", () => { @@ -57,4 +88,10 @@ describe("InheritedBudgetHint", () => { expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Organization Acme: $5,000.00"); expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Organization Acme: $5,000.00 /"); }); + + it("shows the owner's user budget on hover", async () => { + render(); + await userEvent.setup().hover(screen.getByLabelText("question-circle")); + expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("User Key Owner: $1,500.00 / 1mo"); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.tsx b/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.tsx index 5fc80758cfd..85129f65948 100644 --- a/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.tsx +++ b/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.tsx @@ -6,7 +6,7 @@ import type { Organization } from "@/components/networking"; import { formatNumberWithCommas } from "@/utils/dataUtils"; export interface InheritedBudgetGate { - scope: "Team" | "Organization"; + scope: "Team" | "Organization" | "User"; alias: string; maxBudget: number; budgetDuration: string | null; @@ -15,6 +15,14 @@ export interface InheritedBudgetGate { type TeamBudgetSource = Pick; type OrganizationBudgetSource = Pick; +export interface UserBudgetSource { + user_id: string; + user_alias?: string | null; + user_email?: string | null; + max_budget?: number | null; + budget_duration?: string | null; +} + const teamGate = (team: TeamBudgetSource | null | undefined): InheritedBudgetGate | null => team && team.max_budget != null ? { @@ -38,10 +46,22 @@ const organizationGate = (organization: OrganizationBudgetSource | null | undefi : null; }; +const userGate = (user: UserBudgetSource | null | undefined): InheritedBudgetGate | null => + user && user.max_budget != null + ? { + scope: "User", + alias: user.user_alias || user.user_email || user.user_id, + maxBudget: user.max_budget, + budgetDuration: user.budget_duration ?? null, + } + : null; + export const inheritedBudgetGates = ( team: TeamBudgetSource | null | undefined, organization: OrganizationBudgetSource | null | undefined, -): readonly InheritedBudgetGate[] => [teamGate(team), organizationGate(organization)].filter((gate) => gate !== null); + user?: UserBudgetSource | null, +): readonly InheritedBudgetGate[] => + [teamGate(team), organizationGate(organization), userGate(user)].filter((gate) => gate !== null); const formatGate = (gate: InheritedBudgetGate): string => `${gate.scope} ${gate.alias}: $${formatNumberWithCommas(gate.maxBudget, 2)}${gate.budgetDuration ? ` / ${gate.budgetDuration}` : ""}`; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx index f506c0e51d7..cf4e0ca7039 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx @@ -258,6 +258,71 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => { expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Team Org Team"); }); + it("lists the owner's user budget in the hint for a personal key with no budget of its own", async () => { + renderWithProviders( + {}} + keyId={"test-key-id"} + onKeyDataUpdate={() => {}} + teams={[]} + />, + ); + await waitFor(() => { + expect(screen.getByText(/of Unlimited/)).toBeInTheDocument(); + }); + await userEvent.setup().hover(screen.getByLabelText("question-circle")); + expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("User Budget Owner: $1,500.00 / 1mo"); + }); + + it("omits the owner's user budget from the hint for a team key", async () => { + vi.mocked(useTeams).mockReturnValue({ + teams: [makeTeam({ team_id: "team-123", team_alias: "Test Budget", max_budget: 1200, budget_duration: "30d" })], + setTeams: vi.fn(), + }); + renderWithProviders( + {}} + keyId={"test-key-id"} + onKeyDataUpdate={() => {}} + teams={[]} + />, + ); + await waitFor(() => { + expect(screen.getByText(/of Unlimited/)).toBeInTheDocument(); + }); + await userEvent.setup().hover(screen.getByLabelText("question-circle")); + expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Team Test Budget: $1,200.00 / 30d"); + expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("User Budget Owner"); + }); + it("renders 'Unlimited' with no hint when neither key, team, nor org has a budget", async () => { vi.mocked(useTeams).mockReturnValue({ teams: [makeTeam({ team_id: "team-789", team_alias: "Free Team" })], diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 59950303c45..0b35fd1e230 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -472,7 +472,8 @@ export default function KeyInfoView({ const hasOwnBudget = currentKeyData.max_budget !== null; const budgetDisplay = hasOwnBudget ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` : "Unlimited"; - const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg); + const ownerUser = currentKeyData.team_id ? null : currentKeyData.user; + const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser); return (