Repository navigation
perf(proxy): one MGET and one pipeline for post-call spend counters, no team/user/org refetch on the response path - #40841
Conversation
…GET, one query and one pipeline Auth read each object with its own Redis GET and, on a miss, its own DB query, then the admission spend counters with one GET each. The prefetch warms every entry the checks read with one MGET, one raw query for the Redis misses and one pipeline write, and a per-request batch serves the spend counter reads from one MGET. The per-object getters stay the readers and the fallback, so enforcement does not depend on the prefetch Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…T instead of one GET per counter Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…el access check passes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…itellm_auth_single_io_lookup
…ity reasons Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…havior shard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…prefetch join Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…te through the request Post-call warm checks, reservation reads and reconcile reads for one request now go through a task-local spend counter batch: one MGET answers every counter, successful increments write their result back into the batch so no second Redis read follows, and invalidation forgets the key. RedisCache.async_increment sends INCRBYFLOAT and its TTL command in one pipeline round trip. Auth pins frozen team, user and org budget snapshots on UserAPIKeyAuth, the pre-call setup writes them into the request metadata, and Prometheus reads them back instead of calling get_key_object, get_team_object, get_user_object and get_org_object on the response path. The getters stay as the fallback for requests that carried nothing (custom auth, unauthenticated routes, skipped checks). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…checks from one MGET and one pipeline A scope opened inside an open spend counter batch binds into it instead of starting its own, so the reservation reconcile and the post-call warm checks share the request's single MGET. The reconcile reads every reserved counter concurrently, sends the consistent adjustments in one INCRBYFLOAT+EXPIRE pipeline and settles a flushed or reseeded counter on its own afterwards, keeping the pre-call resize fail-closed. PendingSpendIncrement moves to spend_counter_batch so budget_reservation can build a pipeline without importing a private name Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ndIncrement move Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
|
Greptile SummaryThis PR reduces post-call spend-accounting latency by batching Redis reads and writes and carrying auth-time budget state into response-path telemetry.
Confidence Score: 5/5The PR appears safe to merge; no outstanding blocking or non-blocking code issue was established in the current changes. The current implementation preserves fallback behavior, invalidates uncertain counter state after failed writes, and avoids treating partial carried metadata as authoritative. Both earlier Greptile threads were resolved without explanatory replies and therefore do not remain outstanding.
|
| Filename | Overview |
|---|---|
| litellm/proxy/spend_tracking/spend_counter_batch.py | Extends task-local spend-counter batching with post-call key binding, cached write results, and invalidation support. |
| litellm/proxy/proxy_server.py | Runs post-call counter preparation and reservation reconciliation in one batch and applies increments through one Redis pipeline. |
| litellm/proxy/spend_tracking/budget_reservation.py | Batches reservation reconciliation reads and applies consistent adjustments together while preserving inconsistent-counter reseeding. |
| litellm/caching/redis_cache.py | Combines INCRBYFLOAT with TTL or EXPIRE in a non-transactional Redis pipeline without changing the public increment contract. |
| litellm/proxy/spend_tracking/carried_budget_state.py | Captures auth-resolved budget state and serializes available snapshots into request metadata. |
| litellm/integrations/prometheus.py | Consumes carried budget snapshots for post-response metrics and retains existing object-lookup fallbacks. |
Reviews (7): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile
…n Python 3.10 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…tate tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
bugbot run |
… request batch Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
…try TTLs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…pend_batch Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> # Conflicts: # tests/test_litellm/caching/test_redis_cache.py
|
bugbot run |
… adjustments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
…he prefetch join and read user memberships the way get_user_object does Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
…itellm_post_call_spend_batch Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> # Conflicts: # litellm/proxy/auth/user_api_key_auth.py # litellm/proxy/db/spend_counter_reseed.py # litellm/proxy/proxy_server.py # litellm/proxy/spend_tracking/spend_counter_batch.py # tests/test_litellm/caching/test_redis_cache.py # tests/test_litellm/proxy/spend_tracking/test_spend_counter_batch.py
|
bugbot run |
Merging this PR will not alter performance
Comparing |
There was a problem hiding this comment.
✅ 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 b4f81a7. Configure here.
…rement_ttl Upstream (BerriAI#40834, BerriAI#40841) moved the increment into a _incrbyfloat_with_ttl helper that pipelines INCRBYFLOAT with EXPIRE or TTL and still issues a separate EXPIRE when the key had no TTL. The helper now runs the single Lua script instead, so the first increment of a fresh key cannot be split by a cancellation either. Upstream's round-trip test keeps its fake client, which gained an eval that applies the script's semantics, and now expects one round trip per increment in every case Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Co-authored-by: songkuan-zheng <songkuan-zheng@users.noreply.github.com>
…ed spend counters Sync brought in _increment_spend_counters_batched (one MGET/pipeline for post-call spend counters), whose branching pushed ruff_strict_gate.py's C901 total 1 over base's own count. Annotated the def line rather than refactoring upstream's own new code or touching the budget file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NBqWMrCtccbSpogG7e2bC
Was stacked on #40834, which is now merged (squash f2e0a5d), so this PR targets
litellm_internal_stagingdirectly. The tip b4f81a7 is a merge of staging that resolves the conflicts with that squash; its diff against staging is byte-identical togit diff e75b541bec...0626401197, so every measurement and gate result below, taken at 0626401, describes the same code. At b4f81a7 the changed-file ruff, ruff format, and strict gate pass, and the touched test files re-run as before (Redis cache file alone 76 of 76, auth suites 480 passed, spend batch, Prometheus carried state and prefetch tests all green; the same isolation failure appears when the Redis cache and Prometheus modules are collected together)TLDR
Problem this solves:
INCRBYFLOATis followed by a separateTTLorEXPIREround tripHow it solves it:
INCRBYFLOATand its TTL command share a round tripget_*_objectUser Flow
Before: a developer's chat request returns fine but the gateway keeps talking to Redis and Postgres long after the answer, so p95 under load is inflated
{"model": "gpt-5.6", "messages": [{"role": "user", "content": "hi"}]}and a key that belongs to a team, user and orgAfter: the same request returns the same body and the post-response tail is one MGET, one pipeline, no Postgres object lookups
Relevant issues
Follow-up to #40834 (auth phase). Together they cover the whole-request I/O reduction tracked in LIT-7478
Linear ticket
Resolves LIT-7644
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more (1926 passed across the nine touched files; two order-dependent tests unrelated to this diff pass in isolation, see Tests and gates)@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Design notes
The post-call path in
_PROXY_track_cost_callbacknow runs insidespend_counter_batch_scope(redis, counter_keys=post_call_counter_keys(...))._is_spend_counter_cache_warmasks the task-localSpendCounterBatchfirst; the first reader pays one MGET for every bound key, later readers hit the loaded mapping. Budget-window counters that are only known at read time bind on read and join the same MGET when they get there before it fires. Reservation reconcile (_set_reserved_entries_actual_cost) opens the same scope type; nested inside an open outer batch it binds into it instead of starting a second MGETWrites:
increment_spend_counters_pipeline(pending)sends everyINCRBYFLOATplus itsEXPIREin one pipeline and callsrecord_spend_counter_valuewith each returned value, so a counter incremented in this request is served from the batch on the next read.RedisCache.async_incrementfor a single key now sendsINCRBYFLOATandTTL(orEXPIRE) in one pipeline instead of two round trips, and only issues a thirdEXPIREwhen the key turned out to have no TTL. On any pipeline failure every pending counter is invalidated (forget_spend_counterplus in-memory delete) before the error propagates, so a half-applied batch is never read as authoritativeResponse path:
carry_team_and_user_budget_stateandcarry_organization_budget_statepin frozenTeamBudgetSnapshot,UserBudgetSnapshot,OrgBudgetSnapshotonUserAPIKeyAuthright where auth already holds the objects.add_litellm_data_to_requestwrites them into metadata under the existinguser_api_key_*names. Prometheus_set_team_budget_metrics_after_api_request,_set_user_budget_metrics_after_api_requestand_set_org_budget_metrics_after_api_requestbuild from the snapshot when it is present and otherwise call the sameget_*_objectthey always did. Custom auth, unauthenticated routes and requests where a budget check was skipped therefore behave exactly as beforeNot changed on purpose: the rate limiter's own post-call
INCRBYFLOAT/TTL/EXPIREtriplets (9 each in the cold trace) and the pre-call rate limiter reads are left alone, as the brief excluded a rate-limiter redesign. Those are the material pre-call round trips left; see the PR (c) decision belowRedis pool diagnosis (why 6 parallel GETs each took ~272 ms in prod)
Measured with
redis_pool_diag.pyagainst the repo'sRedisCache(redis-pyBlockingConnectionPool,max_connections=50) and a local Redis underMONITOR:asyncio.gathered GETsThe GETs leave on six different connections within a millisecond of each other, so nothing serializes them on the Redis side or in the pool. What produces "six parallel calls, each ~272 ms" is the event loop not being free to read the replies: every reply is already in the socket buffer, and all six
awaits resolve together once the loop gets control back. In the prod trace the neighbour isbatch_write_to_db(433 ms) running in the same loop. The fix in this PR removes five of the six reads rather than the stall itself; the stall itself (what in_PROXY_track_cost_callback/ the DB writer holds the loop) is written up as a follow-up on LIT-7644. A third leg that held the Redis server busy with a 200 ms Lua script timed out on the client and is not citedScreenshots / Proof of Fix
Both runs use the same local rig: staging proxy from the given checkout, PostgreSQL 16.15 with
log_statement=alland oneapplication_nameper proxy, Redis 7.4.11 underMONITOR,user_api_key_cache_ttl: 2, Prometheus callback enabled, plus the rate limiter v3 and budget reservation on. One master-key-created key with a team, user, org, end user and two tags, plus a second key whosemodelslist only allowsreal-chatfor the denied case. Before is the merge base e75b541 (the tip of #40834), After is this PR's tip 0626401. Both sides run two proxy processes (pod A on 20900, pod B on 20901) sharing the same Redis and Postgres, so the pod B cases show a key whose objects are already in Redis but not in that process's memory. Redis round trips are estimated from MONITOR by grouping commands that arrive on the same client connection within the same pipeline; Postgres counts are statements attributed to this request'sapplication_namein the log, split into the auth, pre-call, post-call, DB-writer and response-path windowsThe real-provider run below hits
openai/gpt-5.6(asreal-chat) andanthropic/claude-sonnet-5(asreal-claude) with the provisioned provider keys and costs real money. The synthetic matrix further down usesmock_responseso latency deltas isolate the gateway; it is labeled as syntheticShared setup for every case:
Before (e75b541)
chat, cold cache, pod A (port 20900), real OpenAI call
curl -s -w '\n%{http_code} %{time_total}s' -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$CHAT" http://127.0.0.1:20900/v1/chat/completions200, 1430.3 ms, body{"id":"...","created":1789205475,"model":"real-chat","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hi! How can I help?","role":"assistant","provider_specific_fiel...GET6,MGET0,INCRBYFLOAT6,EXPIRE6. Post-call spend counters:GET12,MGET0,INCRBYFLOAT8,EXPIRE8,SET1SELECT -1,SELECT LiteLLM_ObjectPermissionTable1,SELECT LiteLLM_ShadowEvalJob1,SELECT LiteLLM_UserTable1chat, same key on pod B (port 20901, Redis warm, in-memory cold), real OpenAI call
curl -s -w '\n%{http_code} %{time_total}s' -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$CHAT" http://127.0.0.1:20901/v1/chat/completions200, 1098.7 ms, body{"id":"...","created":1789205498,"model":"real-chat","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hi! How can I help?","role":"assistant","provider_specific_fiel...GET5,MGET0,INCRBYFLOAT5,EXPIRE5. Post-call spend counters:GET9,MGET0,INCRBYFLOAT8,EXPIRE8SELECT LiteLLM_ObjectPermissionTable1,SELECT LiteLLM_ShadowEvalJob1messages, cold cache, pod A, real Anthropic call
curl -s -w '\n%{http_code} %{time_total}s' -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$MSG" http://127.0.0.1:20900/v1/messages200, 1232.2 ms, body{"model":"real-claude","id":"...","type":"message","role":"assistant","content":[{"type":"text","text":"Hi! How can I help you today?"}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"stop_deta...GET5,MGET0,INCRBYFLOAT5,EXPIRE5. Post-call spend counters:GET9,MGET0,INCRBYFLOAT8,EXPIRE8SELECT LiteLLM_ShadowEvalJob1messages, same key on pod B, real Anthropic call
curl -s -w '\n%{http_code} %{time_total}s' -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$MSG" http://127.0.0.1:20901/v1/messages200, 1189.3 ms, body{"model":"real-claude","id":"...","type":"message","role":"assistant","content":[{"type":"text","text":"Hi! How can I help you today?"}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"stop_deta...GET5,MGET0,INCRBYFLOAT5,EXPIRE5. Post-call spend counters:GET9,MGET0,INCRBYFLOAT8,EXPIRE8SELECT -1,SELECT LiteLLM_ShadowEvalJob1chat, denied (key not allowed for the model), pod A
curl -s -w '\n%{http_code}' -H "Authorization: Bearer $DENIED_KEY" -H 'Content-Type: application/json' -d '{"model":"real-claude","messages":[{"role":"user","content":"hi"}],"user":"authio-enduser"}' http://127.0.0.1:20900/v1/chat/completions403, 39.9 ms, body{"error":{"message":"key not allowed to access model. This key can only access models=['real-chat']. Tried to access real-claude","type":"key_model_access_denied","param":"model","code":"403"}}After (0626401)
chat, cold cache, pod A (port 20900), real OpenAI call
curl -s -w '\n%{http_code} %{time_total}s' -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$CHAT" http://127.0.0.1:20900/v1/chat/completions200, 2131.6 ms, body{"id":"...","created":1789205251,"model":"real-chat","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hi! How can I help?","role":"assistant","provider_specific_fiel...GET0,MGET0,INCRBYFLOAT6,EXPIRE6. Post-call spend counters:GET1,MGET1,INCRBYFLOAT8,EXPIRE8,SET1SELECT -1,SELECT LiteLLM_ShadowEvalJob1,SELECT LiteLLM_UserTable1chat, same key on pod B (port 20901, Redis warm, in-memory cold), real OpenAI call
curl -s -w '\n%{http_code} %{time_total}s' -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$CHAT" http://127.0.0.1:20901/v1/chat/completions200, 2273.6 ms, body{"id":"...","created":1789205276,"model":"real-chat","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hi! How can I help?","role":"assistant","provider_specific_fiel...GET0,MGET0,INCRBYFLOAT5,EXPIRE5. Post-call spend counters:GET1,MGET1,INCRBYFLOAT8,EXPIRE8SELECT LiteLLM_ObjectPermissionTable1,SELECT LiteLLM_ShadowEvalJob1messages, cold cache, pod A, real Anthropic call
curl -s -w '\n%{http_code} %{time_total}s' -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$MSG" http://127.0.0.1:20900/v1/messages200, 1225.7 ms, body{"model":"real-claude","id":"...","type":"message","role":"assistant","content":[{"type":"text","text":"Hi! How can I help you today?"}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"stop_deta...GET0,MGET0,INCRBYFLOAT5,EXPIRE5. Post-call spend counters:GET1,MGET1,INCRBYFLOAT8,EXPIRE8SELECT LiteLLM_ShadowEvalJob1messages, same key on pod B, real Anthropic call
curl -s -w '\n%{http_code} %{time_total}s' -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$MSG" http://127.0.0.1:20901/v1/messages200, 1136.0 ms, body{"model":"real-claude","id":"...","type":"message","role":"assistant","content":[{"type":"text","text":"Hi there! How can I help you today?"}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"sto...GET0,MGET0,INCRBYFLOAT5,EXPIRE5. Post-call spend counters:GET1,MGET1,INCRBYFLOAT8,EXPIRE8SELECT -1,SELECT LiteLLM_ObjectPermissionTable1,SELECT LiteLLM_ShadowEvalJob1chat, denied (key not allowed for the model), pod A
curl -s -w '\n%{http_code}' -H "Authorization: Bearer $DENIED_KEY" -H 'Content-Type: application/json' -d '{"model":"real-claude","messages":[{"role":"user","content":"hi"}],"user":"authio-enduser"}' http://127.0.0.1:20900/v1/chat/completions403, 37.6 ms, body{"error":{"message":"key not allowed to access model. This key can only access models=['real-chat']. Tried to access real-claude","type":"key_model_access_denied","param":"model","code":"403"}}Real-provider latency moved 1430 -> 2132 ms (chat cold, pod A), 1099 -> 2274 ms (chat, pod B), 1232 -> 1226 ms (messages cold, pod A), 1189 -> 1136 ms (messages, pod B). Single samples against live providers are dominated by provider time: in the MONITOR timestamps the gap between the last pre-call and the first post-call Redis command (the provider wait) is 1312 -> 2029 ms and 1016 -> 2199 ms for the two chat samples, while the proxy-side time from request receipt to the last pre-call command drops on every row (122 -> 107, 86 -> 78, 42 -> 35, 39 -> 37 ms), so the round-trip and statement counts are the signal: cold chat goes from 73 to 40 estimated Redis round trips (post-call 26 -> 7, pre-call 19 -> 7 because the reservation warm checks join the MGET), pod B chat from 52 to 26, messages from 46 to 20 on both pods. Denied requests stay at 403 with the same body, 10 or 11 Redis commands all in auth, and no provider call. The one remaining post-call
GETis the spend-counterSET/GETfor the reseed marker, not a warm check. The response-path Postgres statements left on both sides are the shadow-eval job lookup, the object-permission read and, on the cold pod, the spend-counter reseed described above; in this rig the Before side served the Prometheus team/user/org lookups from the in-process cache, so the delayed-response check below is where the removedget_*_objectSELECTs show upSynthetic before/after matrix (mock_response, same rig, gateway time only)
Latency is a single measured request per cell after the same warm-up; the concurrent-load rows are 200 requests at 8 in flight. "idle" is a request after 60 s of no traffic (in-memory caches expired, Redis still warm). The 3464 ms Before idle cell for chat/Rust off is a one-off Redis reconnect the old code hit after the idle gap and is not representative. Matrix captured at 6012071; the commits after it are the
typing_extensions.Selfimport for Python 3.10, a fixture string rename, two extra batch tests, the typed reservation entry (annotation only) and the two merges of #40834 whose runtime change is the prefetch SQL alias fix, which the two-pod real-provider run above covers at the exact tip, so this synthetic section was not re-runchat (Rust admission off), before -> after
Concurrent load: p50 115.4 -> 103.0 ms, p95 134.5 -> 114.4 ms, p99 442.5 -> 442.4 ms, 62.8 -> 69.3 rps, Redis commands/request 81.5 -> 70.3, PG statements/request 0.15 -> 0.09, errors none -> none
messages (Rust admission off), before -> after
Concurrent load: p50 108.5 -> 102.1 ms, p95 123.4 -> 117.7 ms, p99 444.6 -> 441.1 ms, 66.0 -> 69.6 rps, Redis commands/request 72.3 -> 64.3, PG statements/request 0.12 -> 0.12, errors none -> none
chat (Rust admission on), before -> after
Concurrent load: p50 119.5 -> 107.3 ms, p95 160.6 -> 126.6 ms, p99 452.7 -> 442.5 ms, 59.8 -> 66.7 rps, Redis commands/request 81.5 -> 70.3, PG statements/request 0.12 -> 0.09, errors none -> none
messages (Rust admission on), before -> after
Concurrent load: p50 113.3 -> 104.8 ms, p95 245.2 -> 127.6 ms, p99 448.2 -> 465.4 ms, 61.5 -> 67.1 rps, Redis commands/request 72.4 -> 64.3, PG statements/request 0.20 -> 0.14, errors none -> none
Denied request, all four matrix cells: HTTP 403 -> 403, body byte-identical
Redis commands by call site, cold cache, chat, Rust on (before -> after), only the rows that changed
Every other row (rate limiter
INCRBYFLOAT/TTL/EXPIRE, routerMGET, auth objectGET/SET) is unchanged, which is the pre-call leftover discussed belowSynthetic delayed-response check of the response path
With
mock_responseplus a 3 smock_delayso the post-response window is unambiguous, Rust on, cold cache. Postgres statements in the response-path window:Before (897d561):
SELECT LiteLLM_UserTable2,SELECT LiteLLM_TeamTable1,SELECT LiteLLM_OrganizationMembership1,SELECT LiteLLM_ShadowEvalJob1After (6012071; the commits after it are the
typing_extensions.Selfimport for Python 3.10, a fixture string rename, two extra batch tests, the typed reservation entry (annotation only) and the two merges of #40834 whose runtime change is the prefetch SQL alias fix, which the two-pod real-provider run above covers at the exact tip, so this synthetic section was not re-run):SELECT LiteLLM_UserTable1 (the spend-counter reseed described above),SELECT LiteLLM_ShadowEvalJob1Tests and gates
New and extended tests (all
uv run pytest, counting fakes over a realRedisCacheinterface, no monkeypatching of production classes):tests/test_litellm/proxy/spend_tracking/test_spend_counter_batch.py: nested scope joins the outer batch;recordmakes later reads skip Redis;forgetsends the next read to Redis; exact MGET count for warm checks;post_call_counter_keyscovers tag and access-group counterstests/test_litellm/proxy/proxy_server/test_spend_counters.py: one MGET plus one pipeline for the whole post-call path; pipeline failure invalidates every counter; inconsistent counter still reseeds; end-user, tag, access-group and budget-window counters keep their valuestests/test_litellm/proxy/spend_tracking/test_budget_reservation.py,test_spend_counter_reseed.py: reconcile shares the post-call MGET, pre-call resize against an inconsistent counter still raisestests/test_litellm/caching/test_redis_cache.py:INCRBYFLOATandTTL/EXPIREin one pipeline, thirdEXPIREonly for a key without TTLtests/test_litellm/proxy/spend_tracking/test_carried_budget_state.py,tests/test_litellm/proxy/auth/test_auth_checks.py,test_user_api_key_auth.py: snapshots pinned from the objects auth holds, metadata round trip,from_metadatareturnsNoneon partial metadatatests/test_litellm/integrations/test_prometheus_carried_budget_state.py,tests/test_litellm/proxy/test_proxy_server.py: Prometheus reads carried state and calls noget_*_object; missing snapshot falls back to the lookupRunning the ten touched test files together at 0626401: 736 passed, 1 failed. The failure,
test_breaker_metrics_track_state_and_failure_class, reads the Prometheus registry and fails whenever any Prometheus test module is collected in the same run: at the merge base e75b541 the same two-file run with the pre-existingtest_prometheus_budget_metric_guard.pyfails identically, and the file alone passes 76 of 76 at this tip, so it is a test-isolation defect that predates this branch. Denied-request bodies compared byte-for-byte before/after in every regimeState at 0626401:
make check: PASS at 0626401 (ruff, ruff strict gate, type-discipline gate, basedpyright gate, test-quality gate, OpenAPI/dashboard type sync), full log read, no warningsrecordnot caching,forgetkeeping the stale value, nested scope opening a second batch, tag counters dropped from the post-call keys) and the Redis pipeline (refreshEXPIREreplaced byTTL, thirdEXPIREsuppressed). At f7e03fb the two batch mutants survived, which is what the two tests in 189c563 add; re-run at 189c563, 13 of 13 are killed/qais the two-pod real-provider run above;/live-pr-riskis the section below and its checkbox at the end of this descriptionLive risk (what else depends on the changed code, and what was driven live)
Changed contracts:
RedisCache.async_incrementkeeps its signature and return type and only changes how theTTL/EXPIREfollow-up travels; callers other than the spend path areDualCache.async_increment(parallel request limiter v1/v2, dynamic rate limiter, provider budget limiter), rate limiter v3, the MCP gateway DCR flow and the spend-counter reseed.UserAPIKeyAuthgains threeexclude=Truefields, so no response body,/key/info, dashboard or SDK type sees them. Request metadata gains sevenuser_api_key_*entries written throughmodel_dump(mode='json'), so every downstream reader (spend log metadata allowlist, logging callbacks, Prometheus) receives strings and floats, matching the existinguser_api_key_budget_reset_atentry.SpendCounterBatchchanges are internal to the spend pathDriven live on both sides (base e75b541 vs head 0626401, two proxy processes per side sharing one Postgres and one Redis, rate limiter v3 and budget reservation on, Prometheus callback on, real OpenAI and Anthropic calls):
/v1/chat/completionsand/v1/messagescold on pod A and warm-Redis on pod B, a denied request, the post-call spend path, the reseed on the cold pod, the rate limiter's ownINCRBYFLOAT/TTL/EXPIREtriplets through the new pipeline, and the Prometheus callback. Statuses and denied bodies match between sides, and the db-writer statement set (theLiteLLM_SpendLogsinsert, the daily spend inserts, the team, user, org, key, membership and tag spend updates) is identical on both sides; only the Redis and Postgres counts differ, as tabulated above. The removed Prometheusget_*_objectSELECTs show up in the synthetic delayed-response check because this rig served them from the in-process cache on the Before side. The synthetic matrix adds Rust admission on/off, expired-cache and 8-way concurrencyCovered by tests but not driven live: a Redis failure between
INCRBYFLOATandEXPIRE, a failed increment pipeline (every touched counter invalidated,test_spend_counters.py), a non-numeric counter payload and a failed MGET (batch hands the read back to the caller,test_spend_counter_batch.py), and a partial carried snapshot (Prometheus falls back toget_*_object,test_prometheus_carried_budget_state.py). Not verified:DualCache.async_incrementcallers outside the proxy request path (provider budget limiter, dynamic rate limiter, MCP DCR flow) were not exercised on the rig; they call the unchanged signature and the behavior change is one fewer round trip, so the risk is a Redis client that rejectspipeline(transaction=False), which redis-py has supported since 2.x. No lockfile,pyproject.toml, Dockerfile orschema.prismachange, so there is no packaging legPR (c) decision
Remaining pre-call round trips after (a) and (b), cold chat with Rust on: rate limiter 6
GET+ 4SET+ 6EXPIRE+ 2INCRBY+ 2TTL(mostly individually issued), 3EVALSHA, 2TIME, 1 routerMGET. That is well over the two-round-trip threshold, but all of it sits inside the rate limiter v3 and the router's own cache, which the brief excluded from redesign. A PR (c) would be a rate-limiter change, so it is recorded on LIT-7478 as a candidate rather than opened hereType
🚄 Infrastructure
Caveats (if any)
Medium
INCRBYFLOATandEXPIREnow travel in one non-transactional pipeline; a Redis failure between them leaves the same state the old two-call path could leave, and the counter is invalidated either wayLow
budget_reset_atis not carried, so the org remaining-hours gauge is set fromNoneon the carried path. The fallback path producesNonetoo, becauseLiteLLM_OrganizationTable.litellm_budget_tableis typed asLiteLLM_BudgetTable, which has nobudget_reset_at; no behavior change, noted for a follow-upINCRBYFLOAT/TTL/EXPIREtriplets and the pre-call rate limiter reads are unchanged (see PR (c) decision)Final Attestation
Link to Devin session: https://app.devin.ai/sessions/bbe614d754bf4e779ed46e0d131c407e
Open in Devin Desktop: https://app.devin.ai/desktop/session/bbe614d754bf4e779ed46e0d131c407e?variant=devin
Requested by: @yassin-berriai
Note
Medium Risk
Changes cross-pod spend counter reads/writes and budget reservation reconcile; incorrect batching or invalidation could skew enforcement, though tests and explicit invalidate-on-pipeline-failure mitigate that.
Overview
This PR cuts Redis and Postgres work on the post-response path: spend counter warm checks, reservation reconcile, and Prometheus budget gauges.
Spend counters: Post-call
increment_spend_countersruns inside a task-localspend_counter_batch_scopeso warm reads and budget reservation reconcile share one MGET instead of per-key GETs. Increments go throughincrement_spend_counters_pipeline(singleINCRBYFLOAT+EXPIREpipeline);record_spend_counter_value/forget_spend_counterkeep later reads in the same request off Redis.RedisCache.async_incrementnow pipelines increment with TTL/EXPIREvia_incrbyfloat_with_ttl. Pipeline failures invalidate every touched counter before re-raising.Prometheus / logging: Auth pins team/user/org budget snapshots on
UserAPIKeyAuthand writes them into request metadata; success logging builds budget objects fromKeyBudgetSnapshot/TeamBudgetSnapshot/ etc. when present, skippingget_team_object/get_user_object/get_org_objecton the happy path.Reservation apply/reconcile and reseed paths are wired into the same batch helpers; behavior when snapshots are missing or Redis fails stays on the previous lookup and invalidation fallbacks.
Reviewed by Cursor Bugbot for commit b4f81a7. Bugbot is set up for automated code reviews on this repo. Configure here.