Skip to content

feat(spend): add net auto-router savings to the cost-optimization dashboard - #35402

Closed
tin-berri wants to merge 24 commits into
litellm_internal_stagingfrom
litellm_lit5046_autorouter_savings
Closed

feat(spend): add net auto-router savings to the cost-optimization dashboard#35402
tin-berri wants to merge 24 commits into
litellm_internal_stagingfrom
litellm_lit5046_autorouter_savings

Conversation

@tin-berri

@tin-berri tin-berri commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Dashboard credits compression and prompt caching, not auto-router model switches
  • Switching models leaves the new one with a cold cache

How it solves it:

  • Adds an auto-router driver to the card, donut and savings graph
  • Prices the traffic against the priciest model the router could pick
  • Charges the cold-cache write to the switch that caused it
  • Reads the daily rollup tables, never LiteLLM_SpendLogs

Relevant issues

Resolves LIT-5046

Linear ticket

Resolves LIT-5046

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

Changes

Auto-router savings join compression and prompt caching as a third optimization driver on the cost-optimization dashboard, with a summary card, a donut segment and a series in the savings graph across the cumulative and per-day views.

What it is measured against. Without an auto-router a deployment has to pick one model, and it has to be one that can carry the hardest request, so the counterfactual is the priciest model that router could have chosen. That baseline is derived from the router's own candidates rather than fixed, because a hard-coded flagship credits savings against a model the operator may never have run: a router choosing only between sonnet and haiku saved nobody the price of opus. Routes name model groups rather than models, so each is resolved through the parent router's deployments and qualified with its provider before being priced; a bare name can otherwise resolve to a different vendor's rates, or to nothing at all. A deployment is priced by its base_model where it has one, which is how Azure deployments are priced everywhere else in the codebase; reading litellm_params.model alone drops azure/my-deployment-name out of the candidate pool and silently measures against the second priciest model instead.

Each router supplies its own candidates. A semantic auto-router offers every model group its routes can reach. A complexity router offers its hardest configured tier rather than everything it can reach, because the tier ladder already names the model an operator would have had to run to serve the hardest request; a cheap tier is a choice the router made, not a ceiling it was bounded by. auto_router_savings_baseline_model overrides the derived value for operators who would genuinely have run something else, and is qualified the same way. When nothing can be priced the baseline is absent and the driver reports zero rather than inventing one.

How the dollars are computed. Both arms price the request's real usage through generic_cost_per_token, litellm's own cost engine, rather than re-deriving per-token arithmetic, so tiered rates, ephemeral cache-write tiers and regional uplifts stay consistent with what the request was actually billed.

Cache is where this gets decided. prompt_tokens already includes cache-read and cache-creation tokens, so charging them again at the input rate prices the same tokens twice. More importantly, a deployment that stays on one model writes the prompt cache once and reads it thereafter; only switching leaves a model cold and forces it to write the whole prompt again. The baseline is therefore priced as the warm cache a single-model deployment would have had, so the cold-cache write counts against the saving instead of being credited to both sides. That holds whether or not the request also read anything, because a switch to a cold model reads nothing precisely because its cache is empty; gating the warm baseline on a read would charge the baseline a write it never repeats, and a cold switch would then report a larger saving than the same traffic with caching turned off.

Both arms are priced on the request's whole usage, not just its cache split. Audio, image and video counts, and the character counts and durations Vertex prices multimodal embeddings by, all travel into the baseline; pricing it as a text-only request would have made multimodal traffic report less saving than it earned.

Known limitation: a first turn is undercounted, and can read as a loss. A cold cache reads the same in the spend log whether it is cold because the router switched or because the conversation is new, so a genuine first turn is charged as a switch. The gap is not a rounding error. On a 20k prompt with a 1k completion, an opus-5 to haiku-4-5 first turn reports +$0.005 against a true +$0.12, and a conversation only converges as it lengthens: 22% of its real value at two turns, 50% at five, 69% at ten. Because the write premium is fixed by prompt size while the saving grows with completion length, the understated number can cross zero; on a 20k cached prompt anything under roughly 750 completion tokens reads as a loss. Traffic that is mostly single-turn with a large cached system prompt and a short answer, an ordinary agent shape, can therefore show the auto-router losing money on requests that each genuinely saved around $0.12.

The error never runs in the flattering direction, which for a savings claim is the half that matters, and the card says so in its popover. Telling a first turn from a switch needs the previously served model, which is not recorded today; that is LIT-5087, which follows this PR

Why it is signed. Whether a switch pays off is a race between the rate gap and the cache-write cost, and a narrow gap loses: a mid-conversation sonnet to haiku switch costs $0.0104 on the shape used in the tests. Flooring that at zero would leave a number that can only move in one direction and would hide exactly the routing behaviour an operator needs to see, so a cache-thrashing route reduces the total. The donut plots only drivers that saved, since a negative slice has no meaning, while the card and the range total keep the sign.

Where it is stored and read. Savings accrue into a new autorouter_savings_spend column on the six LiteLLM_Daily*Spend rollup tables, declared NotRequired because rows queued by a pod on the previous release carry no such key. The field is summed by the rollup merge that the cross-pod Redis drain also runs, and is carried through the aggregation query, the per-row accumulation and the response model, so the dashboard reads a value the API actually sends. Regression tests enumerate the savings drivers from the response model itself and assert each one is summed, accumulated, carried and totalled, so a driver added later cannot be half-wired.

The baseline is recorded alongside the routing decision by a single per-attempt writer, so a fallback that re-enters the hook with no strategy clears both facts together. It is stripped from untrusted caller metadata and registered in all_litellm_params, so a caller cannot supply one and a provider never receives it.

Screenshots / Proof of Fix

Partial, and the gap is stated rather than glossed.

Demonstrated on a live proxy on port 4300 against a real Postgres, at commit f66687b96d, with the daily rollup rows written by compute_savings_spend itself:

$ curl -s -H "Authorization: Bearer sk-1234" \
    "http://localhost:4300/user/daily/activity?start_date=2026-07-28&end_date=2026-07-31"

per-day autorouter_savings_spend:
  2026-07-28  +0.800000
  2026-07-29  +0.434500
  2026-07-30  -0.342085      <- sonnet to haiku switch; the cold cache outweighs the rate gap
  2026-07-31  +2.587400
  total_autorouter_savings_spend  +3.479815

