From b9134f2d738d2105bd98ad7f08059adbdf0dad2e Mon Sep 17 00:00:00 2001 From: Aaron Sproul Date: Wed, 7 Oct 2026 19:03:25 +0000 Subject: [PATCH] fix(focus): re-send the last two whole days so late-flushed spend lands LiteLLM writes DailyUserSpend in batches, so spend dated yesterday can be flushed after the 00:05 UTC daily export. That export only covered the previous UTC midnight onward, and the next day's export moved past it, so the tail of each day was never sent. Start the window at UTC midnight two days back: the following run re-sends yesterday whole, overwriting its per-day object with the complete day. --- .../integrations/ternary/ternary_logger.py | 4 +- .../ternary/test_ternary_logger.py | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/litellm/integrations/ternary/ternary_logger.py b/litellm/integrations/ternary/ternary_logger.py index 4a29eafd9ae..4d9037636df 100644 --- a/litellm/integrations/ternary/ternary_logger.py +++ b/litellm/integrations/ternary/ternary_logger.py @@ -256,9 +256,9 @@ class TernaryLogger(FocusLogger): ) def _compute_time_window(self, now: datetime) -> FocusTimeWindow: - """Snap the window start to the previous UTC midnight so each push carries whole days.""" + """Start at UTC midnight two days back so each push re-sends whole days, including spend flushed late.""" now_utc: Final = now.astimezone(timezone.utc) - start_time: Final = (now_utc - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + start_time: Final = (now_utc - timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0) return FocusTimeWindow(start_time=start_time, end_time=now_utc, frequency=self.frequency) def _ensure_engine(self) -> FocusExportEngine: diff --git a/tests/unit/integrations/ternary/test_ternary_logger.py b/tests/unit/integrations/ternary/test_ternary_logger.py index 2e76490f7ec..5a96e5bd679 100644 --- a/tests/unit/integrations/ternary/test_ternary_logger.py +++ b/tests/unit/integrations/ternary/test_ternary_logger.py @@ -94,9 +94,46 @@ def test_compute_time_window_is_day_aligned_and_carries_whole_days(): logger = TernaryLogger(api_key="k", connection_id="c", base_url="http://localhost:8080") now = datetime(2026, 9, 4, 13, 47, 5, tzinfo=timezone.utc) window = logger._compute_time_window(now) - assert window.start_time == datetime(2026, 9, 3, 0, 0, 0, tzinfo=timezone.utc) + assert window.start_time == datetime(2026, 9, 2, 0, 0, 0, tzinfo=timezone.utc) assert window.end_time == now - assert window.start_time.hour == 0 and window.start_time.minute == 0 + + +@pytest.mark.asyncio +async def test_daily_export_resends_yesterday_whole_including_spend_flushed_after_midnight(): + """LiteLLM flushes spend in batches, so a 2026-10-07 row can be written at 2026-10-08 00:10, after that + day's 00:05 export. The next daily run (2026-10-09 00:05) must still upload 2026-10-07 complete.""" + from datetime import datetime, timezone + + rows = pl.DataFrame( + { + "date": ["2026-10-07", "2026-10-07", "2026-10-08"], + "updated_at": [ + datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc), + datetime(2026, 10, 8, 0, 10, tzinfo=timezone.utc), + datetime(2026, 10, 8, 9, 0, tzinfo=timezone.utc), + ], + "spend": [1.0, 2.0, 3.0], + } + ) + logger = TernaryLogger(api_key="k", connection_id="c", base_url="http://localhost:8080") + window = logger._compute_time_window(datetime(2026, 10, 9, 0, 5, tzinfo=timezone.utc)) + + engine = MagicMock() + + async def fake_get_usage_data(*, limit, start_time_utc, end_time_utc): + return rows.filter((pl.col("updated_at") >= start_time_utc) & (pl.col("updated_at") <= end_time_utc)) + + engine._database.get_usage_data = fake_get_usage_data + captured = {} + + async def fake_deliver(data, _window): + captured["data"] = data + + engine._deliver_enriched = fake_deliver + await _TernaryExportEngine.export_window(engine, window=window, limit=None) + + sent = captured["data"] + assert sent.filter(pl.col("date") == "2026-10-07")["spend"].sum() == 3.0 def test_drop_days_before_removes_older_days():