Skip to content

feat(spend): add a per-session auto-router benchmarks rollup - #35839

Open
tin-berri wants to merge 9 commits into
litellm_internal_stagingfrom
litellm_lit4712_benchmarks_backend
Open

feat(spend): add a per-session auto-router benchmarks rollup#35839
tin-berri wants to merge 9 commits into
litellm_internal_stagingfrom
litellm_lit4712_benchmarks_backend

Conversation

@tin-berri

@tin-berri tin-berri commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • The auto-router benchmarks tab has no backend; every figure it needs (savings, session shape, prompt-cache behaviour) can only be had today by scanning LiteLLM_SpendLogs, which is the widest table in the schema and unbounded at customer scale
  • The facts the tab wants are sequential (which tier served the previous turn, how long a tier had been idle), and the request that produces a turn already knows all of them, so re-deriving them at read time redoes work on every page load
  • Session-level metrics cannot come from the daily rollups at all: session_id is not on them, and a session does not close on a day boundary

How it solves it:

  • A LiteLLM_AutoRouterSession row per (api key, session, auto-router) carries the counters and, in tiers, what each model that session used left in the prompt cache
  • A turn bucket comes from the model record already being written: no live prefix is a first visit, a recorded TTL decides warm versus expired, a negative idle gap is unordered, and a missing TTL stays unknown
  • Nothing is read before the write, so there is no state to load, parse or validate, and the statement is atomic; the read path is one aggregate over pre-folded rows and never touches LiteLLM_SpendLogs

Relevant issues

Linear ticket

Resolves LIT-4712

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Real proxy, real Postgres, real Bedrock calls costing real money. A complexity auto-router over a haiku and an opus deployment, with autorouter_savings_baseline_model set to the opus one.

Before, on the base commit, the same command the after leg runs:

$ curl -sS -w "
HTTP %{http_code}
" ".../auto_router/benchmarks?start_date=2026-07-06&end_date=2026-08-04" -H "Authorization: Bearer sk-1234"
{"detail":"Not Found"}
HTTP 404

Two real auto-routed turns on that same base proxy, to show the traffic lands and still produces no rollup:

SpendLogs rows : 2
rollup rows    : 0

After, on this branch, four turns in one session whose prompts classify to different tiers:

$ psql -tAc "SELECT row_number() OVER (ORDER BY \"startTime\"), model FROM \"LiteLLM_SpendLogs\" WHERE session_id='$SID' ORDER BY \"startTime\""
1|bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
2|bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
3|bedrock/us.anthropic.claude-opus-4-6-v1
4|bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0

The rollup advanced from the request path alone, and the buckets sum to the turn count:

$ psql -x -c 'SELECT ... FROM "LiteLLM_AutoRouterSession"'
turns             | 4
first_visit_turns | 4
warm_turns        | 0
expired_turns     | 0
unordered_turns   | 0
bucket_sum        | 4
turns_with_usage  | 4
spend             | 0.001198
baseline_spend    | 0.002624
tiers             | {"bedrock/...opus-4-6-v1": [1785893222.296381, null, 0],
                     "bedrock/...haiku-4-5-...": [1785893219.9244, null, 0]}

Read back through the endpoint:

$ curl -sS ".../auto_router/benchmarks?start_date=2026-07-07&end_date=2026-08-05&model_group=live-auto" -H "Authorization: Bearer sk-1234"
window=2026-07-07..2026-08-05 routers_in_scope=1
sessions=1 turns=4 avg_turns/session=4.0 avg_session_seconds=4.002 avg_tokens/session=141.0
routed=$0.001198 baseline=$0.002623 saved=$0.001426 (54.3%) saved/session=$0.001426
cache: hit_rate=0.0% coverage=100.0% misses=4
  ttl attribution: five-minute, one-hour and unknown are returned as separate counters
  first_visit 4t | warm 0t | expired 0t | unordered 0t -> sum=4 == turns 4
  miss causes: cold=4 prefix_changed=0 expired=0 unattributed=0 sum=100.0%
group: live-auto kind=complexity baseline=bedrock/us.anthropic.claude-opus-4-6-v1

Every turn reads as a first visit because these prompts are far below the provider's minimum cacheable size, so no tier ever holds a live prefix; a tier with nothing cached is cold by definition rather than warm. That is the honest reading of this traffic, and the warm, expired, unordered and unknown-TTL transitions are covered against a real Postgres in tests/proxy_behavior/spend instead.

Restarting the proxy and sending a fifth turn in the same session keeps both tiers and keeps accumulating, which is the record round-tripping with nothing held in memory:

turns=5 first_visit=5 warm=0 expired=0 unordered=0 tiers_kept=2

Window validation:

$ curl -sS -w "
HTTP %{http_code}
" ".../benchmarks?start_date=2026-08-05&end_date=2026-07-07" -H "Authorization: Bearer sk-1234"
{"detail":{"error":"end_date must not be earlier than start_date."}}
HTTP 400

$ curl -sS -o /dev/null -w "HTTP %{http_code}
" ".../benchmarks?start_date=banana&end_date=2026-08-05" -H "Authorization: Bearer sk-1234"
HTTP 422

Type

🆕 New Feature

Changes

  • New LiteLLM_AutoRouterSession table keyed on (api_key, session_id, model_group), with a migration and the three schema copies
  • auto_router_sessions.py stages a priced turn on the logging path and writes it as one atomic upsert from a dedicated scheduler job, so rollup writes never extend the wall time of the budget-commit job; the batch is sorted by session key so every pod locks rows in the same order. Cache writes establish a five-minute or one-hour TTL, reads inherit the stored TTL, and turns without evidence remain unknown
  • auto_router_benchmarks.py sums those rows and derives every figure once, for a single router and for the totals alike
  • GET /auto_router/benchmarks, admin-only, typed with a response_model so the dashboard client gets real types
  • Retention is intrinsic: the endpoint clamps every window into the most recent 30 days, so the cleanup job (now always scheduled) collects rollup rows past that horizon even when maximum_spend_logs_retention_period is unset; a shorter configured retention still wins. Keyed on last activity through SpendLogCleanup._delete_old_rows_batched

What does not change: pricing. Both arms of the savings comparison resolve through compute_savings_spend, which already owns it, so this adds no pricing code and cannot disagree with the usage tab.