The negative day reduces the total rather than being floored away. On the base commit the response model carried no such field at all, so the dashboard would have rendered $0.00 permanently however much routing saved.

Not demonstrated: no request has been routed through the auto-router against a real provider, because the available Anthropic key is out of credit and there is no embedding key for the semantic router. The read path, the schema migration, the signed handling, the dashboard render and the baseline derivation are exercised end to end; the pricing arithmetic is covered by unit tests only. That is the remaining gap before this is ready for a maintainer

Type

New Feature

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

Cursor Bugbot is generating a summary for commit 5410306. Configure here.

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you review this PR, give me a score, and explain why you gave the score

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds net auto-router savings to the cost-optimization dashboard.

  • Derives provider-qualified baseline models from semantic and complexity router candidates.
  • Prices selected and baseline usage through the shared cost engine, including cache and multimodal dimensions.
  • Records per-attempt baseline metadata and clears it when fallbacks leave auto-routing.
  • Persists signed savings through daily rollup queues, database tables, management APIs, and dashboard visualizations.
  • Adds the required migration and regression coverage for pricing, aggregation, API, and UI behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported pricing, metadata-production, fallback-cleanup, queue-aggregation, and migration issues are addressed in the current code.

Important Files Changed

Filename Overview
litellm/proxy/spend_tracking/savings.py Prices both counterfactual and selected-model usage through the shared cost engine while transforming cold cache writes into warm baseline reads.
litellm/router.py Records routing decisions and savings baselines together and clears both when a fallback attempt does not use an auto-router strategy.
litellm/router_strategy/savings_baseline.py Resolves provider-qualified, priceable baseline deployments from each router's candidate model groups.
litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py Aggregates signed auto-router savings across same-key queued requests, including payloads from older pods that omit the field.
litellm/proxy/db/db_spend_update_writer.py Computes and persists auto-router savings through create and increment paths for all daily spend entities.
litellm-proxy-extras/litellm_proxy_extras/migrations/20260731000000_add_autorouter_savings_spend/migration.sql Adds the required non-null savings column with a zero default to all six daily rollup tables.
litellm/proxy/management_endpoints/common_daily_activity.py Carries the new savings driver through daily aggregation and activity responses.
ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx Displays signed auto-router savings in the dashboard card, chart, and positive-only donut visualization.

Reviews (5): Last reviewed commit: "style: bring the savings changes under t..." | Re-trigger Greptile

Comment thread litellm/proxy/spend_tracking/savings.py Outdated
Comment thread litellm/proxy/db/db_spend_update_writer.py Outdated
Comment thread DESIGN_LIT5046.md Outdated
@tin-berri

Copy link
Copy Markdown
Contributor Author

Proof of Concept

Added comprehensive documentation: PROOF_OF_CONCEPT.md

This document verifies:

  • ✅ All 6 Daily*Spend models updated across 3 schema copies
  • ✅ Backend savings computation logic (baseline cost - selected cost - cache-write cost)
  • ✅ Spend writer integration (extract, compute, persist)
  • ✅ Frontend: 4 stat cards, 3-segment donut, 3-series graph
  • ✅ Type safety throughout (SavingsSpend, BaseDailySpendTransaction, SpendMetrics)
  • ✅ No breaking changes to existing code

Ready for live-proxy repro once baseline_model stashing is implemented in auto-router hook.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5046_autorouter_savings (5410306) with litellm_internal_staging (b1fd20f)

Open in CodSpeed

…hboard

Adds auto-router as a third savings driver alongside compression and prompt
caching: a summary card, a donut segment, and a series in the savings graph.

Savings are the net dollars from routing a request to the selected model
instead of a counterfactual baseline. The delta prices prompt tokens at each
model's input rate and completion tokens at each model's output rate, then
subtracts the cache-write penalty the selected deployment incurs on a cold
cache. Cache-read discounts stay attributed to the prompt-caching driver to
avoid double-counting. The result is floored at zero so an escalation to a
pricier model never reads as negative savings.

The baseline model defaults to claude-opus-5 and is operator-configurable per
deployment via the auto_router_savings_baseline_model litellm_param. It flows
AutoRouter to PreRoutingHookResponse to the metadata bucket to SpendLogsMetadata
to the daily spend writer, mirroring the routing_decision path, and is stripped
from untrusted caller metadata so it cannot be spoofed.

Savings accrue into a new autorouter_savings_spend column on the six
LiteLLM_Daily*Spend rollup tables; no LiteLLM_SpendLogs queries are added.
@tin-berri
tin-berri force-pushed the litellm_lit5046_autorouter_savings branch from d8a5cde to e30b7f2 Compare July 31, 2026 22:15
@tin-berri
tin-berri requested a review from a team July 31, 2026 22:15
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review after force-push to commit e30b7f2; pricing fixed (completion tokens at output rate, cache-write at selected model rate), baseline producer added, migration included

Comment thread litellm/proxy/spend_tracking/savings.py Outdated
Comment thread litellm/proxy/db/db_spend_update_writer.py
Comment thread litellm/router.py Outdated
@veria-ai

veria-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

@tin-berri tin-berri changed the title feat(LIT-5046): Auto-router savings on cost-optimization dashboard feat(spend): add net auto-router savings to the cost-optimization dashboard Jul 31, 2026
…nd surface them

Three defects, one cause: the driver was wired by hand at each stage of the
savings pipeline instead of going through the owner of each stage.

Pricing re-derived per-token arithmetic instead of calling the cost engine.
`prompt_tokens` already includes cache-read and cache-creation tokens, so
charging the whole total at the flat input rate and then subtracting a separate
cache-write penalty priced those tokens twice. Both arms now price the identical
usage through `generic_cost_per_token`, so each token is charged once in its own
dimension and tiered rates, ephemeral cache-write tiers and regional uplifts stay
consistent with what the request was actually billed. On a cache-heavy
opus-to-haiku switch the old formula reported $0.0458 against a true $0.0717.

The savings baseline was recorded by a second per-attempt writer sitting beside
the routing decision, and the exit path that runs when no pre-routing strategy
applies only cleared the decision. A fallback to a plain model group therefore
kept the previous attempt's baseline, letting a caller who forces a router
failure inflate the recorded savings. Both facts now travel from one response
through one recorder, so no exit can clear one and leave the other.

