Skip to content

feat: add Postgres support to per-model budget read path - #25374

Open
jk-f5 wants to merge 2 commits into
BerriAI:mainfrom
jk-f5:fix/model-budget-postgres-fallback
Open

feat: add Postgres support to per-model budget read path#25374
jk-f5 wants to merge 2 commits into
BerriAI:mainfrom
jk-f5:fix/model-budget-postgres-fallback

Conversation

@jk-f5

@jk-f5 jk-f5 commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Adds Postgres fallback to per-model budget read path so budgets survive pod restarts and work across replicas.

Pre-Submission checklist

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Type

🆕 New Feature

Changes

Problem

Per-model budget enforcement (model_max_budget on keys and end users) relies entirely on in-memory cache for spend lookups. When a pod restarts, all tracked spend is lost and budgets reset to zero. In multi-replica deployments, each pod tracks spend independently, so a user can exceed their budget by spreading requests across pods.

Fix

Override the two read-path methods (_get_virtual_key_spend_for_model and _get_end_user_spend_for_model) to add a Postgres fallback after a cache miss. The class continues to inherit from RouterBudgetLimiting and the write path is completely unchanged.

Read path (changed):

  1. Check in-memory cache (primary key, then stripped-prefix fallback — matching upstream two-key lookup behavior)
  2. On complete cache miss, query LiteLLM_DailyUserSpend / LiteLLM_DailyEndUserSpend tables using Prisma find_many with model_group OR clause and date >= start_date filter
  3. Cache the result with a 60-second TTL
  4. On DB error, fail open (return None, do not block the request)
  5. When prisma_client is None, return None immediately

Write path (unchanged):

  • async_log_success_event still calls _increment_spend_for_key from the RouterBudgetLimiting parent class
  • This preserves single-instance and no-Postgres deployments where the in-memory cache is the only source of truth

New module-level helpers:

  • _budget_window_start_date(budget_duration): Converts a duration like "30d" to a YYYY-MM-DD start date for the Prisma date filter. Rounds to at least 1 day since daily tables are per-calendar-day.
  • _strip_provider_prefix(model): "openai/gpt-4""gpt-4". Used in the OR clause so we match model_group regardless of whether it was stored with or without the provider prefix.

Performance impact

No budgets configured (most users): Zero. The budget check methods are only called from user_api_key_auth when model_max_budget / end_user_model_max_budget dicts are non-empty. async_log_success_event also early-returns when those metadata dicts are empty. None of the new code runs.

In-memory only (no Postgres), budgets enabled: On cache hit, identical to upstream. On cache miss, one extra function call into _query_virtual_key_model_spend / _query_end_user_model_spend which checks prisma_client is None and returns immediately. Cost: one function call, one attribute check.

Postgres enabled, budgets enabled: On cache hit, identical. On cache miss (cold start, pod restart, 60s TTL expiry), one find_many query per model/entity combo. After the query, the result is cached for 60 seconds. The write path still increments the in-memory cache immediately via _increment_spend_for_key, so within a single pod, spend tracking is still near-instant.

@vercel

vercel Bot commented Apr 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 8, 2026 10:35pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing jk-f5:fix/model-budget-postgres-fallback (7b7c22d) with main (072d410)

Open in CodSpeed

@codecov

codecov Bot commented Apr 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR overrides _get_virtual_key_spend_for_model and _get_end_user_spend_for_model in _PROXY_VirtualKeyModelMaxBudgetLimiter to add a Postgres fallback (via LiteLLM_DailyUserSpend / LiteLLM_DailyEndUserSpend) on cache miss, making per-model budgets durable across pod restarts and consistent across replicas. The write path is unchanged; the new DB queries are gated behind a 60-second cache TTL and fail open on error.

The main open concerns from prior review threads (direct DB queries in auth hot path, missing api_key+date composite index on LiteLLM_DailyUserSpend, misleading log message when budget_duration is None) remain unaddressed. A new finding: the DB query helpers use _strip_provider_prefix (strips only the first /), while the cache fallback uses _get_model_without_custom_llm_provider (takes the last segment after all /s) — for three-segment models like \"azure/openai/gpt-4\" this causes the DB fallback to miss rows stored as model_group = \"gpt-4\", silently under-reporting spend after a pod restart.

Confidence Score: 3/5

Not ready to merge — prior P1 concerns (direct DB in auth critical path, missing composite index) are unaddressed and the new provider-prefix inconsistency can silently under-report spend after pod restart.

Three unresolved P1-level findings from the previous review round (direct find_many in the auth hot path violating the no-raw-DB-in-critical-path rule, missing api_key+date composite index on LiteLLM_DailyUserSpend, and the misleading log/error path for None budget_duration) remain open. A new P2 finding adds a provider-prefix stripping inconsistency between cache and DB paths. Individually some are edge cases, but the combination — a fresh DB query per cache miss directly in user_api_key_auth with a suboptimal index — is a real production reliability risk at scale.

litellm/proxy/hooks/model_max_budget_limiter.py — specifically the _query_virtual_key_model_spend / _query_end_user_model_spend static methods and the provider-prefix stripping logic

Vulnerabilities

No security concerns identified. The fail-open design on DB error is intentional and does not introduce an exploitable bypass — a budget check returning None (spend unknown) still passes through as it did before this change.

Important Files Changed