Cache buckets

A turn lands in exactly one bucket, decided by that tier's own cache record:

bucket means
first visit the tier holds no live cached prefix for this session
warm the tier was used again inside the TTL it was written with
expired the tier was used again after that TTL had passed
unordered the turn arrived after a later turn on the same tier, so its cache state at its own time is unknowable
unknown TTL the provider reported cache activity but neither this turn nor an earlier recorded write established its TTL

Every miss therefore has exactly one cause on one denominator: cold by design, the prefix changed, the entry aged out, or unattributable. Unordered and unknown-TTL turns still count, and provider-reported hits stay in the headline hit rate even when cause attribution abstains. The response exposes five-minute, one-hour and unknown TTL counts instead of collapsing mixed traffic to one majority TTL

There is deliberately no cache-warming dollar estimate. Pricing one means modelling a daemon that does not exist, and its ping interval, stop policy and coordination with real traffic are all free parameters; expired_misses reports the measured signal an operator would act on instead.

Things a reviewer will ask about

Why the classification is in SQL. Because it needs the session's cache record, and that record is a column on the row being written. Doing it in the upsert means there is no state to load, nothing to parse or validate in Python, no batch of keys to bound, and no read that can fail: the statement is atomic and its counters compose across pods. GREATEST/LEAST on the record and the timestamps make a late-arriving turn unable to rewind the session. The cost is that the predicates are not unit-testable in pure Python, which is why they are covered by tests/proxy_behavior/spend, a shard CI already runs against a real cimg/postgres:16.0; eight mutations of those predicates were each confirmed to fail a test.

Why api_key is in the primary key. session_id is caller-controlled, so on its own it lets any caller write into another tenant's rollup by reusing their id. Every LiteLLM_Daily*Spend table carries api_key in its key for the same reason. A key rotated mid-conversation splits that session's rollup rather than merging two tenants into one wrong number.

Why the totals are computed server-side. Aggregating per-router percentages by averaging them is wrong, and the numbers the tab leads with are all ratios. summarize runs over raw counters and produces the per-router view and the totals through the same function, so the two cannot disagree.

Cost at a million requests. The read touches only LiteLLM_AutoRouterSession, which holds one row per conversation rather than per request, filtered on first_turn_at and served by a dedicated index on that column; a second index on last_turn_at serves retention. Measured on 400k sessions inside a 30 day window, the aggregate runs in 60 ms. A per-model variant of this table was prototyped and measured at 1.13 s for the same window, because reconstructing a session from its tiers forces a second grouping level; that is why the record lives in a column on the session row instead. The write is one statement per auto-routed turn, batched per flush interval, and never runs on the request path.

QA runbook

  1. Boot a proxy with an auto-router configured and autorouter_savings_baseline_model set
  2. Send two turns in one session through the auto-router alias with the same litellm_session_id; after the next rollup flush the row should read turns=2 with one first visit and one same-model turn
  3. GET /auto_router/benchmarks?start_date=<30d ago>&end_date=<today> and confirm, for the totals and each group, that first_visit_turns + warm_turns + expired_turns + unordered_turns + unknown_ttl_turns equals cache.turns, and that the four miss shares sum to 100
  4. Restart the proxy and send a third turn in that session; it should read as warm rather than as a new first visit
  5. Pass model_group=<alias> and confirm every figure narrows to that router
  6. Pass an end_date earlier than start_date for a 400, and a malformed date for a 422
  7. Confirm the table is pruned by the existing spend-log retention cutoff rather than growing without bound

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Touches the hot spend-tracking path and adds durable DB writes on every auto-routed turn; design limits blast radius (non-blocking queue, no raise on rollup errors), but mis-bucketing or retention bugs would skew admin benchmarks rather than block requests.

Overview
Adds LiteLLM_AutoRouterSession and a write path that folds each auto-routed request into one row per (api_key, session_id, model_group)—spend, baseline savings, and prompt-cache buckets (first visit / warm / expired / unordered / unknown TTL) updated in a single Postgres upsert using a per-model tiers JSON state.

Spend tracking stages turns via _enqueue_auto_router_turn on every request (even when spend logs are disabled), then flushes ordered upserts with the existing spend batch; failures are swallowed so rollup work cannot break billing.

Read path: admin-only GET /auto_router/benchmarks aggregates pre-folded rows (30-day window, optional model_group) into savings, session shape, and cache miss attribution without scanning LiteLLM_SpendLogs. OpenAPI types are updated for the dashboard.

Retention: spend-log cleanup also deletes stale rollup rows by last_turn_at. Tests cover SQL bucketing against real Postgres and pure read-path math.

Reviewed by Cursor Bugbot for commit 798e54d. Bugbot is set up for automated code reviews on this repo. Configure here.

@tin-berri
tin-berri requested a review from a team August 4, 2026 22:23
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

The design is genuinely good — atomic upsert with all classification in SQL eliminates the read-modify-write race, the write path is off the critical request path (batched flush), tenant isolation via (api_key, session_id, model_group) PK is correct, and the real-Postgres integration tests cover the key invariants (bucket partitioning, late arrivals, multi-flush accumulation, multi-tenant isolation). The endpoint correctly reuses litellm's existing @router.get and is admin-only with proper 403/400/500 gates.

One correctness issue worth noting before merge:

In _summarize_cache:

misses: Final = counters.turns - hits

hits is drawn only from turns that actually reported cache behavior (same_model_hits + first_visit_hits + return_hits), but counters.turns is the total across all turns including those where reports_cache_usage=False. This means the displayed misses and hit_rate_pct conflate two distinct things: turns that genuinely missed the cache, and turns from providers that don't report cache telemetry at all. The coverage_pct field separately surfaces turns_with_usage / turns, but misses still absorbs the uncovered turns. The return_misses sub-field is computed cleanly (return_turns - return_hits), making the inconsistency visible if the caller tries to reconcile them.

The more precise expression would be:

    misses: Final = counters.turns_with_usage - hits

This would make hit_rate_pct meaningful relative to a denominator of turns (overall hit share), while misses correctly counts only the turns where a miss actually occurred. Alternatively, renaming to uncached_turns and keeping turns as denominator throughout is equally coherent — the important thing is internal consistency.

