Skip to content
Merged
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
13 changes: 11 additions & 2 deletions litellm/proxy/management_endpoints/common_daily_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ def _build_aggregated_sql_query(
api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path
timezone_offset_minutes: int | None = None,
include_current_utc_day: bool = False,
) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params
"""Build a parameterized SQL GROUP BY query for aggregated daily activity.

Expand All @@ -673,7 +674,9 @@ def _build_aggregated_sql_query(
if pg_table is None:
raise ValueError(f"Unknown table name: {table_name}")

adjusted_start, adjusted_end = _adjust_dates_for_timezone(start_date, end_date, timezone_offset_minutes)
adjusted_start, adjusted_end = _adjust_dates_for_timezone(
start_date, end_date, timezone_offset_minutes, include_current_utc_day
)

where_clause, sql_params = _build_aggregated_where_clause(
entity_id_field=entity_id_field,
Expand Down Expand Up @@ -755,6 +758,7 @@ def _build_entity_rollup_sql_query(
api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path
timezone_offset_minutes: int | None = None,
include_current_utc_day: bool = False,
) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params
"""Per-entity companion to _build_aggregated_sql_query.

Expand All @@ -766,7 +770,9 @@ def _build_entity_rollup_sql_query(
if pg_table is None:
raise ValueError(f"Unknown table name: {table_name}")

adjusted_start, adjusted_end = _adjust_dates_for_timezone(start_date, end_date, timezone_offset_minutes)
adjusted_start, adjusted_end = _adjust_dates_for_timezone(
start_date, end_date, timezone_offset_minutes, include_current_utc_day
)

where_clause, sql_params = _build_aggregated_where_clause(
entity_id_field=entity_id_field,
Expand Down Expand Up @@ -1256,6 +1262,7 @@ async def get_daily_activity_aggregated(
exclude_entity_ids: list[str] | None = None,
timezone_offset_minutes: int | None = None,
include_entity_breakdown: bool = False,
include_current_utc_day: bool = False,
) -> SpendAnalyticsPaginatedResponse:
"""Aggregated variant that returns the full result set (no pagination).

Expand Down Expand Up @@ -1291,6 +1298,7 @@ async def get_daily_activity_aggregated(
api_key=api_key,
exclude_entity_ids=exclude_entity_ids,
timezone_offset_minutes=timezone_offset_minutes,
include_current_utc_day=include_current_utc_day,
)

entity_query: Final = (
Expand All @@ -1304,6 +1312,7 @@ async def get_daily_activity_aggregated(
api_key=api_key,
exclude_entity_ids=exclude_entity_ids,
timezone_offset_minutes=timezone_offset_minutes,
include_current_utc_day=include_current_utc_day,
)
if include_entity_breakdown
else None
Expand Down
8 changes: 8 additions & 0 deletions litellm/proxy/management_endpoints/internal_user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2790,6 +2790,13 @@ async def get_user_daily_activity_aggregated(
description="Timezone offset in minutes from UTC (e.g., 480 for PST). "
"Matches JavaScript's Date.getTimezoneOffset() convention.",
),
include_current_utc_day: bool = fastapi.Query(
default=False,
description="When the range ends on the caller's current local day, extend it to "
"today's UTC bucket so spend written after the caller's local midnight (in UTC "
"terms) is included. Requires the timezone parameter. Historical ranges are "
"never extended.",
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> SpendAnalyticsPaginatedResponse:
"""
Expand Down Expand Up @@ -2837,6 +2844,7 @@ async def get_user_daily_activity_aggregated(
model=model,
api_key=api_key,
timezone_offset_minutes=timezone,
include_current_utc_day=include_current_utc_day,
)

except HTTPException:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
import sys
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock
Expand Down Expand Up @@ -928,6 +928,33 @@ def test_sql_date_bounds_are_user_supplied_dates(self, offset_minutes):
assert "date >= $1" in sql
assert "date <= $2" in sql

@pytest.mark.parametrize("build", [_build_aggregated_sql_query, _build_entity_rollup_sql_query])
def test_include_current_utc_day_extends_live_end_bound(self, build):
"""
An offset larger than 24h keeps the caller's local date behind UTC at any
wall-clock hour, so the live-end extension is deterministic: a range ending
on the caller's local today must reach today's UTC bucket (LIT-5818, guards
the #36051 behavior on the aggregated path).
"""
offset_minutes: Final = 1500
caller_local_today: Final = (datetime.now(timezone.utc) - timedelta(minutes=offset_minutes)).date().isoformat()
utc_today: Final = datetime.now(timezone.utc).date().isoformat()

_sql, params = build(
table_name="litellm_dailyuserspend",
entity_id_field="user_id",
entity_id="user-1",
start_date="2026-05-01",
end_date=caller_local_today,
model=None,
api_key=None,
timezone_offset_minutes=offset_minutes,
include_current_utc_day=True,
)

assert params[0] == "2026-05-01"
assert params[1] == utc_today

def test_optional_filters_appear_in_params_in_order(self):
sql, params = _build_aggregated_sql_query(
table_name="litellm_dailyuserspend",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2253,7 +2253,8 @@ async def test_get_user_daily_activity_aggregated_rejects_service_account_caller


@pytest.mark.asyncio
async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch):
@pytest.mark.parametrize("include_current_utc_day", [False, True])
async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch, include_current_utc_day):
"""
Test that admin users can call the aggregated endpoint without a user_id
to get a global view. Also verifies that the correct arguments are forwarded
Expand Down Expand Up @@ -2291,6 +2292,7 @@ async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch)
api_key=None,
user_id=None,
timezone=480,
include_current_utc_day=include_current_utc_day,
user_api_key_dict=admin_key_dict,
)

Expand All @@ -2308,6 +2310,7 @@ async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch)
model="gpt-4",
api_key=None,
timezone_offset_minutes=480,
include_current_utc_day=include_current_utc_day,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const mockUserDailyActivityCall = vi.fn();
const mockUserDailyActivityAggregatedCall = vi.fn();
const { useAuthorizedMock, mockToolSpendResponse } = vi.hoisted(() => ({
useAuthorizedMock: vi.fn(),
mockToolSpendResponse: { by_tool: [], daily: [], start_date: null, end_date: null },
Expand All @@ -15,6 +16,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({

vi.mock("@/components/networking", () => ({
userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args),
userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args),
getToolSpend: vi.fn().mockResolvedValue(mockToolSpendResponse),
getGeneralSettingsCall: vi.fn().mockResolvedValue([]),
organizationListCall: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -48,7 +50,7 @@ const singlePage = {

describe("CostOptimizationView daily activity", () => {
it("fetches daily activity once for the page and shares it with every tab that needs it", async () => {
mockUserDailyActivityCall.mockResolvedValue(singlePage);
mockUserDailyActivityAggregatedCall.mockResolvedValue(singlePage);
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

Expand All @@ -58,11 +60,12 @@ describe("CostOptimizationView daily activity", () => {
</QueryClientProvider>,
);

await waitFor(() => expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1));

fireEvent.click(getByRole("tab", { name: "Prompt Caching" }));
await findByTestId("caching-settings");

expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(1);
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1);
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ vi.mock("@/components/networking", () => ({
userDailyActivityCall: vi
.fn()
.mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }),
userDailyActivityAggregatedCall: vi
.fn()
.mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }),
}));

vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", (

vi.mock("@/components/networking", () => ({
userDailyActivityCall: vi.fn(),
userDailyActivityAggregatedCall: vi.fn(),
}));

import { userDailyActivityAggregatedCall } from "@/components/networking";
import { useDailyActivityRange } from "./useDailyActivityRange";

const argsOfLastCall = () => mockUsePaginatedDailyActivity.mock.calls.at(-1)?.[0].args as unknown[];
Expand All @@ -31,6 +33,14 @@ describe("useDailyActivityRange", () => {
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true]);
});

it("fetches through the single-shot aggregated endpoint first so days never fragment across pages", () => {
renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));

expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ aggregatedFetchFn: userDailyActivityAggregatedCall }),
);
});

it("stays disabled until an access token is available", () => {
renderHook(() => useDailyActivityRange(null, "u1", "proxy_admin"));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";

import { userDailyActivityCall } from "@/components/networking";
import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking";
import { DailyData } from "@/components/UsagePage/types";
import { all_admin_roles } from "@/utils/roles";
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
Expand Down Expand Up @@ -33,8 +33,9 @@
const endTime = dateValue.to ?? null;
const effectiveUserId = all_admin_roles.includes(userRole) ? null : userId;

const { data, loading, isFetchingMore } = usePaginatedDailyActivity({

Check warning on line 36 in ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 4 properties passed inline as an argument; assign it to a named variable first
fetchFn: userDailyActivityCall,
aggregatedFetchFn: userDailyActivityAggregatedCall,
args: [accessToken, startTime, endTime, effectiveUserId, true],
enabled: !!accessToken && !!startTime && !!endTime,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { sumMetadata } from "./usePaginatedDailyActivity";
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { mergeDailyResults, 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 +51,108 @@ describe("sumMetadata", () => {
}
});
});

const metricsOf = (spend: number): SpendMetrics => ({
spend,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
compression_savings_spend: spend,
});

const dayOf = (date: string, spend: number, apiKey: string = "sk-1"): DailyData => ({
date,
metrics: metricsOf(spend),
breakdown: {
models: {
"gpt-4o": {
metrics: metricsOf(spend),
metadata: {},
api_key_breakdown: {
[apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } },
},
},
},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: { [apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } } },
entities: {},
},
});

describe("mergeDailyResults", () => {
it("collapses repeated dates into one entry with summed metrics (the LIT-5818 $2/$2/$1 case)", () => {
const merged = mergeDailyResults(mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-16", 2)]), [
dayOf("2026-08-16", 1),
]);

expect(merged).toHaveLength(1);
expect(merged[0].metrics.spend).toBe(5);
expect(merged[0].metrics.compression_savings_spend).toBe(5);
});

it("appends unseen dates in arrival order", () => {
const merged = mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-15", 0.5)]);

expect(merged.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]);
expect(merged[1].metrics.spend).toBe(0.5);
});

it("merges every breakdown level including the nested per-key breakdown", () => {
const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-1")]);

expect(merged[0].breakdown.models["gpt-4o"].metrics.spend).toBe(5);
expect(merged[0].breakdown.models["gpt-4o"].api_key_breakdown["sk-1"].metrics.spend).toBe(5);
expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(5);
expect(merged[0].breakdown.api_keys["sk-1"].metadata.key_alias).toBe("alias-1");
});

it("unions breakdown keys that appear on different pages", () => {
const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-2")]);

expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(2);
expect(merged[0].breakdown.api_keys["sk-2"].metrics.spend).toBe(3);
});

it("sums metric keys it has never heard of so a future backend column cannot silently freeze", () => {
const withExtra = (spend: number): DailyData => ({
...dayOf("2026-08-16", spend),
metrics: { ...metricsOf(spend), future_savings_spend: spend } as SpendMetrics,
});
const merged = mergeDailyResults([withExtra(2)], [withExtra(3)]);

expect((merged[0].metrics as Record<string, number>).future_savings_spend).toBe(5);
});
});

describe("usePaginatedDailyActivity page accumulation", () => {
it("returns one entry per date when a date's rows span multiple pages", async () => {
const pages = [
{ results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } },
{ results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 2, total_spend: 2 } },
{
results: [dayOf("2026-08-16", 1), dayOf("2026-08-15", 0.5)],
metadata: { total_pages: 3, page: 3, total_spend: 1.5 },
},
];
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1]));
const start = new Date("2026-08-10");
const end = new Date("2026-08-17");

const { result } = renderHook(() =>
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
);

await waitFor(() => expect(result.current.data.metadata.page).toBe(3), { timeout: 5000 });

expect(result.current.data.results.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]);
expect(result.current.data.results[0].metrics.spend).toBe(5);
expect(result.current.data.metadata.total_spend).toBe(5.5);
});
});
Loading
Loading