Filename Overview
litellm/proxy/hooks/model_max_budget_limiter.py Adds Postgres fallback to per-model budget read path; introduces two helper functions with subtly different provider-prefix stripping behavior (one-level vs all-levels), creating a potential spend-miss for multi-segment model names after a cache miss.
tests/test_litellm/proxy/hooks/test_model_budget_postgres_fallback.py Comprehensive mock-only test suite covering cache-hit short circuit, DB fallback, fail-open on error, None budget_duration guard, and write-path preservation; all tests use proper mocking with no real network calls.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Budget check called\nuser_api_key_auth] --> B{Cache hit?\nprimary key}
    B -- Yes --> Z[Return cached spend]
    B -- No --> C{Model has\nprovider prefix?}
    C -- Yes --> D{Cache hit?\nstripped key}
    D -- Yes --> Z
    D -- No --> E{budget_duration\nis set?}
    C -- No --> E
    E -- No --> F[Return None\nskip DB]
    E -- Yes --> G{prisma_client\navailable?}
    G -- No --> F
    G -- Yes --> H[find_many on\nLiteLLM_DailyUserSpend\nor DailyEndUserSpend]
    H -- Success --> I[Cache result\n60s TTL]
    I --> Z
    H -- Exception --> J[Log warning\nreturn None\nfail open]
Loading

Reviews (2): Last reviewed commit: "fix: address CodeQL log injection, black..." | Re-trigger Greptile

Comment thread litellm/proxy/hooks/model_max_budget_limiter.py
Comment on lines +301 to +302
start_date = _budget_window_start_date(budget_duration)
model_without_provider = _strip_provider_prefix(model)

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.

P1 Missing composite index for api_key + date on LiteLLM_DailyUserSpend

The query filters on api_key, date >= start_date, and model_group, but LiteLLM_DailyUserSpend only has separate @@index([api_key]) and @@index([date]) indexes. The planner will use the single-column api_key index and then scan all rows for that key to apply the date/model_group predicates. For keys with high request volume, this scan grows unbounded. CLAUDE.md requires checking index coverage for new queries and extending an existing index rather than adding a new one — here @@index([api_key]) should be extended to @@index([api_key, date]).

LiteLLM_DailyEndUserSpend already has @@index([end_user_id, date]) so the end-user path is fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll add this if it's desired, figured a new index might be more likely to get my PR rejected

The existing @@index([api_key]) index will handle the filter fine for this use case. These queries only run on cache miss (once every 60s per key+model), not on every request, so we're not talking about a hot query path. Adding a composite index on a table that exists in every user's Postgres instance is a big ask for a marginal improvement on an infrequent query. If this becomes a performance problem in practice we can revisit it.

Comment thread litellm/proxy/hooks/model_max_budget_limiter.py
Comment thread tests/test_litellm/proxy/hooks/test_model_budget_postgres_fallback.py Outdated
Comment thread litellm/proxy/hooks/model_max_budget_limiter.py Fixed
jk-f5 added 2 commits April 8, 2026 14:59
The per-model budget limiter previously relied entirely on in-memory
cache for spend lookups.  This meant spend data was lost on pod restart
and was not shared across replicas, so budgets were not enforced
reliably in multi-pod deployments.

This commit adds a Postgres fallback to the read path.  On a cache
miss, the limiter now queries the LiteLLM_DailyUserSpend /
LiteLLM_DailyEndUserSpend tables (already populated by the existing
db_spend_update_writer pipeline) and caches the result for 60 seconds.

The write path is deliberately unchanged.  RouterBudgetLimiting remains
the parent class and _increment_spend_for_key still seeds and
increments the in-memory cache on every request.  This preserves
single-instance and no-Postgres deployments where the cache is the
only source of truth.

Performance impact:
- No budgets configured: zero, none of the new code runs.
- In-memory only (no Postgres): one extra function call on cache miss
  that checks prisma_client is None and returns immediately.
- Postgres enabled: one find_many query per model/entity combo when
  the cache is cold or after 60s TTL expiry.  Cache hits are unchanged.

Includes 36 new tests covering the DB fallback, cache population,
fail-open behavior, two-key cache lookup, helper functions, and
write-path preservation.

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.

maybe i'm missing something, but where is per model spend written to the db? @jk-f5

@jk-f5 jk-f5 Apr 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@krrish-berri-2 , WIth postgres enabled, it's already written at a granular level in the LiteLLM_SpendLogs table, I'm just using what's already available.

@krrish-berri-2

Copy link
Copy Markdown
Contributor

@ishaan-berri can you review this approach? i don't agree with this approach but i understand this concern

@jk-f5

jk-f5 commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

@ishaan-berri @krrish-berri-2

Is there a plan to address the underlying problem here?

To recap the issue: per-model budget spend (model_max_budget) is tracked entirely in-memory, which means:

  1. Spend resets to zero on every pod restart. Users get a free budget refill every deploy
  2. Multi-replica deployments track spend independently per pod. a user can exceed their budget by N times with N replicas

The daily spend tables (LiteLLM_DailyUserSpend and LiteLLM_DailyEndUserSpend) already record per-model spend to Postgres on every request. This PR just reads from them on cache miss so the data that's already being written actually gets used for enforcement.

I also have #25368 open for the related auth bypass where master-key and cached-admin-token paths skip per-model budget checks entirely. Neither PR has received a maintainer review.

I see that #27334 added a Redis flush fix for this same module, which helps the multi-replica case when Redis is configured but doesn't solve the pod-restart problem (Redis cache entries still expire) and doesn't help deployments without Redis at all. The Postgres tables remain the only durable source of truth for per-model spend.

If you disagree with the approach, could you share what alternative you'd prefer? Happy to rework this if there's a direction you'd rather go. We're running a patched version of this module in production to work around these issues and would love to drop the patch in favor of upstream support.

@jk-f5

jk-f5 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Guess we're just going to sit on this until it's auto closed due to inactivity.

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.

3 participants