Everything else — the upsert SQL, the schema, the index choices (dedicated last_turn_at index for bare time-range reads, composite for model-group-scoped reads), the 30-day window clamp — is well-reasoned and production-ready. The 4 rather than 5 is solely the misses denominator inconsistency above.

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a durable per-session auto-router rollup and an admin benchmarks endpoint that aggregates savings, session shape, and prompt-cache metrics

  • Adds the AutoRouterSession schema, migration, retention cleanup, and atomic upsert writer
  • Adds the benchmark response models, aggregation endpoint, backend allowlist entry, and generated UI types
  • Adds unit and PostgreSQL-backed tests for classification and response derivation

Confidence Score: 4/5

The date-window overcounting and cross-pod ordering defects should be fixed before merging because they can make the new benchmark endpoint return incorrect metrics

The endpoint sums lifetime session counters for sessions active in the selected range, and independently flushed turns can be classified against nonchronological state across pods

Files Needing Attention: litellm/proxy/spend_tracking/auto_router_benchmarks.py, litellm/proxy/spend_tracking/auto_router_sessions.py, litellm/proxy/db/db_spend_update_writer.py

Important Files Changed

Filename Overview
litellm/proxy/spend_tracking/auto_router_sessions.py Adds turn reduction and atomic session upserts, but cross-pod late arrivals can misclassify chronological session state
litellm/proxy/spend_tracking/auto_router_benchmarks.py Adds typed benchmark derivation and aggregation, but date filtering includes lifetime counters from sessions merely active in the window
litellm/proxy/db/db_spend_update_writer.py Stages and flushes auto-router turns through the spend writer; per-pod flushing contributes to the ordering issue
litellm/proxy/proxy_server.py Adds an admin-only validated benchmarks endpoint and response model
litellm-proxy-extras/litellm_proxy_extras/migrations/20260804000000_add_auto_router_session_rollup/migration.sql Creates the session rollup table and indexes consistently with the synchronized Prisma schemas
litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py Extends existing retention cleanup to prune inactive auto-router session rows by last activity

Reviews (1): Last reviewed commit: "feat(spend): add a per-session auto-rout..." | Re-trigger Greptile

Comment thread litellm/proxy/spend_tracking/auto_router_benchmarks.py Outdated
Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py Outdated
Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py Outdated
Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py Outdated
Comment thread litellm/proxy/spend_tracking/auto_router_benchmarks.py Outdated
Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py Outdated
Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py Outdated
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

@codspeed-hq

codspeed-hq Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit4712_benchmarks_backend (270da20) with litellm_internal_staging (31a86da)

Open in CodSpeed

@tin-berri
tin-berri force-pushed the litellm_lit4712_benchmarks_backend branch from cea0818 to f3f954b Compare August 4, 2026 23:18
@tin-berri

Copy link
Copy Markdown
Contributor Author

Redesigned rather than patched. All seven findings came from one root cause: the row stored a session-wide history that had to be serialized, read back and threaded before every write.

Buckets are now decided by the tier's own cache record, held in a tiers column on the row being written: absent means a first visit, present means warm or expired on one comparison of the idle gap against the TTL that entry was written with. That removes the read-before-write entirely, so there is no state to load or validate, no ordering between pods to preserve, and the upsert is a single atomic statement whose counters compose.

Consequences: cross-pod ordering can no longer change a bucket; expiry measures against the TTL the cache was written with; replays are priced on the cached prefix and charged on hits too; the window filters first_turn_at so a session is attributed whole to the window it started in; the response echoes the clamped window; cache_rates gets the router so negotiated prices apply; and the production comments are gone.

The middle bucket is deliberately "warm" rather than "same model": bucketing on what the previous turn did is what required the stored history, and it filed expired same-model turns where the warming panel could not see them.

Measured on 400k sessions in a 30 day window the read aggregate is 60 ms. Eight mutations of the SQL predicates were each confirmed to fail a test in tests/proxy_behavior/spend, which CI runs against a real Postgres.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

The redesign is sound and the root cause — storing session-wide history that had to be read back before every write — has been correctly eliminated. Here is what earns the 4 and what holds it from 5.


What earns the score

The core design is correct. Encoding classification inside the upsert means the write is atomic, two pods composing one session cannot produce a split brain, and there is no state to deserialise before the insert. GREATEST/LEAST guards against late-arriving turns rewinding the session. The decision to key on (api_key, session_id, model_group) rather than just (session_id, model_group) correctly prevents cross-tenant session collisions.

Test strategy is the right one. SQL predicates that cannot be unit-tested in Python are covered by tests/proxy_behavior/spend against a real cimg/postgres:16.0. The claim that eight predicate mutations each fail a test is the right bar; the tests themselves (test_expiry_measures_against_the_ttl_the_cache_was_written_with, test_a_late_turn_cannot_rewind_the_session, test_two_callers_reusing_one_session_id_keep_separate_rollups) cover exactly the cases that went wrong in the previous revision.

Retention is correct. Keying cleanup on last_turn_at rather than first_turn_at ensures a live session is not pruned mid-conversation. Plugging into the existing SpendLogCleanup path avoids a parallel retention regime.

Error handling is appropriate. Both the enqueue path (_enqueue_auto_router_turn) and the flush path (AutoRouterSessionQueue.flush) swallow exceptions with BLE001 suppression. A dashboard rollup must never fail spend tracking; the decision to log and continue is correct.

The savings computation flows through compute_savings_spend. This means the benchmarks tab and the usage tab cannot disagree on savings for the same traffic, which was a stated requirement.


What holds it from 5

Duplicate key in the test fixture. In test_auto_router_benchmarks.py, _row() defines expired_hits twice:

"expired_hits": 1,
        "expired_hits": 0,

Python dicts silently take the last value (0), so all tests that call _row() without overriding expired_hits get 0 rather than 1. This does not cause a test failure — the second value happens to be the safer default — but it is a latent source of confusion and should be a single explicit assignment.

compute_savings_spend is called twice per auto-routed turn. _enqueue_auto_router_turn calls it independently; the main spend path already computed it. This is not a correctness issue, and the cost is small, but it is redundant work on the hot path for every auto-routed request. Passing the already-computed savings figure through would remove the duplication without changing anything else.

