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 (