diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 781fe264eb8..3d2fa798e03 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -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. @@ -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, @@ -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. @@ -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, @@ -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). @@ -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 = ( @@ -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 diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 99a85e02b52..9c725c54d08 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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: """ @@ -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: diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 62c05841197..1491782419f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -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 @@ -928,6 +928,33 @@ class TestBuildAggregatedSqlQuery: 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", diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 06ae02c17bb..11b7f4553ac 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -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 @@ -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, ) @@ -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, ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx index 70d7dade97a..9d98233f110 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx @@ -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 }, @@ -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([]), @@ -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 } } }); @@ -58,11 +60,12 @@ describe("CostOptimizationView daily activity", () => { , ); - 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(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 60926f575bc..384c6cdbc8f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -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: () =>
})); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx index e26a3629e8c..43c4aa04e2b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx @@ -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[]; @@ -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")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts index 3a2a38c5955..e16458728a1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts @@ -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"; @@ -35,6 +35,7 @@ export const useDailyActivityRange = ( const { data, loading, isFetchingMore } = usePaginatedDailyActivity({ fetchFn: userDailyActivityCall, + aggregatedFetchFn: userDailyActivityAggregatedCall, args: [accessToken, startTime, endTime, effectiveUserId, true], enabled: !!accessToken && !!startTime && !!endTime, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts index b1d467074f6..0537f469920 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts @@ -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", () => { @@ -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