17 positional parameters in _UPSERT_SQL. The SQL is dense and the parameter mapping ($1–$17) is only verified by the mutation tests, not by anything that would catch a positional shift at review time. A named-parameter approach or a structured comment mapping position to semantic name would make future edits safer. This is a maintainability concern rather than a current bug.


These are all minor relative to the quality of the core design and test coverage. The PR is ready for maintainer review.

Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py Outdated
@tin-berri

Copy link
Copy Markdown
Contributor Author

Fixed in ea8ed73, plus three findings from review.

The out-of-order case was a real hole and a second round on one mechanism, so I wrote the state machine down before touching it rather than adjusting the predicate. A turn arriving before an already-recorded turn on the same tier has an unknowable cache state at its own time; a negative idle gap is not evidence of warmth. There are four guards, not three: model absent is a first visit, t < cached_at is unordered, 0 <= idle <= ttl is warm, idle > ttl is expired. Mutually exclusive and exhaustive. An unordered turn still counts as a turn and its hit is still known, so the headline hit rate stays exact and only the cause attribution abstains; misses now decompose into cold, prefix changed, expired and unattributed on one denominator. Reverting the guard to the reported bug fails two tests.

Walking that state machine also surfaced an adjacent hole nothing had flagged: a first visit to a new tier on an existing row, where the turn wrote no cache, fell through to GREATEST(NULL, 1) and stored a one second TTL. The guard now sets the terms whenever the tier is unseen.

The 17 positional parameters are gone. Placeholders are unpacked once from a single ordered PARAM_NAMES tuple and the SQL refers to them by name, so the statement reads as {_STARTED_AT} rather than $7 and a positional shift is not expressible; bind() returns the values in that same order.

Duplicate expired_hits key in the test fixture is fixed. I also ran an AST pass over every file I had touched with regex to confirm there were no other silently-shadowed keys; that was the only one.

On the duplicated compute_savings_spend: it is called six times per request by the daily path already, so this hook is the seventh. Removing just this one saves a seventh of a pure, lru-cached computation with no I/O. Computing it once per request and threading it into the six daily builders is the real fix and removes six calls rather than one, but it changes shared spend code for every request, not just auto-routed ones, so I would rather do it as its own reviewable change than fold it in here.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

5/5.

All four previously flagged holes are closed, and each fix is verified by a test that was confirmed to fail without it.

Out-of-order turns. The state machine is now formally correct: absent → first_visit, idle < 0 → unordered, 0 ≤ idle ≤ ttl → warm, idle > ttl → expired. Mutually exclusive and exhaustive by construction. test_a_turn_arriving_before_an_already_recorded_one_is_not_called_warm and test_every_turn_lands_in_exactly_one_of_the_four_buckets pin both the semantic and the arithmetic. Headline hit rate stays exact because the provider's answer to "did it hit" is always known even when the cause is not.

New-tier-no-write TTL fallthrough. _SET_TERMS = NOT seen OR (written_tokens > 0 AND idle >= 0) covers all four cases correctly: first visit always sets terms; a cache-writing turn that arrived in order updates them; a read-only turn preserves them; an out-of-order cache-writing turn preserves the later-recorded entry. test_a_first_visit_to_a_new_tier_keeps_its_own_ttl_even_with_no_cache_write closes the specific gap, and test_a_turn_that_only_read_the_cache_leaves_the_written_terms_alone guards the read-only case.

Named parameters. PARAM_NAMES drives both the SQL placeholders and bind(), so a positional shift is not representable. The statement now reads as {_STARTED_AT} rather than $7.

Duplicate fixture key. Fixed, and the AST scan for other shadowed keys gives confidence this was the only one.

compute_savings_spend call count. The reasoning is sound: removing the seventh of an lru-cached, I/O-free call saves one-seventh of nothing measurable; the real fix removes six calls on every request and belongs in its own reviewable change. Deferral is the right call here.

The integration test suite against real Postgres covers the eight state-machine mutations the description mentions, plus warming arithmetic, multi-flush accumulation, late-arrival non-rewind, and tenant isolation. The pure unit tests cover the math derivations and window clamping without a database. Coverage is thorough at both layers.

Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py
@tin-berri
tin-berri force-pushed the litellm_lit4712_benchmarks_backend branch from ea8ed73 to 3bf3f72 Compare August 4, 2026 23:44
@tin-berri

Copy link
Copy Markdown
Contributor Author

Fixed in 3bf3f72, and the fix is in what the column means rather than in the guard.

cached_tokens is supposed to be the prefix currently live in that tier's cache, which is what a warming replay has to keep alive. I was storing cache_creation_tokens, which on a growing conversation is only the newly appended segment, so each warm incremental write shrank the recorded prefix and every later expired return priced its replay off the shrunken value. The provider reports the whole answer per turn: the live prefix after a turn is cache_read_input_tokens + cache_creation_input_tokens.

Storing that also removed a condition rather than adding one. The old guard needed written > 0 precisely because a read-only turn's cache_creation_tokens is zero and would have zeroed the prefix; with the live prefix, a read-only turn reports read + 0, which is correct, so the tokens rule is now just "any in-order turn". TTL keeps the stricter guard, because the tier is a property of the write and a read-only turn carrying the five minute default must not clobber a stored one hour entry.

That leaves three cache facts on the turn, each meaning one thing: cache_hit for the buckets, cache_creation_tokens for what a missed turn actually paid to rewrite, and cached_prefix_tokens for what is live. Two new tests cover a growing conversation and a replay priced after the prefix grew; reverting the column to the new segment fails both.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

5/5.

The fix is correct and well-scoped.

The root cause and fix are precise. cache_creation_tokens alone is wrong for a growing conversation because turn 2 reading 2000 tokens and writing 500 more would store only 500, shrinking the tracked prefix with each warm turn and under-pricing every subsequent replay. Storing cache_read_tokens + cache_creation_tokens gives the whole live prefix regardless of how the conversation grew.

The guard simplification is a consequence, not a workaround. The old code needed written > 0 before updating the prefix because cache_creation_tokens = 0 on a read-only turn would zero it out. With the new formula a read-only turn contributes read + 0, which is the correct live prefix, so the guard falls away. TTL keeps its guard (_WROTE_CACHE) and that guard is still needed: a read-only turn carries the 5-minute default and must not overwrite a stored 1-hour entry.

