diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx index 79e3be1750d..016d359273b 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx @@ -23,7 +23,7 @@ describe("TeamMemberSpendBudgetCell", () => { expect(screen.getByText("My Spend")).toBeInTheDocument(); expect(screen.getByText("$50.00")).toBeInTheDocument(); expect(screen.getByText("/ $100.00")).toBeInTheDocument(); - expect(screen.getByText("Resets Oct 20, 2026 UTC")).toBeInTheDocument(); + expect(screen.getByText("Resets Oct 20, 2026 12:00 UTC")).toBeInTheDocument(); expect(screen.getAllByRole("meter")[0]).toHaveAttribute("aria-valuetext", "Team $600.00 of $1,000.00"); expect(screen.getAllByRole("meter")[1]).toHaveAttribute("aria-valuetext", "My Spend $50.00 of $100.00"); }); diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx index 02870bc1571..0c22f488978 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx @@ -133,7 +133,7 @@ it("renders caller membership spend and budget in the team row", async () => { expect(await screen.findByText("My Spend")).toBeInTheDocument(); expect(screen.getByText("$50.00")).toBeInTheDocument(); - expect(screen.getByText("Resets Oct 20, 2026 UTC")).toBeInTheDocument(); + expect(screen.getByText("Resets Oct 20, 2026 12:00 UTC")).toBeInTheDocument(); }); it("renders the Resources cell with member, model, and key counts", () => { diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 47fd554d196..11ac1b7601b 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -1,7 +1,7 @@ import { act, screen, waitFor, within, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; -import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest"; +import { vi, it, expect, beforeEach, afterEach, describe, Mock, MockedFunction } from "vitest"; import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import { VirtualKeysTable } from "./VirtualKeysTable"; import { KEY_TABLE_HIDDEN_COLUMNS, KEY_TABLE_SORT_FIELDS } from "./keyTableColumns"; @@ -585,6 +585,27 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user }); }); +describe("Budget Reset column", () => { + const originalTimezone: string | undefined = process.env.TZ; + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + return; + } + process.env.TZ = originalTimezone; + }); + + it("shows the reset time in UTC with a UTC label for a viewer in another timezone", async () => { + process.env.TZ = "America/Los_Angeles"; + mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, budget_reset_at: "2026-11-01T00:00:00Z" }])); + + renderWithProviders(); + + expect(await screen.findByText("Nov 1, 00:00:00 UTC")).toBeInTheDocument(); + }); +}); + describe("entity links out of the key rows", () => { const keyRow = async () => (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement; diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx index fad64c57040..57ec85a155a 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx @@ -389,11 +389,15 @@ export const getKeyTableColumns = ( header: ({ column }) => ( ), - size: 130, + size: 160, enableSorting: true, sortDescFirst: false, cell: (info) => ( - + ), }, { diff --git a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx index 4d77218a776..fd039d3a824 100644 --- a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx @@ -22,8 +22,8 @@ const noBudget = { spend: null, maxBudget: null, budgetDuration: "30d", budgetRe 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"; + it("describes the current period in UTC, max budget, interval and spend when a reset is scheduled", () => { + process.env.TZ = "America/Los_Angeles"; expect(budgetPeriodLines(scheduled)).toEqual([ "Budget period: Mar 1, 2026 00:00 to Apr 1, 2026 00:00 (UTC)", "Max budget: $100.00", @@ -32,8 +32,8 @@ describe("budgetPeriodLines", () => { ]); }); - it("falls back to the next reset when the duration cannot be parsed", () => { - process.env.TZ = "UTC"; + it("falls back to the next reset in UTC when the duration cannot be parsed", () => { + process.env.TZ = "Asia/Tokyo"; expect(budgetPeriodLines(unparseableDuration)).toEqual([ "Next reset: Apr 1, 2026 00:00 (UTC)", "Max budget: $100.00", diff --git a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx index 39f2e2e4e11..711979d1ad5 100644 --- a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx +++ b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx @@ -1,6 +1,7 @@ "use client"; import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; import { CircleHelp } from "lucide-react"; import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown"; @@ -16,20 +17,21 @@ export interface BudgetPeriodTooltipProps { budgetResetAt: string | null | undefined; } -const formatPeriodDate = (iso: string): string => dayjs(iso).format("MMM D, YYYY HH:mm"); +dayjs.extend(utc); + +const formatPeriodDate = (iso: string): string => dayjs.utc(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 i18n.t("shared.budgetPeriodTooltip.budgetPeriod", { start: formatPeriodDate(period.start), end: formatPeriodDate(period.end), - timeZone, + timeZone: "UTC", }); } - if (budgetResetAt && dayjs(budgetResetAt).isValid()) { - return i18n.t("shared.budgetPeriodTooltip.nextReset", { date: formatPeriodDate(budgetResetAt), timeZone }); + if (budgetResetAt && dayjs.utc(budgetResetAt).isValid()) { + return i18n.t("shared.budgetPeriodTooltip.nextReset", { date: formatPeriodDate(budgetResetAt), timeZone: "UTC" }); } return null; }; diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx index 719047e0cfe..2e7fc6dfcbd 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; import { DateCell, formatCellDate, formatFullTimestamp } from "./date_cell"; @@ -55,3 +56,35 @@ describe("DateCell", () => { expect(screen.queryByText(/Invalid/)).not.toBeInTheDocument(); }); }); + +describe("DateCell with timeZone UTC", () => { + const originalTimezone: string | undefined = process.env.TZ; + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + return; + } + process.env.TZ = originalTimezone; + }); + + it("prints the UTC clock time and labels it UTC for a viewer in another zone", async () => { + process.env.TZ = "America/Los_Angeles"; + const user = userEvent.setup(); + render(); + + const cell = screen.getByText("Nov 1, 00:00:00 UTC"); + await user.hover(cell); + expect(await screen.findByText("Nov 1, 2026, 00:00:00 (UTC)")).toBeInTheDocument(); + }); + + it("labels date precision with UTC too", () => { + process.env.TZ = "Asia/Tokyo"; + expect(formatCellDate(new Date("2026-10-31T20:00:00Z"), "date", "UTC")).toBe("Oct 31, 2026 UTC"); + }); + + it("keeps the local default unlabeled for every other column", () => { + process.env.TZ = "America/Los_Angeles"; + expect(formatCellDate(new Date("2026-11-01T00:00:00Z"), "datetime")).toBe("Oct 31, 17:00:00"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx index ae61bbd76af..51c9ae619dd 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx @@ -23,27 +23,65 @@ const MONTHS: readonly ParseKeys[] = [ export type DatePrecision = "datetime" | "date"; +export type DateCellTimeZone = "local" | "UTC"; + interface DateCellProps { value: string | null | undefined; precision?: DatePrecision; fallback?: string; + timeZone?: DateCellTimeZone; +} + +interface DateParts { + year: number; + month: number; + day: number; + hours: number; + minutes: number; + seconds: number; } const pad = (n: number): string => String(n).padStart(2, "0"); -export const formatCellDate = (date: Date, precision: DatePrecision): string => - precision === "date" - ? `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${date.getFullYear()}` - : `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +const dateParts = (date: Date, timeZone: DateCellTimeZone): DateParts => + timeZone === "UTC" + ? { + year: date.getUTCFullYear(), + month: date.getUTCMonth(), + day: date.getUTCDate(), + hours: date.getUTCHours(), + minutes: date.getUTCMinutes(), + seconds: date.getUTCSeconds(), + } + : { + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + }; -export const formatFullTimestamp = (date: Date): string => { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const day = `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${date.getFullYear()}`; - const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; - return `${day}, ${time} (${timeZone})`; +const clockTime = ({ hours, minutes, seconds }: DateParts): string => `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; + +const monthName = ({ month }: DateParts): string => i18n.t(MONTHS[month]); + +export const formatCellDate = (date: Date, precision: DatePrecision, timeZone: DateCellTimeZone = "local"): string => { + const parts = dateParts(date, timeZone); + const formatted = + precision === "date" + ? `${monthName(parts)} ${parts.day}, ${parts.year}` + : `${monthName(parts)} ${parts.day}, ${clockTime(parts)}`; + return timeZone === "UTC" ? `${formatted} UTC` : formatted; }; -export function DateCell({ value, precision = "datetime", fallback = "-" }: DateCellProps) { +export const formatFullTimestamp = (date: Date, timeZone: DateCellTimeZone = "local"): string => { + const parts = dateParts(date, timeZone); + const zoneName = timeZone === "UTC" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone; + return `${monthName(parts)} ${parts.day}, ${parts.year}, ${clockTime(parts)} (${zoneName})`; +}; + +export function DateCell({ value, precision = "datetime", fallback = "-", timeZone = "local" }: DateCellProps) { const date = value ? new Date(value) : null; if (!date || Number.isNaN(date.getTime())) { return {fallback}; @@ -51,8 +89,8 @@ export function DateCell({ value, precision = "datetime", fallback = "-" }: Date return ( {formatCellDate(date, precision)}} + content={formatFullTimestamp(date, timeZone)} + trigger={{formatCellDate(date, precision, timeZone)}} /> ); } diff --git a/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx b/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx index eee9fcdadd1..01fb727089b 100644 --- a/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx @@ -60,7 +60,7 @@ describe("MyUserTab", () => { litellm_budget_table: { max_budget: 100, budget_duration: "30d", - budget_reset_at: new Date(2026, 6, 15, 12, 0, 0).toISOString(), + budget_reset_at: "2026-07-15T12:00:00.000Z", }, }, isLoading: false, @@ -70,7 +70,9 @@ describe("MyUserTab", () => { renderWithProviders(); 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.getByText("Jun 15, 2026 12:00 - Jul 15, 2026 12:00 UTC (resets Jul 15, 2026 12:00 UTC)"), + ).toBeInTheDocument(); expect(screen.queryByText(/cycle/i)).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index 9350de8a8fc..b0cc1c56cab 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -32,7 +32,7 @@ const mockSetIsAddMemberModalVisible = vi.fn(); const mockOnMemberSpendReset = vi.fn(); const mockOnMemberBudgetReset = vi.fn(); -const budgetResetIso = new Date(2026, 6, 15, 12, 0, 0).toISOString(); +const budgetResetIso = "2026-07-15T12:00:00.000Z"; const createMockTeamData = (overrides: Partial = {}): TeamData => ({ team_id: "team-123", @@ -313,7 +313,7 @@ describe("TeamMembersComponent", () => { />, ); - expect(screen.getByText("Jul 15, 2026 12:00")).toBeInTheDocument(); + expect(screen.getByText("Jul 15, 2026 12:00 UTC")).toBeInTheDocument(); }); it("shows the budget period date range instead of a bare reset date when the duration is known", () => { @@ -334,8 +334,8 @@ describe("TeamMembersComponent", () => { 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.getByText("Jun 15, 2026 12:00 - Jul 15, 2026 12:00 UTC")).toBeInTheDocument(); + expect(screen.queryByText("Jul 15, 2026 12:00 UTC")).not.toBeInTheDocument(); expect(screen.queryByText(/cycle/i)).not.toBeInTheDocument(); }); @@ -355,7 +355,7 @@ describe("TeamMembersComponent", () => { />, ); - expect(screen.queryByText("Jul 15, 2026 12:00")).not.toBeInTheDocument(); + expect(screen.queryByText("Jul 15, 2026 12:00 UTC")).not.toBeInTheDocument(); expect(screen.getAllByText("Never resets").length).toBeGreaterThan(0); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index c353a3e56be..86769cf7345 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -1,5 +1,5 @@ import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor, within } from "../../../tests/test-utils"; import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; @@ -184,6 +184,33 @@ describe("TeamVirtualKeysTable", () => { expect(screen.getByText("bob_key_team1")).toBeInTheDocument(); }); + describe("Budget Reset column", () => { + const originalTimezone: string | undefined = process.env.TZ; + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + return; + } + process.env.TZ = originalTimezone; + }); + + it("shows the reset time in UTC with a UTC label for a viewer in another timezone", async () => { + process.env.TZ = "America/Los_Angeles"; + const keysResponse: KeysResponse = { + keys: [createMockKey({ budget_reset_at: "2026-11-01T00:00:00Z" })], + total_count: 1, + current_page: 1, + total_pages: 1, + }; + mockUseKeys.mockReturnValue(createKeysQueryResult(keysResponse)); + + renderWithProviders(); + + expect(await screen.findByText("Nov 1, 00:00:00 UTC")).toBeInTheDocument(); + }); + }); + it("shows the team member budget for budgetless keys and keeps each key's own budget", async () => { const keysResponse: KeysResponse = { keys: [ diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 550709edebf..219a2d5f1fd 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -406,11 +406,15 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: ({ column }) => ( ), - size: 130, + size: 160, enableSorting: true, sortDescFirst: false, cell: (info) => ( - + ), }, { diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 9e48c4e3aba..c0770253b28 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -517,7 +517,7 @@ describe("KeyInfoView handleKeyUpdate budget_duration", () => { ); fireEvent.click(screen.getByText("Settings")); - expect(screen.getByTestId("budget-reset-value")).toHaveTextContent("Every 30d"); + expect(screen.getByTestId("budget-reset-value")).toHaveTextContent("Every 30d, next Sep 1, 2026 at 12:00 AM UTC"); fireEvent.click(screen.getByText("Edit Settings")); (globalThis as any).__TEST_FORM_VALUES = { 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 965fb3e8054..a0ee41c937b 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 @@ -424,7 +424,7 @@ describe("KeyInfoView budget reset visibility", () => { />, ); await waitFor(() => { - expect(screen.getByText(/^Resets Jul 22, 2026/)).toBeInTheDocument(); + expect(screen.getByText("Resets Jul 22, 2026 at 12:00 PM UTC")).toBeInTheDocument(); }); }); 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 62293c0c3a2..120770f2001 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -5,6 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings" import { useApplyUserBudgetToTeamKeys } from "@/app/(dashboard)/hooks/uiSettings/useApplyUserBudgetToTeamKeys"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { formatBudgetResetTime } from "@/utils/budgetUtils"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import { ArrowLeft, Info } from "lucide-react"; @@ -579,6 +580,7 @@ export default function KeyInfoView({ return t("keyDetail.keyInfoView.budget.unlimited"); })(); const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser, memberGate); + const budgetResetTime = formatBudgetResetTime(currentKeyData.budget_reset_at); return (
@@ -782,10 +784,10 @@ export default function KeyInfoView({ : t("keyDetail.keyInfoView.overview.ofBudget", { budget: budgetDisplay })}

- {currentKeyData.budget_reset_at && ( + {budgetResetTime && (

{t("keyDetail.keyInfoView.overview.resets")} - {formatTimestamp(currentKeyData.budget_reset_at)} + {budgetResetTime}

)}

@@ -1111,8 +1113,8 @@ export default function KeyInfoView({

{t("keyDetail.keyInfoView.settings.budgetReset")}

- {currentKeyData.budget_reset_at - ? `${currentKeyData.budget_duration ? t("keyDetail.keyInfoView.settings.budgetResetEvery", { duration: currentKeyData.budget_duration }) : ""}${formatTimestamp(currentKeyData.budget_reset_at)}` + {budgetResetTime + ? `${currentKeyData.budget_duration ? t("keyDetail.keyInfoView.settings.budgetResetEvery", { duration: currentKeyData.budget_duration }) : ""}${budgetResetTime}` : t("keyDetail.keyInfoView.settings.never")}

diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts index 11339d08bc5..2d05d7140d4 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts @@ -1,9 +1,14 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { budgetPeriodRange, describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils"; +import { + budgetPeriodRange, + describeBudgetResetSchedule, + formatBudgetResetTime, + formatBudgetResetUtc, +} from "./budgetUtils"; -const localIso = (year: number, monthIndex: number, day: number, hour = 12): string => - new Date(year, monthIndex, day, hour, 0, 0).toISOString(); +const utcIso = (year: number, monthIndex: number, day: number, hour = 12): string => + new Date(Date.UTC(year, monthIndex, day, hour, 0, 0)).toISOString(); const originalTimezone: string | undefined = process.env.TZ; @@ -18,14 +23,36 @@ afterEach(() => { describe("formatBudgetResetUtc", () => { it("formats the reset date in UTC regardless of the local timezone", () => { process.env.TZ = "America/Los_Angeles"; - const resetAt: string = "2026-10-20T00:00:00Z"; + expect(formatBudgetResetUtc("2026-10-20T00:00:00Z")).toBe("Oct 20, 2026"); + }); - expect(formatBudgetResetUtc(resetAt)).toBe("Oct 20, 2026"); - expect(formatBudgetReset(resetAt)).toBe("Oct 19, 2026"); + it("adds the UTC clock time when the reset is not at midnight UTC", () => { + process.env.TZ = "America/Los_Angeles"; + expect(formatBudgetResetUtc("2026-11-30T18:30:00Z")).toBe("Nov 30, 2026 18:30"); + }); +}); + +describe("formatBudgetResetTime", () => { + it.each(["America/Los_Angeles", "Asia/Tokyo", "UTC"])( + "prints the reset instant in UTC and says so for a %s viewer", + (timeZone) => { + process.env.TZ = timeZone; + expect(formatBudgetResetTime("2026-11-01T00:00:00Z")).toBe("Nov 1, 2026 at 12:00 AM UTC"); + expect(formatBudgetResetTime("2026-10-31T17:30:00-07:00")).toBe("Nov 1, 2026 at 12:30 AM UTC"); + expect(formatBudgetResetTime("2026-11-01T13:05:00Z")).toBe("Nov 1, 2026 at 1:05 PM UTC"); + }, + ); + + it.each([null, undefined, "", "not-a-date"])("returns null for %s", (value) => { + expect(formatBudgetResetTime(value)).toBeNull(); }); }); describe("describeBudgetResetSchedule", () => { + beforeEach(() => { + process.env.TZ = "America/Los_Angeles"; + }); + 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"); @@ -41,62 +68,67 @@ describe("describeBudgetResetSchedule", () => { expect(schedule.tooltip).toContain("every 30d"); }); - it("shows the next reset with its time zone and the rollover carryover note", () => { - const resetAt = new Date(2026, 6, 15, 12, 0, 0); - const schedule = describeBudgetResetSchedule("30d", resetAt.toISOString()); - expect(schedule.label).toBe("Jul 15, 2026 12:00"); - expect(schedule.resetAt).toBe(resetAt.toISOString()); - expect(schedule.tooltip).toContain(Intl.DateTimeFormat().resolvedOptions().timeZone); + it("shows the next reset in UTC with the rollover carryover note", () => { + const resetAt = utcIso(2026, 6, 15); + const schedule = describeBudgetResetSchedule("30d", resetAt); + expect(schedule.label).toBe("Jul 15, 2026 12:00 UTC"); + expect(schedule.resetAt).toBe(resetAt); + expect(schedule.tooltip).toContain("Next reset Jul 15, 2026 12:00 (UTC)."); 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"); + const schedule = describeBudgetResetSchedule("30d", utcIso(2026, 10, 1, 0)); + expect(schedule.period).toBe("Oct 1 - Nov 1, 2026 UTC"); + expect(schedule.tooltip).toContain("covers Oct 1 - Nov 1, 2026 UTC 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"); + expect(describeBudgetResetSchedule("7d", utcIso(2026, 6, 15, 0)).period).toBe("Jul 8 - Jul 15, 2026 UTC"); + expect(describeBudgetResetSchedule("2w", utcIso(2026, 6, 15, 0)).period).toBe("Jul 1 - Jul 15, 2026 UTC"); + expect(describeBudgetResetSchedule("weekly", utcIso(2026, 6, 15, 0)).period).toBe("Jul 8 - Jul 15, 2026 UTC"); }); - it("walks the month back in UTC so a non-UTC viewer sees the proxy's window", () => { - const originalTz = process.env.TZ; + it("shows the proxy's UTC month window to a viewer whose local date is a day behind", () => { 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; - } + const schedule = describeBudgetResetSchedule("30d", "2026-03-01T00:00:00Z"); + expect(schedule.period).toBe("Feb 1 - Mar 1, 2026 UTC"); + expect(schedule.label).toBe("Mar 1, 2026 00:00 UTC"); }); 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"); + expect(describeBudgetResetSchedule("1mo", utcIso(2026, 2, 31, 0)).period).toBe("Feb 28 - Mar 31, 2026 UTC"); }); 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"); + expect(describeBudgetResetSchedule("30d", utcIso(2027, 0, 10, 0)).period).toBe("Dec 10, 2026 - Jan 10, 2027 UTC"); + }); + + it("keeps the clock times when the reset is not at midnight UTC", () => { + expect(describeBudgetResetSchedule("1d", "2026-11-08T18:30:00Z").period).toBe( + "Nov 7, 2026 18:30 - Nov 8, 2026 18:30 UTC", + ); + expect(describeBudgetResetSchedule("30d", "2026-12-01T09:00:00Z").period).toBe( + "Nov 1, 2026 09:00 - Dec 1, 2026 09:00 UTC", + ); }); 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", + expect(describeBudgetResetSchedule("1h", utcIso(2026, 6, 16, 0)).period).toBe( + "Jul 15, 2026 23:00 - Jul 16, 2026 00:00 UTC", ); }); 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"); + expect(describeBudgetResetSchedule("30s", utcIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:59:30 - 12:00:00 UTC"); }); 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"); + expect(describeBudgetResetSchedule("1h", utcIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:00 - 12:00 UTC"); }); 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"); + expect(describeBudgetResetSchedule("fortnightly", utcIso(2026, 6, 15, 0)).period).toBe("Jul 14 - Jul 15, 2026 UTC"); }); it("shows no period when the reset date is unparseable", () => { @@ -108,7 +140,7 @@ describe("describeBudgetResetSchedule", () => { it("never describes the window as a cycle", () => { const schedules = [ - describeBudgetResetSchedule("30d", localIso(2026, 6, 15)), + describeBudgetResetSchedule("30d", utcIso(2026, 6, 15)), describeBudgetResetSchedule("30d", null), describeBudgetResetSchedule(null, null), ]; diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.ts b/ui/litellm-dashboard/src/utils/budgetUtils.ts index de144b13dd7..8b87768186d 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.ts @@ -6,18 +6,24 @@ import i18n from "@/i18n/i18n"; dayjs.extend(utc); -export function formatBudgetReset(iso: string | null | undefined): string | null { +export function formatBudgetResetTime(iso: string | null | undefined, t: TFunction = i18n.t.bind(i18n)): string | null { if (!iso) return null; - const resetDate = dayjs(iso); + const resetDate = dayjs.utc(iso); if (!resetDate.isValid()) return null; - return resetDate.format("MMM D, YYYY"); + const timestamp = t("keyDetail.keyInfoView.timestamp", { + date: resetDate.format("MMM D, YYYY"), + time: resetDate.format("h:mm A"), + }); + return `${timestamp} UTC`; } +const isMidnight = (instant: Dayjs): boolean => instant.isSame(instant.startOf("day")); + export function formatBudgetResetUtc(iso: string | null | undefined): string | null { if (!iso) return null; const resetDate = dayjs.utc(iso); if (!resetDate.isValid()) return null; - return resetDate.format("MMM D, YYYY"); + return resetDate.format(isMidnight(resetDate) ? "MMM D, YYYY" : "MMM D, YYYY HH:mm"); } export interface BudgetResetSchedule { @@ -62,7 +68,7 @@ export function getBudgetPeriodStart(budgetDuration: string, resetAt: Dayjs): Da export function formatBudgetPeriod(start: Dayjs, end: Dayjs): string { const windowSeconds = end.diff(start, "second"); - if (windowSeconds < 24 * 60 * 60) { + if (windowSeconds < 24 * 60 * 60 || !isMidnight(start) || !isMidnight(end)) { 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)}`; @@ -76,17 +82,21 @@ export function describeBudgetResetSchedule( budgetResetAt: string | null | undefined, t: TFunction = i18n.t.bind(i18n), ): BudgetResetSchedule { - const resetDate = budgetResetAt ? dayjs(budgetResetAt) : null; + const resetDate = budgetResetAt ? dayjs.utc(budgetResetAt) : null; if (resetDate?.isValid()) { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const label = resetDate.format("MMM D, YYYY HH:mm"); + const label = resetDate.format("MMM D, YYYY HH:mm [UTC]"); const period = budgetDuration - ? formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate.utc()).local(), resetDate) + ? `${formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate), resetDate)} UTC` : null; const coverage = period ? t("budgetSchedule.coverageWithPeriod", { period, duration: budgetDuration }) : t("budgetSchedule.coverageWithoutPeriod"); - const tooltipValues = { coverage, label, timeZone, rolloverNote: t("budgetSchedule.rolloverNote") }; + const tooltipValues = { + coverage, + label: resetDate.format("MMM D, YYYY HH:mm"), + timeZone: "UTC", + rolloverNote: t("budgetSchedule.rolloverNote"), + }; return { label, resetAt: resetDate.toISOString(),