Skip to content

Commit 8cf0b50

Browse files
fix(ptu): hand the prune a plain delete filter the query builder can serialise (#37571)
* fix(ptu): hand the prune a plain delete filter the query builder can serialise The bounded sweep built its predicate as a read-only mapping view, which the query builder refuses to serialise, so the nightly job raised as soon as a config-declared deployment was priced. The charges were already written by then, which is why the run looked like it had produced its rows. The in-memory table these tests run against accepts any mapping, so only a live run caught it. A predicate builder now returns a plain dict and is asserted as one, and the catch-up pass has a test covering a config-declared reservation. * refactor(ptu): build the prune predicate in one shot Both filter shapes are known upfront, so the bounded one is constructed directly rather than by mutating a value already declared Final. The catch-up test took two independent clock reads, which disagree across UTC midnight; it now derives both the reservation start and the expected last charged day from a single read, matching the three sibling tests.
1 parent 57b328f commit 8cf0b50

2 files changed

Lines changed: 55 additions & 16 deletions

File tree

litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,20 @@ async def _deliver_alert(alert: "Callable[[str], Awaitable[None]] | None", messa
724724
verbose_proxy_logger.error("PTU rollup: could not deliver the failed-charge alert: %s", exc)
725725

726726

727+
def _prune_filter(*, date_str: str, cutoff: datetime, chunk: "tuple[str, ...] | None") -> "Mapping[str, object]":
728+
"""One delete statement's predicate. An absent chunk leaves the sweep unbounded.
729+
730+
Returns a plain dict because the query builder serialises the mapping it is handed and
731+
rejects a read-only view of one.
732+
"""
733+
return { # mutable-ok: prisma delete filter
734+
"date": date_str,
735+
"api_key": PTU_SENTINEL_API_KEY,
736+
"updated_at": {"lt": cutoff}, # mutable-ok: prisma comparison filter
737+
**({} if chunk is None else {"model": {"in": chunk}}), # mutable-ok: prisma membership filter
738+
}
739+
740+
727741
async def _prune_unrefreshed_sentinel_rows(
728742
prisma_client: "PrismaClient",
729743
*,
@@ -752,27 +766,15 @@ async def _prune_unrefreshed_sentinel_rows(
752766
that many deployments would otherwise hit every night with no handler above here.
753767
"""
754768
cutoff: Final = run_started - timedelta(seconds=PTU_PRUNE_SKEW_GRACE_SECONDS)
755-
unbounded: Final = { # mutable-ok: prisma delete filter
756-
"date": date_str,
757-
"api_key": PTU_SENTINEL_API_KEY,
758-
"updated_at": {"lt": cutoff}, # mutable-ok: prisma comparison filter
759-
}
760769
ordered: Final = () if scanned_ids is None else tuple(sorted(scanned_ids))
761-
filters: Final = (
762-
(unbounded,)
770+
chunks: Final = (
771+
(None,)
763772
if scanned_ids is None
764773
else tuple(
765-
MappingProxyType(
766-
{
767-
**unbounded,
768-
"model": { # mutable-ok: prisma membership filter
769-
"in": ordered[start : start + _PRUNE_ID_CHUNK_SIZE]
770-
},
771-
}
772-
)
773-
for start in range(0, len(ordered), _PRUNE_ID_CHUNK_SIZE)
774+
ordered[start : start + _PRUNE_ID_CHUNK_SIZE] for start in range(0, len(ordered), _PRUNE_ID_CHUNK_SIZE)
774775
)
775776
)
777+
filters: Final = tuple(_prune_filter(date_str=date_str, cutoff=cutoff, chunk=chunk) for chunk in chunks)
776778
deletions: Final = tuple(
777779
[await prisma_client.db.litellm_dailyteamspend.delete_many(where=where) for where in filters]
778780
)

tests/test_litellm/proxy/spend_tracking/test_ptu_flat_cost_rollup.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2045,3 +2045,40 @@ def test_the_router_lookup_returns_none_outside_a_proxy():
20452045
finally:
20462046
if real is not None:
20472047
sys.modules["litellm.proxy.proxy_server"] = real
2048+
2049+
2050+
@pytest.mark.parametrize("chunk", [None, ("dep-a", "dep-b")], ids=["unbounded", "bounded"])
2051+
def test_the_prune_filter_is_a_plain_dict(chunk):
2052+
"""The query builder serialises the mapping it is handed and rejects a read-only view of
2053+
one, which the in-memory table in these tests accepts happily. Only a live run caught it."""
2054+
predicate = ptu_rollup._prune_filter(date_str=DAY.isoformat(), cutoff=datetime.now(timezone.utc), chunk=chunk)
2055+
2056+
assert type(predicate) is dict
2057+
assert type(predicate["updated_at"]) is dict
2058+
if chunk is None:
2059+
assert "model" not in predicate
2060+
else:
2061+
assert type(predicate["model"]) is dict
2062+
assert predicate["model"]["in"] == chunk
2063+
2064+
2065+
@pytest.mark.asyncio
2066+
async def test_the_catch_up_pass_reaches_a_config_declared_deployment(monkeypatch):
2067+
"""The catch-up shares the loader, so config deployments join it without being wired in.
2068+
That is what prices the elapsed days of a reservation declared before today."""
2069+
table = _FakeSentinelTable()
2070+
now = datetime.now(timezone.utc)
2071+
started = (now - timedelta(days=3)).strftime("%Y-%m-%dT00:00:00Z")
2072+
entry = _router_entry(
2073+
model_id="cfg-back",
2074+
model_info={"ptu_count": 100, "cost_per_ptu_per_hour": 0.02, "team_id": "t", "ptu_effective_from": started},
2075+
)
2076+
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(entry))
2077+
2078+
await run_scheduled_ptu_rollup(_prisma_for([], table), pod_lock_manager=_pod_lock(acquired=True))
2079+
2080+
charged = sorted(day for (_, day, _, model) in table.rows if model == "cfg-back")
2081+
yesterday = (now.date() - timedelta(days=1)).isoformat()
2082+
assert len(charged) == 3, charged
2083+
assert charged[-1] == yesterday
2084+
assert all(row["ptu_flat_cost"] == pytest.approx(48.0) for row in table.rows.values())

0 commit comments

Comments
 (0)