The SQL upsert composes correctly. _IN_ORDER gates whether _PREFIX_TOKENS (now read+creation) or _CACHED_TOKENS (the previously stored value) is written. Unordered turns preserve the existing record; in-order turns update it to the live prefix.

Test coverage is exact. test_a_growing_conversation_records_the_whole_live_prefix checks that tiers[HAIKU] ends at 2500 rather than 500 after a warm turn on a growing prompt. test_a_replay_after_a_grown_prefix_is_priced_on_the_whole_prefix checks that the replay spend uses 2500 rather than 2000. The developer's claim that reverting to the old column fails both is plausible and the tests are structured to catch exactly that regression.

Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py
@devin-ai-integration

Copy link
Copy Markdown
Contributor

QA: are the dollars right?

QA'd 3bf3f72 against a live proxy on real Postgres with real Anthropic traffic (complexity auto-router over haiku-4-5 / sonnet-4-6, autorouter_savings_baseline_model: anthropic/claude-opus-4-6), plus direct upsert probes for the cases live traffic cannot reach on demand. The savings side is exact. The warming estimate has two errors that mostly cancel in the net but not in the numbers the panel leads with.

Savings: exact

Four real turns in one session (haiku, haiku warm, sonnet, haiku after a 5m gap):

$ psql -x -c 'SELECT "startTime", model, spend, prompt_tokens, completion_tokens,
    metadata->'"'"'usage_object'"'"'->>'"'"'cache_creation_input_tokens'"'"' AS created,
    metadata->'"'"'usage_object'"'"'->'"'"'prompt_tokens_details'"'"'->>'"'"'cached_tokens'"'"' AS readtok
  FROM "LiteLLM_SpendLogs" WHERE session_id='"'"'qa2-1785888582'"'"' ORDER BY "startTime"'
 00:09:42 | anthropic/claude-haiku-4-5  | 0.0110255  | 8695 | 32 | 8682 |    0
 00:09:49 | anthropic/claude-haiku-4-5  | 0.0010012  | 8695 | 24 |    0 | 8682
 00:09:55 | anthropic/claude-sonnet-4-6 | 0.03317025 | 8726 | 32 | 8683 |    0
 00:15:08 | anthropic/claude-haiku-4-5  | 0.0109355  | 8695 | 14 | 8682 |    0

$ psql -x -c 'SELECT * FROM "LiteLLM_AutoRouterSession"'
turns             | 4     first_visit_turns | 2     warm_turns    | 1 (1 hit)
turns_with_usage  | 4     expired_turns     | 1     unordered     | 0
spend             | 0.05613245
baseline_spend    | 0.17009475
rescued_spend     | 0.0108525
replay_spend      | 0.0017364
tiers             | {"...haiku-4-5": [1785888908.16, 300, 8682], "...sonnet-4-6": [1785888595.90, 300, 8683]}

spend is the sum of the four spend-log rows to the last digit. baseline_spend is the sum of the four opus counterfactuals priced by hand off the same usage; e.g. turn 1 is 13*5e-6 + 8682*6.25e-6 + 32*25e-6 = 0.0551275, and the four sum to 0.17009475, which is the stored value exactly.

The cross-check that matters most, since the claim is that this tab and the usage tab cannot disagree:

$ psql -c 'SELECT model, spend, autorouter_savings_spend FROM "LiteLLM_DailyUserSpend" WHERE date='"'"'2026-08-05'"'"''
 anthropic/claude-sonnet-4-6 | 0.0474405 | 0.03162700
 anthropic/claude-haiku-4-5  | 0.0342572 | 0.13702880
                               ---------   ----------
                               0.0816977   0.16865580

$ curl -sS ".../auto_router/benchmarks?start_date=2026-08-04&end_date=2026-08-05" -H "Authorization: Bearer sk-1234"
spend=0.0816977 savings=0.1686558 (67.4%)

Same dollars on both sides.

Buckets were right on live timings too: turn 2 at +6s is warm and its hit is recorded, turn 4 at an idle gap of 319s against a 300s TTL is expired rather than a fresh visit. Buckets partition turns (2+1+1+0 = 4), miss causes sum to 100%, model_group narrows every figure, reversed dates give 400, a malformed date 422, no key 401. All 55 tests in tests/proxy_behavior/spend and the two test_litellm modules pass locally against postgres:16.

Warming: two errors, and they only cancel in the net

Rescue is charged at the full write rate. A turn that warming rescues still reads the prefix; it does not become free. The saving is written * (write_rate - read_rate), not written * write_rate. On the live turn above: 0.0108525 recorded against 0.0099843 real, so 8.7% high for haiku 4.5, and the gap is the read rate as a share of the write rate for whatever model is in play.

Replays are charged one window too many. A read refreshes the entry's TTL, so bridging an idle gap needs ceil(idle/ttl) - 1 replays, not ceil(idle/ttl): one replay just before expiry already covers 2*ttl. The live turn's 319s gap on a 300s TTL was charged 2 replays (0.0017364) where 1 does the job (0.0008682). A probe with a 301s gap shows the worst case, 2 charged against 1 needed, i.e. double.

The two errors are equal and opposite whenever the whole prefix is rewritten (both are prefix * read_rate), so warming_net_spend came out identical either way on this session, at 0.0091161. What does move is everything the panel shows beside it:

recorded corrected
warming_rescued_spend 0.0108525 0.0099843
warming_replay_spend 0.0017364 0.0008682
warming_break_even_pct 16.0 8.7

Break-even is the number an operator would act on, and it reads roughly twice its real value. Both fixes are one term each in the upsert: ({_WRITE_RATE} - {_READ_RATE}) on the rescue arm, and GREATEST(CEIL({_IDLE} / {_CACHED_TTL}) - 1, 0) on the replay arm.

Turns with no cache telemetry get a cause they did not earn

First live run used a 3655 token prefix, which is under Anthropic's minimum cacheable length for haiku 4.5, so the provider reported usage but never cached anything. The second haiku turn was therefore recorded as a warm miss and attributed to prefix_change_misses, though no prefix changed and no cache existed. Same shape in a probe where 3 of 4 turns report nothing at all about caching: coverage_pct reads 25% while 2 turns land in prefix_change_misses.

The headline hit_rate_pct over all turns is defensible, since a turn that did not hit really did not hit. The cause split is not: it claims a fact about a cache nobody observed. Attributing only over turns_with_usage, or folding the rest into unattributed_misses, would keep the split honest.

