- {labelWithTooltip(
- "Current Cycle Spend (USD)",
- "Spend for the current budget cycle. Resets to $0 when the budget window rolls over.",
- )}
+ {labelWithTooltip("Current Cycle Spend (USD)", budgetReset.tooltip)}
${formatNumber(spend, 4)}
of {maxBudget === null ? "Unlimited" : `$${formatNumber(maxBudget, 4)}`}
- {budgetReset && Resets {budgetReset}
}
+
+ {budgetReset.resetAt ? `Resets ${budgetReset.label}` : budgetReset.label}
+
@@ -120,7 +119,10 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
- {labelWithTooltip("Total Spend (USD)", "Cumulative spend across all budget cycles within this team.")}
+ {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.",
+ )}
${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 cd51bcedb7b..38a8e4f97d5 100644
--- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
@@ -313,7 +313,47 @@ describe("TeamMembersComponent", () => {
/>,
);
- expect(screen.getByText("Jul 15, 2026")).toBeInTheDocument();
+ expect(screen.getByText("Jul 15, 2026 12:00")).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;
+ renderWithProviders(
+
,
+ );
+
+ expect(screen.queryByText("Jul 15, 2026 12:00")).not.toBeInTheDocument();
+ expect(screen.getAllByText("Never resets").length).toBeGreaterThan(0);
+ });
+
+ it("shows Unknown for a member whose budget has a duration but no scheduled reset yet", () => {
+ const teamData = createMockTeamData();
+ teamData.team_memberships[0].litellm_budget_table!.budget_duration = "30d";
+ teamData.team_memberships[0].litellm_budget_table!.budget_reset_at = null;
+ renderWithProviders(
+
,
+ );
+
+ expect(screen.getByText("Unknown")).toBeInTheDocument();
});
it("shows each member the values from its own membership row", () => {
diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx
index ddfd251cf82..319f8c973f6 100644
--- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx
@@ -10,8 +10,9 @@ import MemberTable from "@/components/common_components/MemberTable";
import type { StoredModelMaxBudget } from "@/components/key_team_helpers/modelMaxBudgetPayload";
import { Member } from "@/components/networking";
import { parseErrorMessage } from "@/components/shared/errorUtils";
-import { DateCell, MoneyCell } from "@/components/shared/table_cells";
+import { MoneyCell } from "@/components/shared/table_cells";
import { toast } from "@/lib/toast";
+import { describeBudgetResetSchedule } from "@/utils/budgetUtils";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { CircleHelp } from "lucide-react";
@@ -152,6 +153,16 @@ export default function TeamMemberTab({
return findMembership(userId)?.litellm_budget_table?.budget_reset_at ?? null;
};
+ const renderBudgetReset = (userId: string | null) => {
+ const budget = findMembership(userId)?.litellm_budget_table;
+ const schedule = describeBudgetResetSchedule(budget?.budget_duration ?? null, budget?.budget_reset_at ?? null);
+ return (
+
+ {schedule.label}
+
+ );
+ };
+
const extraColumns: NonNullable
["extraColumns"]> = [
{
title: (
@@ -190,7 +201,7 @@ export default function TeamMemberTab({
title: (
Current Cycle Spend (USD)
-
+
@@ -204,7 +215,7 @@ export default function TeamMemberTab({
title: (
Total Spend (USD)
-
+
@@ -255,7 +266,7 @@ export default function TeamMemberTab({
title: "Budget Reset",
key: "budget_reset",
sortValue: (record: Member) => getUserBudgetReset(record.user_id),
- render: (record: Member) => ,
+ render: (record: Member) => renderBudgetReset(record.user_id),
},
{
title: (
diff --git a/ui/litellm-dashboard/src/lib/http/chatTelemetry.test.ts b/ui/litellm-dashboard/src/lib/http/chatTelemetry.test.ts
deleted file mode 100644
index 389406dd8b7..00000000000
--- a/ui/litellm-dashboard/src/lib/http/chatTelemetry.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { captureChatRequest } from "./chatTelemetry";
-
-afterEach(() => {
- vi.restoreAllMocks();
- vi.unstubAllGlobals();
-});
-
-describe("Chat telemetry transport", () => {
- it("should send only pseudonymous user and request IDs without gateway credentials or referrer", async () => {
- const send = vi.fn().mockResolvedValue(new Response("{}"));
- const timeout = vi.spyOn(AbortSignal, "timeout");
- vi.stubGlobal("fetch", send);
- await captureChatRequest("opaque-user", "unique-request");
-
- expect(send).toHaveBeenCalledOnce();
- const [url, request] = send.mock.calls[0];
- expect(url).toBe("https://chat-telemetry.litellm.ai/v1/chat/events");
- const expectedRequest = {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "omit",
- referrerPolicy: "no-referrer",
- keepalive: true,
- signal: expect.any(AbortSignal),
- body: expect.any(String),
- };
- expect(request).toEqual(expectedRequest);
- expect(timeout).toHaveBeenCalledExactlyOnceWith(5000);
- expect(JSON.parse(request.body)).toEqual({
- user_id: "opaque-user",
- request_id: "unique-request",
- });
- });
-
- it("should reject an unsuccessful ingestion response for the caller to handle", async () => {
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 503 })));
- await expect(captureChatRequest("opaque-user", "unique-request")).rejects.toThrow(
- "Chat telemetry was not accepted",
- );
- expect(fetch).toHaveBeenCalledOnce();
- });
-});
diff --git a/ui/litellm-dashboard/src/lib/http/chatTelemetry.ts b/ui/litellm-dashboard/src/lib/http/chatTelemetry.ts
deleted file mode 100644
index c2f172250ee..00000000000
--- a/ui/litellm-dashboard/src/lib/http/chatTelemetry.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export async function captureChatRequest(userId: string, requestId: string): Promise {
- const request: RequestInit = {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "omit",
- referrerPolicy: "no-referrer",
- keepalive: true,
- signal: AbortSignal.timeout(5000),
- body: JSON.stringify({
- user_id: userId,
- request_id: requestId,
- }),
- };
- const response = await fetch("https://chat-telemetry.litellm.ai/v1/chat/events", request);
- if (!response.ok) throw new Error("Chat telemetry was not accepted");
-}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 1d36f5fd9a2..262e8b6deae 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -28439,6 +28439,15 @@ export interface components {
*/
uncached_input_tokens: number;
};
+ /** CacheWriteTokensDetails */
+ CacheWriteTokensDetails: {
+ /** Ttl 12H Tokens */
+ ttl_12h_tokens: number;
+ /** Ttl 30M Tokens */
+ ttl_30m_tokens: number;
+ } & {
+ [key: string]: unknown;
+ };
/** CachedTokensDetails */
CachedTokensDetails: {
/** Audio Tokens */
@@ -33589,6 +33598,9 @@ export interface components {
InputTokensDetails: {
/** Audio Tokens */
audio_tokens?: number | null;
+ /** Cache Write Tokens */
+ cache_write_tokens?: number | null;
+ cache_write_tokens_details?: components["schemas"]["CacheWriteTokensDetails"] | null;
/**
* Cached Tokens
* @default 0
@@ -35207,6 +35219,26 @@ export interface components {
cache_creation_input_token_cost_above_100k_tokens?: number | null;
/** Cache Creation Input Token Cost Above 100K Tokens Batches */
cache_creation_input_token_cost_above_100k_tokens_batches?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr */
+ cache_creation_input_token_cost_above_12hr?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Batches */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_batches?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Flex */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_flex?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Priority */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_priority?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Ultrafast */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_ultrafast?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Batches */
+ cache_creation_input_token_cost_above_12hr_batches?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Flex */
+ cache_creation_input_token_cost_above_12hr_flex?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Priority */
+ cache_creation_input_token_cost_above_12hr_priority?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Ultrafast */
+ cache_creation_input_token_cost_above_12hr_ultrafast?: number | null;
/** Cache Creation Input Token Cost Above 1Hr */
cache_creation_input_token_cost_above_1hr?: number | null;
/** Cache Creation Input Token Cost Above 1Hr Above 100K Tokens */
@@ -35235,6 +35267,12 @@ export interface components {
cache_creation_input_token_cost_ultrafast?: number | null;
/** Cache Read Input Audio Token Cost */
cache_read_input_audio_token_cost?: number | null;
+ /** Cache Read Input Audio Token Cost Batches */
+ cache_read_input_audio_token_cost_batches?: number | null;
+ /** Cache Read Input Audio Token Cost Flex */
+ cache_read_input_audio_token_cost_flex?: number | null;
+ /** Cache Read Input Audio Token Cost Priority */
+ cache_read_input_audio_token_cost_priority?: number | null;
/** Cache Read Input Image Token Cost */
cache_read_input_image_token_cost?: number | null;
/** Cache Read Input Token Cost */
@@ -35314,6 +35352,10 @@ export interface components {
input_cost_per_audio_token?: number | null;
/** Input Cost Per Audio Token Batches */
input_cost_per_audio_token_batches?: number | null;
+ /** Input Cost Per Audio Token Flex */
+ input_cost_per_audio_token_flex?: number | null;
+ /** Input Cost Per Audio Token Priority */
+ input_cost_per_audio_token_priority?: number | null;
/** Input Cost Per Character */
input_cost_per_character?: number | null;
/** Input Cost Per Character Above 128K Tokens */
@@ -41506,9 +41548,9 @@ export interface components {
mode?: "implicit" | "explicit";
/**
* Ttl
- * @constant
+ * @enum {string}
*/
- ttl?: "30m";
+ ttl?: "30m" | "12h";
};
/** PromptCachingRequest */
PromptCachingRequest: {
@@ -46285,6 +46327,21 @@ export interface components {
/** Team Id */
team_id: string;
};
+ /**
+ * TeamListCallerMembership
+ * @description The calling user's own spend and effective member budget in a team.
+ */
+ TeamListCallerMembership: {
+ /** Budget Reset At */
+ budget_reset_at?: string | null;
+ /** Max Budget */
+ max_budget?: number | null;
+ /**
+ * Spend
+ * @default 0
+ */
+ spend: number;
+ };
/**
* TeamListItem
* @description A team item in the paginated list response, enriched with computed fields.
@@ -46321,6 +46378,7 @@ export interface components {
budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null;
/** Budget Reset At */
budget_reset_at?: string | null;
+ caller_membership?: components["schemas"]["TeamListCallerMembership"] | null;
/** Created At */
created_at?: string | null;
/** Default Team Member Models */
@@ -50199,6 +50257,26 @@ export interface components {
blocked?: boolean | null;
/** Cache Creation Input Token Cost */
cache_creation_input_token_cost?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr */
+ cache_creation_input_token_cost_above_12hr?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Batches */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_batches?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Flex */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_flex?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Priority */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_priority?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Ultrafast */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_ultrafast?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Batches */
+ cache_creation_input_token_cost_above_12hr_batches?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Flex */
+ cache_creation_input_token_cost_above_12hr_flex?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Priority */
+ cache_creation_input_token_cost_above_12hr_priority?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Ultrafast */
+ cache_creation_input_token_cost_above_12hr_ultrafast?: number | null;
/** Cache Creation Input Token Cost Above 1Hr */
cache_creation_input_token_cost_above_1hr?: number | null;
/** Cache Read Input Token Cost */
@@ -50492,6 +50570,26 @@ export interface components {
cache_creation_input_token_cost_above_100k_tokens?: number | null;
/** Cache Creation Input Token Cost Above 100K Tokens Batches */
cache_creation_input_token_cost_above_100k_tokens_batches?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr */
+ cache_creation_input_token_cost_above_12hr?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Batches */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_batches?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Flex */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_flex?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Priority */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_priority?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Above 272K Tokens Ultrafast */
+ cache_creation_input_token_cost_above_12hr_above_272k_tokens_ultrafast?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Batches */
+ cache_creation_input_token_cost_above_12hr_batches?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Flex */
+ cache_creation_input_token_cost_above_12hr_flex?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Priority */
+ cache_creation_input_token_cost_above_12hr_priority?: number | null;
+ /** Cache Creation Input Token Cost Above 12Hr Ultrafast */
+ cache_creation_input_token_cost_above_12hr_ultrafast?: number | null;
/** Cache Creation Input Token Cost Above 1Hr */
cache_creation_input_token_cost_above_1hr?: number | null;
/** Cache Creation Input Token Cost Above 1Hr Above 100K Tokens */
@@ -50520,6 +50618,12 @@ export interface components {
cache_creation_input_token_cost_ultrafast?: number | null;
/** Cache Read Input Audio Token Cost */
cache_read_input_audio_token_cost?: number | null;
+ /** Cache Read Input Audio Token Cost Batches */
+ cache_read_input_audio_token_cost_batches?: number | null;
+ /** Cache Read Input Audio Token Cost Flex */
+ cache_read_input_audio_token_cost_flex?: number | null;
+ /** Cache Read Input Audio Token Cost Priority */
+ cache_read_input_audio_token_cost_priority?: number | null;
/** Cache Read Input Image Token Cost */
cache_read_input_image_token_cost?: number | null;
/** Cache Read Input Token Cost */
@@ -50599,6 +50703,10 @@ export interface components {
input_cost_per_audio_token?: number | null;
/** Input Cost Per Audio Token Batches */
input_cost_per_audio_token_batches?: number | null;
+ /** Input Cost Per Audio Token Flex */
+ input_cost_per_audio_token_flex?: number | null;
+ /** Input Cost Per Audio Token Priority */
+ input_cost_per_audio_token_priority?: number | null;
/** Input Cost Per Character */
input_cost_per_character?: number | null;
/** Input Cost Per Character Above 128K Tokens */
diff --git a/ui/litellm-dashboard/src/lib/telemetry/chat.test.ts b/ui/litellm-dashboard/src/lib/telemetry/chat.test.ts
deleted file mode 100644
index 599290ec7e4..00000000000
--- a/ui/litellm-dashboard/src/lib/telemetry/chat.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-const mocks = vi.hoisted(() => ({ capture: vi.fn(), fetch: vi.fn() }));
-vi.mock("@/lib/http/chatTelemetry", () => ({ captureChatRequest: mocks.capture }));
-
-let chat: typeof import("./chat");
-
-beforeEach(async () => {
- vi.resetModules();
- vi.resetAllMocks();
- vi.stubEnv("NODE_ENV", "production");
- vi.stubGlobal("window", { location: { origin: "https://gateway.example.com" } });
- vi.stubGlobal("fetch", mocks.fetch);
- mocks.capture.mockResolvedValue(undefined);
- chat = await import("./chat");
-});
-
-afterEach(() => {
- vi.useRealTimers();
- vi.unstubAllEnvs();
- vi.unstubAllGlobals();
-});
-
-describe("Chat request metrics", () => {
- it("should do nothing when imported without a submission", () => {
- expect(mocks.fetch).not.toHaveBeenCalled();
- expect(mocks.capture).not.toHaveBeenCalled();
- });
-
- it("should emit by default with a stable user ID, fresh request IDs, and no backend settings request", async () => {
- await chat.recordChatQuery("alice@example.com");
- await chat.recordChatQuery("alice@example.com");
-
- expect(mocks.capture).toHaveBeenCalledTimes(2);
- const ids = mocks.capture.mock.calls.map(([id]) => id);
- expect(ids[0]).toMatch(/^[\da-f]{8}-[\da-f]{4}-5[\da-f]{3}-[\da-f]{4}-[\da-f]{12}$/);
- expect(ids[0]).toBe(ids[1]);
- const requestIds = mocks.capture.mock.calls.map(([, requestId]) => requestId);
- for (const requestId of requestIds) {
- expect(requestId).toMatch(/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[\da-f]{4}-[\da-f]{12}$/);
- }
- expect(new Set(requestIds).size).toBe(2);
- expect(mocks.capture.mock.calls.every((args) => args.length === 2)).toBe(true);
- expect(JSON.stringify(mocks.capture.mock.calls)).not.toContain("alice@example.com");
- expect(JSON.stringify(mocks.capture.mock.calls)).not.toContain("gateway.example.com");
- expect(mocks.fetch).not.toHaveBeenCalled();
- });
-
- it("should use the same user ID across reloads and browsers without stored identifiers", async () => {
- await chat.recordChatQuery("alice");
- vi.resetModules();
- chat = await import("./chat");
- vi.stubGlobal("window", { location: { origin: "https://gateway.example.com" } });
- await chat.recordChatQuery("alice");
- expect(mocks.capture.mock.calls[0][0]).toBe(mocks.capture.mock.calls[1][0]);
- });
-
- it("should distinguish accounts and deployments", async () => {
- await chat.recordChatQuery("alice");
- await chat.recordChatQuery("bob");
- vi.stubGlobal("window", { location: { origin: "https://another.example.com" } });
- await chat.recordChatQuery("alice");
- const ids = mocks.capture.mock.calls.map(([id]) => id);
- expect(new Set(ids).size).toBe(3);
- });
-
- it("should preserve user identity across days while leaving event timestamps to the collector", async () => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-10-23T23:59:00Z"));
- await chat.recordChatQuery("alice");
- vi.setSystemTime(new Date("2026-10-24T00:01:00Z"));
- await chat.recordChatQuery("alice");
- expect(mocks.capture.mock.calls[0][0]).toBe(mocks.capture.mock.calls[1][0]);
- expect(mocks.capture.mock.calls[0][1]).not.toBe(mocks.capture.mock.calls[1][1]);
- expect(JSON.stringify(mocks.capture.mock.calls)).not.toContain("2026-10-");
- });
-
- it("should not collect from development, unauthenticated, or server rendering", async () => {
- vi.stubEnv("NODE_ENV", "development");
- await chat.recordChatQuery("alice");
- vi.stubEnv("NODE_ENV", "production");
- await chat.recordChatQuery("");
- vi.stubGlobal("window", undefined);
- await chat.recordChatQuery("alice");
- expect(mocks.fetch).not.toHaveBeenCalled();
- expect(mocks.capture).not.toHaveBeenCalled();
- });
-
- it("should count requests on HTTP deployments without secure-context APIs or browser storage", async () => {
- vi.stubGlobal("window", { location: { origin: "http://gateway.internal:4000" } });
- vi.stubGlobal("crypto", { getRandomValues: crypto.getRandomValues.bind(crypto) });
- vi.stubGlobal("localStorage", undefined);
- await chat.recordChatQuery("alice");
- expect(mocks.capture).toHaveBeenCalledOnce();
- });
-
- it("should separate deployments mounted under different root paths", async () => {
- const { setServerRootPath } = await import("@/lib/serverRootPath");
- setServerRootPath("/team-a/");
- await chat.recordChatQuery("alice");
- setServerRootPath("team-a");
- await chat.recordChatQuery("alice");
- setServerRootPath("/team-b");
- await chat.recordChatQuery("alice");
- const ids = mocks.capture.mock.calls.map(([id]) => id);
- expect(ids[0]).toBe(ids[1]);
- expect(ids[0]).not.toBe(ids[2]);
- });
-
- it("should swallow transport failures without retrying requests", async () => {
- mocks.capture.mockRejectedValueOnce(new Error("blocked"));
- await expect(chat.recordChatQuery("alice")).resolves.toBeUndefined();
- expect(mocks.capture).toHaveBeenCalledTimes(1);
- await chat.recordChatQuery("alice");
- expect(mocks.capture).toHaveBeenCalledTimes(2);
- });
-});
diff --git a/ui/litellm-dashboard/src/lib/telemetry/chat.ts b/ui/litellm-dashboard/src/lib/telemetry/chat.ts
deleted file mode 100644
index 706c49527fd..00000000000
--- a/ui/litellm-dashboard/src/lib/telemetry/chat.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { v4 as uuidv4, v5 as uuidv5 } from "uuid";
-import { serverRootPath } from "@/lib/serverRootPath";
-import { captureChatRequest } from "@/lib/http/chatTelemetry";
-
-// Called only by Chat's accepted-submit handler. Daily unique users = DAU;
-// event count = total requests. Page visits never count as activity.
-export async function recordChatQuery(userId: string): Promise {
- // Match docs: development previews never contribute to production metrics.
- if (process.env.NODE_ENV !== "production" || typeof window === "undefined" || !userId) return;
- try {
- // Stable across browsers for an account on this deployment. The payload
- // excludes its raw account ID, email, and gateway origin.
- const root = serverRootPath.replace(/^\/+|\/+$/g, "");
- const identity = JSON.stringify([window.location.origin, root, userId]);
- const distinctId = uuidv5(identity, uuidv5.URL);
- await captureChatRequest(distinctId, uuidv4());
- } catch {
- // Analytics failures must never interrupt a request or trigger a retry.
- }
-}
diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts
new file mode 100644
index 00000000000..a3b5f4da00a
--- /dev/null
+++ b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts
@@ -0,0 +1,46 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils";
+
+const originalTimezone: string | undefined = process.env.TZ;
+
+afterEach(() => {
+ if (originalTimezone === undefined) {
+ delete process.env.TZ;
+ return;
+ }
+ process.env.TZ = originalTimezone;
+});
+
+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(resetAt)).toBe("Oct 20, 2026");
+ expect(formatBudgetReset(resetAt)).toBe("Oct 19, 2026");
+ });
+});
+
+describe("describeBudgetResetSchedule", () => {
+ it("reports Never resets 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();
+ });
+
+ 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.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);
+ expect(schedule.tooltip).toContain("carries into the next cycle");
+ });
+});
diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.ts b/ui/litellm-dashboard/src/utils/budgetUtils.ts
index 3d3278db88f..171ce5818f7 100644
--- a/ui/litellm-dashboard/src/utils/budgetUtils.ts
+++ b/ui/litellm-dashboard/src/utils/budgetUtils.ts
@@ -1,4 +1,7 @@
import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+
+dayjs.extend(utc);
export function formatBudgetReset(iso: string | null | undefined): string | null {
if (!iso) return null;
@@ -6,3 +9,48 @@ export function formatBudgetReset(iso: string | null | undefined): string | null
if (!resetDate.isValid()) return null;
return resetDate.format("MMM D, YYYY");
}
+
+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");
+}
+
+export interface BudgetResetSchedule {
+ label: string;
+ resetAt: string | null;
+ tooltip: string;
+}
+
+const ROLLOVER_NOTE = "Spend above a rollover cap at reset carries into the next cycle.";
+
+export function describeBudgetResetSchedule(
+ budgetDuration: string | null | undefined,
+ budgetResetAt: string | null | undefined,
+): 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");
+ return {
+ label,
+ resetAt: resetDate.toISOString(),
+ tooltip: `Current Cycle Spend resets ${cadence}. 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.",
+ };
+ }
+ return {
+ label: "Unknown",
+ resetAt: null,
+ tooltip: `Resets every ${budgetDuration}, but the next reset time has not been scheduled yet. ${ROLLOVER_NOTE}`,
+ };
+}