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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
failed_requests: number;
api_requests: number;
};
metadata: Record<string, any>;

Check warning on line 59 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
}

interface EntitySpendData {
Expand Down Expand Up @@ -87,7 +87,7 @@
dateValue: DateRangePickerValue;
}

const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {

Check warning on line 90 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

Check warning on line 90 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
tag: tagDailyActivityCall,
team: teamDailyActivityCall,
organization: organizationDailyActivityCall,
Expand All @@ -108,7 +108,7 @@
entityList,
userRole,
dateValue,
}) => {

Check warning on line 111 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Arrow function has a complexity of 41. Maximum allowed is 20
const { teams } = useTeams();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
Expand Down Expand Up @@ -137,6 +137,8 @@
isFetchingMore,
progress,
cancelled,
failed,
incomplete,
cancel,
} = usePaginatedDailyActivity({
fetchFn,
Expand All @@ -151,6 +153,7 @@
isFetchingMore: agentIsFetchingMore,
progress: agentProgress,
cancelled: agentCancelled,
failed: agentFailed,
cancel: agentCancel,
} = usePaginatedDailyActivity({
fetchFn: agentDailyActivityCall,
Expand All @@ -171,7 +174,7 @@
}
};

const getEntityLabel = (entity: string, metadata?: Record<string, any>): string => {

Check warning on line 177 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (entityList) {
const entityItem = entityList.find((item) => item.value === entity);
if (entityItem) {
Expand Down Expand Up @@ -216,7 +219,7 @@
cache_creation_input_tokens: 0,
},
metadata: {
alias: getEntityLabel(entity, data.metadata as any),

Check warning on line 222 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
id: entity,
},
};
Expand Down Expand Up @@ -651,10 +654,11 @@
</AlertDescription>
</Alert>
)}
{cancelled && (
<Alert variant="info" className="mb-2">
{(cancelled || failed) && (
<Alert variant={failed ? "error" : "info"} className="mb-2">
<AlertDescription className="text-inherit">
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
{failed ? "Fetching spend data failed, so totals cover only part of the range" : "Showing partial data"} (
{progress.currentPage}/{progress.totalPages} pages loaded)
</AlertDescription>
</Alert>
)}
Expand All @@ -677,10 +681,13 @@
</AlertDescription>
</Alert>
)}
{agentCancelled && showAgentBreakdown && (
<Alert variant="info" className="mb-2">
{(agentCancelled || agentFailed) && showAgentBreakdown && (

Check warning on line 684 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 4 conditions; extract it into a named variable
<Alert variant={agentFailed ? "error" : "info"} className="mb-2">
<AlertDescription className="text-inherit">
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
{agentFailed
? "Fetching agent data failed, so totals cover only part of the range"
: "Showing partial agent data"}{" "}
({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
</AlertDescription>
</Alert>
)}
Expand All @@ -696,6 +703,12 @@
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
teams={teams || []}
exportDisabled={incomplete}
exportDisabledReason={
failed
? "Spend data failed to load for the whole range, so an export would under-report. Reload the page first."
: "Spend data is still loading, so an export would under-report. Wait for it to finish."
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancelled export shows loading reason

Low Severity

incomplete is true when the user cancels, so Export stays disabled, but exportDisabledReason only special-cases failed and otherwise says spend data is still loading and to wait. After Stop, nothing is loading, so the tooltip steers users to wait for a fetch that will never finish.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 45ad441. Configure here.

/>
<Tabs defaultValue={tabs[0].key}>
<TabsList className="mt-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,12 @@
organizations: Organization[];
}

const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {

Check warning on line 70 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Arrow function has a complexity of 88. Maximum allowed is 20
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
// Aggregated endpoint: try first, fall back to paginated if unavailable
const [aggregatedData, setAggregatedData] = useState<FetchedForRange<{
results: DailyData[];
metadata: any;

Check warning on line 75 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
}> | null>(null);
// Stamped like the data itself: the flag decides whether the paginated
// fallback is read, and a flag left over from the previous range would let
Expand Down Expand Up @@ -216,7 +216,7 @@
const paginatedResult = usePaginatedDailyActivity({
fetchFn: userDailyActivityCall,
args: [accessToken, startTime, endTime, effectiveUserId],
enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime,

Check warning on line 219 in ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 4 conditions; extract it into a named variable
});

// Derive userSpendData from whichever source is active
Expand Down Expand Up @@ -482,11 +482,13 @@
</AlertDescription>
</Alert>
)}
{paginatedResult.cancelled && (
<Alert variant="info" className="mb-2">
{(paginatedResult.cancelled || paginatedResult.failed) && (
<Alert variant={paginatedResult.failed ? "error" : "info"} className="mb-2">
<AlertDescription className="text-inherit">
Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages
loaded)
{paginatedResult.failed
? "Fetching spend data failed, so totals cover only part of the range"
: "Showing partial data"}{" "}
({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages loaded)
</AlertDescription>
</Alert>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { mergeDailyResults } from "./mergeDailyActivity";

const metrics = (overrides: Partial<SpendMetrics> = {}): SpendMetrics => ({
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
...overrides,
});

const day = (date: string, spend: number, teamSpend: Record<string, number>, keySpend: number): DailyData => ({
date,
metrics: metrics({ spend, total_tokens: spend * 10, api_requests: 1 }),
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {
"sk-a": { metrics: metrics({ spend: keySpend }), metadata: { key_alias: "a", team_id: "team-1" } },
},
entities: Object.fromEntries(
Object.entries(teamSpend).map(([team, value]) => [
team,
{
metrics: metrics({ spend: value, total_tokens: value * 10 }),
metadata: { team_alias: team },
api_key_breakdown: {
"sk-a": { metrics: metrics({ spend: value }), metadata: { key_alias: "a", team_id: team } },
},
},
]),
),
},
});

describe("mergeDailyResults", () => {
it("keeps one entry per date when a date straddles a page boundary", () => {
const pageOne = [day("2026-06-26", 5, { "team-1": 5 }, 5), day("2026-06-25", 22.38, { "team-1": 22.38 }, 22.38)];
const pageTwo = [day("2026-06-25", 14.52, { "team-1": 14.52 }, 14.52), day("2026-06-24", 3, { "team-1": 3 }, 3)];

const merged = mergeDailyResults(pageOne, pageTwo);

expect(merged.map((d) => d.date)).toEqual(["2026-06-26", "2026-06-25", "2026-06-24"]);
const splitDay = merged.find((d) => d.date === "2026-06-25")!;
expect(splitDay.metrics.spend).toBeCloseTo(36.9, 10);
expect(splitDay.metrics.total_tokens).toBeCloseTo(369, 10);
expect(splitDay.metrics.api_requests).toBe(2);
});

it("merges every breakdown bucket of a split date instead of dropping one page's share", () => {
const merged = mergeDailyResults(
[day("2026-06-25", 10, { "team-1": 6, "team-2": 4 }, 10)],
[day("2026-06-25", 5, { "team-2": 5 }, 5)],
);

const { entities, api_keys } = merged[0].breakdown;
expect(entities["team-1"].metrics.spend).toBeCloseTo(6, 10);
expect(entities["team-2"].metrics.spend).toBeCloseTo(9, 10);
expect(entities["team-2"].api_key_breakdown["sk-a"].metrics.spend).toBeCloseTo(9, 10);
expect(api_keys["sk-a"].metrics.spend).toBeCloseTo(15, 10);
});

it("preserves the per-day total across pages so day sums match the response metadata", () => {
const pages = [
[day("2026-06-25", 22.38, { "team-1": 22.38 }, 22.38)],
[day("2026-06-25", 14.52, { "team-1": 14.52 }, 14.52)],
[day("2026-06-24", 3, { "team-1": 3 }, 3)],
];

const merged = pages.reduce<DailyData[]>((acc, page) => mergeDailyResults(acc, page), []);

expect(merged).toHaveLength(2);
expect(merged.reduce((total, d) => total + d.metrics.spend, 0)).toBeCloseTo(39.9, 10);
});

it("leaves distinct dates untouched", () => {
const pageOne = [day("2026-06-26", 5, { "team-1": 5 }, 5)];
const pageTwo = [day("2026-06-25", 7, { "team-1": 7 }, 7)];

expect(mergeDailyResults(pageOne, pageTwo)).toEqual([...pageOne, ...pageTwo]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type {
BreakdownMetrics,
DailyData,
KeyMetricWithMetadata,
MetricWithMetadata,
SpendMetrics,
} from "@/components/UsagePage/types";

const METRIC_KEYS: readonly (keyof SpendMetrics)[] = [
"spend",
"prompt_tokens",
"completion_tokens",
"total_tokens",
"api_requests",
"successful_requests",
"failed_requests",
"cache_read_input_tokens",
"cache_creation_input_tokens",
"compression_saved_tokens",
"compression_savings_spend",
"prompt_caching_savings_spend",
"autorouter_savings_spend",
];

const addMetrics = (a: SpendMetrics, b: SpendMetrics): SpendMetrics =>
METRIC_KEYS.reduce(
(acc, key) =>
a[key] === undefined && b[key] === undefined ? acc : { ...acc, [key]: (a[key] ?? 0) + (b[key] ?? 0) },
{} as SpendMetrics,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge drops daily flat cost

High Severity

METRIC_KEYS omits flat_cost, so addMetrics rebuilds day and breakdown metrics without it. When a date straddles pages, chart Flat Cost and CSV Flat Cost ($) rows go to zero even though total_flat_cost in metadata still sums correctly, so wide Team Usage ranges under-report PTU flat cost on the exact split days this PR aims to fix.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 45ad441. Configure here.


const mergeBuckets = <T>(
a: Record<string, T> | undefined,
b: Record<string, T> | undefined,
mergeEntry: (left: T, right: T) => T,
): Record<string, T> => {
const left = a ?? {};
const right = b ?? {};
return Object.fromEntries(
Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((key) => {
const leftEntry = left[key];
const rightEntry = right[key];
if (leftEntry === undefined) return [key, rightEntry];
if (rightEntry === undefined) return [key, leftEntry];
return [key, mergeEntry(leftEntry, rightEntry)];
}),
);
};

const mergeKeyMetric = (a: KeyMetricWithMetadata, b: KeyMetricWithMetadata): KeyMetricWithMetadata => ({
...a,
metrics: addMetrics(a.metrics, b.metrics),
});

const mergeMetricWithMetadata = (a: MetricWithMetadata, b: MetricWithMetadata): MetricWithMetadata => ({
...a,
metrics: addMetrics(a.metrics, b.metrics),
api_key_breakdown: mergeBuckets(a.api_key_breakdown, b.api_key_breakdown, mergeKeyMetric),
});

const mergeBreakdown = (a: BreakdownMetrics, b: BreakdownMetrics): BreakdownMetrics => ({
models: mergeBuckets(a.models, b.models, mergeMetricWithMetadata),
model_groups: mergeBuckets(a.model_groups, b.model_groups, mergeMetricWithMetadata),
mcp_servers: mergeBuckets(a.mcp_servers, b.mcp_servers, mergeMetricWithMetadata),
providers: mergeBuckets(a.providers, b.providers, mergeMetricWithMetadata),
entities: mergeBuckets(a.entities, b.entities, mergeMetricWithMetadata),
endpoints: mergeBuckets(a.endpoints, b.endpoints, mergeMetricWithMetadata),
api_keys: mergeBuckets(a.api_keys, b.api_keys, mergeKeyMetric),
});

const mergeDay = (a: DailyData, b: DailyData): DailyData => ({
...a,
metrics: addMetrics(a.metrics, b.metrics),
breakdown: mergeBreakdown(a.breakdown, b.breakdown),
});

export const mergeDailyResults = (existing: readonly DailyData[], incoming: readonly DailyData[]): DailyData[] =>
incoming.reduce<DailyData[]>(
(acc, day) => {
const index = acc.findIndex((existingDay) => existingDay.date === day.date);
if (index === -1) return [...acc, day];
return acc.map((existingDay, i) => (i === index ? mergeDay(existingDay, day) : existingDay));
},
[...existing],
);
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { sumMetadata } from "./usePaginatedDailyActivity";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sumMetadata, usePaginatedDailyActivity } from "./usePaginatedDailyActivity";

describe("sumMetadata", () => {
it("sums flat cost across pages instead of keeping the first page's value", () => {
Expand Down Expand Up @@ -49,3 +50,105 @@ describe("sumMetadata", () => {
}
});
});

const page = (date: string, spend: number, totalPages: number, pageNumber: number) => ({
results: [
{
date,
metrics: {
spend,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: spend * 10,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {
"team-1": {
metrics: {
spend,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: spend * 10,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
},
},
},
},
],
metadata: { total_spend: spend, total_tokens: spend * 10, total_pages: totalPages, page: pageNumber },
});

const args = ["token", new Date("2026-02-05"), new Date("2026-08-05"), null];

describe("usePaginatedDailyActivity", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});

it("collapses a date split across pages into a single day entry", async () => {
const fetchFn = vi
.fn()
.mockResolvedValueOnce(page("2026-06-25", 22.38, 2, 1))
.mockResolvedValueOnce(page("2026-06-25", 14.52, 2, 2));

const { result } = renderHook(() => usePaginatedDailyActivity({ fetchFn, args, enabled: true }));

await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2), { timeout: 3000 });
await waitFor(() => expect(result.current.data.metadata.total_spend).toBeCloseTo(36.9, 10), { timeout: 3000 });

expect(result.current.data.results).toHaveLength(1);
expect(result.current.data.results[0].metrics.spend).toBeCloseTo(36.9, 10);
expect(result.current.data.results[0].breakdown.entities["team-1"].metrics.spend).toBeCloseTo(36.9, 10);
expect(result.current.incomplete).toBe(false);
});

it("stays incomplete while the first page is still in flight", async () => {
let resolveFirstPage: (value: ReturnType<typeof page>) => void = () => {};
const fetchFn = vi.fn().mockReturnValueOnce(
new Promise<ReturnType<typeof page>>((resolve) => {
resolveFirstPage = resolve;
}),
);

const { result } = renderHook(() => usePaginatedDailyActivity({ fetchFn, args, enabled: true }));

await waitFor(() => expect(result.current.loading).toBe(true), { timeout: 3000 });
expect(result.current.incomplete).toBe(true);

resolveFirstPage(page("2026-06-25", 22.38, 1, 1));

await waitFor(() => expect(result.current.incomplete).toBe(false), { timeout: 3000 });
});

it("flags the range as incomplete when a page fetch fails instead of looking complete", async () => {
const fetchFn = vi
.fn()
.mockResolvedValueOnce(page("2026-06-25", 22.38, 3, 1))
.mockRejectedValueOnce(new Error("boom"));

const { result } = renderHook(() => usePaginatedDailyActivity({ fetchFn, args, enabled: true }));

await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 3000 });

expect(result.current.incomplete).toBe(true);
expect(result.current.isFetchingMore).toBe(false);
expect(result.current.data.metadata.total_spend).toBeCloseTo(22.38, 10);
});
});
Loading
Loading