diff --git a/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx b/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx index 24ce626ecb7..eee9fcdadd1 100644 --- a/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx @@ -48,7 +48,7 @@ describe("MyUserTab", () => { expect(screen.getByText("Never resets")).toBeInTheDocument(); }); - it("shows the next reset time when the member budget has a reset window", () => { + it("shows the budget period and next reset when the member budget has a reset window", () => { vi.mocked(useMyTeamMember).mockReturnValue({ data: { user_id: "user-1", @@ -69,6 +69,8 @@ describe("MyUserTab", () => { renderWithProviders(); - expect(screen.getByText("Resets Jul 15, 2026 12:00")).toBeInTheDocument(); + expect(screen.getByText("Current Period Spend (USD)")).toBeInTheDocument(); + expect(screen.getByText("Jun 15 - Jul 15, 2026 (resets Jul 15, 2026 12:00)")).toBeInTheDocument(); + expect(screen.queryByText(/cycle/i)).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/MyUserTab.tsx b/ui/litellm-dashboard/src/components/team/MyUserTab.tsx index bf1a2c2c242..8d7583c51e4 100644 --- a/ui/litellm-dashboard/src/components/team/MyUserTab.tsx +++ b/ui/litellm-dashboard/src/components/team/MyUserTab.tsx @@ -1,4 +1,4 @@ -import { describeBudgetResetSchedule } from "@/utils/budgetUtils"; +import { BudgetResetSchedule, describeBudgetResetSchedule } from "@/utils/budgetUtils"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { Badge } from "@/components/ui/badge"; @@ -30,6 +30,12 @@ const formatRateLimit = (value: number | null | undefined): string => { return formatNumberWithCommas(value, 0); }; +function describePeriodLine(schedule: BudgetResetSchedule): string { + if (schedule.period) return `${schedule.period} (resets ${schedule.label})`; + if (schedule.resetAt) return `Resets ${schedule.label}`; + return schedule.label; +} + export default function MyUserTab({ teamId }: MyUserTabProps) { const { data, isLoading, error } = useMyTeamMember(teamId); @@ -93,16 +99,14 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
- {labelWithTooltip("Current Cycle Spend (USD)", budgetReset.tooltip)} + {labelWithTooltip("Current Period Spend (USD)", budgetReset.tooltip)}

${formatNumber(spend, 4)}

of {maxBudget === null ? "Unlimited" : `$${formatNumber(maxBudget, 4)}`}
-
- {budgetReset.resetAt ? `Resets ${budgetReset.label}` : budgetReset.label} -
+
{describePeriodLine(budgetReset)}
@@ -121,7 +125,7 @@ export default function MyUserTab({ teamId }: MyUserTabProps) { {labelWithTooltip( "Total Spend (USD)", - "Cumulative spend across budget cycles within this team. This is a lower bound: lifetime tracking began 2026-04-21, so cycles completed before then are not included. It is never less than Current Cycle Spend.", + "Cumulative spend within this team across budget periods. This is a lower bound: lifetime tracking began 2026-04-21, so periods that reset before then are not included. It is never less than Current Period Spend.", )}

${formatNumber(totalSpend, 4)}

diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index 38a8e4f97d5..9350de8a8fc 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -316,6 +316,29 @@ describe("TeamMembersComponent", () => { expect(screen.getByText("Jul 15, 2026 12:00")).toBeInTheDocument(); }); + it("shows the budget period date range instead of a bare reset date when the duration is known", () => { + const teamData = createMockTeamData(); + teamData.team_memberships[0].litellm_budget_table!.budget_duration = "30d"; + renderWithProviders( + , + ); + + expect(screen.getByRole("columnheader", { name: /^Budget Period/ })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /^Current Period Spend \(USD\)/ })).toBeInTheDocument(); + expect(screen.getByText("Jun 15 - Jul 15, 2026")).toBeInTheDocument(); + expect(screen.queryByText("Jul 15, 2026 12:00")).not.toBeInTheDocument(); + expect(screen.queryByText(/cycle/i)).not.toBeInTheDocument(); + }); + it("shows Never resets for a member whose budget has no reset window", () => { const teamData = createMockTeamData(); teamData.team_memberships[0].litellm_budget_table!.budget_reset_at = null; @@ -696,7 +719,7 @@ describe("TeamMembersComponent", () => { />, ); - it("resets the member's current cycle spend to $0 after confirming, then refreshes the team", async () => { + it("resets the member's current period spend to $0 after confirming, then refreshes the team", async () => { const user = userEvent.setup(); POST.mockResolvedValue({ data: {} }); renderEditableTab(); @@ -745,7 +768,7 @@ describe("TeamMembersComponent", () => { expect(POST).not.toHaveBeenCalled(); }); - it("disables the reset with a reason on members that have no current cycle spend", async () => { + it("disables the reset with a reason on members that have no current period spend", async () => { const user = userEvent.setup(); renderEditableTab(); @@ -753,7 +776,7 @@ describe("TeamMembersComponent", () => { "reset-member-spend", ); await user.hover(resetButton); - expect(await screen.findByText("No current cycle spend to reset")).toBeInTheDocument(); + expect(await screen.findByText("No current period spend to reset")).toBeInTheDocument(); await user.click(resetButton); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index 319f8c973f6..1ff77f565a5 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -102,7 +102,7 @@ export default function TeamMemberTab({ return "0"; }; - const getUserCurrentCycleSpend = (userId: string | null): number => { + const getUserCurrentPeriodSpend = (userId: string | null): number => { if (!userId) return 0; return findMembership(userId)?.spend ?? 0; }; @@ -153,12 +153,16 @@ export default function TeamMemberTab({ return findMembership(userId)?.litellm_budget_table?.budget_reset_at ?? null; }; - const renderBudgetReset = (userId: string | null) => { + const getUserBudgetSchedule = (userId: string | null) => { const budget = findMembership(userId)?.litellm_budget_table; - const schedule = describeBudgetResetSchedule(budget?.budget_duration ?? null, budget?.budget_reset_at ?? null); + return describeBudgetResetSchedule(budget?.budget_duration ?? null, budget?.budget_reset_at ?? null); + }; + + const renderBudgetPeriod = (userId: string | null) => { + const schedule = getUserBudgetSchedule(userId); return ( - {schedule.label} + {schedule.period ?? schedule.label} ); }; @@ -200,22 +204,22 @@ export default function TeamMemberTab({ { title: ( - Current Cycle Spend (USD) - - + Current Period Spend (USD) + + ), key: "spend", numeric: true, - sortValue: (record: Member) => getUserCurrentCycleSpend(record.user_id), - render: (record: Member) => , + sortValue: (record: Member) => getUserCurrentPeriodSpend(record.user_id), + render: (record: Member) => , }, { title: ( Total Spend (USD) - + @@ -263,10 +267,10 @@ export default function TeamMemberTab({ }, }, { - title: "Budget Reset", + title: "Budget Period", key: "budget_reset", sortValue: (record: Member) => getUserBudgetReset(record.user_id), - render: (record: Member) => renderBudgetReset(record.user_id), + render: (record: Member) => renderBudgetPeriod(record.user_id), }, { title: ( @@ -314,10 +318,12 @@ export default function TeamMemberTab({ const resetSpendDisabledReason = (member: Member): string | null => { if (!isProxyAdmin && member.user_id === userId) return "Ask a proxy admin to reset your own spend"; - if (getUserCurrentCycleSpend(member.user_id) <= 0) return "No current cycle spend to reset"; + if (getUserCurrentPeriodSpend(member.user_id) <= 0) return "No current period spend to reset"; return null; }; + const resetSpendPeriod = getUserBudgetSchedule(memberToResetSpend?.user_id ?? null).period; + return ( <> Reset Team Member Spend

- Reset current cycle spend for{" "} + Reset current period spend for{" "} {memberToResetSpend?.user_email || memberToResetSpend?.user_id} in this team to{" "} $0?

- Current cycle spend:{" "} - ${formatNumberWithCommas(getUserCurrentCycleSpend(memberToResetSpend?.user_id ?? null), 4)} + Current period spend + {resetSpendPeriod ? ` (${resetSpendPeriod})` : ""}:{" "} + + ${formatNumberWithCommas(getUserCurrentPeriodSpend(memberToResetSpend?.user_id ?? null), 4)} + . This is the value checked against the member's budget. Total spend and logs are preserved.

diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts index e33c779bb65..11339d08bc5 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts @@ -2,6 +2,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { budgetPeriodRange, describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils"; +const localIso = (year: number, monthIndex: number, day: number, hour = 12): string => + new Date(year, monthIndex, day, hour, 0, 0).toISOString(); + const originalTimezone: string | undefined = process.env.TZ; afterEach(() => { @@ -23,15 +26,18 @@ describe("formatBudgetResetUtc", () => { }); describe("describeBudgetResetSchedule", () => { - it("reports Never resets when the budget has no duration and no scheduled reset", () => { + it("reports lifetime spend with no period when the budget has no duration and no scheduled reset", () => { const schedule = describeBudgetResetSchedule(null, null); expect(schedule.label).toBe("Never resets"); expect(schedule.resetAt).toBeNull(); + expect(schedule.period).toBeNull(); + expect(schedule.tooltip).toContain("lifetime spend"); }); it("reports Unknown when a duration is set but no reset has been scheduled yet", () => { const schedule = describeBudgetResetSchedule("30d", null); expect(schedule.label).toBe("Unknown"); + expect(schedule.period).toBeNull(); expect(schedule.tooltip).toContain("every 30d"); }); @@ -41,7 +47,74 @@ describe("describeBudgetResetSchedule", () => { expect(schedule.label).toBe("Jul 15, 2026 12:00"); expect(schedule.resetAt).toBe(resetAt.toISOString()); expect(schedule.tooltip).toContain(Intl.DateTimeFormat().resolvedOptions().timeZone); - expect(schedule.tooltip).toContain("carries into the next cycle"); + expect(schedule.tooltip).toContain("carries into the next period"); + }); + + it("treats 30d as one calendar month back from the reset, like the proxy does", () => { + const schedule = describeBudgetResetSchedule("30d", localIso(2026, 10, 1, 0)); + expect(schedule.period).toBe("Oct 1 - Nov 1, 2026"); + expect(schedule.tooltip).toContain("covers Oct 1 - Nov 1, 2026 and resets every 30d"); + }); + + it("subtracts the exact duration for day and week windows", () => { + expect(describeBudgetResetSchedule("7d", localIso(2026, 6, 15)).period).toBe("Jul 8 - Jul 15, 2026"); + expect(describeBudgetResetSchedule("2w", localIso(2026, 6, 15)).period).toBe("Jul 1 - Jul 15, 2026"); + expect(describeBudgetResetSchedule("weekly", localIso(2026, 6, 15)).period).toBe("Jul 8 - Jul 15, 2026"); + }); + + it("walks the month back in UTC so a non-UTC viewer sees the proxy's window", () => { + const originalTz = process.env.TZ; + process.env.TZ = "America/New_York"; + try { + const schedule = describeBudgetResetSchedule("30d", "2026-03-01T00:00:00Z"); + expect(schedule.period).toBe("Jan 31 - Feb 28, 2026"); + } finally { + process.env.TZ = originalTz; + } + }); + + it("clamps month windows to the last day of a shorter month", () => { + expect(describeBudgetResetSchedule("1mo", localIso(2026, 2, 31)).period).toBe("Feb 28 - Mar 31, 2026"); + }); + + it("spells out both years when the period crosses a year boundary", () => { + expect(describeBudgetResetSchedule("30d", localIso(2027, 0, 10)).period).toBe("Dec 10, 2026 - Jan 10, 2027"); + }); + + it("keeps both clock times when an hourly window crosses midnight", () => { + expect(describeBudgetResetSchedule("1h", localIso(2026, 6, 16, 0)).period).toBe( + "Jul 15, 2026 23:00 - Jul 16, 2026 00:00", + ); + }); + + it("shows seconds for a window shorter than a minute", () => { + expect(describeBudgetResetSchedule("30s", localIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:59:30 - 12:00:00"); + }); + + it("shows clock times for a window that starts and ends on the same day", () => { + expect(describeBudgetResetSchedule("1h", localIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:00 - 12:00"); + }); + + it("falls back to a one day window for a duration the proxy would not recognise", () => { + expect(describeBudgetResetSchedule("fortnightly", localIso(2026, 6, 15)).period).toBe("Jul 14 - Jul 15, 2026"); + }); + + it("shows no period when the reset date is unparseable", () => { + const schedule = describeBudgetResetSchedule("30d", "not-a-date"); + expect(schedule.label).toBe("Unknown"); + expect(schedule.period).toBeNull(); + expect(schedule.tooltip).not.toMatch(/NaN|Invalid/); + }); + + it("never describes the window as a cycle", () => { + const schedules = [ + describeBudgetResetSchedule("30d", localIso(2026, 6, 15)), + describeBudgetResetSchedule("30d", null), + describeBudgetResetSchedule(null, null), + ]; + for (const schedule of schedules) { + expect(`${schedule.label} ${schedule.tooltip}`).not.toMatch(/cycle/i); + } }); }); diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.ts b/ui/litellm-dashboard/src/utils/budgetUtils.ts index 23af15eb539..71638551f9f 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.ts @@ -1,4 +1,4 @@ -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; dayjs.extend(utc); @@ -20,10 +20,55 @@ export function formatBudgetResetUtc(iso: string | null | undefined): string | n export interface BudgetResetSchedule { label: string; resetAt: string | null; + period: string | null; tooltip: string; } -const ROLLOVER_NOTE = "Spend above a rollover cap at reset carries into the next cycle."; +const ROLLOVER_NOTE = "Spend above a rollover cap at reset carries into the next period."; + +const DURATION_WORD_ALIASES: Record = { + hourly: "1h", + daily: "24h", + weekly: "7d", + monthly: "30d", +}; + +const DURATION_PATTERN = /^(\d+)(mo|[smhdw])$/; + +// Mirrors the proxy's get_budget_window_start: "30d" and "Nmo" span calendar months, +// everything else is a fixed length, and an unrecognised duration resets at the next midnight. +export function getBudgetPeriodStart(budgetDuration: string, resetAt: Dayjs): Dayjs { + const trimmed = budgetDuration.trim(); + const normalized = DURATION_WORD_ALIASES[trimmed.toLowerCase()] ?? trimmed; + const match = DURATION_PATTERN.exec(normalized); + if (!match) return resetAt.subtract(1, "day"); + const value = Number(match[1]); + switch (match[2]) { + case "mo": + return resetAt.subtract(value, "month"); + case "d": + return value === 30 ? resetAt.subtract(1, "month") : resetAt.subtract(value, "day"); + case "w": + return resetAt.subtract(value, "week"); + case "h": + return resetAt.subtract(value, "hour"); + case "m": + return resetAt.subtract(value, "minute"); + default: + return resetAt.subtract(value, "second"); + } +} + +export function formatBudgetPeriod(start: Dayjs, end: Dayjs): string { + const windowSeconds = end.diff(start, "second"); + if (windowSeconds < 24 * 60 * 60) { + const time = windowSeconds < 60 ? "HH:mm:ss" : "HH:mm"; + const endFormat = start.isSame(end, "day") ? time : `MMM D, YYYY ${time}`; + return `${start.format(`MMM D, YYYY ${time}`)} - ${end.format(endFormat)}`; + } + if (start.isSame(end, "year")) return `${start.format("MMM D")} - ${end.format("MMM D, YYYY")}`; + return `${start.format("MMM D, YYYY")} - ${end.format("MMM D, YYYY")}`; +} export function describeBudgetResetSchedule( budgetDuration: string | null | undefined, @@ -31,26 +76,33 @@ export function describeBudgetResetSchedule( ): BudgetResetSchedule { const resetDate = budgetResetAt ? dayjs(budgetResetAt) : null; if (resetDate?.isValid()) { - const cadence = budgetDuration ? `every ${budgetDuration}` : "on a schedule"; const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const label = resetDate.format("MMM D, YYYY HH:mm"); + const period = budgetDuration + ? formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate.utc()).local(), resetDate) + : null; + const coverage = period + ? `Current Period Spend covers ${period} and resets every ${budgetDuration}.` + : "Current Period Spend resets on a schedule."; return { label, resetAt: resetDate.toISOString(), - tooltip: `Current Cycle Spend resets ${cadence}. Next reset ${label} (${timeZone}). ${ROLLOVER_NOTE}`, + period, + tooltip: `${coverage} Next reset ${label} (${timeZone}). ${ROLLOVER_NOTE}`, }; } if (!budgetDuration) { return { label: "Never resets", resetAt: null, - tooltip: - "This budget has no reset window, so Current Cycle Spend accumulates for as long as the membership exists.", + period: null, + tooltip: "This budget has no reset window, so this is lifetime spend for as long as the membership exists.", }; } return { label: "Unknown", resetAt: null, + period: null, tooltip: `Resets every ${budgetDuration}, but the next reset time has not been scheduled yet. ${ROLLOVER_NOTE}`, }; }