The window means "sessions that started here"

Counters are lifetime and the row is selected on first_turn_at, which the docstring says, but it is worth pinning what it costs. A session running 23:50 to 00:30 is invisible in a same-day window and fully counted, including the part that happened today, in a window that contains yesterday:

window 2026-08-05..2026-08-05:  sessions 2  turns 8   spend 0.0817  savings 0.1687
window 2026-08-04..2026-08-05:  sessions 3  turns 18  spend 1.0817  savings 2.1687

So the agreement with the usage tab holds on pricing, and on any window no session straddles; it is not an identity per date. Worth scoping the claim in the description, and labelling the window in the UI as sessions started in the range.

Minor

_enqueue_auto_router_turn uses put_nowait and swallows QueueFull at debug level. Not blocking spend tracking is right, but under sustained load the rollup silently under-counts with no operator-visible signal; a warning or a counter on that path would make the drop visible.

Verdict

Savings arithmetic ships as is; it reconciles with both spend logs and the daily rollups exactly. The two warming terms and the cause attribution are worth fixing before the tab renders them, since break-even and the miss split are the figures an operator would act on.

@tin-berri
tin-berri force-pushed the litellm_lit4712_benchmarks_backend branch from 3bf3f72 to 9eb3532 Compare August 5, 2026 00:27
@tin-berri

Copy link
Copy Markdown
Contributor Author

Both warming figures corrected in 9eb3532; two line changes in the upsert.

A rescue is now priced at written * (write_rate - read_rate). Warming does not make the prefix free, it converts a write into a read, so charging the whole write overstated the saving by the read rate as a share of the write rate.

A replay is now FLOOR(idle / ttl) rather than CEIL. A read refreshes the entry, so a replay placed just before expiry already covers the following window and a gap barely past one TTL needs one replay rather than two.

Worth noting on the second one: CEIL - 1 is right everywhere except exact multiples, where the entry is dead at the instant its window ends, so a gap of exactly two TTLs still needs two replays and CEIL - 1 would give one. FLOOR is the closed form that covers both, and it is never zero here since a turn only reaches this branch when the gap already exceeds the TTL.

Both figures are differences rather than absolutes now, which is the same rule the savings arms already follow.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The two warming formula corrections in the upsert are both mathematically correct:

Rescued spendwritten_tokens * (write_rate - read_rate): warming converts a write to a read, so the saving is the rate difference, not the full write cost. Previous version overstated by written_tokens * read_rate.

Replay spendFLOOR(idle / ttl): the closed form is right. A replay placed just before expiry refreshes the window, so a gap of exactly N TTLs needs exactly N replays. CEIL(N) - 1 = N - 1 fails at exact multiples; FLOOR(N) = N handles both.

Both figures are now differences rather than absolutes, consistent with how the savings arms work.


Score: 4/5

What pushes it to 4 rather than 5 is one subtle edge in the tiers update. When a turn is an expired miss — the cache aged out and no new cache was written — _WROTE_CACHE is false so the TTL entry is preserved, but the timestamp still advances to GREATEST(cached_at, started_at). That means the next turn computes its idle gap against the expired-miss timestamp rather than the original write timestamp. If the expired miss was recent, the next turn can land inside the old TTL window and be classified as warm despite the fact that nothing refreshed the cache after the expiry. The correct timestamp on a miss is the original write time, not the miss time.

In practice this is small — it only matters when turns arrive close together after expiry and the expired turn happened to be a miss — but it is a real misclassification that moves a cold turn into the warm bucket, understating savable misses and overstating warm hits.

Everything else is solid: the primary key design, the atomic upsert that needs no read-before-write, the GREATEST/LEAST guards against out-of-order delivery, the index on first_turn_at for the read path, and the test suite running against a real Postgres shard.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-QA at aaf66ee (warming estimate removed)

Re-ran the live verification on the trimmed revision: four real Anthropic turns through the complexity router (haiku cold write, warm hit at +6s, sonnet on the complex turn, haiku return after a 318s gap against the 300s TTL)

$ psql -x -c 'SELECT * FROM "LiteLLM_AutoRouterSession" WHERE session_id='"'"'qa4-1785893274'"'"''
spend          | 0.05621745    <- sum of the 4 spend-log rows to the last digit
baseline_spend | 0.17051975    <- sum of the 4 hand-priced opus counterfactuals, exact
first_visit=2 (haiku, sonnet)  warm=1 hit=1  expired=1 hit=0  unordered=0

$ curl ".../auto_router/benchmarks?start_date=2026-08-05&end_date=2026-08-05" -H "Authorization: Bearer sk-1234"
savings=0.1143023 (67.0%)  hit_rate=25%  coverage=100%  cold=2 prefix_change=0 expired=1

$ psql -c 'SELECT SUM(spend), SUM(autorouter_savings_spend) FROM "LiteLLM_DailyUserSpend" WHERE date='"'"'2026-08-05'"'"''
0.05621745 | 0.1143023     <- identical to the endpoint, so the two tabs agree

Savings and prompt-cache bucketing are both correct: every dollar figure reconciles exactly across SpendLogs, the session rollup, the daily table, and the endpoint, and the four turns land in the right buckets with the tier records carrying the right timestamps, TTLs, and prefixes. The warming columns, rates plumbing, and endpoint fields are cleanly gone (savable_misses is now expired_misses); endpoint validation is unchanged (401 without a key, 400 on a reversed window). The residuals from the previous round all lived in the deleted estimate, so nothing outstanding remains

Fold each auto-routed turn into a per-(api_key, session, auto-router) row when it
happens, and serve the benchmarks dashboard by summing those rows. Nothing in the
feature reads LiteLLM_SpendLogs.

The row carries what each tier the session used left in the prompt cache, so a turn's
bucket is a question about one model's own record and the upsert answers it against the
row it is already writing. Absent from that record means a first visit; present means
warm or expired, on one comparison of the idle gap against the TTL the entry was written
with. Nothing is read before the write, so there is no state to load or validate, and the
statement is atomic.
@tin-berri
tin-berri force-pushed the litellm_lit4712_benchmarks_backend branch from aaf66ee to 2e2b262 Compare August 5, 2026 01:50
@tin-berri

