Repository navigation
perf(auth): read user, team, membership, org, project and spend counters in one MGET, one query and one pipeline - #40834
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>
🤖 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 authentication-path Redis and PostgreSQL round trips by batching referenced auth-object reads, cache writes, and spend-counter reads
Confidence Score: 5/5The PR appears safe to merge, with no outstanding correctness, security, or repository-rule findings The latest changes bring prefetched user data in line with the established getter and correctly map team model aliases, while all previous findings were resolved or conceded
|
| Filename | Overview |
|---|---|
| litellm/proxy/auth/auth_object_prefetch.py | Adds best-effort batched cache and database warming, with the latest changes matching established user and team model relation shapes |
| litellm/proxy/auth/user_api_key_auth.py | Integrates auth-object and spend-counter batching after model access enforcement and scopes it to authentication |
| litellm/proxy/spend_tracking/spend_counter_batch.py | Implements task-local spend-counter MGET batching with clean-miss and Redis-failure distinction |
| litellm/proxy/db/spend_counter_reseed.py | Reuses active batched counter reads before performing per-key Redis or database reseeding |
| litellm/caching/redis_cache.py | Adds non-transactional pipelined writes with independent TTLs for each cache entry |
| tests/proxy_behavior/auth/test_auth_object_prefetch.py | Verifies the combined SQL against PostgreSQL, including membership selection, nullable lists, and team model aliases |
Reviews (6): Last reviewed commit: "fix(auth): map the model table's aliases..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…havior shard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
|
bugbot run |
…prefetch join 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>
|
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 |
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 e75b541. 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>
TLDR
Problem this solves:
How it solves it:
User Flow
Before: a developer's first request on a virtual key waits on a chain of cache and DB lookups before the model is even called
{"model": "gpt-5.4-mini", "messages": [...]}and a virtual key that belongs to a user, a team, an org and has an end userchatcmpl-...id, tens of milliseconds later than the model call alone would takeAfter: the same request does the same checks in the same order, with the reads batched
chatcmpl-...shape; a key without access to the model still gets the identical HTTP 403 body it got beforeRelevant issues
Part of the request I/O work tracked in #40539
Linear ticket
Resolves LIT-7634
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@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
litellm/proxy/auth/auth_object_prefetch.pyis new.AuthObjectRefs.from_token(valid_token)lists the ids the checks will read.prefetch_auth_objectsthen does at most three I/Os for all of them:_SQLis one statement with five scalar subselects, each building the object as jsonb with its budget, model table and object permission nested the way the existing per-object queries return them. Nullable scalar lists are coalesced to[]because prisma reads a NULL array as an empty list andto_jsonbreads it asnull. Cache keys and TTLs are the ones the getters already use (team_id:{id},org_id:{id}andorg_id:{id}:with_budget,project_id:{id},team_membership:{team}:{user}at 5 s, the membership reservation key, and the plain user id), so the getters hit the in-memory cache on the very next line and nothing about invalidation or the management team freshness check changes. The prefetch is best effort: any Redis or DB failure logs a warning and the getters fetch exactly as they do today, so enforcement never depends on it_prefetch_referenced_auth_objectsis called from_user_api_key_auth_builderright after_enforce_key_and_fallback_model_access, so a key that cannot call the requested model is denied before any of this runslitellm/proxy/spend_tracking/spend_counter_batch.pyis new.spend_counter_batch_scope(redis)opens a task-localSpendCounterBatchfor the auth span.bind_admission_counter_keys(token, end_user_id)registers the key, user, team, team member, org and end user counter keys. The first reader triggers oneMGETfor all bound keys; keys bound later join the nextMGET.async_batch_get_cachemaps a clean miss toNoneand drops a key only when Redis failed, so the batch treats a presentNoneas an authoritative miss and an absent key as "read Redis yourself".SpendCounterReseed.coalescedandproxy_server.read_spend_counter_cache_valueconsult the active batch before issuing their ownGET, which is what removes the per-counter GETs from the hot pathRedisCache.async_set_cache_pipeline_with_ttlsis a small addition next toasync_set_cache_pipeline: the same non-transactional pipeline, but each entry carries its own TTL, since the objects above have different lifetimesTests:
tests/test_litellm/proxy/auth/test_auth_object_prefetch.pycounts Redis round trips and DB queries per cache regime with fakes (cold, Redis warm, hot, partial hit, invalidation, missing column, Redis failure, no Redis, no DB).tests/proxy_behavior/auth/test_auth_object_prefetch.pyruns the raw join on the real Postgres of the proxy-behavior CI shard: a user in two teams with different member budgets gets the requested team's membership row, and the getters are then served from cache with the DB mocked dead.tests/test_litellm/proxy/spend_tracking/test_spend_counter_batch.pycovers the batch (one MGET, incremental binding, authoritative miss, Redis failure fallback, task isolation) andtest_user_api_key_auth.pyasserts the prefetch does not run for a key denied on model accessBenchmarks
Local rig: one proxy process per run, Postgres with
log_statement=all(statements attributed byapplication_name), RedisMONITOR(commands grouped into round trips by an 80 µs gap). Before islitellm_internal_stagingat the branch point (1be9306), after is this branch. Model calls in this matrix are mocked so the numbers isolate gateway I/O. Everything below is single process on one box and is not a production fleet numberRegimes: cold = Redis flushed and fresh proxy; hot = second request on the same proxy; idle = the same key after the in-memory TTL lapsed; expired = after the Redis entries expired; denied = a key that may not call the requested model. "rt" = estimated round trips
chat (Rust admission off), before -> after
Concurrent load, 8 in flight, 200 requests, before -> after: p50 116.8 -> 118.0 ms, p95 139.4 -> 146.7 ms, p99 484.3 -> 555.4 ms, 60.5 -> 58.6 rps, Redis commands/request 86.4 -> 81.4, PG statements/request 0.15 -> 0.17
messages (Rust admission off), before -> after
Concurrent load, 8 in flight, 200 requests, before -> after: p50 112.7 -> 110.5 ms, p95 143.1 -> 141.0 ms, p99 496.6 -> 464.5 ms, 61.1 -> 63.7 rps, Redis commands/request 77.4 -> 72.4, PG statements/request 0.14 -> 0.1
chat (Rust admission on), before -> after
Concurrent load, 8 in flight, 200 requests, before -> after: p50 118.7 -> 131.5 ms, p95 170.4 -> 160.2 ms, p99 492.2 -> 579.8 ms, 58.8 -> 53.3 rps, Redis commands/request 86.5 -> 81.3, PG statements/request 0.17 -> 0.1
messages (Rust admission on), before -> after
Concurrent load, 8 in flight, 200 requests, before -> after: p50 115.2 -> 113.0 ms, p95 296.8 -> 136.8 ms, p99 532.9 -> 496.0 ms, 57.2 -> 62.3 rps, Redis commands/request 77.6 -> 72.3, PG statements/request 0.34 -> 0.09
How to read it: the auth columns are the ones this PR touches and they move the same way in every leg (Redis 43 -> 24 cold, 6 -> 1 hot, 12/20 -> 4 idle, 17/21 -> 8 expired; Postgres 18 -> 11 cold, 6 to 10 -> 1 idle and expired). The remaining cold auth I/O is the key lookup itself, the end user, the model access group budget and the spend counter reseeds, which are separate call sites (next PR). Pre-call and post-call columns are unchanged by design. The "db writer" column is the background spend writer flushing whatever happened to land in the capture window and is noise for the denied rows. Single-request latency on a warm box is dominated by the mocked model call plus Python work, so it moves by a few ms at most except where the removed I/O was serial and cold (idle 78 -> 36 ms). The 8-in-flight p50/p95 vary between runs by more than the auth savings on a single box, so this PR claims fewer round trips and statements, not a fixed latency win
Auth-phase Redis commands by call site, cold, chat, Rust on (before -> after):
Auth-phase Postgres statements, cold, chat, Rust on (before -> after): the one combined
LiteLLM_UserTable + TeamTable + TeamMembership + OrganizationTable + OrganizationMembership + ProjectTable + BudgetTable + ModelTable + ObjectPermissionTableselect replacesSELECT LiteLLM_UserTable(1 -> 0),SELECT LiteLLM_TeamTable(2 -> 1),SELECT LiteLLM_TeamMembership(3 -> 2),SELECT LiteLLM_OrganizationTable(2 -> 1),SELECT LiteLLM_OrganizationMembership(1 -> 0) andSELECT LiteLLM_BudgetTable(4 -> 1)Screenshots / Proof of Fix
Live proxy on the same rig (Postgres + Redis in docker,
LITELLM_RUSTunset), real provider calls toopenai/gpt-5.6(aliasreal-chat) andanthropic/claude-sonnet-5(aliasreal-claude). Fixture: an org (live-org), a team in it with a team budget, a user with a max budget who is a team member with a member budget, an end user with its own budget, and a key$KEYbound to that user and team.$KEY_DENIEDis a second key on the same user and team allowed onlyreal-chat. Redis is flushed and the proxy restarted before each side's first request. The third line of every case is what RedisMONITORand Postgreslog_statement=allrecorded for that one request, split by phaseBefore (19c8553)
/v1/chat/completions, cold caches (Redis flushed, fresh proxy)
curl -s http://127.0.0.1:20900/v1/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model": "real-chat", "messages": [{"role": "user", "content": "hi"}], "user": "authio-enduser"}'{"id":"chatcmpl-EN9...","created":1789185...,"model":"real-chat","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hi! How can I help?","role":"assistant",...log_statement=all: 34 statements (auth 18, pre-call 0, post-call 2, db writer 14)/v1/chat/completions, second request on the same proxy
/v1/messages, first request for that route on the same proxy
curl -s http://127.0.0.1:20900/v1/messages -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model": "real-claude", "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}], "metadata": {"user_id": "authio-enduser"}}'{"model":"real-claude","id":"msg_011Cex...","type":"message","role":"assistant","content":[{"type":"text","text":"Hi there! How can I help you today?"}],.../v1/messages, second request
key without access to the model (denied)
curl -s http://127.0.0.1:20900/v1/chat/completions -H "Authorization: Bearer $KEY_DENIED" -H 'Content-Type: application/json' -d '{"model": "real-claude", "messages": [{"role": "user", "content": "hi"}], "user": "authio-enduser"}'{"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 (e75b541)
/v1/chat/completions, cold caches (Redis flushed, fresh proxy)
curl -s http://127.0.0.1:20900/v1/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model": "real-chat", "messages": [{"role": "user", "content": "hi"}], "user": "authio-enduser"}'{"id":"chatcmpl-ENE8zvBFcIj2mJ5xQKtOIPwy5wLy0","created":1789204621,"model":"real-chat","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hi! How can I help?","role":"assistant","provider_specific...log_statement=all: 27 statements (auth 11, pre-call 0, post-call 0, db writer 14)/v1/chat/completions, second request on the same proxy
curl -s http://127.0.0.1:20900/v1/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model": "real-chat", "messages": [{"role": "user", "content": "hi"}], "user": "authio-enduser"}'{"id":"chatcmpl-ENE9PplIBYBvXSP3nQ8uF3p6zj0hb","created":1789204647,"model":"real-chat","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hi! How can I help?","role":"assistant","provider_specific...log_statement=all: 18 statements (auth 0, pre-call 1, post-call 0, db writer 14)/v1/messages, first request for that route on the same proxy
curl -s http://127.0.0.1:20900/v1/messages -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model": "real-claude", "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}], "metadata": {"user_id": "authio-enduser"}}'{"model":"real-claude","id":"msg_011CeyJ3xgn3KFKGMzXZZutx","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,"stop_det...log_statement=all: 20 statements (auth 2, pre-call 1, post-call 0, db writer 14)/v1/messages, second request
curl -s http://127.0.0.1:20900/v1/messages -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model": "real-claude", "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}], "metadata": {"user_id": "authio-enduser"}}'{"model":"real-claude","id":"msg_011CeyJ5pZgG7pHHUaJ3A63U","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,"stop_det...log_statement=all: 15 statements (auth 0, pre-call 0, post-call 0, db writer 14)key without access to the model (denied)
curl -s http://127.0.0.1:20900/v1/chat/completions -H "Authorization: Bearer $KEY_DENIED" -H 'Content-Type: application/json' -d '{"model": "real-claude", "messages": [{"role": "user", "content": "hi"}], "user": "authio-enduser"}'{"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"}}log_statement=all: 16 statements (auth 3, pre-call 0, post-call 0, db writer 12)Same rig, same fixture, same five requests as the Before block, re-captured after the last runtime commit (e75b541). The auth column is what this PR changes and it matches the earlier capture at 897d561 exactly (cold chat 22 Redis round trips and 11 statements, hot chat 1 and 0, messages 4 and 2 then 1 and 0). Latency differs run to run because the provider and the shared box do; the denied request in this capture also shows the pre-existing organization budget check refreshing
org_id:live-org:with_budget(2 extra Redis commands, 2 extra statements) because its in-memory entry had lapsed, which the base branch does on the same scheduleTests and gates
State at e75b541:
make check: PASS at e75b541 (ruff, ruff strict gate, type-discipline gate, basedpyright gate, test-quality gate, OpenAPI/dashboard type sync); no budget file touchedcodecov/patchat 97.68% of the diff against an 80.69% target, CodSpeed "Performance Gate Passed" and buildkite e2e; nothing pending or failed@greptileaiafter the final push, summary refreshed 09:16 UTC)nullwas fixed in 897d561. Teammodel_aliaseslost throughto_jsonb(the physical column isaliases, mapped by prisma) and the user row hydrating budgets and permissions thatget_user_objectdoes not load were marked resolved by the bot but were still in the code; both are fixed in e75b541, with a real-Postgres regression test (test_join_reads_team_model_aliases_from_the_mapped_column) that fails on the previous SQL and passes nowaliases(1, real Postgres),model_aliasesfilled from NULL (1, real Postgres), memberships subselect dropped (3, real Postgres). Spend batch: closed batch still answers reads (2), late-bound keys never join the MGET (9), fetched keys re-read on every access (4), MGET failure propagates instead of handing the read back (3), release does not close the batch (2), org counter dropped from the admission keys (4), reseed ignores the batched value (1), reseed re-reads Redis after a batched clean miss (2). Redis: pipeline writes drop the TTL (1)/qais the real-provider before/after 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:
RedisCachegainsasync_set_cache_pipeline_with_ttlsnext toasync_set_cache_pipeline; nothing existing changes signature._user_api_key_auth_buildergains one awaited call after_enforce_key_and_fallback_model_accesswhose every failure path is caught and logged, so a denied key, a key with no team or user, a proxy without Redis and a proxy without a DB all take the same code path as before. The getters (get_user_object,get_team_object,get_team_membership,get_org_object,get_project_object) are untouched and remain the enforcement point; the prefetch writes the same cache keys with the same TTLs they use, so key, team, user and org invalidation (/key/update,/team/update,/user/update, deletion) keeps working because it deletes those same keys. The user row is now shaped exactly likeget_user_object'sinclude={"organization_memberships": True}result, and the team row carrieslitellm_model_table.model_aliases, soteam_model_aliases(team_object)(the JWT auth path) and any reader ofteam.litellm_model_tableget the same value from a prefetched team as from a getter-fetched one (the virtual key path readsteam_model_aliasesfrom the key's own combined_view query, which is untouched).read_spend_counter_cache_valueandSpendCounterReseed.coalescedconsult the task-local batch first and fall back to their existing Redis read when there is no batch or the batch has no answer, so the post-call spend path and the background reseed job, which run outside the auth span, are unchangedDriven live on both sides (base 19c8553 vs head e75b541, one proxy process per side on the same Postgres and Redis, real OpenAI and Anthropic calls):
/v1/chat/completionsand/v1/messagescold and on a second request, a key denied on model access (identical HTTP 403 body on both sides), the org, team, membership, user, end-user and member budgets all present on the key, and the spend flush of the db writer. Statuses and bodies match between sides; only the auth-phase Redis and Postgres counts differ, as tabulated above. The synthetic matrix adds Rust admission on and off, the idle (in-memory TTL lapsed) and expired (Redis TTL lapsed) regimes and 8-way concurrencyCovered by tests but not driven live: a Redis outage during the MGET or the write-back pipeline (getters fetch as before,
test_auth_object_prefetch.py), a prefetched row that fails pydantic validation (not cached, getter fetches it, same file), a proxy with no Redis or no DB, a user in two teams (the join returns the requested team's membership,tests/proxy_behavior/auth), a team with model aliases (same file), and an MGET failure inside the spend batch (test_spend_counter_batch.py). Not verified: JWT and custom-auth paths do not call_user_api_key_auth_builder's prefetch branch and were not exercised; a Redis deployment that rejectspipeline(transaction=False)(redis-py has supported it since 2.x). No lockfile,pyproject.toml, Dockerfile orschema.prismachange, so there is no packaging or migration legType
🆕 New Feature
Caveats (if any)
Medium
@maprenames by hand:LiteLLM_ModelTable.aliasesis exposed asmodel_aliases. Any future@mapon a table the join reads needs the same treatment or the value is dropped at validation and the getter fetches the row the old waycommon_checks; the getters remain the single reader. Passing an immutable bundle throughcommon_checkswould save no further I/O and is left out to keep the diff reviewableCOALESCElist or that row fails validation and the getter fetches it the old way (logged at warning, no behavior change)Low
The spend counter batch is scoped to the auth span only; the post-call warm checks and increments still do one GET per counter and are the subject of the next PR
The remaining cold auth I/O (key lookup, end user, model access group budget, counter reseed) is untouched here
e75b541 passes /live-pr-risk
Note
Medium Risk
Touches the auth and budget-admission read path and changes spend-counter cache miss semantics when the batch is active; enforcement still relies on existing getters, but incorrect prefetch SQL or batch behavior could affect latency or spend checks until fallback runs.
Overview
Batches proxy auth cache I/O so a single request warms user, team, membership, org, and project entries with one Redis MGET, one combined Postgres query when cold, and one pipeline SET (per-key TTLs via new
async_set_cache_pipeline_with_ttls). Prefetch runs only after the key passes model access; failures are swallowed and existing per-object getters still enforce.Spend admission reads during auth are collapsed into a task-local
SpendCounterBatch(one MGET for key/user/team/member/org/end-user counters).read_spend_counter_cache_value,SpendCounterReseed.coalesced, andcommon_checksuse the batch scope so batched Redis misses stay authoritative and reseed skips redundant GETs.Reviewed by Cursor Bugbot for commit e75b541. Bugbot is set up for automated code reviews on this repo. Configure here.
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