From bddfa3fc3dd5cf2e3a44b5e026e17f0e72dba5c6 Mon Sep 17 00:00:00 2001 From: OldFangDong Date: Tue, 3 Nov 2026 20:46:46 -0500 Subject: [PATCH 1/3] fix(proxy): handle UTC day boundary in daily activity aggregation and UI views --- .../daily_activity_scopes.py | 47 ++++++++++++++++--- .../gateway_request_endpoints.py | 8 +++- .../test_daily_activity_routes.py | 14 ++++++ .../home/_components/useHomeUsage.ts | 7 ++- .../_components/components/UsagePageView.tsx | 1 + .../src/components/chat/UsagePanel.tsx | 1 + .../src/components/networking.tsx | 2 +- 7 files changed, 71 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/management_endpoints/daily_activity_scopes.py b/litellm/proxy/management_endpoints/daily_activity_scopes.py index 7ff73ac6e2e..00e955e6c4c 100644 --- a/litellm/proxy/management_endpoints/daily_activity_scopes.py +++ b/litellm/proxy/management_endpoints/daily_activity_scopes.py @@ -232,7 +232,7 @@ def _user_query( "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 " @@ -259,6 +259,13 @@ def _team_query( api_key: str | None = None, exclude_team_ids: str | None = None, timezone: int | None = None, + 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 +275,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, ) @@ -279,6 +286,13 @@ def _tag_query( api_key: str | None = None, tags: str | None = None, timezone: int | None = None, + 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 +302,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, ) @@ -300,6 +314,13 @@ def _organization_query( api_key: str | None = None, exclude_organization_ids: str | None = None, timezone: int | None = None, + 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 +330,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, ) @@ -321,6 +342,13 @@ def _customer_query( api_key: str | None = None, exclude_end_user_ids: str | None = None, timezone: int | None = None, + 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 +358,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, ) @@ -342,6 +370,13 @@ def _agent_query( api_key: str | None = None, exclude_agent_ids: str | None = None, timezone: int | None = None, + 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 +386,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..51d12f763cc 100644 --- a/litellm/proxy/management_endpoints/gateway_request_endpoints.py +++ b/litellm/proxy/management_endpoints/gateway_request_endpoints.py @@ -109,7 +109,13 @@ 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) + utc_today: Final = datetime.now(timezone.utc).date().isoformat() + query_end: Final = ( + max(selected_end, utc_today) + if selected_end >= (datetime.now(timezone.utc) - timedelta(days=1)).date().isoformat() + else selected_end + ) + activity_rows: Final = await activity.get_activity(selected_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/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py index e487c00f25d..cc582f648c0 100644 --- a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -1499,6 +1499,20 @@ 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 + + @pytest.mark.parametrize( ("start_date", "end_date", "message"), ( 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 7c891754195..da5f8238bf8 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 @@ -254,6 +254,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 e839890a357..92896295c02 100644 --- a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx @@ -101,6 +101,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.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 994c2948346..3d51720fa72 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1378,7 +1378,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 ? "true" : undefined, ...extra, }; }; From 4b934cfd852c86d4f7564c85329365509401888f Mon Sep 17 00:00:00 2001 From: OldFangDong Date: Tue, 3 Nov 2026 20:55:50 -0500 Subject: [PATCH 2/3] fix(proxy): bound timezone offset and revalidate adjusted date range --- .../daily_activity_routes.py | 32 +++++++++++--- .../daily_activity_scopes.py | 42 ++++++++++++++++--- litellm/repositories/daily_activity_sql.py | 10 ++++- .../test_daily_activity_routes.py | 12 ++++++ .../repositories/test_daily_activity_sql.py | 6 +++ 5 files changed, 91 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/management_endpoints/daily_activity_routes.py b/litellm/proxy/management_endpoints/daily_activity_routes.py index 1a3445aae52..9cd58dd92a6 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,12 +84,33 @@ 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) - return None if range_error is None else InvalidDateRange(reason=range_error) + raw_error: Final = ( + parse_canonical_date_range(query.start_date, query.end_date) + if user_aggregated + else aggregated_date_range_error(query.start_date, query.end_date) + ) + if raw_error is not None: + return raw_error if isinstance(raw_error, InvalidDateRange) else InvalidDateRange(reason=raw_error) + + if query.start_date is not None and query.end_date is not None: + adjusted_start, adjusted_end = adjust_dates_for_timezone( + query.start_date, + query.end_date, + query.timezone_offset_minutes, + query.include_current_utc_day, + ) + adjusted_error: Final = ( + parse_canonical_date_range(adjusted_start, adjusted_end) + if user_aggregated + else aggregated_date_range_error(adjusted_start, adjusted_end) + ) + if adjusted_error is not None: + return adjusted_error if isinstance(adjusted_error, InvalidDateRange) else InvalidDateRange(reason=adjusted_error) + + return None async def _resolved_scope( diff --git a/litellm/proxy/management_endpoints/daily_activity_scopes.py b/litellm/proxy/management_endpoints/daily_activity_scopes.py index 00e955e6c4c..e06abdd6215 100644 --- a/litellm/proxy/management_endpoints/daily_activity_scopes.py +++ b/litellm/proxy/management_endpoints/daily_activity_scopes.py @@ -228,6 +228,8 @@ 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.", ), @@ -258,7 +260,13 @@ 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 " @@ -285,7 +293,13 @@ 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 " @@ -313,7 +327,13 @@ 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 " @@ -341,7 +361,13 @@ 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 " @@ -369,7 +395,13 @@ 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 " diff --git a/litellm/repositories/daily_activity_sql.py b/litellm/repositories/daily_activity_sql.py index 4bb52767de6..cd2134f43b0 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 cc582f648c0..717282afae7 100644 --- a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -1513,6 +1513,18 @@ def test_aggregate_routes_default_include_current_utc_day_to_true( 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/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]) From 0b18ff4d3ebbc3c7631c383c87c1b363c33fde06 Mon Sep 17 00:00:00 2001 From: ZHENHAO YANG <49468724+Allenyzh@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:57:11 -0500 Subject: [PATCH 3/3] fix(proxy): preserve daily activity date boundaries --- litellm/proxy/_lazy_openapi_snapshot.json | 80 ++++++++++++++++ .../daily_activity_routes.py | 36 +++---- .../gateway_request_endpoints.py | 29 ++++-- litellm/repositories/daily_activity_sql.py | 2 +- .../test_gateway_request_endpoints.py | 80 ++++++++++++++++ .../networking.dailyActivity.test.ts | 47 ++++++++-- .../src/components/networking.tsx | 4 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 94 +++++++++++++++++++ 8 files changed, 334 insertions(+), 38 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 5cc1c53d396..f2ebc21c7c5 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 9cd58dd92a6..e6bd97ae725 100644 --- a/litellm/proxy/management_endpoints/daily_activity_routes.py +++ b/litellm/proxy/management_endpoints/daily_activity_routes.py @@ -87,30 +87,20 @@ def _date_range_error(query: EntityQuery, *, user_aggregated: bool) -> InvalidDa 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") - raw_error: Final = ( - parse_canonical_date_range(query.start_date, query.end_date) - if user_aggregated - else 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, ) - if raw_error is not None: - return raw_error if isinstance(raw_error, InvalidDateRange) else InvalidDateRange(reason=raw_error) - - if query.start_date is not None and query.end_date is not None: - adjusted_start, adjusted_end = adjust_dates_for_timezone( - query.start_date, - query.end_date, - query.timezone_offset_minutes, - query.include_current_utc_day, - ) - adjusted_error: Final = ( - parse_canonical_date_range(adjusted_start, adjusted_end) - if user_aggregated - else aggregated_date_range_error(adjusted_start, adjusted_end) - ) - if adjusted_error is not None: - return adjusted_error if isinstance(adjusted_error, InvalidDateRange) else InvalidDateRange(reason=adjusted_error) - - return None + range_error: Final = aggregated_date_range_error(adjusted_start, adjusted_end) + return None if range_error is None else InvalidDateRange(reason=range_error) async def _resolved_scope( diff --git a/litellm/proxy/management_endpoints/gateway_request_endpoints.py b/litellm/proxy/management_endpoints/gateway_request_endpoints.py index 51d12f763cc..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,13 +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 - utc_today: Final = datetime.now(timezone.utc).date().isoformat() - query_end: Final = ( - max(selected_end, utc_today) - if selected_end >= (datetime.now(timezone.utc) - timedelta(days=1)).date().isoformat() - else 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(selected_start, query_end) + 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 cd2134f43b0..16c5bcc3980 100644 --- a/litellm/repositories/daily_activity_sql.py +++ b/litellm/repositories/daily_activity_sql.py @@ -48,7 +48,7 @@ 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 +_MAX_TIMEZONE_OFFSET_MINUTES: Final = 840 # UTC-14 def adjust_dates_for_timezone( 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/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 3d51720fa72..91ae4c0904e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1378,7 +1378,7 @@ const dailyActivityQuery = ( ? { [excludeParam]: excludeEntityIds && excludeEntityIds.length > 0 ? excludeEntityIds.join(",") : undefined } : {}), timezone: new Date().getTimezoneOffset().toString(), - include_current_utc_day: req.includeCurrentUtcDay ? "true" : undefined, + include_current_utc_day: req.includeCurrentUtcDay?.toString(), ...extra, }; }; @@ -2262,6 +2262,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 e2e1210bc2a..06e9dcba1ca 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -52896,7 +52896,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; @@ -52935,7 +52938,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; @@ -52975,7 +52981,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; @@ -53014,7 +53023,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; @@ -53053,7 +53065,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; @@ -58383,7 +58398,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; @@ -58422,7 +58440,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; @@ -58462,7 +58483,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; @@ -58501,7 +58525,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; @@ -58540,7 +58567,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; @@ -59434,7 +59464,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; @@ -59473,7 +59506,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; @@ -59513,7 +59549,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; @@ -59552,7 +59591,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; @@ -59591,7 +59633,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; @@ -61205,6 +61250,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; @@ -68794,7 +68843,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; @@ -68833,7 +68885,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; @@ -68873,7 +68928,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; @@ -68912,7 +68970,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; @@ -68951,7 +69012,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; @@ -74337,7 +74401,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; @@ -74375,7 +74442,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; @@ -74414,7 +74484,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; @@ -74452,7 +74525,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; @@ -74490,7 +74566,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; @@ -75028,7 +75107,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; @@ -75067,7 +75149,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; @@ -75107,7 +75192,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; @@ -75146,7 +75234,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; @@ -75185,7 +75276,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;