From b3aacaf49df0fd6b4b89987865adf72b5cbfa976 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Wed, 30 Sep 2026 10:45:43 -0700 Subject: [PATCH] fix(roi): normalize scheduler timestamps to UTC --- .../roi_calculator_endpoints.py | 6 ++++- litellm/proxy/roi_calculator/sync_store.py | 4 ++-- .../database/test_roi_sync_store.py | 13 +++++++++- .../test_roi_calculator_endpoints.py | 24 ++++++++++++++++++- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/roi_calculator_endpoints.py b/litellm/proxy/management_endpoints/roi_calculator_endpoints.py index 94febf7d940..22ee17bbc5f 100644 --- a/litellm/proxy/management_endpoints/roi_calculator_endpoints.py +++ b/litellm/proxy/management_endpoints/roi_calculator_endpoints.py @@ -549,7 +549,11 @@ def _next_update(settings: ROISettings, status: ROISyncStatus, report: ROIReport ): return None anchor: Final = status.finished_at or status.started_at or report["synced_at"] - return datetime.fromisoformat(anchor.replace("Z", "+00:00")) + timedelta(minutes=settings.update_interval_minutes) + parsed: Final = datetime.fromisoformat(anchor.replace("Z", "+00:00")) + utc_anchor: Final = ( + parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc) + ) + return utc_anchor + timedelta(minutes=settings.update_interval_minutes) async def run_scheduled_sync() -> None: diff --git a/litellm/proxy/roi_calculator/sync_store.py b/litellm/proxy/roi_calculator/sync_store.py index 75c0af3ea28..c074b0f16d5 100644 --- a/litellm/proxy/roi_calculator/sync_store.py +++ b/litellm/proxy/roi_calculator/sync_store.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from types import MappingProxyType from typing import Final, Protocol, cast # noqa: TID251 - PrismaWrapper dynamically delegates database methods @@ -117,7 +117,7 @@ class SyncStore: { "running": False, "phase": "error", - "finished_at": rows[0].last_run_at.isoformat(), + "finished_at": rows[0].last_run_at.replace(tzinfo=timezone.utc).isoformat(), "stage": "Sync interrupted", "error": "The worker stopped responding. Run analysis again to resume saved estimates.", } diff --git a/tests/integration/database/test_roi_sync_store.py b/tests/integration/database/test_roi_sync_store.py index 57cbcea3539..7f3762af8a7 100644 --- a/tests/integration/database/test_roi_sync_store.py +++ b/tests/integration/database/test_roi_sync_store.py @@ -72,7 +72,18 @@ async def test_roi_cache_survives_scope_changes_and_uses_writer(monkeypatch: pyt ) == ("roi_calculator_pull_new", "roi_calculator_pull_outside-window") assert not await store.acquire("scheduled", running, 1440) assert await store.acquire("manual", running) - assert await store.finish("manual", complete, empty) + write_rows( + "UPDATE \"LiteLLM_Config\" SET last_run_at = NOW() - INTERVAL '2 minutes' WHERE param_name = %s", + ("roi_calculator_sync",), + database_url=writer_url, + ) + expired: Final = await store.status() + assert expired is not None and expired.phase == "error" and expired.finished_at is not None + assert datetime.fromisoformat(expired.finished_at).tzinfo == timezone.utc + assert not await store.heartbeat("manual", running) + assert await store.acquire("replacement", running) + assert not await store.finish("manual", complete, empty) + assert await store.finish("replacement", complete, empty) assert ( len( read_rows( diff --git a/tests/unit/proxy/management_endpoints/test_roi_calculator_endpoints.py b/tests/unit/proxy/management_endpoints/test_roi_calculator_endpoints.py index bd635193ba6..f3b4808926c 100644 --- a/tests/unit/proxy/management_endpoints/test_roi_calculator_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_roi_calculator_endpoints.py @@ -1,5 +1,6 @@ import json from collections.abc import Mapping +from datetime import datetime, timezone from types import MappingProxyType from typing import Final, cast @@ -12,11 +13,13 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.roi_calculator_endpoints import ( _estimator_models_from_deployments, + _next_update, get_roi_config_repository, router, ) from litellm.proxy.roi_calculator.estimator import estimator_options -from litellm.types.roi_calculator import ROISettings +from litellm.proxy.roi_calculator.sample import sample_report +from litellm.types.roi_calculator import ROISettings, ROISyncStatus _JSON_HEADERS: Final = MappingProxyType({"content-type": "application/json"}) @@ -187,3 +190,22 @@ def test_sample_preview_does_not_change_live_settings_or_report() -> None: def test_schedule_rejects_intervals_under_five_minutes(interval: float) -> None: client: Final = _client(LitellmUserRoles.PROXY_ADMIN, _ConfigRepository()) assert client.put("/roi-calculator/settings", json={"update_interval_minutes": interval}).status_code == 422 + + +@pytest.mark.parametrize("anchor", ("2026-09-30T12:00:00", "2026-09-30T12:00:00Z", "2026-09-30T14:00:00+02:00")) +def test_schedule_normalizes_legacy_and_offset_timestamps(anchor: str) -> None: + settings: Final = ROISettings(repos=("example/repo",), estimator_model="estimator", update_interval_minutes=60) + status: Final = ROISyncStatus( + running=False, + phase="error", + stage="Interrupted", + done=0, + total=0, + estimated=0, + reused=0, + needs_attention=0, + error=None, + finished_at=anchor, + ) + report: Final = sample_report(datetime(2026, 9, 30, tzinfo=timezone.utc)) + assert _next_update(settings, status, report) == datetime(2026, 9, 30, 13, tzinfo=timezone.utc)