Copy link
Copy Markdown
Contributor Author

Pushed 2e2b262.

Coverage was measuring the wrong thing. _reports_cache_usage asked whether the provider mentioned cache fields, and most providers emit cache_read_input_tokens on every response even when it is zero, so a model with caching switched off read as fully covered, got bucketed, and counted as a wall of misses in the hit rate. A turn now enters the cache view only when it actually touched the cache, which is cache_read + cache_creation > 0, a value already computed. The buckets partition cache-eligible turns rather than all turns, coverage_pct says how much of the traffic that is, and the hit rate stops being dragged down by traffic that was never cacheable.

That also dissolves the first_visit accumulation concern rather than renaming around it: a never-caching model contributes to no bucket at all. The naming point stands on its own and is tracked as LIT-5212.

The read path lost the four-times-repeated field list. _SUM_COLUMNS is derived from the counter dataclass and the aggregate SQL is generated from it, _counters_from_row builds by field name, and _combine is one zip over astuple. Adding a counter now means adding it in one place instead of four, which is where the read-path metric bugs came from in the first place.

Net effect, and the deletions are the point: auto_router_benchmarks.py 303 -> 258 lines, _reports_cache_usage and its two key tuples gone, production 842 -> 783.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

5/5.

The fix is correct and the implementation is cleaner after this commit.

What was wrong and why this fixes it. _reports_cache_usage tested whether the provider mentioned cache fields, which was always true for providers that emit cache_read_input_tokens: 0 on every response. So every turn from a non-caching model was counted as covered, bucketed as a first visit miss, and dragged the hit rate toward 0%. The new _COVERED predicate — prefix_tokens::float8 > 0 where prefix_tokens = max(cache_read, 0) + max(cache_creation, 0) — only admits turns where something actually happened at the cache layer. The gate moves from the read path to the write path where it belongs, and turns_with_usage accumulates the right count atomically in the same upsert.

The refactoring is correct. _SUM_COLUMNS derived from _Counters fields means a new counter requires exactly one edit instead of four. _counters_from_row iterating over fields(_Counters) and _combine using zip(astuple(...)) are both type-safe with the dataclass — any field added or removed is immediately reflected everywhere.

The miss attribution math holds. The four _COVERED branches (NOT _LIVE, _UNORDERED, _WARM, _EXPIRED) are mutually exclusive and exhaustive over all covered turns, so first_visit + warm + expired + unordered = turns_with_usage. cold + prefix_change + expired_misses + unattributed = misses follows algebraically, and test_every_miss_has_one_cause_and_they_stack_to_the_whole confirms it numerically.

Test coverage is complete. test_a_turn_that_touched_no_cache_is_left_out_of_the_cache_view directly exercises the fixed regression in the Postgres integration suite. test_traffic_that_never_touched_the_cache_is_left_out_of_the_hit_rate covers it at the read path. The claimed eight SQL-predicate mutations each failing a test is credible given the integration test file covers warm/expired/unordered/rewind/multi-tenant in separate cases.

One minor observation that is not a blocker: the ttl_seconds majority vote in _summarize_cache uses counters.turns (total turns) as the denominator rather than counters.turns_with_usage. On a deployment where most traffic skips the cache, a router whose cache-eligible turns are predominantly 1h could still display the 5m TTL. The display effect is cosmetic and the bucket counts are unaffected.

Comment thread litellm/proxy/spend_tracking/auto_router_benchmarks.py Outdated
Comment thread litellm/proxy/spend_tracking/auto_router_sessions.py
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with score & why

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

This is production-quality work. The architecture is sound, the test coverage is genuine (real Postgres in CI, not just mocked), and the design decisions are well-reasoned. Here's a breakdown:


What's done well

  • No read-before-write. The upsert classifies a turn entirely against the row it is already writing. There is no SELECT on the hot path, no in-memory state to hydrate, and no window of inconsistency between pods.
  • Correct tenant isolation. api_key in the PK means a caller reusing someone else's session_id writes into their own rollup, not the other tenant's — consistent with every other LiteLLM_Daily*Spend table.
  • GREATEST/LEAST guards. A late-arriving turn cannot rewind tiers, first_turn_at, or last_turn_at. The unordered bucket is the correct accounting for turns whose cache state is unknowable.
  • Totals are computed from summed counters, not averaged percentages. Averaging per-router ratios is wrong; summarize runs over raw sums for both the per-router view and the totals, so the two cannot disagree.
  • Never-fail contract maintained. Both _enqueue_auto_router_turn and AutoRouterSessionQueue.flush swallow exceptions with a log line. The rollup path cannot propagate into spend tracking.
  • Test coverage is real. The proxy-behavior suite runs 18 upsert mutations against a live Postgres shard. The pure-Python suite covers build_turn_facts and ttl_seconds exhaustively. The PR description notes that eight mutations of the predicates were each confirmed to fail a test — that is the right bar for this class of SQL logic.
  • 30-day window clamp, admin-only gate, proper date validation — all present and exercised in the screenshots.
  • Retention piggybacked on the existing cleanup path, keyed on last_turn_at so a live session isn't pruned mid-conversation.

What to flag before merge

1. disable_spend_logs bypass (medium)
_enqueue_auto_router_turn runs unconditionally — the comment explicitly says "Independent of disable_spend_logs". That is intentional here because the benchmarks tab now reads this table instead of SpendLogs, but it is a silent behavior change for operators who disabled spend logging for compliance or data-minimization reasons. They have opted out of one retention surface; they are now opted into another one without a config knob. Worth documenting in the proxy config reference or guarding behind a separate disable_autorouter_rollup flag.

2. Window attribution gap for long-running sessions (low)
_AGGREGATE_SQL filters on first_turn_at >= $1. A session that started before the query window but had the majority of its turns inside it will be absent from every bounded query window once it ages past start_date. For typical chat sessions this is a non-issue; for long-running agent loops it produces a silent undercount. The fix is either to document the attribution semantics on the endpoint or filter on last_turn_at (with its own tradeoff of attributing a session to the window it ended in).

