feat(ui): budget period tooltip on key, team, user and org spend tiles (#48487)

* feat(ui): budget period tooltip on key, team, user and org spend tiles

Adds a shared BudgetPeriodTooltip to the four detail-view spend tiles showing the current budget period, max budget, reset interval and spend so far, and makes organization create schedule budget_reset_at and organization info return it

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): give the budget period tooltip trigger its own aria label

The default SimpleTooltip trigger shares the question-circle label with InheritedBudgetHint, which broke the key budget display tests that count that icon

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): match the proxy's budget window rules in the period tooltip

30d and Nmo now span calendar months like get_budget_window_start, the arithmetic runs in UTC so a viewer's DST change cannot shift the start, the hourly/daily/weekly/monthly aliases the proxy accepts are normalized before parsing, and spend without a duration reads as lifetime spend

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: joshua <joshua@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-26 17:05:37 -07:00 • committed by GitHub
parent b9825e1f87
commit 1263f70ab6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 336 additions and 9 deletions

View file

@ -3277,7 +3277,7 @@ class LiteLLM_OrganizationTableWithMembers(LiteLLM_OrganizationTable):
members: list[LiteLLM_OrganizationMembershipTable] = []
teams: list[LiteLLM_TeamTable] = []
litellm_budget_table: LiteLLM_BudgetTable | None = None
litellm_budget_table: LiteLLM_BudgetTableFull | None = None
created_at: datetime
updated_at: datetime

View file

@ -504,9 +504,8 @@ async def new_organization(
_budget: Final = await _table(BudgetRepository(prisma_client)).create(
data={
**new_budget,
**build_budget_write_data(new_budget, user_api_key_dict.user_id or litellm_proxy_admin_name),
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
}
)

View file

@ -1615,3 +1615,68 @@ async def test_delete_organization_is_refused_while_one_of_its_teams_holds_a_ptu
prisma_client.db.litellm_teamtable.delete_many.assert_not_called()
prisma_client.db.litellm_verificationtoken.delete_many.assert_not_called()
prisma_client.db.litellm_organizationtable.delete.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("budget_duration", ["30d", None])
async def test_new_organization_schedules_budget_reset_from_duration(monkeypatch, budget_duration):
"""/organization/new with an inline budget_duration must write budget_reset_at on the auto-created
budget row, the way team create and /budget/new do, or the org budget never resets and the UI
cannot show the current budget period."""
from datetime import datetime
from litellm.proxy._types import LitellmUserRoles, NewOrganizationRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.organization_endpoints import new_organization
from litellm.proxy.utils import PrismaClient
prisma_client = MagicMock()
prisma_client.jsonify_object = MagicMock(side_effect=lambda data: PrismaClient.jsonify_object(prisma_client, data))
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
prisma_client.db.litellm_budgettable.create = AsyncMock(return_value=MagicMock(budget_id="budget-1"))
prisma_client.db.litellm_organizationtable.create = AsyncMock(return_value={"organization_id": "org-1"})
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", MagicMock())
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True, raising=False)
await new_organization(
data=NewOrganizationRequest(organization_alias="org", max_budget=100, budget_duration=budget_duration),
user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
budget_write = prisma_client.db.litellm_budgettable.create.await_args.kwargs["data"]
if budget_duration is None:
assert "budget_reset_at" not in budget_write, budget_write
return
reset_at = budget_write["budget_reset_at"]
assert isinstance(reset_at, datetime) and reset_at.tzinfo is not None, budget_write
assert budget_write["budget_duration"] == budget_duration
def test_organization_info_response_keeps_budget_reset_at():
"""/organization/info must return the budget row's budget_reset_at so the dashboard can show
which budget period the org's spend covers."""
from datetime import datetime, timezone
from litellm.proxy._types import LiteLLM_OrganizationTableWithMembers
reset_at = datetime(2026, 11, 1, tzinfo=timezone.utc)
created_at = datetime(2026, 10, 1, tzinfo=timezone.utc)
response = LiteLLM_OrganizationTableWithMembers(
organization_id="org-1",
organization_alias="org",
budget_id="budget-1",
models=[],
created_by="admin",
updated_by="admin",
created_at=created_at,
updated_at=created_at,
litellm_budget_table={
"budget_id": "budget-1",
"max_budget": 100.0,
"budget_duration": "30d",
"budget_reset_at": reset_at,
"created_at": created_at,
},
)
assert response.model_dump()["litellm_budget_table"]["budget_reset_at"] == reset_at

View file

@ -47,6 +47,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import ScopedSavingsTab from "@/components/shared/ScopedSavingsTab";
import { AutoRouterUsageView } from "@/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab";
import { useActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
import { BudgetPeriodTooltip } from "@/components/shared/BudgetPeriodTooltip";
interface UserInfoViewProps {
userId: string;
@ -507,7 +508,15 @@ export default function UserInfoView({
<TabsContent value="overview" keepMounted>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p>Spend</p>
<p>
Spend
<BudgetPeriodTooltip
spend={userData.spend}
maxBudget={userData.max_budget}
budgetDuration={userData.budget_duration}
budgetResetAt={userData.budget_reset_at}
/>
</p>
<div className="mt-2">
<h3 className="text-lg font-medium">${formatNumberWithCommas(userData.spend || 0, 2)}</h3>
<p>

View file

@ -27,6 +27,7 @@ import ObjectPermissionsView from "../object_permissions_view";
import MemberModal from "../team/EditMembership";
import { OrgSettingsForm } from "./org-settings/OrgSettingsForm";
import { ORGANIZATION_TAB_URL_KEY, ORGANIZATION_TABS, type OrganizationTab } from "./organizationTabs";
import { BudgetPeriodTooltip } from "@/components/shared/BudgetPeriodTooltip";
interface OrganizationInfoProps {
organizationId: string;
@ -193,7 +194,15 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Budget Status</p>
<p className="text-sm text-muted-foreground">
Budget Status
<BudgetPeriodTooltip
spend={orgData.spend}
maxBudget={orgData.litellm_budget_table.max_budget}
budgetDuration={orgData.litellm_budget_table.budget_duration}
budgetResetAt={orgData.litellm_budget_table.budget_reset_at}
/>
</p>
<div className="mt-2 text-sm text-foreground">
<p className="text-xl font-semibold">${formatNumberWithCommas(orgData.spend, 4)}</p>
<p>

View file

@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { BudgetPeriodTooltip, budgetPeriodLines } from "./BudgetPeriodTooltip";
const originalTimezone: string | undefined = process.env.TZ;
afterEach(() => {
if (originalTimezone === undefined) {
delete process.env.TZ;
return;
}
process.env.TZ = originalTimezone;
});
const scheduled = { spend: 12.5, maxBudget: 100, budgetDuration: "30d", budgetResetAt: "2026-04-01T00:00:00Z" };
const unparseableDuration = { ...scheduled, spend: 0, budgetDuration: "fortnight" };
const unscheduled = { spend: 0, maxBudget: 100, budgetDuration: "30d", budgetResetAt: null };
const lifetime = { spend: 3, maxBudget: 50, budgetDuration: null, budgetResetAt: null };
const noBudget = { spend: null, maxBudget: null, budgetDuration: "30d", budgetResetAt: null };
const noBudgetNoDuration = { spend: 2, maxBudget: null, budgetDuration: null, budgetResetAt: null };
describe("budgetPeriodLines", () => {
it("describes the current period, max budget, interval and spend when a reset is scheduled", () => {
process.env.TZ = "UTC";
expect(budgetPeriodLines(scheduled)).toEqual([
"Budget period: Mar 1, 2026 00:00 to Apr 1, 2026 00:00 (UTC)",
"Max budget: $100.00",
"Reset interval: monthly",
"Spend this period: $12.5000",
]);
});
it("falls back to the next reset when the duration cannot be parsed", () => {
process.env.TZ = "UTC";
expect(budgetPeriodLines(unparseableDuration)).toEqual([
"Next reset: Apr 1, 2026 00:00 (UTC)",
"Max budget: $100.00",
"Reset interval: fortnight",
"Spend this period: $0.0000",
]);
});
it("omits the period line when no reset has been scheduled yet", () => {
expect(budgetPeriodLines(unscheduled)).toEqual([
"Max budget: $100.00",
"Reset interval: monthly",
"Spend this period: $0.0000",
]);
});
it("reports a lifetime budget when there is no duration", () => {
expect(budgetPeriodLines(lifetime)).toEqual([
"Lifetime budget, never resets",
"Max budget: $50.00",
"Spend so far: $3.0000",
]);
});
it("reports no budget when max budget is unset", () => {
expect(budgetPeriodLines(noBudget)).toEqual(["No budget set", "Spend this period: $0.0000"]);
});
it("uses lifetime wording when there is neither a budget nor a duration", () => {
expect(budgetPeriodLines(noBudgetNoDuration)).toEqual(["No budget set", "Spend so far: $2.0000"]);
});
});
describe("BudgetPeriodTooltip", () => {
it("shows the budget period details on hover", async () => {
const user = userEvent.setup();
render(<BudgetPeriodTooltip spend={1} maxBudget={10} budgetDuration="1d" budgetResetAt="2026-10-27T00:00:00Z" />);
await user.hover(screen.getByLabelText("budget-period-details"));
const tooltip = await screen.findByTestId("budget-period-tooltip");
expect(tooltip).toHaveTextContent("Max budget: $10.00");
expect(tooltip).toHaveTextContent("Reset interval: 1d");
expect(tooltip).toHaveTextContent("Spend this period: $1.0000");
expect(tooltip).not.toHaveTextContent("Invalid Date");
});
});

View file

@ -0,0 +1,65 @@
"use client";
import dayjs from "dayjs";
import { CircleHelp } from "lucide-react";
import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { budgetPeriodRange } from "@/utils/budgetUtils";
import { formatNumberWithCommas } from "@/utils/dataUtils";
export interface BudgetPeriodTooltipProps {
spend: number | null | undefined;
maxBudget: number | null | undefined;
budgetDuration: string | null | undefined;
budgetResetAt: string | null | undefined;
}
const formatPeriodDate = (iso: string): string => dayjs(iso).format("MMM D, YYYY HH:mm");
const describePeriod = (budgetDuration: string, budgetResetAt: string | null | undefined): string | null => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const period = budgetPeriodRange(budgetDuration, budgetResetAt);
if (period) {
return `Budget period: ${formatPeriodDate(period.start)} to ${formatPeriodDate(period.end)} (${timeZone})`;
}
if (budgetResetAt && dayjs(budgetResetAt).isValid()) {
return `Next reset: ${formatPeriodDate(budgetResetAt)} (${timeZone})`;
}
return null;
};
export const budgetPeriodLines = ({
spend,
maxBudget,
budgetDuration,
budgetResetAt,
}: BudgetPeriodTooltipProps): readonly string[] => {
const spendLine = `${budgetDuration ? "Spend this period" : "Spend so far"}: $${formatNumberWithCommas(spend ?? 0, 4)}`;
if (maxBudget == null) return ["No budget set", spendLine];
const maxBudgetLine = `Max budget: $${formatNumberWithCommas(maxBudget, 2)}`;
if (!budgetDuration) return ["Lifetime budget, never resets", maxBudgetLine, spendLine];
const periodLine = describePeriod(budgetDuration, budgetResetAt);
return [
...(periodLine ? [periodLine] : []),
maxBudgetLine,
`Reset interval: ${getBudgetDurationLabel(budgetDuration)}`,
spendLine,
];
};
export function BudgetPeriodTooltip(props: BudgetPeriodTooltipProps) {
return (
<SimpleTooltip
content={
<div data-testid="budget-period-tooltip" className="flex flex-col gap-1">
{budgetPeriodLines(props).map((line) => (
<span key={line}>{line}</span>
))}
</div>
}
>
<CircleHelp aria-label="budget-period-details" className="ml-1 size-4 text-muted-foreground" />
</SimpleTooltip>
);
}

View file

@ -135,6 +135,7 @@ import ResetMemberBudgetsDialog from "./ResetMemberBudgetsDialog";
import { customBudgetMemberUserIds, shouldPromptMemberBudgetReset } from "./memberBudgetReset";
import { useMemberBudgetReset } from "./useMemberBudgetReset";
import { fetchClient } from "@/lib/http/api";
import { BudgetPeriodTooltip } from "@/components/shared/BudgetPeriodTooltip";
const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = new Set([
"logging",
@ -1375,7 +1376,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
children: (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p>Budget Status</p>
<p>
Budget Status
<BudgetPeriodTooltip
spend={info.spend}
maxBudget={info.max_budget}
budgetDuration={info.budget_duration}
budgetResetAt={info.budget_reset_at}
/>
</p>
<div className="mt-2">
<h3 className="text-lg font-medium">${formatNumberWithCommas(info.spend, 2)}</h3>
<p>of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 2)}`}</p>

View file

@ -57,6 +57,7 @@ import {
} from "../shared/InheritedBudgetHint";
import { KeyEditView } from "./key_edit_view";
import { isTeamAdminEditingMemberKey, teamAdminMemberKeyPayload } from "./teamAdminMemberKeyPayload";
import { BudgetPeriodTooltip } from "@/components/shared/BudgetPeriodTooltip";
export function needsLifetimeSpendBackfill(spend: number, totalSpend: number | null | undefined): boolean {
return (totalSpend ?? 0) < spend;
@ -722,7 +723,15 @@ export default function KeyInfoView({
<TabsContent value="overview" keepMounted>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p className="text-sm">Spend</p>
<p className="text-sm">
Spend
<BudgetPeriodTooltip
spend={currentKeyData.spend}
maxBudget={currentKeyData.max_budget}
budgetDuration={currentKeyData.budget_duration}
budgetResetAt={currentKeyData.budget_reset_at}
/>
</p>
<div className="mt-2">
<h3 className="text-lg font-medium">${formatNumberWithCommas(currentKeyData.spend, 4)}</h3>
<p className="text-sm">

View file

@ -35066,7 +35066,7 @@ export interface components {
created_at: string;
/** Created By */
created_by: string;
litellm_budget_table?: components["schemas"]["LiteLLM_BudgetTable"] | null;
litellm_budget_table?: components["schemas"]["LiteLLM_BudgetTableFull"] | null;
/**
* Members
* @default []

View file

@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils";
import { budgetPeriodRange, describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils";
const originalTimezone: string | undefined = process.env.TZ;
@ -44,3 +44,41 @@ describe("describeBudgetResetSchedule", () => {
expect(schedule.tooltip).toContain("carries into the next cycle");
});
});
describe("budgetPeriodRange", () => {
const resetAt = "2026-04-01T00:00:00Z";
it.each([
["30d", "2026-03-01T00:00:00.000Z"],
["monthly", "2026-03-01T00:00:00.000Z"],
["1mo", "2026-03-01T00:00:00.000Z"],
["2mo", "2026-02-01T00:00:00.000Z"],
["7d", "2026-03-25T00:00:00.000Z"],
["weekly", "2026-03-25T00:00:00.000Z"],
["1w", "2026-03-25T00:00:00.000Z"],
["15d", "2026-03-17T00:00:00.000Z"],
["24h", "2026-03-31T00:00:00.000Z"],
["daily", "2026-03-31T00:00:00.000Z"],
["1h", "2026-03-31T23:00:00.000Z"],
["hourly", "2026-03-31T23:00:00.000Z"],
])("computes the period start for %s the way the proxy does", (duration, start) => {
expect(budgetPeriodRange(duration, resetAt)).toEqual({ start, end: "2026-04-01T00:00:00.000Z" });
});
it("spans a calendar month for 30d even when the viewer is in a DST-shifting timezone", () => {
process.env.TZ = "America/New_York";
expect(budgetPeriodRange("30d", "2026-04-01T00:00:00Z")?.start).toBe("2026-03-01T00:00:00.000Z");
expect(budgetPeriodRange("30d", "2026-03-01T00:00:00Z")?.start).toBe("2026-02-01T00:00:00.000Z");
});
it.each([
["bogus", resetAt],
["0d", resetAt],
["30d", "not-a-date"],
["30d", null],
[null, resetAt],
["", resetAt],
])("returns null for duration %s and reset %s instead of an invalid date", (duration, reset) => {
expect(budgetPeriodRange(duration, reset)).toBeNull();
});
});

View file

@ -54,3 +54,44 @@ export function describeBudgetResetSchedule(
tooltip: `Resets every ${budgetDuration}, but the next reset time has not been scheduled yet. ${ROLLOVER_NOTE}`,
};
}
const BUDGET_DURATION_WORD_ALIASES: Record<string, string> = {
hourly: "1h",
daily: "24h",
weekly: "7d",
monthly: "30d",
};
const BUDGET_DURATION_UNITS: Record<string, dayjs.ManipulateType> = {
s: "second",
m: "minute",
h: "hour",
d: "day",
w: "week",
mo: "month",
};
export interface BudgetPeriod {
start: string;
end: string;
}
/** Mirrors the proxy's get_budget_window_start: `30d` and `Nmo` span calendar months, the rest are fixed deltas. */
export function budgetPeriodRange(
budgetDuration: string | null | undefined,
budgetResetAt: string | null | undefined,
): BudgetPeriod | null {
if (!budgetDuration || !budgetResetAt) return null;
const end = dayjs.utc(budgetResetAt);
if (!end.isValid()) return null;
const normalized = budgetDuration.trim().toLowerCase();
const match = /^(\d+)(mo|s|m|h|d|w)$/.exec(BUDGET_DURATION_WORD_ALIASES[normalized] ?? normalized);
if (!match) return null;
const amount = Number(match[1]);
if (amount <= 0) return null;
const start =
match[2] === "d" && amount === 30
? end.subtract(1, "month")
: end.subtract(amount, BUDGET_DURATION_UNITS[match[2]]);
return { start: start.toISOString(), end: end.toISOString() };
}