diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 5cb63c98d56..fb73ec72458 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -5222,20 +5222,36 @@ } }, { + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "in": "query", "name": "timezone", "required": false, "schema": { "anyOf": [ { + "maximum": 840, + "minimum": -840, "type": "integer" }, { "type": "null" } ], + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "title": "Timezone" } + }, + { + "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.", + "in": "query", + "name": "include_current_utc_day", + "required": false, + "schema": { + "default": true, + "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.", + "title": "Include Current Utc Day", + "type": "boolean" + } } ], "responses": { @@ -5395,20 +5411,36 @@ } }, { + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "in": "query", "name": "timezone", "required": false, "schema": { "anyOf": [ { + "maximum": 840, + "minimum": -840, "type": "integer" }, { "type": "null" } ], + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "title": "Timezone" } + }, + { + "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.", + "in": "query", + "name": "include_current_utc_day", + "required": false, + "schema": { + "default": true, + "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.", + "title": "Include Current Utc Day", + "type": "boolean" + } } ], "responses": { @@ -5577,20 +5609,36 @@ } }, { + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "in": "query", "name": "timezone", "required": false, "schema": { "anyOf": [ { + "maximum": 840, + "minimum": -840, "type": "integer" }, { "type": "null" } ], + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "title": "Timezone" } + }, + { + "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.", + "in": "query", + "name": "include_current_utc_day", + "required": false, + "schema": { + "default": true, + "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.", + "title": "Include Current Utc Day", + "type": "boolean" + } } ], "responses": { @@ -5749,20 +5797,36 @@ } }, { + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "in": "query", "name": "timezone", "required": false, "schema": { "anyOf": [ { + "maximum": 840, + "minimum": -840, "type": "integer" }, { "type": "null" } ], + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "title": "Timezone" } + }, + { + "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.", + "in": "query", + "name": "include_current_utc_day", + "required": false, + "schema": { + "default": true, + "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.", + "title": "Include Current Utc Day", + "type": "boolean" + } } ], "responses": { @@ -5922,20 +5986,36 @@ } }, { + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "in": "query", "name": "timezone", "required": false, "schema": { "anyOf": [ { + "maximum": 840, + "minimum": -840, "type": "integer" }, { "type": "null" } ], + "description": "Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention.", "title": "Timezone" } + }, + { + "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.", + "in": "query", + "name": "include_current_utc_day", + "required": false, + "schema": { + "default": true, + "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.", + "title": "Include Current Utc Day", + "type": "boolean" + } } ], "responses": { diff --git a/litellm/proxy/management_endpoints/daily_activity_routes.py b/litellm/proxy/management_endpoints/daily_activity_routes.py index 1a3445aae52..e6bd97ae725 100644 --- a/litellm/proxy/management_endpoints/daily_activity_routes.py +++ b/litellm/proxy/management_endpoints/daily_activity_routes.py @@ -41,6 +41,7 @@ from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw from litellm.repositories.daily_activity_buckets import api_key_bucket from litellm.repositories.daily_activity_repository import DailyActivityRepository +from litellm.repositories.daily_activity_sql import adjust_dates_for_timezone from litellm.router import Router from litellm.types.proxy.management_endpoints.common_daily_activity import ( CacheLeakageKeysResponse, @@ -83,11 +84,22 @@ def get_daily_activity_llm_router() -> Router | None: def _date_range_error(query: EntityQuery, *, user_aggregated: bool) -> InvalidDateRange | None: - if user_aggregated: - date_range: Final = parse_canonical_date_range(query.start_date, query.end_date) - return date_range if isinstance(date_range, InvalidDateRange) else None + if query.timezone_offset_minutes is not None and not (-840 <= query.timezone_offset_minutes <= 840): + return InvalidDateRange(reason="timezone must be between -840 and 840 minutes") - range_error: Final[str | None] = aggregated_date_range_error(query.start_date, query.end_date) + date_range: Final = parse_canonical_date_range(query.start_date, query.end_date) + if isinstance(date_range, InvalidDateRange): + return date_range + if user_aggregated: + return None + + adjusted_start, adjusted_end = adjust_dates_for_timezone( + date_range.start.isoformat(), + date_range.end.isoformat(), + query.timezone_offset_minutes, + query.include_current_utc_day, + ) + range_error: Final = aggregated_date_range_error(adjusted_start, adjusted_end) return None if range_error is None else InvalidDateRange(reason=range_error) diff --git a/litellm/proxy/management_endpoints/daily_activity_scopes.py b/litellm/proxy/management_endpoints/daily_activity_scopes.py index 1eb6ab3210b..167b36bd78d 100644 --- a/litellm/proxy/management_endpoints/daily_activity_scopes.py +++ b/litellm/proxy/management_endpoints/daily_activity_scopes.py @@ -228,11 +228,13 @@ def _user_query( ), timezone: int | None = Query( default=None, + ge=-840, + le=840, description="Timezone offset in minutes from UTC (e.g., 480 for PST). " "Matches JavaScript's Date.getTimezoneOffset() convention.", ), include_current_utc_day: bool = Query( - default=False, + default=True, 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 " @@ -258,7 +260,20 @@ def _team_query( model: str | None = None, api_key: str | None = None, exclude_team_ids: str | None = None, - timezone: int | None = None, + timezone: int | None = Query( + default=None, + ge=-840, + le=840, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), + include_current_utc_day: bool = Query( + default=True, + 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.", + ), ) -> EntityQuery: return EntityQuery( entity_ids=_query_ids(team_ids), @@ -268,7 +283,7 @@ def _team_query( end_date=end_date, model=model, timezone_offset_minutes=timezone, - include_current_utc_day=False, + include_current_utc_day=include_current_utc_day, ) @@ -278,7 +293,20 @@ def _tag_query( model: str | None = None, api_key: str | None = None, tags: str | None = None, - timezone: int | None = None, + timezone: int | None = Query( + default=None, + ge=-840, + le=840, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), + include_current_utc_day: bool = Query( + default=True, + 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.", + ), ) -> EntityQuery: return EntityQuery( entity_ids=_query_ids(tags), @@ -288,7 +316,7 @@ def _tag_query( end_date=end_date, model=model, timezone_offset_minutes=timezone, - include_current_utc_day=False, + include_current_utc_day=include_current_utc_day, ) @@ -299,7 +327,20 @@ def _organization_query( model: str | None = None, api_key: str | None = None, exclude_organization_ids: str | None = None, - timezone: int | None = None, + timezone: int | None = Query( + default=None, + ge=-840, + le=840, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), + include_current_utc_day: bool = Query( + default=True, + 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.", + ), ) -> EntityQuery: return EntityQuery( entity_ids=_query_ids(organization_ids), @@ -309,7 +350,7 @@ def _organization_query( end_date=end_date, model=model, timezone_offset_minutes=timezone, - include_current_utc_day=False, + include_current_utc_day=include_current_utc_day, ) @@ -320,7 +361,20 @@ def _customer_query( model: str | None = None, api_key: str | None = None, exclude_end_user_ids: str | None = None, - timezone: int | None = None, + timezone: int | None = Query( + default=None, + ge=-840, + le=840, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), + include_current_utc_day: bool = Query( + default=True, + 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.", + ), ) -> EntityQuery: return EntityQuery( entity_ids=_query_ids(end_user_ids), @@ -330,7 +384,7 @@ def _customer_query( end_date=end_date, model=model, timezone_offset_minutes=timezone, - include_current_utc_day=False, + include_current_utc_day=include_current_utc_day, ) @@ -341,7 +395,20 @@ def _agent_query( model: str | None = None, api_key: str | None = None, exclude_agent_ids: str | None = None, - timezone: int | None = None, + timezone: int | None = Query( + default=None, + ge=-840, + le=840, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), + include_current_utc_day: bool = Query( + default=True, + 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.", + ), ) -> EntityQuery: return EntityQuery( entity_ids=_query_ids(agent_ids), @@ -351,7 +418,7 @@ def _agent_query( end_date=end_date, model=model, timezone_offset_minutes=timezone, - include_current_utc_day=False, + include_current_utc_day=include_current_utc_day, ) diff --git a/litellm/proxy/management_endpoints/gateway_request_endpoints.py b/litellm/proxy/management_endpoints/gateway_request_endpoints.py index 01c9ad137c2..4b896062915 100644 --- a/litellm/proxy/management_endpoints/gateway_request_endpoints.py +++ b/litellm/proxy/management_endpoints/gateway_request_endpoints.py @@ -22,6 +22,7 @@ from litellm.models.gateway_activity import GatewayRouteActivity, GatewayStatusC from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.deps.repositories import GatewayActivityRepositoryDep +from litellm.repositories.daily_activity_sql import adjust_dates_for_timezone from litellm.types.proxy.gateway_requests import ( GatewayRequestActivityResponse, GatewayRequestBreakdownEntry, @@ -87,6 +88,21 @@ async def get_gateway_daily_activity( activity: GatewayActivityRepositoryDep, start_date: str | None = Query(default=None, description="Start date in YYYY-MM-DD format"), end_date: str | None = Query(default=None, description="End date in YYYY-MM-DD format"), + timezone_offset_minutes: Annotated[ + int | None, + Query( + alias="timezone", + ge=-840, + le=840, + description="Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset().", + ), + ] = None, + include_current_utc_day: Annotated[ + bool, + Query( + description="Include the current UTC bucket when the range reaches the caller's local today. Requires timezone." + ), + ] = True, ) -> GatewayRequestActivityResponse: """ Successful and failed gateway requests, counted at the ASGI edge. @@ -109,7 +125,14 @@ async def get_gateway_daily_activity( default_start, default_end = _default_range() selected_start: Final = start_date or default_start selected_end: Final = end_date or default_end - activity_rows: Final = await activity.get_activity(selected_start, selected_end) + query_start, query_end = adjust_dates_for_timezone( + selected_start, + selected_end, + timezone_offset_minutes, + include_current_utc_day, + utc_now=datetime.now(timezone.utc), + ) + activity_rows: Final = await activity.get_activity(query_start, query_end) rows: Final = tuple(row for row in activity_rows if isinstance(row, GatewayRouteActivity)) status_code_rows: Final = tuple(row for row in activity_rows if isinstance(row, GatewayStatusCodeActivity)) verbose_proxy_logger.debug("/gateway/daily/activity - aggregated %d rows", len(rows)) diff --git a/litellm/repositories/daily_activity_sql.py b/litellm/repositories/daily_activity_sql.py index 4bb52767de6..16c5bcc3980 100644 --- a/litellm/repositories/daily_activity_sql.py +++ b/litellm/repositories/daily_activity_sql.py @@ -47,6 +47,10 @@ PRISMA_TO_PG_TABLE: Final[Mapping[DailyActivityTable, str]] = MappingProxyType( ) +_MIN_TIMEZONE_OFFSET_MINUTES: Final = -840 # UTC+14 (e.g. Line Islands) +_MAX_TIMEZONE_OFFSET_MINUTES: Final = 840 # UTC-14 + + def adjust_dates_for_timezone( start_date: str, end_date: str, @@ -54,7 +58,11 @@ def adjust_dates_for_timezone( include_current_utc_day: bool = False, utc_now: datetime | None = None, ) -> tuple[str, str]: - if not include_current_utc_day or timezone_offset_minutes is None: + if ( + not include_current_utc_day + or timezone_offset_minutes is None + or not (_MIN_TIMEZONE_OFFSET_MINUTES <= timezone_offset_minutes <= _MAX_TIMEZONE_OFFSET_MINUTES) + ): return start_date, end_date now: Final = utc_now if utc_now is not None else datetime.now(timezone.utc) caller_local_today: Final = (now - timedelta(minutes=timezone_offset_minutes)).date().isoformat() diff --git a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py index d658e59b76c..e5f377937e5 100644 --- a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -1557,6 +1557,32 @@ def test_user_aggregate_keeps_current_day_query_semantics( assert scope.include_current_utc_day is True +def test_aggregate_routes_default_include_current_utc_day_to_true( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + for route in ("/user/daily/activity/aggregated", "/team/daily/activity/aggregated"): + response: Final = client.get( + route, + params={**_DATE_PARAMS, "timezone": 480}, + ) + assert response.status_code == 200, response.text + scope: Final = repository.aggregated.call_args.args[0] + assert scope.include_current_utc_day is True + + +def test_aggregate_routes_reject_invalid_timezone( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + for route in ("/user/daily/activity/aggregated", "/team/daily/activity/aggregated"): + response: Final = client.get( + route, + params={**_DATE_PARAMS, "timezone": 20000000}, + ) + assert response.status_code in (400, 422) + + @pytest.mark.parametrize( ("start_date", "end_date", "message"), ( diff --git a/tests/unit/proxy/management_endpoints/test_gateway_request_endpoints.py b/tests/unit/proxy/management_endpoints/test_gateway_request_endpoints.py index c3bec658457..e71f3a343cd 100644 --- a/tests/unit/proxy/management_endpoints/test_gateway_request_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_gateway_request_endpoints.py @@ -71,6 +71,34 @@ def _prisma_returning(rows: Sequence[Mapping[str, str | int | None]] | None) -> return client +@pytest.fixture +def gateway_daily_activity_client() -> TestClient: + buckets: Final = (("2025-12-31", 3), ("2026-01-01", 7)) + + async def query_raw(_query: str, start_date: str, end_date: str) -> tuple[Mapping[str, str | int | None], ...]: + return tuple( + { + "kind": "route", + "date": date, + "category": "llm", + "route": "/chat/completions", + "successful_requests": successful, + "failed_requests": 0, + "status_code": None, + } + for date, successful in buckets + if start_date <= date <= end_date + ) + + prisma: Final = MagicMock() + prisma.db.query_raw = AsyncMock(side_effect=query_raw) + app: Final = FastAPI() + app.include_router(router) + app.dependency_overrides[user_api_key_auth] = _admin + app.dependency_overrides[get_gateway_activity_repository] = lambda: GatewayActivityRepository(prisma) + return TestClient(app) + + class TestDefaultRange: def test_spans_the_documented_lookback(self): start, end = _default_range() @@ -307,6 +335,58 @@ class TestGatewayDailyActivityRoute: assert response.status_code == 200 assert prisma.db.query_raw.call_args_list[0].args[1:] == _FROZEN_RANGE + @pytest.mark.parametrize( + ("utc_hour", "timezone_offset_minutes", "include_current_utc_day", "expected_successful_requests"), + ( + pytest.param(12, None, True, 3, id="historical-without-timezone"), + pytest.param(12, 0, True, 3, id="historical-utc"), + pytest.param(12, -330, True, 3, id="historical-east"), + pytest.param(12, 300, True, 3, id="historical-west"), + pytest.param(1, 300, True, 10, id="live-west-year-rollover"), + pytest.param(1, 300, None, 10, id="live-west-default"), + pytest.param(1, 300, False, 3, id="live-west-opt-out"), + pytest.param(1, None, True, 3, id="live-without-timezone"), + pytest.param(1, 0, True, 3, id="historical-utc-after-midnight"), + pytest.param(1, 840, True, 10, id="maximum-offset"), + pytest.param(12, -840, True, 3, id="minimum-offset"), + ), + ) + def test_timezone_queries_only_include_the_live_utc_bucket_for_the_callers_current_day( + self, + gateway_daily_activity_client: TestClient, + utc_hour: int, + timezone_offset_minutes: int | None, + include_current_utc_day: bool | None, + expected_successful_requests: int, + ) -> None: + params: Final = { + "start_date": "2025-12-31", + "end_date": "2025-12-31", + **( + {"include_current_utc_day": str(include_current_utc_day).lower()} + if include_current_utc_day is not None + else {} + ), + **({"timezone": str(timezone_offset_minutes)} if timezone_offset_minutes is not None else {}), + } + with patch("litellm.proxy.management_endpoints.gateway_request_endpoints.datetime") as clock: + clock.now.return_value = datetime(2026, 1, 1, utc_hour, tzinfo=timezone.utc) + response: Final = gateway_daily_activity_client.get("/gateway/daily/activity", params=params) + + assert response.status_code == 200, response.text + assert response.json()["total_successful_requests"] == expected_successful_requests + + @pytest.mark.parametrize("timezone_offset_minutes", (-841, 841)) + def test_timezone_outside_supported_offsets_is_rejected( + self, gateway_daily_activity_client: TestClient, timezone_offset_minutes: int + ) -> None: + response: Final = gateway_daily_activity_client.get( + "/gateway/daily/activity", + params={"start_date": "2025-12-31", "end_date": "2025-12-31", "timezone": timezone_offset_minutes}, + ) + + assert response.status_code == 422, response.text + def test_serialized_response_carries_the_documented_shape(self): prisma: Final = _prisma_returning( [ diff --git a/tests/unit/repositories/test_daily_activity_sql.py b/tests/unit/repositories/test_daily_activity_sql.py index f8375b93dad..d69b3e73418 100644 --- a/tests/unit/repositories/test_daily_activity_sql.py +++ b/tests/unit/repositories/test_daily_activity_sql.py @@ -263,6 +263,12 @@ def test_timezone_adjustment_live_end_handles_offset_and_opt_in_cases() -> None: assert adjust_dates_for_timezone( "2026-07-06", "2026-08-09", 420, include_current_utc_day=True, utc_now=pt_evening ) == ("2026-07-06", "2026-08-09") + assert adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", 20000000, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-06", "2026-08-05") + assert adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", -900, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-06", "2026-08-05") @pytest.mark.parametrize("offset_minutes", [None, 0, -330, 480]) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts index fe6931bb6f5..933548d3e07 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts @@ -41,7 +41,12 @@ export function useHomeUsage(): HomeUsage { const request = useMemo( () => accessToken && (isAdmin || userId) - ? { accessToken, ...range, entityIds: isAdmin ? null : [userId as string] } + ? { + accessToken, + ...range, + entityIds: isAdmin ? null : [userId as string], + includeCurrentUtcDay: true, + } : null, [accessToken, isAdmin, range, userId], ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index cd3e5b2d0ef..5d96bf08699 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -256,6 +256,7 @@ const UsagePage: React.FC = ({ teams, organizations, teamsLoadin startTime, endTime, entityIds: effectiveUserId ? [effectiveUserId] : null, + includeCurrentUtcDay: true, } : null, [accessToken, startTime, endTime, effectiveUserId], diff --git a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx index bd925901fef..a5eb66e7f7a 100644 --- a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx @@ -103,6 +103,7 @@ const UsagePanel: React.FC = ({ accessToken, userId }) => { startTime: start, endTime: end, entityIds: userId ? [userId] : null, + includeCurrentUtcDay: true, }; return dailyActivityAggregatedCall("user", request); }, diff --git a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts index 6821d26fb33..7cd97810cdf 100644 --- a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts +++ b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts @@ -6,6 +6,7 @@ import { dailyActivityExportCall, dailyActivityKeySearchCall, dailyActivityModelTopKeysCall, + gatewayDailyActivityCall, } from "./networking"; import type { DailyActivityEntity, DailyActivityRequest } from "./UsagePage/dailyActivityApi"; @@ -26,8 +27,8 @@ const captureFetch = () => { const requestedUrl = (mockFetch: ReturnType, callIndex = 0): URL => new URL(String(mockFetch.mock.calls[callIndex][0]), "http://example.com"); -const start = new Date("2025-01-05T00:00:00Z"); -const end = new Date("2025-01-31T00:00:00Z"); +const start = new Date(2025, 0, 5); +const end = new Date(2025, 0, 31); const req = (overrides: Partial = {}): DailyActivityRequest => ({ accessToken: "sk-key", @@ -38,6 +39,8 @@ const req = (overrides: Partial = {}): DailyActivityReques afterEach(() => { global.fetch = originalFetch; + vi.useRealTimers(); + vi.unstubAllEnvs(); }); describe("dailyActivityAggregatedCall", () => { @@ -115,13 +118,20 @@ describe("dailyActivityAggregatedCall", () => { expect(requestedUrl(mockFetch).searchParams.has("api_key")).toBe(false); }); - it("sends include_current_utc_day only when set", async () => { - const mockFetch = captureFetch(); + it.each<[DailyActivityEntity]>([["user"], ["team"], ["tag"], ["organization"], ["customer"], ["agent"]])( + "preserves true, false, and omitted live-day settings for %s", + async (entity) => { + const mockFetch = captureFetch(); - await dailyActivityAggregatedCall("user", req({ includeCurrentUtcDay: true })); + for (const setting of [true, false, undefined]) { + await dailyActivityAggregatedCall(entity, req({ includeCurrentUtcDay: setting })); - expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true"); - }); + expect( + requestedUrl(mockFetch, mockFetch.mock.calls.length - 1).searchParams.get("include_current_utc_day"), + ).toBe(setting === undefined ? null : String(setting)); + } + }, + ); it("sends api_key_limit only when provided", async () => { const mockFetch = captureFetch(); @@ -134,6 +144,29 @@ describe("dailyActivityAggregatedCall", () => { }); }); +describe("gatewayDailyActivityCall", () => { + it.each(["America/Los_Angeles", "Asia/Kolkata"])( + "sends local date boundaries and the caller's live-day timezone in %s", + async (timezone) => { + vi.stubEnv("TZ", timezone); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-08-06T02:00:00Z")); + const mockFetch = captureFetch(); + const rangeStart = new Date(2026, 11, 31); + const rangeEnd = new Date(2027, 0, 1); + + await gatewayDailyActivityCall("sk-key", rangeStart, rangeEnd); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/gateway/daily/activity"); + expect(url.searchParams.get("start_date")).toBe("2026-12-31"); + expect(url.searchParams.get("end_date")).toBe("2027-01-01"); + expect(url.searchParams.get("timezone")).toBe(String(new Date().getTimezoneOffset())); + expect(url.searchParams.get("include_current_utc_day")).toBe("true"); + }, + ); +}); + describe("dailyActivityKeySearchCall", () => { it("GETs the search route with the search term", async () => { const mockFetch = captureFetch(); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 9640a448a20..ab3f2aea81c 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1384,7 +1384,7 @@ const dailyActivityQuery = ( ? { [excludeParam]: excludeEntityIds && excludeEntityIds.length > 0 ? excludeEntityIds.join(",") : undefined } : {}), timezone: new Date().getTimezoneOffset().toString(), - include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined, + include_current_utc_day: req.includeCurrentUtcDay?.toString(), ...extra, }; }; @@ -2270,6 +2270,8 @@ export const gatewayDailyActivityCall = async (accessToken: string, startTime: D query: { start_date: formatDate(startTime), end_date: formatDate(endTime), + timezone: new Date().getTimezoneOffset().toString(), + include_current_utc_day: "true", }, }); } catch (error) { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 2272b4bea43..6fbe31e43d2 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -53965,7 +53965,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_agent_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -54004,7 +54007,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_agent_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -54044,7 +54050,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_agent_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -54083,7 +54092,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_agent_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -54122,7 +54134,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_agent_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -59652,7 +59667,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -59691,7 +59709,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -59731,7 +59752,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -59770,7 +59794,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -59809,7 +59836,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -60703,7 +60733,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -60742,7 +60775,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -60782,7 +60818,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -60821,7 +60860,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -60860,7 +60902,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_end_user_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -62474,6 +62519,10 @@ export interface operations { start_date?: string | null; /** @description End date in YYYY-MM-DD format */ end_date?: string | null; + /** @description Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset(). */ + timezone?: number | null; + /** @description Include the current UTC bucket when the range reaches the caller's local today. Requires timezone. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -70063,7 +70112,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_organization_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -70102,7 +70154,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_organization_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -70142,7 +70197,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_organization_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -70181,7 +70239,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_organization_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -70220,7 +70281,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_organization_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -76334,7 +76398,10 @@ export interface operations { model?: string | null; api_key?: string | null; tags?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -76372,7 +76439,10 @@ export interface operations { model?: string | null; api_key?: string | null; tags?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -76411,7 +76481,10 @@ export interface operations { model?: string | null; api_key?: string | null; tags?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -76449,7 +76522,10 @@ export interface operations { model?: string | null; api_key?: string | null; tags?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -76487,7 +76563,10 @@ export interface operations { model?: string | null; api_key?: string | null; tags?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -77025,7 +77104,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_team_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -77064,7 +77146,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_team_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -77104,7 +77189,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_team_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -77143,7 +77231,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_team_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; @@ -77182,7 +77273,10 @@ export interface operations { model?: string | null; api_key?: string | null; exclude_team_ids?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; }; header?: never; path?: never;