Aggregation summed every daily metric except this one, so two requests sharing a
rollup key kept only the first value, and since the cross-pod Redis drain runs
the same merge the field was dropped on every flush.

The driver was also absent from the entire read path: no column in the rollup
query, no accumulation, no field on the response model. The dashboard read a key
the API never sent, so the card, donut segment and graph series would have
rendered $0.00 forever however much routing saved. Wiring the write path without
the read path is the failure this had already shipped, so the drivers are now
enumerated from the response model itself and each is asserted to be summed,
accumulated, carried and totalled.

Also collapses the twelve hand-written per-field blocks in the daily upsert into
one enumeration feeding both the create and the increment.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review at 4897f6e

All three findings are fixed, plus a fourth that no bot flagged and that mattered more: autorouter_savings_spend never existed in the read path, so the aggregation query never summed it and the response model never declared it. The dashboard was reading a key the API never sent and would have rendered $0.00 permanently. The savings drivers are now enumerated from the response model itself and each is asserted to be summed, accumulated, carried and totalled.

A malformed usage_object and a model with no discounted cache-read rate both
degrade to zero savings rather than failing the daily spend write; pin both so
the fail-open contract cannot regress into a raise.
The rationale for pricing both arms through one cost engine belongs in the
commit history, not restated above every function.
…ed it

Staying on one model writes the prompt cache once and reads it thereafter.
Switching leaves the new model cold, so it pays to write the whole prompt again,
and that charge exists only because the router switched.

Both arms were priced as if each model wrote the cache, which credited the
baseline a cache-creation charge it would never have paid again. On a
sonnet to haiku switch mid-conversation that phantom write was larger than the
entire real cost of the request: the route lost $0.0104 and was reported as
having saved $0.0179, with the sign inverted.

The baseline is now priced as the warm cache a single-model deployment would
have had, so the cold-cache write counts against the saving. A request that read
nothing from cache is a genuine first turn the baseline would have paid to write
too, so both arms still write there and cold-start savings stay honest.

The result is signed rather than floored at zero. A cache-thrashing route is a
real cost and the dashboard has to be able to report it; flooring per request
would leave a number that can only ever go up and would hide exactly the routing
behaviour an operator needs to see. The donut plots only drivers that saved,
since a negative slice has no meaning, while the card and the range total keep
the signed truth. usd() now sizes and signs off the magnitude so a small loss
reads as -$0.0004 rather than $-0.00.
@tin-berri

Copy link
Copy Markdown
Contributor Author

Re-review at db15ef3

The cache-write accounting changed materially since the last pass. Previously both arms were priced as if each model wrote the prompt cache, which credited the baseline a cache-creation charge it would never have paid again; a mid-conversation sonnet to haiku switch that lost $0.0104 was reported as saving $0.0179, sign inverted. The baseline is now priced as the warm cache a single-model deployment would have had, so the cold-cache write counts against the saving, and a request that read nothing from cache is treated as a genuine first turn where both arms write.

The result is signed rather than floored at zero, so a cache-thrashing route reduces the total instead of disappearing. The donut plots only positive drivers.

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/db/db_spend_update_writer.py
Comment thread litellm/proxy/spend_tracking/savings.py
The two sides of the comparison arrived spelled differently. The spend log
records a normalized model name alongside its provider, while the baseline
arrives as the operator wrote it in config, with the provider prefixed, implied
or absent, so the raw strings were never comparable.

Read as a switch, `anthropic/claude-opus-5` against a served `claude-opus-5`
priced one deployment against itself, and because the baseline arm is priced
warm while the served arm pays its cold-cache write, a request that never
changed model reported a $0.0707 loss. That mis-comparison was harmless until
the cache fix made the two arms asymmetric, so the identity check has to land
with it.

Pricing had the same root cause: a baseline resolved without a provider takes
whichever vendor owns the bare name. `azure_ai/deepseek-r1` and
`deepseek/deepseek-r1` are the same bare model at different rates, and the
difference decides the sign, +$0.039 against -$0.0731 on the same request.

Both now resolve through `get_llm_provider` to a canonical (model, provider)
before being compared or priced, so one deployment spelled two ways is not a
switch and every arm is priced under the vendor that serves it.

The per-day chart no longer stacks its drivers. Stacking sums the series into
one bar, and a driver that goes negative would be drawn below the axis while
the rest of the bar still read as the day's total.
…ates

Without an auto-router a deployment has to pick one model, and it has to be one
that can carry the hardest request, so the counterfactual is the priciest model
that router could have chosen.

A fixed flagship default measured savings against a model the operator may never
have run. A router choosing only between sonnet and haiku saved nobody the price
of opus, so every such deployment would have opened the dashboard to savings it
was never going to make, and the figure drifted the moment the routes changed.

The baseline is now the priciest candidate the router itself can reach, by output
rate with input breaking the tie. Routes name model groups rather than models, so
each is resolved through the parent router's deployments before being priced, and
the default model counts as a candidate. Resolution is lazy and cached, because
the parent router's deployments are still being assembled while the auto-router
is constructed.

`auto_router_savings_baseline_model` still overrides it for operators who would
genuinely have run something else. When nothing can be priced the baseline is
None and the driver reports zero, since a missing number beats a fabricated one.

Total saved needs no change: it sums the drivers, so the derived baseline flows
into it. Compression and prompt caching stay priced at the served model's rates,
which answers what each optimization saved on the request that actually ran.
"vs. the router's baseline model" defined the number by a config field most
operators never set, and now that the baseline is derived it named nothing at
all. The comparison is the priciest model the router can reach, so the card says
that.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you review this and give me a score and explain why

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/router_strategy/auto_router/auto_router.py Outdated
…yment declares

A deployment can name its vendor in `custom_llm_provider` rather than in the
model prefix, which is the normal shape for Azure, Bedrock and Vertex. Pricing
the bare name alone resolves it to whichever vendor owns that name, or to
nothing: `claude-sonnet-4@20250514` prices at $0 without vertex_ai, and
`deepseek-r1` raises without azure_ai. Either way the candidate lost the
priciest-candidate contest, so the derived baseline silently became a cheaper
model and the driver under-reported.

Candidates now resolve through `get_llm_provider` and are carried as
`provider/model`, which is also what reaches the spend writer, so the baseline
resolves back to the vendor that served it rather than to whoever owns the bare
name.

A candidate with no per-token price is no longer eligible. Nothing that costs
nothing can stand in for what the traffic would otherwise have cost, and as a
baseline it would report the whole real spend as a loss. That outcome was
already unreachable, but only because the served model is drawn from the same
candidate set and fallbacks clear the baseline; the driver should not depend on
that chain holding.
@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The three previous issues — autorouter_savings_spend being Required, the value-plus-flag _derived_savings_baseline_model/_baseline_model_derivation_attempted pair, and stale baselines on fallbacks — are all cleanly resolved. Here is my assessment of the current revision:

Score: 4/5

What's well done:

  • NotRequired[float] on autorouter_savings_spend is the correct declaration. The test that unwraps get_args before matching against (int, float) is exactly right and will catch any future NotRequired metric added without aggregation wiring.
  • cached_property for _derived_savings_baseline_model cleanly separates "not yet computed" from "computed to None" without a second bool.
  • _record_routing_decision now atomically writes or clears both routing_decision and auto_router_savings_baseline_model in one pass — the stale-baseline-on-fallback bug is gone.
  • auto_router_savings_baseline_model added to the untrusted-caller strip list in litellm_pre_call_utils.py — the client-supplied baseline injection vector is closed.
  • The cache semantics in _baseline_usage are correctly reasoned: creation tokens are promoted to reads only when there were also reads (mid-conversation switch), leaving first turns symmetric.
  • The optional_metrics refactor in db_spend_update_writer.py eliminates the 12-block duplication and makes adding future metrics a one-liner.

Remaining concerns (why not 5):

  1. baseline_provider is not passed from _common_add_spend_log_transaction_to_daily_transactioncompute_savings_spend receives baseline_model but no baseline_provider. This is safe today because _canonical_model always stores provider/model, and _resolve_model correctly splits that back out via get_llm_provider. But it is an implicit contract: any future code path that stores a bare model name as the baseline (without qualifying it via _canonical_model) will silently zero the driver rather than error. A comment at the call site noting the invariant would make this explicit.

  2. Live-proxy proof is still outstanding per the PR description. The end-to-end path (routing decision → spend log metadata → daily rollup → dashboard query → UI) has many hops; the unit tests cover each individually but the integration path has not been demonstrated yet.

  3. Three schema copies (schema.prisma, litellm/proxy/schema.prisma, litellm-proxy-extras/litellm_proxy_extras/schema.prisma) remain a maintenance hazard, though this is pre-existing and not introduced by this PR.

The core logic is sound, backward compatibility is properly handled, and the test coverage is thorough. Closing the live-proxy gap and documenting the baseline_provider implicit contract would get this to 5.

Comment thread litellm/types/router.py
…equest

`auto_router_savings_baseline_model` was missing from `all_litellm_params`, so
unlike the four auto-router fields beside it, it was classified as a
provider-specific parameter and rode along in extra_body where a downstream
provider could read it.

The guard that exists for exactly this was a hand-written tuple of eleven
params, which can only catch a field being removed from the strip list, never a
new one that was never registered. That is the way this actually goes wrong, and
it is how this field slipped through. The test now derives the list from the
params model itself, so any future router-strategy field is covered the moment
it is declared.

The baseline is also qualified when it comes from configuration, not only when
derived. It travels to the spend writer as a bare string with no provider beside
it, so an operator writing `deepseek-r1` meaning Azure would otherwise be priced
against whoever owns that name. With every baseline qualified at the source,
`compute_savings_spend` no longer takes a `baseline_provider` it could never be
given: the parameter existing at all was the implicit contract, and removing it
is what makes the invariant explicit.
@tin-berri

Copy link
Copy Markdown
Contributor Author

Addressed both at c507283.

Veria's leak: auto_router_savings_baseline_model is now in all_litellm_params. The guard that exists for exactly this was a hand-written tuple of eleven params, which can only catch a field being removed from the strip list, never a new one that was never registered; that is precisely how this slipped through. The test now derives the list from LiteLLM_Params/GenericLiteLLMParams by the router-strategy prefix, so any future router field is covered the moment it is declared. Verified by unregistering the field and watching the test fail.

On the baseline_provider implicit contract, I went structural rather than adding a comment. The baseline is now qualified when it comes from configuration too, not only when derived, so an operator writing deepseek-r1 meaning Azure is no longer priced against whoever owns that bare name. With every baseline qualified at the source, compute_savings_spend no longer accepts a baseline_provider it could never be given; the parameter existing at all was the implicit contract, so removing it is what makes the invariant explicit rather than documented.

The three schema copies are pre-existing and I have deliberately left them alone; a fix there belongs in its own PR.

Live-proxy proof is still genuinely outstanding and I am not claiming otherwise. The read path, dashboard, signed handling and baseline derivation are demonstrated end to end on a local proxy against a real Postgres, but no request has gone through the auto-router because the available Anthropic key is out of credit and there is no embedding key for the semantic router. The pricing math is unit-tested only.

bugbot run

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

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

3.5 / 5

The two specific issues I raised are addressed correctly at c507283. Here is my full accounting of what I find well-done and what still gives me pause.


What is solid

all_litellm_params fix and its test — the new test derives the "must be stripped" list from LiteLLMParams/GenericLiteLLMParams by prefix rather than from a hand-written tuple. That is the right fix: the tuple only caught removals, never additions. The auto_router_savings_baseline_model field is now in all_litellm_params and the strip-guard is self-maintaining going forward.

Baseline qualification at source — the auto-router qualifies the baseline via _canonical_model both when it derives one from candidates and when it reads one from configuration, so compute_savings_spend correctly drops baseline_provider entirely. The invariant is structural, not documented.

_record_routing_decision atomicity — the refactor to write-or-clear both routing_decision and auto_router_savings_baseline_model together is correct. A fallback that re-enters the hook with no strategy now clears both fields in one place; previously only the decision was cleared.

Signed savings — the deliberate choice not to floor at zero is the right call for an honest dashboard. The test test_compute_savings_spend_carries_a_losing_switch_through guards it. The donut correctly plotting only positive drivers while the card and graph keep the signed total is the right UI split.

Rollup merge and upsert refactor — the prior omission of autorouter_savings_spend from get_aggregated_daily_spend_update_transactions (which would have dropped it on every cross-pod Redis drain) is fixed. The twelve hand-written per-field blocks in the upsert collapsing into a single enumeration is a genuine improvement for maintainability.