3. The prefix_change_misses label (cosmetic)
prefix_changed = warm_turns - warm_hits is exposed in the response model as prefix_change_misses. The reasoning is valid (within-TTL miss → prefix changed), but a caller unfamiliar with that interpretation will read it as "the cache write is still live but the prefix was different" — which is correct — versus "the TTL expired", which is expired_misses. The naming is defensible but a one-line doc comment on AutoRouterCacheBenchmark.prefix_change_misses would prevent future confusion.


Why not 5/5

The disable_spend_logs bypass is the deciding factor. It is intentional and probably the right call for this feature to work at all, but it is a behavior change that will surprise compliance-conscious operators and there is currently no way to opt out. That warrants either documentation or a config flag before merge, which keeps this at 4 rather than 5.

Everything else — the SQL correctness, the isolation model, the test strategy, the read-path design — is solid.

Comment thread litellm/proxy/spend_tracking/auto_router_benchmarks.py
@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here and explain why?

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 798e54d. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

Here's the reasoning:

What earns the score:

The architecture is clean. One row per (api_key, session_id, model_group), the write is a single atomic upsert with no read before it, api_key in the PK prevents cross-tenant session pollution (the same reason every LiteLLM_Daily*Spend table carries it), and the read path aggregates only pre-folded rows rather than scanning LiteLLM_SpendLogs. All ten bugs Cursor Bugbot raised — date-window lifetime counters, out-of-order turn classification, TTL majority ignoring cache reads, warm writes shrinking the prefix, dead-tier blocking a late real write, the _number Decimal coercion — were found and fixed, each with a clear commit reference and a regression test. Pricing is fully delegated to compute_savings_spend, so this cannot disagree with the usage tab.

What keeps it from 5/5:

The rollup flush is still awaited inside db_update_spend_transaction_handler before the core spend queues commit. Under a sustained burst of auto-routed requests (unique litellm_session_id per request), each flush is up to N sequential upserts, and N is unbounded in the current design. That means a busy auto-router deployment can add measurable latency to key/team budget enforcement — a correctness-adjacent risk, not just an ops concern. The veria-ai thread on this was marked resolved, but the resolution was "acknowledged" rather than a structural fix (e.g. batching the upserts or moving the rollup flush behind the critical commit with a strict work limit).

Retention also requires maximum_spend_logs_retention_period to be set explicitly; without it, the LiteLLM_AutoRouterSession table grows without bound. That's a reasonable first-ship trade-off, but worth documenting for operators.

Neither of these is a blocker — the spend path itself cannot be broken by rollup failures because they're caught — but they're the gap between good and excellent.

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

This is a well-engineered PR. Here's the breakdown:

What earns a high score:

  • Architecture is sound. Atomic upsert with no read-before-write, one row per session (not per request), a separate flush queue that drops rather than blocks, and failures swallowed so rollup work can never break billing. These are the right constraints for a dashboard-only rollup on the hot spend path.

  • Security is correct. api_key in the primary key is exactly right for a caller-controlled session_id. Any LiteLLM_Daily*Spend table does the same, and the PR follows that precedent.

  • Test coverage is serious. Eight SQL-bucketing mutations each confirmed to fail a test against a real Postgres is the right standard for predicate logic that cannot be unit-tested in pure Python. The pure Python layer (test_auto_router_sessions.py, test_auto_router_benchmarks.py) covers all the read-path math cleanly.

  • The tiers JSON state is clever and cheap. Keeping the per-model cache record on the row itself avoids a secondary grouping level, and the PR quantifies the speedup (60 ms vs 1.13 s at 400k sessions). The GREATEST/LEAST guards on timestamps mean late-arriving turns cannot rewind the session regardless of pod ordering.

What keeps it from a 5:

  1. The cleanup scheduler is now unconditionally registered (_reschedule_spend_log_cleanup_job and initialize_scheduled_background_jobs both drop the if retention_period is not None guard). Deployers who never set maximum_spend_logs_retention_period will now see the cleanup job fire on every interval and log "Deleted N expired auto-router session rollups". The GC rationale is correct, but this is a silent behavior change for existing installations that had no cleanup configured, and it touches a scheduling path that previously had opt-in semantics.

  2. _KNOWN_CACHED_TTL hardcodes two float values. When Anthropic ships a third cache tier (e.g., 24 h), those turns will land in cache_ttl_unknown_turns and be unattributed rather than counted as expired. That is the correct fallback, but the condition is in the SQL layer, so adding a tier requires a code change and a migration rather than a config entry. This is a known trade-off, but worth flagging.

  3. first_visit_turns in the INSERT branch counts every covered turn as a first visit, which is correct for a genuinely new session row but means a race (two pods inserting the same (api_key, session_id, model_group) simultaneously) produces a duplicate first-visit count before the conflict clause fires. Postgres INSERT ... ON CONFLICT is atomic at the statement level, so within one batch this is fine — but it is worth confirming the queue's session-key sort order is sufficient to prevent two pods from racing on a brand-new session's very first turn.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 270da20. Configure here.

tiers = t.tiers || jsonb_build_object({_MODEL}, jsonb_build_array(
CASE WHEN {_REFRESHED} THEN {_STARTED_AT}::float8 ELSE {_CACHED_AT} END,
CASE WHEN {_REWROTE} THEN {_TTL}::float8 ELSE {_CACHED_TTL} END,
CASE WHEN {_REFRESHED} THEN {_PREFIX_TOKENS}::float8 ELSE {_CACHED_TOKENS} END

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null TTL write erases known TTL

High Severity

The _REWROTE condition in the UPSERT_SQL can incorrectly update the ttl within the tiers JSONB. It might overwrite valid TTLs with nulls when cache_creation_tokens are positive but the turn's TTL is null, or incorrectly set TTLs for unordered turns. This leads to misclassification of subsequent turns and inaccurate TTL distribution metrics.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 270da20. Configure here.

horizon even with no retention configured; a shorter configured retention wins."""
now: Final = datetime.now(timezone.utc)
horizon: Final = now - timedelta(days=AUTO_ROUTER_SESSION_RETENTION_DAYS)
return horizon if retention_seconds is None else max(horizon, now - timedelta(seconds=retention_seconds))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active old sessions never pruned

Medium Severity

Retention keys off last_turn_at, while benchmarks attribute sessions by first_turn_at. A long-lived session that started outside the 30-day read window but keeps getting turns stays invisible to every query and is never deleted, so the rollup table can grow without bound for persistent session IDs.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 270da20. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants