Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions ui/litellm-dashboard/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2947,9 +2947,6 @@
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) : []}
/>
);
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 /");
});
});
Original file line number Diff line number Diff line change
@@ -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>
}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,15 +16,12 @@

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 }),
Expand Down Expand Up @@ -130,10 +130,34 @@
...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 () => {
Expand Down Expand Up @@ -188,9 +212,9 @@
});
});

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" })],

Check warning on line 217 in ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 4 properties passed inline as an argument; assign it to a named variable first
setTeams: vi.fn(),
});
renderWithProviders(
Expand All @@ -203,15 +227,20 @@
/>,
);
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}
Expand All @@ -222,11 +251,14 @@
/>,
);
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(),
Expand All @@ -243,13 +275,35 @@
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();
});
});

describe("KeyInfoView budget reset visibility", () => {
beforeEach(() => {
vi.mocked(useTeams).mockReturnValue({ teams: [], setTeams: vi.fn() });
vi.mocked(useAuthorized).mockReturnValue(baseAuthorized);
mockOrganizations([]);
});

const KEY_WITH_RESET = {
Expand Down
Loading
Loading