Test coverage — the unit-test suite for savings.py is thorough: warm-vs-cold cache semantics, cold-start symmetry, normalized-vs-prefixed equality, per-provider pricing, unresolvable baseline failing open, and signed escalation. The regression tests that enumerate savings drivers from the response model itself (so a half-wired future driver fails immediately) are the right structure.


What still concerns me

1. _derived_savings_baseline_model is computed and cached at routing time from model_list indices, which are mutable.

@cached_property
def _derived_savings_baseline_model(self) -> str | None:
    return self._most_expensive_candidate()

_candidate_models walks self.litellm_router_instance.model_list[index] and model_name_to_deployment_indices. Both are mutated by the parent router when deployments are added, removed, or health-checked out. cached_property computes on first access and never recomputes, so if the baseline is derived before the router is fully assembled (the docstring acknowledges this risk but says "Caching through cached_property keeps 'derived to nothing' distinct from 'not derived yet'"), or after a deployment is removed, the cached value could be wrong forever for the life of that AutoRouter instance. A deployment that later becomes the most expensive candidate will never become the baseline. This is a correctness gap rather than a crash, but it can silently misstate savings for a long-lived router.

2. usage_object deserialization is best-effort but the error path is silent in the happy flow.

_usage_from_spend_log catches all exceptions and returns None, which zeroes autorouter savings without any log line at warning or above — only a debug line. If a schema change in Usage starts producing malformed usage_object rows in production, the autorouter savings column will read $0.00 permanently with no alerting signal. A warning-level log on a deserialization failure would help operators catch this.

3. No live-proxy proof.

The author is explicit that no real auto-routed request has gone through the system. The read path, dashboard rendering, and schema migration are all unit-tested and locally verified against Postgres, but the end-to-end spend write → daily rollup → API response → UI render chain has not been exercised with real routing traffic. This is acknowledged, not hidden, and the author says it is outstanding before requesting maintainer review — but it does mean the integration path is unvalidated.

4. Three schema copies pre-exist; the migration only covers one.

The author states this is deliberate and scopes the fix to a separate PR. That is a reasonable call but it means the rollup column is absent in two of the three schema copies until that follow-up lands, which will cause autorouter_savings_spend to silently read as None (and be coerced to 0.0) for whatever entity types those copies back.


Summary

The two issues from the prior review are resolved correctly and the new design is structurally better than what it replaced. The four items above are all real gaps, but items 3 and 4 are acknowledged pre-conditions the author is not claiming to have closed. Items 1 and 2 are correctness/observability concerns worth addressing before this merges. The score reflects solid core work with two remaining gaps in the correctness and observability of the live path.

The parent router adds and removes deployments while it runs (`model_list` is
appended to and popped from during deployment updates and health checks), so a
baseline pinned on first access keeps naming a model the router no longer has,
and a pricier deployment added later can never become the baseline. Nothing
recomputes it, so the value stays wrong for the life of the instance.

The cache was never worth having: resolving the baseline over a four-candidate
router takes about 60 microseconds against a network call three orders of
magnitude larger. Removing it also removes the question the previous round was
spent answering, since there is no longer a cached value whose "not computed
yet" state has to be told apart from "computed to nothing".

A malformed `usage_object` now logs at warning rather than debug. It zeroes the
auto-router driver for every affected row, and a shape change in `Usage` would
otherwise surface only as a dashboard that quietly reads $0.00.
The card header was a wrapping flex row, and the subtitle is longer on
Cumulative ("Running total saved") than on Per day ("Saved per day"). The extra
width pushed the legend and the accumulation toggle onto a second row, so both
jumped whenever the tab changed.

`CardHeader` is now the row itself, the same structure `SummaryCard` in this file
already uses, with the controls held in a shrink-0 box so they keep their place
whatever the subtitle says. The other two headers in the file are title-only and
correctly stay plain.
@tin-berri

Copy link
Copy Markdown
Contributor Author

Pushed e9b4f95. Two things since the last review.

The cached_property you flagged is gone rather than fixed. You were right that model_list mutates at runtime (append at router.py:8417, pop at 8470 and 8519), so pinning the baseline on first access meant a deployment added later could never become it. I measured before deciding: resolving over a four-candidate router is about 60 microseconds against a network call three orders of magnitude larger, so the cache bought nothing and cost correctness. Removing it also removes the question the previous round was spent on, since there is no longer a cached value whose "not computed yet" state has to be distinguished from "computed to nothing". Two tests assert the baseline follows deployments added and removed after the first read, verified by reinstating the cache and watching both fail.

A malformed usage_object now logs at warning rather than debug, since it zeroes the driver for every affected row and would otherwise surface only as a dashboard quietly reading $0.00.

On the schema copies: I believe that one is mistaken. All three carry the column and are byte-identical (md5 b01b030ff2812b1e25e9f4bc9c6949b6 for schema.prisma, litellm/proxy/schema.prisma and litellm-proxy-extras/litellm_proxy_extras/schema.prisma), and the "Verify schema.prisma copies match root" CI check passes. The single migration in litellm-proxy-extras is the only one that exists because that is the package that ships migrations; the other two copies are not independently migrated. Happy to be corrected if you are seeing something I am not.

Live-proxy proof is still outstanding and I am not claiming otherwise.

bugbot run

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

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

The round of revisions is solid. Here's the breakdown:

What landed well:

  • _record_routing_decision atomicity — both routing_decision and auto_router_savings_baseline_model are now written or cleared together in a single pass over both buckets. The stale-baseline-on-fallback bug is gone and the logic is easy to follow.

  • cached_property removal is the right call. The two tests asserting the baseline follows deployments added and removed after the first read verify it correctly; reinstating the cache and watching them fail is exactly the evidence needed.

  • Cache semantics are correct. The warm-cache counterfactual (_baseline_usage moves cache-creation tokens into the cached count and zeros the creation charge) accurately models what staying on one model would have cost. The cold-start case (nothing in cache → both arms write) is tested explicitly, as is the mid-conversation losing switch.

  • Rollup merge fixautorouter_savings_spend is now summed alongside the other optional metrics in get_aggregated_daily_spend_update_transactions. The NotRequired[float] TypedDict annotation is the right shape for a field that older queued rows won't carry.

  • TestEverySavingsDriverSurvivesTheReadPath is a genuinely good pattern. Enumerating drivers from the response model and asserting each one is summed in SQL, accumulated, and carried through a single-row conversion means the next driver cannot be half-wired silently.

  • auto_router_savings_baseline_model in INTERNAL_CALL_METADATA_KEYS — client-supplied baselines are stripped before the spend write.

  • usd() negative fix$-0.00 is gone; -$0.0012 is legible.

  • Migration uses ADD COLUMN IF NOT EXISTS with a default on all six tables — safe for rolling deploys.

