fix(ui): stop pairing key spend with the team budget when a key has no budget (#37196)

The key overview card and the Virtual Keys spend meter fell back to the parent team's max_budget as the denominator while the numerator stayed the key's own spend, so a $0.50 key on a $1,200 team read as "$0.50 of $1,200 (Team)" and drew a meter against a limit that governs the whole team's aggregate spend, not this key. Both surfaces now show Unlimited for a budgetless key and, when the parent team or organization does carry a budget, a hover hint listing those inherited caps so the reader knows what still gates the key
This commit is contained in:
ryan-crabbe-berri 2026-08-17 13:03:05 -07:00 • committed by GitHub
parent f04b1dadfb
commit d0a815d2e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 229 additions and 35 deletions

View file

@ -2947,9 +2947,6 @@
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}

View file

@ -5,6 +5,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { Popover, Typography } from "antd";
import { DataTableMultiSortHeader, DataTableSortHeader, type DataTableSortField } from "@/components/shared/DataTable";
import { inheritedBudgetGates } from "@/components/shared/InheritedBudgetHint";
import { Skeleton } from "@/components/ui/skeleton";
import {
DateCell,
@ -304,13 +305,14 @@ export const getKeyTableColumns = ({
size: 180,
enableSorting: true,
cell: ({ row }) => {
const teamId = row.original.team_id;
const team = allTeams.find((t) => t.team_id === teamId);
const team = allTeams.find((t) => t.team_id === row.original.team_id);
const orgId = row.original.organization_id || row.original.org_id || team?.organization_id;
const organization = organizations.find((o) => o.organization_id === orgId);
return (
<SpendBudgetCell
spend={row.original.spend}
maxBudget={row.original.max_budget}
teamMaxBudget={team?.max_budget ?? null}
inheritedGates={row.original.max_budget == null ? inheritedBudgetGates(team, organization) : []}
/>
);
},

View file

@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { InheritedBudgetHint, inheritedBudgetGates } from "./InheritedBudgetHint";
const team = { team_id: "team-1", team_alias: "Platform", max_budget: 1200, budget_duration: "30d" };
const organization = {
organization_id: "org-1",
organization_alias: "Acme",
litellm_budget_table: { max_budget: 5000, budget_duration: null },
};
describe("inheritedBudgetGates", () => {
it("returns team then org gates when both have budgets", () => {
expect(inheritedBudgetGates(team, organization)).toEqual([
{ scope: "Team", alias: "Platform", maxBudget: 1200, budgetDuration: "30d" },
{ scope: "Organization", alias: "Acme", maxBudget: 5000, budgetDuration: null },
]);
});
it("skips a team or org whose max_budget is null", () => {
expect(inheritedBudgetGates({ ...team, max_budget: null }, organization)).toEqual([
{ scope: "Organization", alias: "Acme", maxBudget: 5000, budgetDuration: null },
]);
expect(inheritedBudgetGates(team, { ...organization, litellm_budget_table: { max_budget: null } })).toEqual([
{ scope: "Team", alias: "Platform", maxBudget: 1200, budgetDuration: "30d" },
]);
});
it("returns nothing when team and org are missing or budgetless", () => {
expect(inheritedBudgetGates(null, undefined)).toEqual([]);
expect(
inheritedBudgetGates({ ...team, max_budget: null }, { ...organization, litellm_budget_table: null }),
).toEqual([]);
});
it("falls back to ids when aliases are empty", () => {
expect(
inheritedBudgetGates({ ...team, team_alias: "" }, { ...organization, organization_alias: "" }).map(
(g) => g.alias,
),
).toEqual(["team-1", "org-1"]);
});
});
describe("InheritedBudgetHint", () => {
it("renders nothing without gates", () => {
const { container } = render(<InheritedBudgetHint gates={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("shows each gate with its budget and duration on hover", async () => {
render(<InheritedBudgetHint gates={inheritedBudgetGates(team, organization)} />);
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Team Platform: $1,200.00 / 30d");
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 /");
});
});

View file

@ -0,0 +1,67 @@
"use client";
import { Tooltip } from "@/components/atoms/Tooltip";
import type { Team } from "@/components/key_team_helpers/key_list";
import type { Organization } from "@/components/networking";
import { formatNumberWithCommas } from "@/utils/dataUtils";
export interface InheritedBudgetGate {
scope: "Team" | "Organization";
alias: string;
maxBudget: number;
budgetDuration: string | null;
}
type TeamBudgetSource = Pick<Team, "team_id" | "team_alias" | "max_budget" | "budget_duration">;
type OrganizationBudgetSource = Pick<Organization, "organization_id" | "organization_alias" | "litellm_budget_table">;
const teamGate = (team: TeamBudgetSource | null | undefined): InheritedBudgetGate | null =>
team && team.max_budget != null
? {
scope: "Team",
alias: team.team_alias || team.team_id,
maxBudget: team.max_budget,
budgetDuration: team.budget_duration ?? null,
}
: null;
const organizationGate = (organization: OrganizationBudgetSource | null | undefined): InheritedBudgetGate | null => {
const budgetTable: { max_budget?: number | null; budget_duration?: string | null } | null | undefined =
organization?.litellm_budget_table;
return organization && budgetTable?.max_budget != null
? {
scope: "Organization",
alias: organization.organization_alias || organization.organization_id,
maxBudget: budgetTable.max_budget,
budgetDuration: budgetTable.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);
const formatGate = (gate: InheritedBudgetGate): string =>
`${gate.scope} ${gate.alias}: $${formatNumberWithCommas(gate.maxBudget, 2)}${gate.budgetDuration ? ` / ${gate.budgetDuration}` : ""}`;
interface InheritedBudgetHintProps {
gates: readonly InheritedBudgetGate[];
}
export function InheritedBudgetHint({ gates }: InheritedBudgetHintProps) {
if (gates.length === 0) return null;
return (
<Tooltip
content={
<div data-testid="inherited-budget-hint" className="flex flex-col gap-1">
<span>This key has no budget of its own, but its spend still counts toward:</span>
{gates.map((gate) => (
<span key={gate.scope}>{formatGate(gate)}</span>
))}
</div>
}
/>
);
}

View file

@ -53,9 +53,24 @@ describe("SpendBudgetCell", () => {
expect(indicator(container)?.className).toContain("bg-destructive");
});
it("falls back to the team budget and labels it", () => {
render(<SpendBudgetCell spend={10} maxBudget={null} teamMaxBudget={200} />);
expect(screen.getByText("of $200 (Team)")).toBeInTheDocument();
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuemax", "200");
it("never meters key spend against an inherited team/org budget", () => {
const gates = [{ scope: "Team" as const, alias: "Team A", maxBudget: 200, budgetDuration: "30d" }];
render(<SpendBudgetCell spend={10} maxBudget={null} inheritedGates={gates} />);
expect(screen.getByText("· Unlimited")).toBeInTheDocument();
expect(screen.queryByText(/\(Team\)/)).not.toBeInTheDocument();
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
expect(screen.getByLabelText("question-circle")).toBeInTheDocument();
});
it("shows no inherited-budget hint when there is nothing to inherit", () => {
render(<SpendBudgetCell spend={10} maxBudget={null} inheritedGates={[]} />);
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
});
it("shows no inherited-budget hint when the key has its own budget", () => {
const gates = [{ scope: "Team" as const, alias: "Team A", maxBudget: 200, budgetDuration: null }];
render(<SpendBudgetCell spend={10} maxBudget={50} inheritedGates={gates} />);
expect(screen.getByText("of $50")).toBeInTheDocument();
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
});
});

View file

@ -1,12 +1,13 @@
"use client";
import { InheritedBudgetHint, type InheritedBudgetGate } from "@/components/shared/InheritedBudgetHint";
import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter";
import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils";
interface SpendBudgetCellProps {
spend: number | null | undefined;
maxBudget: number | null | undefined;
teamMaxBudget?: number | null;
inheritedGates?: readonly InheritedBudgetGate[];
spendDecimals?: number;
budgetDecimals?: number;
}
@ -20,27 +21,24 @@ const meterTone = (pct: number): "default" | "warning" | "over" => {
export function SpendBudgetCell({
spend,
maxBudget,
teamMaxBudget,
inheritedGates = [],
spendDecimals = 4,
budgetDecimals = 0,
}: SpendBudgetCellProps) {
const spendValue = typeof spend === "number" && !Number.isNaN(spend) ? spend : 0;
const budget = maxBudget ?? teamMaxBudget ?? null;
const isTeamBudget = maxBudget == null && teamMaxBudget != null;
const budget = maxBudget ?? null;
const hasBudget = typeof budget === "number" && budget > 0;
const pct = hasBudget ? (spendValue / budget) * 100 : 0;
const spendText = spendValue > 0 ? getSpendString(spendValue, spendDecimals) : "$0.00";
const budgetLabel =
budget === null
? "· Unlimited"
: `of $${formatNumberWithCommas(budget, budgetDecimals)}${isTeamBudget ? " (Team)" : ""}`;
const budgetLabel = budget === null ? "· Unlimited" : `of $${formatNumberWithCommas(budget, budgetDecimals)}`;
return (
<div className="flex min-w-[130px] flex-col gap-1">
<div className="whitespace-nowrap text-xs">
<span className="font-medium tabular-nums text-foreground">{spendText}</span>{" "}
<span className="text-muted-foreground">{budgetLabel}</span>
{budget === null && <InheritedBudgetHint gates={inheritedGates} />}
</div>
{hasBudget && (
<Meter

View file

@ -1,10 +1,13 @@
import { renderWithProviders } from "../../../tests/test-utils";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import KeyInfoView from "./key_info_view";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import type { Organization } from "../networking";
// IMPORTANT: do not mock `@/utils/dataUtils` here. We want to exercise the
// real `formatNumberWithCommas` so this test catches the LIT-2845 regression
@ -13,15 +16,12 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => ({ data: [] }),
}));
vi.mock("./key_edit_view", () => ({
KeyEditView: () => <div data-testid="key-edit-view-stub" />,
}));
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
@ -130,10 +130,34 @@ const makeTeam = (overrides: Partial<Team>): Team => ({
...overrides,
});
const makeOrganization = (overrides: Partial<Organization>): Organization =>
({
organization_id: "org-1",
organization_alias: "Acme Org",
budget_id: "budget-1",
metadata: {},
models: [],
spend: 0,
model_spend: {},
created_at: "2026-01-01T00:00:00Z",
created_by: "admin",
updated_at: "2026-01-01T00:00:00Z",
updated_by: "admin",
litellm_budget_table: { max_budget: null, budget_duration: null },
teams: null,
users: null,
members: null,
...overrides,
}) as Organization;
const mockOrganizations = (organizations: Organization[]) =>
vi.mocked(useOrganizations).mockReturnValue({ data: organizations } as ReturnType<typeof useOrganizations>);
describe("KeyInfoView overview budget display (LIT-2845)", () => {
beforeEach(() => {
vi.mocked(useTeams).mockReturnValue({ teams: [], setTeams: vi.fn() });
vi.mocked(useAuthorized).mockReturnValue(baseAuthorized);
mockOrganizations([]);
});
it("renders a sub-dollar max_budget ($0.10) with 2-decimal precision in the overview Spend card", async () => {
@ -188,7 +212,7 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
});
});
it("renders team budget with alias and duration when key has no own budget but team has one", async () => {
it("never pairs key spend with the team budget: shows Unlimited plus an inherited-budget hint", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-123", team_alias: "Test Budget", max_budget: 1200, budget_duration: "30d" })],
setTeams: vi.fn(),
@ -203,15 +227,20 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
/>,
);
await waitFor(() => {
expect(screen.getByText(/of \$1,200\.00 \(Team: Test Budget \/ 30d\)/)).toBeInTheDocument();
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
});
expect(screen.queryByText(/of \$1,200\.00/)).not.toBeInTheDocument();
expect(screen.queryByText(/\(Team: Test Budget/)).not.toBeInTheDocument();
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Team Test Budget: $1,200.00 / 30d");
});
it("renders team budget without duration when team has no budget_duration", async () => {
it("lists the organization budget in the hint when the team's org has one", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-456", team_alias: "No Duration Team", max_budget: 500 })],
teams: [makeTeam({ team_id: "team-456", team_alias: "Org Team", organization_id: "org-1" })],
setTeams: vi.fn(),
});
mockOrganizations([makeOrganization({ litellm_budget_table: { max_budget: 5000, budget_duration: null } })]);
renderWithProviders(
<KeyInfoView
keyData={{ ...MOCK_KEY_DATA, max_budget: null, team_id: "team-456" } as unknown as KeyResponse}
@ -222,11 +251,14 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
/>,
);
await waitFor(() => {
expect(screen.getByText(/of \$500\.00 \(Team: No Duration Team\)/)).toBeInTheDocument();
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
});
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Organization Acme Org: $5,000.00");
expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Team Org Team");
});
it("renders 'Unlimited' when key has no budget and team also has no budget", async () => {
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" })],
setTeams: vi.fn(),
@ -243,6 +275,27 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
await waitFor(() => {
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
});
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
});
it("shows no hint when the key has its own budget even if the team has one", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-123", team_alias: "Test Budget", max_budget: 1200 })],
setTeams: vi.fn(),
});
renderWithProviders(
<KeyInfoView
keyData={{ ...MOCK_KEY_DATA, max_budget: 25, team_id: "team-123" } as unknown as KeyResponse}
onClose={() => {}}
keyId={"test-key-id"}
onKeyDataUpdate={() => {}}
teams={[]}
/>,
);
await waitFor(() => {
expect(screen.getByText(/of \$25\.00/)).toBeInTheDocument();
});
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
});
});
@ -250,6 +303,7 @@ describe("KeyInfoView budget reset visibility", () => {
beforeEach(() => {
vi.mocked(useTeams).mockReturnValue({ teams: [], setTeams: vi.fn() });
vi.mocked(useAuthorized).mockReturnValue(baseAuthorized);
mockOrganizations([]);
});
const KEY_WITH_RESET = {

View file

@ -36,6 +36,7 @@ import { extractMcpEntitlement } from "../mcp_server_management/mcpEntitlement";
import ObjectPermissionsView from "../object_permissions_view";
import { RegenerateKeyModal } from "../organisms/RegenerateKeyModal";
import { parseErrorMessage } from "../shared/errorUtils";
import { InheritedBudgetHint, inheritedBudgetGates } from "../shared/InheritedBudgetHint";
import { KeyEditView } from "./key_edit_view";
interface KeyInfoViewProps {
@ -460,12 +461,9 @@ export default function KeyInfoView({
const orgId = currentKeyData.organization_id || currentKeyData.org_id || parentTeam?.organization_id || "";
const parentOrg = orgId ? organizations?.find((org) => org.organization_id === orgId) : null;
const budgetDisplay =
currentKeyData.max_budget !== null
? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}`
: parentTeam?.max_budget != null
? `$${formatNumberWithCommas(parentTeam.max_budget, 2)} (Team: ${parentTeam.team_alias || parentTeam.team_id}${parentTeam.budget_duration ? ` / ${parentTeam.budget_duration}` : ""})`
: "Unlimited";
const hasOwnBudget = currentKeyData.max_budget !== null;
const budgetDisplay = hasOwnBudget ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` : "Unlimited";
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg);
return (
<div className="w-full h-full overflow-y-auto p-4">
@ -616,7 +614,10 @@ export default function KeyInfoView({
<p className="text-sm">Spend</p>
<div className="mt-2">
<h3 className="text-lg font-medium">${formatNumberWithCommas(currentKeyData.spend, 4)}</h3>
<p className="text-sm">of {budgetDisplay}</p>
<p className="text-sm">
of {budgetDisplay}
<InheritedBudgetHint gates={inheritedGates} />
</p>
{currentKeyData.budget_reset_at && (
<p className="text-sm">Resets {formatTimestamp(currentKeyData.budget_reset_at)}</p>
)}