Why not 5/5:

Live-proxy proof is explicitly still outstanding. You've said so yourself, and it's the right call not to claim otherwise. Unit tests can verify the arithmetic and the wiring, but they cannot catch a misconfigured deployment where the baseline never reaches the spend writer, or a subtle provider-resolution difference between the router's _canonical_model and the spend writer's _resolve_model that only surfaces on real traffic. That screenshot and the four cards/three donut segments/three graph series on real routed traffic are the last gap before this is ready for a maintainer review.

Title, legend, toggle and subtitle all shared a row. The subtitle is longer on
Cumulative than on Per day, so it wrapped on one tab and not the other, growing
the header by a line and shifting the legend, the toggle and the chart with it.

The title and the controls now hold a fixed row and the subtitle sits on its own
line beneath, so nothing above the chart depends on how long that text is.

The test that guards this selected the subtitle by class and matched a summary
card's hint instead, which made it pass with the subtitle moved back into the
row. It now finds the element by its text.
The header was hand-rolled rows, so the title, the subtitle, the legend and the
tab control all competed for one line. The subtitle is longer on Cumulative than
on Per day, so it wrapped on one tab and not the other and the chart moved with
it; pinning the controls against shrinking then pushed them past the card edge
once the viewport narrowed.

CardHeader already solves this. It is a grid that switches to
`grid-cols-[1fr_auto]` when a card-action slot is present, sizing the controls to
their content and giving the rest to the title, with the description on its own
row. Using CardTitle, CardDescription and CardAction removes the bespoke layout
rather than tuning it, and the controls wrap inside their own column instead of
overflowing.

The guard test now anchors on those slots. Its previous selector matched a
summary card's hint rather than this subtitle, so it passed with the subtitle
moved back into the controls.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

QA pass on f66687b (re-run of an earlier pass on db15ef3), focused on whether the auto-router savings number is economically right: does it price against a sane counterfactual, and does it charge the cold-cache write that a model switch causes

The plumbing is sound end to end. The baseline reaches the spend log metadata, survives serialization, and lands as a non-zero autorouter_savings_spend on the daily rollup; the aggregation, the API totals and the dashboard all read it. The two things fixed since the earlier pass both check out: _resolve_model canonicalizes both sides, so anthropic/claude-opus-5 against a log recording claude-opus-5 now correctly reports 0.0 instead of inventing a saving, and deriving the baseline from the router's own candidates re-derives per call (measured 18us for a two-candidate pool, so the per-request cost is not a concern) and picks up a pricier deployment added at runtime

The cold-cache write is still charged on the wrong branch

_baseline_usage only rewrites the usage when cache_read > 0 and cache_creation > 0. That means the switch penalty never fires on the request that actually paid for the switch, and does fire on the steady-state turn whose incremental write happens no matter who serves it

Numbers below are anthropic/claude-opus-5 as baseline, claude-haiku-4-5 selected, 20k prompt tokens and 500 completion tokens held constant, so every row is the same traffic and only the cache split moves

cache split reported savings
read 0, write 20000 (cold switch) +$0.1100
read 0, write 0 (no caching at all) +$0.0900
read 1, write 19999 -$0.0050
read 19900, write 100 (steady state) +$0.0179

The first row is the problem: a cold switch reports more saving than the identical traffic with caching turned off entirely, because the counterfactual is credited a 20k-token cache-write premium it would never have paid, while the model that actually paid it is charged nothing extra. Rows two and three are the same request with one token moved between buckets, and the sign flips on a $0.115 swing

The condition is doing real work in the steady-state case, so the fix is not to delete it; the read-nothing case is the one that needs the opposite treatment. read == 0, write == everything is genuinely ambiguous though: it is both the signature of a fresh conversation, where the baseline pays the write too, and of a switch into a cold model, where it does not. Distinguishing them needs the previous turn's model, which is not available here. Three options, in my order of preference:

Charge the write when read == 0 and price symmetrically when read > 0. This is the exact inversion of what is there now, and it is right whenever the router changes models mid-conversation, which is the case the driver exists to measure; it over-penalizes genuine first turns, which biases the card conservative rather than flattering, and that seems like the correct direction for a savings claim

Track the previously serving model per conversation and charge the write only on an actual change. Correct, and more state than this is probably worth right now

Drop the cache-switch modelling, price identical usage on both arms, and say so on the card. Under-claims, but never reports a number that is wrong in the flattering direction

Relatedly, the tooltip on the card no longer mentions the cold-cache write or the possibility of a negative total, while the code still produces both; the bar chart comment two hundred lines down explicitly reasons about negative drivers. Whichever way the semantics land, the popover should describe what the number actually is

Smaller findings

A candidate that does not resolve in the cost map drops out of the baseline pool with only a debug log. azure/my-deployment-name, anthropic/* and openrouter/anthropic/claude-opus-5 all return None from _priced_candidate. Azure is the one that bites in practice: _deployment_model reads only litellm_params, so model_info.base_model, which is how Azure deployments are priced everywhere else in the codebase, is ignored. A pool whose most expensive member is an Azure deployment silently measures against the second most expensive model, and a pool that is entirely Azure reports $0.00 with nothing in the logs at default verbosity to say why

_baseline_usage reconstructs PromptTokensDetailsWrapper with only the cache fields, so audio_tokens and image_tokens come back None. For multimodal traffic the baseline arm is priced on a request that is not the one that ran, and the two arms are no longer comparable

The complexity and adaptive routers never populate savings_baseline_model, so the auto-router card reads $0.00 for any deployment using them. Fine if intended, worth a line in the description if so

Deriving the baseline from the router's candidates is a better counterfactual than a hardcoded flagship, and I agree with the change; note it does mean the number answers "versus the priciest model this router could have picked", not "versus opus-5". On a haiku plus sonnet pool the savings are measured against sonnet. Operators who want the flagship comparison need auto_router_savings_baseline_model: claude-opus-5, which works and canonicalizes correctly, but is not the default

Verification was numeric against the branch: direct compute_autorouter_savings calls for the table, a real Router with an auto-router deployment for the derived-baseline and runtime-mutation cases, and tests/test_litellm/proxy/spend_tracking/test_savings.py plus the auto-router, complexity-router and daily-spend-queue suites, all 305 passing

…paid it

The warm baseline was gated on the request having read from cache, so it never
applied to the case it exists for. A switch to a cold model reads nothing
precisely because that model's cache is empty, so the gate skipped it and priced
the baseline as if it too had written the whole prompt. Staying on one model
would have had that prompt cached already and paid only the read rate, so the
counterfactual side was inflated by a write it would never repeat.

On a 20k-token prompt switching opus-5 to haiku that reported +$0.1200 saved
against +$0.1000 for the same traffic with caching off entirely, so paying to
re-warm a cold model looked better than not caching at all. It now reports
+$0.0050, which is worse, as it should be.

The gate also made the write bucket a proxy for "this was a switch", which put a
cliff between a request that read nothing and one that read a single token: the
same prompt moved between +$0.1200 and +$0.0050 depending on one token. Pricing
the baseline warm whenever there is anything to write removes the cliff; the two
now differ by a rounding error.

A first turn of a genuinely new conversation is charged the same way, which
understates its saving slightly, since nothing was cached anywhere and the
baseline would have paid to write too. A single rollup row cannot tell that apart
from a switch, and understating is the safe direction for this number.

The test that asserted both arms write on a cold start was asserting the bug.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-QA'd on d539fa6. The cold-cache gate flip is correct and the two things I flagged as wrong before are gone: a cold switch no longer beats turning caching off, and the one-token cliff between read 0 and read 1 is gone. Nothing in the number is now wrong in the flattering direction, which for a savings claim is the important half

Numbers below are from the branch, anthropic/claude-opus-5 as baseline against claude-haiku-4-5 selected, 20k prompt and 1k completion held constant so only the cache split moves:

opus-5 -> haiku-4-5, 20k prompt / 1k completion held constant
  cold switch      read 0      write 20000   +0.0050
  one token read   read 1      write 19999   +0.0050
  steady state     read 19900  write 100     +0.0279
  warm read only   read 20000  write 0       +0.0280
  caching off      read 0      write 0       +0.1000

genuine first turn: true +0.1200, reported +0.0050

one conversation, first turn cold then N-1 warm turns
  turns= 1  reported +0.0050  true +0.1200   4% of it
  turns= 2  reported +0.0329  true +0.1479  22% of it
  turns= 5  reported +0.1165  true +0.2315  50% of it
  turns=10  reported +0.2560  true +0.3710  69% of it

cold switch by completion length, 20k cached prompt
  completion   200 tokens   -0.0110
  completion   500 tokens   -0.0050
  completion   750 tokens   -0.0000
  completion  1000 tokens   +0.0050
  completion  4000 tokens   +0.0650
script
import os

os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"

import litellm
from litellm.proxy.spend_tracking.savings import compute_autorouter_savings
from litellm.types.utils import PromptTokensDetailsWrapper, Usage

PROMPT, OUT = 20_000, 1_000
opus = litellm.get_model_info("claude-opus-5", "anthropic")
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")


def usage(read: int, write: int) -> Usage:
    return Usage(
        prompt_tokens=PROMPT,
        completion_tokens=OUT,
        total_tokens=PROMPT + OUT,
        prompt_tokens_details=PromptTokensDetailsWrapper(
            cached_tokens=read, cache_creation_tokens=write, text_tokens=max(PROMPT - read - write, 0)
        ),
    )


def savings(read: int, write: int) -> float:
    return compute_autorouter_savings("anthropic/claude-opus-5", "claude-haiku-4-5", "anthropic", usage(read, write))


print("opus-5 -> haiku-4-5, 20k prompt / 1k completion held constant")
for label, read, write in (
    ("cold switch      read 0      write 20000", 0, 20_000),
    ("one token read   read 1      write 19999", 1, 19_999),
    ("steady state     read 19900  write 100", 19_900, 100),
    ("warm read only   read 20000  write 0", 20_000, 0),
    ("caching off      read 0      write 0", 0, 0),
):
    print(f"  {label}   {savings(read, write):+.4f}")

true_first_turn = (PROMPT * opus["cache_creation_input_token_cost"] + OUT * opus["output_cost_per_token"]) - (
    PROMPT * haiku["cache_creation_input_token_cost"] + OUT * haiku["output_cost_per_token"]
)
print(f"\ngenuine first turn: true {true_first_turn:+.4f}, reported {savings(0, 20_000):+.4f}")

print("\none conversation, first turn cold then N-1 warm turns")
for turns in (1, 2, 5, 10):
    reported = savings(0, 20_000) + (turns - 1) * savings(19_900, 100)
    truth = true_first_turn + (turns - 1) * savings(19_900, 100)
    print(f"  turns={turns:2d}  reported {reported:+.4f}  true {truth:+.4f}  {reported / truth:.0%} of it")

print("\ncold switch by completion length, 20k cached prompt")
for out_tokens in (200, 500, 750, 1_000, 4_000):
    baseline = PROMPT * opus["cache_read_input_token_cost"] + out_tokens * opus["output_cost_per_token"]
    paid = PROMPT * haiku["cache_creation_input_token_cost"] + out_tokens * haiku["output_cost_per_token"]
    print(f"  completion {out_tokens:5d} tokens   {baseline - paid:+.4f}")

On the tradeoff you named

Agreed with the direction, and agreed that recording the previous model belongs in its own PR. The one thing I'd push back on is the word "slightly". The first turn of a new conversation with cache_control set is charged the full write on the selected side while the baseline is credited a read, so it reports 4% of its true saving, and the whole-conversation number only converges as the conversation gets long: 22% at two turns, 50% at five, 69% at ten. That is not a rounding error on short-conversation traffic

The sharper edge is that the understated value can be negative. The write premium is fixed by prompt size while the saving grows with completion length, so on a 20k cached prompt the reported number crosses zero at roughly 750 completion tokens; anything shorter reads as a loss. Traffic that is mostly single-turn with a large cached system prompt and a short answer, which is a pretty ordinary agent shape, can therefore make the card show the auto-router losing money on requests that each genuinely saved about $0.12

None of that makes the current behavior wrong to ship; it makes the caveat load-bearing. Two things would carry it: put it in the PR description as a known limitation rather than only in the commit body, and put it on the card. The popover currently reads "What this traffic would have cost had every request gone to the most expensive model the auto-router can route to, minus what it actually cost", which does not prepare anyone for a negative total or for a systematic undercount, and the earlier copy that did mention the cold-cache write was dropped in f66687b. Something like noting that a switch pays to re-warm the cache, that a cold request is priced as though the baseline were already warm, and that the total can be negative would cover it

Still open from the previous pass

The Azure one is the one I'd actually fix before merge. _deployment_model reads only litellm_params, so model_info.base_model, which is how Azure deployments get priced everywhere else, is ignored; azure/my-deployment-name returns None from _priced_candidate and drops out of the baseline pool with only a debug log. A pool whose priciest member is an Azure deployment silently measures against the second priciest, and an all-Azure pool reports $0.00 with nothing at default verbosity explaining why. Same silent drop applies to anthropic/* and openrouter/anthropic/claude-opus-5

_baseline_usage still rebuilds PromptTokensDetailsWrapper with only the cache fields, so audio_tokens and image_tokens come back None and multimodal traffic prices the baseline on a request that did not run

The complexity and adaptive routers still never populate savings_baseline_model, so the card reads $0.00 for those deployments. Worth one line in the description if that is intended

Test run

tests/test_litellm/proxy/spend_tracking/, tests/test_litellm/proxy/db/db_transaction_queue/, test_auto_router.py and test_complexity_router.py: 669 passed, 6 skipped. The two failures in test_redis_update_buffer.py (test_get_transaction_buffer_redis_cache_builds_from_env, ..._parses_string_flag) reproduce identically on origin/litellm_internal_staging in a clean worktree, so they are not from this branch

The two new tests are the right ones; test_a_cold_switch_never_beats_turning_caching_off and test_moving_one_token_between_cache_buckets_does_not_move_the_answer both fail if the gate goes back, which is exactly what the regression needed

…ardest tier

The savings driver only ever worked for the semantic auto-router. It was the one
strategy router that declared a baseline model, so every complexity, quality and
adaptive router fell through to no baseline, compute_autorouter_savings
short-circuited, and autorouter_savings_spend was structurally zero. A deployment
routing exclusively through complexity routers saw $0.00 against real spend, which
reads as "routing saved nothing" rather than "nothing was measured".

A complexity router's tier ladder already names the model an operator would have
had to run to serve the hardest request, so the counterfactual is the priciest
model in the REASONING tier, falling back to the highest-severity tier configured
when REASONING is absent. Deliberately not the priciest model the router can
reach: a pricey model sitting in a low tier is a choice the router made, not a
ceiling it was bounded by, and crediting savings against it would overstate them.

The pricing and resolution both routers need is now one module rather than two
copies. Deployments resolve through model_info.base_model before litellm_params
.model, because on Azure the latter is a deployment name that is absent from the
cost map; without that hop an Azure candidate never prices, and if it was the
priciest the baseline silently drops to the second priciest and understates every
saving.

resolve_baseline can no longer raise. It is read on the routing path while
decorating a request that is about to be served, and a dashboard counterfactual
must not be able to take a live request down; an unresolvable baseline zeroes the
driver instead.
…ent name

`litellm_params.model` is not always a model. On Azure it is the deployment
name, which is absent from the cost map, so pricing it directly returned nothing
and the candidate dropped out of the pool with only a debug line. If that
candidate was the priciest, the baseline quietly became the second priciest and
every saving was understated; if the pool was all Azure, nothing priced, the
driver was disabled and the card read $0.00 with nothing at default log level
saying why. Wildcard and aliased deployments drop the same way.

`model_info.base_model` is what names the real model, and router.py already
resolves pricing through the same base_model, base_model, model chain in
`_get_model_from_deployment` and its cooldown counterpart. Candidate resolution
now follows it.
`litellm/proxy/_experimental/out` is release build output, not source. Rebuilding
it locally to look at the dashboard left the files dirty in the worktree, and a
`git add -A` on the previous commit swept 145 of them in.

The path is restored to exactly what litellm_internal_staging carries, so this
branch no longer touches it.
litellm/proxy/_experimental/out is release build output, not source, and this
branch has no business touching it.
…he split

`_baseline_usage` rebuilt `PromptTokensDetailsWrapper` from three fields, so
everything else the request was priced on came back `None`. Audio, image and
video counts, the character and image counts Vertex prices multimodal
embeddings by, and the audio and video durations all vanished from the
baseline arm, which then priced a text-only request that never ran. On a 20k
prompt carrying 4k image tokens the baseline came out 36% light, and since it
is the arm being subtracted from, every one of those requests reported less
saving than it earned.

Dump the details and override only the cache buckets, so a field added to the
wrapper later is carried without anyone remembering to add it here. The 5m/1h
creation breakdown is dropped along with the creation count: `generic_cost_per_token`
charges a cache-write whenever that breakdown is present, even against a zeroed
count, which would have put the phantom write back on the baseline for every
long-cache request.

The exclude set is a frozenset fed straight to `model_dump`, so the override
is a call signature rather than a dict literal the reader has to trust.
The popover explained the counterfactual and stopped there, so nothing
prepared a reader for a total below zero or for a first turn reading at a
fraction of what it saved. The copy that did mention the cold-cache write was
dropped in f66687b; this puts it back and adds the two consequences that
follow from it.
…itellm_lit5046_autorouter_savings_fixes

# Conflicts:
#	tests/test_litellm/router_strategy/test_complexity_router.py
… budgets

`Optional[PreRoutingHookResponse]` becomes the union form the codebase is moving
to, the recorded metadata is built in one shot rather than seeded empty and
mutated, and the spend log's usage object is typed as a Mapping of object rather
than a bare mutable dict, which also drops the Any the strict gate bans.

The local run compared against a stale base and read clean, so these only
surfaced once CI diffed against the real merge base.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you review, give a score and tell me why

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@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 5410306. 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.

1 participant