Repository navigation
feat(proxy): cap batch file records, daily batch uploads, and per-file downloads - #43632
Conversation
…e downloads
Adds three opt-in limits for batch jobs, each settable in general_settings as a
per-key default and overridable in key or team metadata by a proxy admin:
max_batch_file_records rejects a purpose=batch upload with more request lines
than allowed with a 413 before it reaches the provider.
max_batch_file_uploads_per_day counts accepted batch uploads per key and per
team in a UTC day and returns 429 with Retry-After once the count is used.
max_file_downloads_per_minute counts GET /v1/files/{id}/content per key and per
team for each file in a one-minute window and returns 429 with Retry-After.
The existing admin-only guard for batch_enqueued_token_limit now covers all four
metadata keys.
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
| from_key: Final = _read_limit(user_api_key_dict.metadata, setting, "key") | ||
| if from_key is not None: | ||
| return from_key | ||
| return _read_limit(general_settings, setting, "general_settings") |
There was a problem hiding this comment.
Global record cap is overridden When
general_settings.max_batch_file_records is 10 and a key has an admin-set value of 100, _key_limit drops the general setting before the code chooses the lowest limit. An 11-record batch is accepted even though the new setting says the lowest applicable value wins.
There was a problem hiding this comment.
By design, a key's metadata replaces the general_settings default for that key, and only a proxy admin can set that value
There was a problem hiding this comment.
You're right — the key metadata is intentionally an override of the general_settings default for that key, rather than an additional limit. _key_limit implements that precedence, while the team metadata remains an independent limit that is applied alongside it. My finding was incorrect; no change is needed.
| count: Final = await cache.async_increment_cache( | ||
| key=counter_key, value=1, litellm_parent_otel_span=None, ttl=ttl_seconds | ||
| ) | ||
| over_here: Final = count is not None and count > scoped.limit.value | ||
| exceeded: Final = scoped if over_here else await _increment_all_or_none(cache, rest, ttl_seconds) | ||
| if exceeded is not None: | ||
| await cache.async_increment_cache(key=counter_key, value=-1, litellm_parent_otel_span=None, ttl=ttl_seconds) |
There was a problem hiding this comment.
Interrupted checks retain quota charges If a request is cancelled or errors after the key counter is incremented but before the team counter is checked or the charge is reversed, the key increment remains. Valid uploads or downloads can then receive 429 responses for the rest of the counter's lifetime.
Knowledge Base Used: Spend, budgets, and rate limits
There was a problem hiding this comment.
A cancel between increment and rollback leaks at most one slot for the rest of that window, which we accept over adding a lock
There was a problem hiding this comment.
Understood. Given the explicit tradeoff of accepting at most one leaked slot per window to avoid introducing a lock, I’ll withdraw this as a blocking finding. The behavior is documented and bounded by the counter window.
…h limits on user creation
… caps, and take the upload slot after request validation
|
bugbot run |
…n after the match
|
bugbot run |
…age_caps Conflicts in litellm/proxy/auth/auth_utils.py and tests/unit/proxy/test__types.py are resolved by keeping both sides. Two lint budgets broke against main's ceilings after the merge, so the merge also drops an unused suppression marker in file_usage_caps.py and reads general settings and key or team metadata through typed views (general_settings_view and a TypeAdapter)
…out Redis The file usage counter store used a default in-memory cache, which holds 200 entries and evicts the one that expires soonest. Without Redis, a caller got a fresh per-file download allowance after touching about 200 other file ids in the same minute, and a key got a fresh daily upload allowance once about 200 other keys had uploaded that day. The store now tracks up to 20,000 live counters per worker, the same bound the login throttle uses
…xy shard runs Main's shard coverage check found tests/unit/proxy/openai_files_endpoints claimed by no shard, so its tests would not run in CI. The file moves next to the other files endpoint tests in tests/unit/proxy/openai_files_endpoint, which the proxy-endpoints shard already runs
|
bugbot run |
Main's redis producer gate requires every module that writes a shared cache to name its key family, and the file usage counters wrote theirs without one.
|
bugbot run |
…ges, and config reloads
… Redis breakers Two chaos cells each failed once in the audit run. The restart check ran three sequential downloads with no guard against straddling a UTC minute, and the exact-cap probe after a Redis outage ran while both workers' Redis circuit breakers were still open (60 s default recovery), so it counted in per-process memory and the two workers split the cap Every burst now carries a window guard, the chaos fixture lowers the breaker recovery to 2 s, and the post-outage check drives a fresh key to its cap through a one-worker sibling proxy and then expects the two-worker candidate to refuse the whole burst, which only the shared Redis count can produce, polled until the breakers close
|
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 4ee2d9a. Configure here.
TLDR
Problem this solves:
How it solves it:
max_batch_file_recordsrejects a bigger batch file with a 413max_batch_file_uploads_per_dayreturns 429 once the day's count is usedmax_file_downloads_per_minutereturns 429 per file withRetry-Aftergeneral_settingssets a per-key defaultIntentional product change: the Router Settings page (General tab) and
GET /config/listshow the three new settings, and a user who is not a proxy admin can no longer put any batch limit,batch_enqueued_token_limitincluded, on the key thatPOST /user/neworPOST /management/v1/users/bulkcreates. Before, those two routes accepted any value there, so an org admin could hand a new user a raised limitUser Flow
Before: a team running batch jobs through the gateway has no way to cap how much one caller pushes through the batch routes
POST https://litellm-domain/v1/fileswithpurpose=batchsix times in one day, and every upload returns 200 with afile-...idGET https://litellm-domain/v1/files/{output_file_id}/contenttwelve times in a minute, and all twelve return 200https://litellm-domain/ui/?page=router-settings, clicks General, and finds only a batch file size cap in MBAfter: the admin sets the three limits, and a caller who goes past one gets a clear 413 or 429 that says which limit it hit
general_settings, or in key or team metadata)Retry-Aftercounting down to 00:00 UTCRetry-After, while other files still downloadPOST /key/update) or a new user's key (POST /user/new) gets a 403Router Settings, General tab, before (
50190134c3): only the MB size cap exists for batch filesAfter (
c74eb7d1c9): the three new settings, here read from the config fileDocs for the three settings and the admin-only rule are in BerriAI/litellm-docs#1910
Linear ticket
Resolves LIT-5275
Design decisions
These are the calls made on purpose, each with the alternative it beat:
max_batch_file_size_mbRetry-After; counting stored files would need a DB query per upload and would ignore deleted-then-reuploaded filesexpires_after) does not use up a slot, since the count happens after validation. One that a pre-call hook or batch guardrail rejects, or that fails at the provider, does use one: the slot is taken before the guardrail scan so a caller over the limit cannot keep triggering scans, and counting only after success would let concurrent uploads go over the limitgeneral_settingsvalue is a default for every key, admin keys included. A key's own metadata value replaces the default for that key, and a team's metadata value always applies on topgeneral_settingsdefault applies to it too. A JWT caller with neither a key nor a user id gets only its team's limitbatch_enqueued_token_limitalready had (the existing guard now covers all four keys). A non-admin resending the stored value unchanged still works, so the UI edit form keeps saving. The same guard now also runs on the key thatPOST /user/newandPOST /management/v1/users/bulkcreate for a new user, since both copy the requestmetadataonto that key and an org admin could otherwise hand a new user any limit/config/updateand/config/field/updatereject a value that is not a positive integer. One that still reaches key or team metadata or the config file is ignored with a warning rather than failing requestsFILE_USAGE_MAX_TRACKED_COUNTERS, the same bound the login throttle uses) instead of the in-memory cache's default of 200, which let a caller get a fresh allowance on one file by downloading about 200 other file ids in the same minutegeneral_settingsdefault per end-user id as a third counter was the other option, but it only binds callers who choose to send an end-user id, so it would not bound what a team pushes through. Either can be added later without changing what these limits mean/openai/...,/vertex_ai/...,/azure/...,/bedrock/...) are not counted, the same boundarymax_batch_file_size_mband the batch enqueued-token limit already have. Those routes forward each provider's own file API as is, so counting them means parsing every provider's upload and download shapes, which is its own change. An admin who needs the caps airtight gives the keyallowed_routes: ["openai_routes"], which keeps/v1/filesand/v1/batchesopen and returns 403 on the pass-through file routesLEGACY_MULTI_INSTANCE_RATE_LIMITINGand these limits should not change behavior with itPre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<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).
Screenshots / Proof of Fix
Last updated for
c74eb7d1c9. Both legs ran the same scenario script against real OpenAI (gpt-5.4-nano, Files and Batches API) with curl as the client, the way the customer's batch callers hit/v1/files. Each leg is two proxy processes on two random ports (B1,B2), each with--num_workers 2, sharing one Postgres database and one Redis, and requests alternate between them, so a count taken on one instance has to be seen by the otherThe config sets
max_batch_file_records: 1000,max_batch_file_uploads_per_day: 5, andmax_file_downloads_per_minute: 10ingeneral_settings, turns on JWT auth against a local JWKS, and setsLITELLM_LICENSEso team and org admins can be assigned. The callers are:KEY, a team keyRUN_KEY, the team key that ran the two-request batch whose output file is downloadedDEV_KEY, a team admin's own keyORG_ADMIN_KEY, an org admin's key in org$ORGXKEY, a fresh team key whose metadata setsmax_batch_file_uploads_per_day: 1JWT, a signed token with no virtual key behind itBefore (5019013)
B1=http://127.0.0.1:28045,B2=http://127.0.0.1:388291. A 2,000-line batch file (
max_batch_file_records: 1000)2. Six batch uploads in one day across both instances (
max_batch_file_uploads_per_day: 5)3. Twelve downloads of one batch output file in a minute across both instances (
max_file_downloads_per_minute: 10)4. A proxy admin sets a download limit of 2 in the key's metadata
5. A team admin, not a proxy admin, raises their own key's upload limit
6. An org admin, not a proxy admin, creates a user with a download limit in
metadata7. A JWT caller with no virtual key uploads six batch files in one day
8. A key limited to one upload a day sends a malformed
expires_after, then two valid uploads9. A proxy admin sets a limit of 0 through the settings API
/config/field/updatedoes not know the field/config/updateaccepts the 0 and says it saved it (the stored row stays{})10. The settings a proxy admin sees
After (c74eb7d)
B1=http://127.0.0.1:59122,B2=http://127.0.0.1:451911. A 2,000-line batch file (
max_batch_file_records: 1000)2. Six batch uploads in one day across both instances (
max_batch_file_uploads_per_day: 5)retry-aftercounts down to 00:00 UTC3. Twelve downloads of one batch output file in a minute across both instances (
max_file_downloads_per_minute: 10)retry-after4. A proxy admin sets a download limit of 2 in the key's metadata
5. A team admin, not a proxy admin, raises their own key's upload limit
6. An org admin, not a proxy admin, creates a user with a download limit in
metadata7. A JWT caller with no virtual key uploads six batch files in one day
8. A key limited to one upload a day sends a malformed
expires_after, then two valid uploads9. A proxy admin sets a limit of 0 through the settings API
/config/field/updaterefuses the 0/config/updaterefuses the 0 with a 42210. The settings a proxy admin sees
What changed between the legs: the 2,000-line file gets a 413 and is not forwarded, the sixth upload of the day gets a 429 whose
retry-aftercounts down to 00:00 UTC (14767 s at 19:53:53Z is exactly the time left to midnight), the eleventh download of one file in a minute gets a 429 while the batch's input file still downloads, a key's own metadata limit of 2 wins over the default of 10, a JWT caller is counted per user, a 400 upload uses no slot, a non-admin can no longer raise a limit on their own key or a new user's key, a 0 is refused by both settings routes instead of being saved as nothing, and/config/listshows the three settings. The five uploads that fit were split 3 and 2 across the two instances and the sixth was refused on the second one, so the count is shared through RedisAlso checked at the tip
The PR merged into main
Main has moved six commits past the merge base
50190134c3(traces and lens, cost map rows, a Bedrock streaming timeout, a Makefile target), none touching a file this PR changes, and the merge is clean. The core cases ran again on that merge (ebda28890b, mainf445e466b4plus this tip) with one proxy with--num_workers 2and RedisThe counter's Redis span in a trace
The same merged proxy with
callbacks: ["otel"],service_callback: ["otel"], andOTEL_EXPORTER=otlp_httppointed at a local OTLP receiver that decodes the protobuf. WithLITELLM_OTEL_V2=true, one batch upload exports the counter's Redis increment asredis.incr rate_limits, nested under the request span, withlitellm.service.target=rate_limitsand the caller chain_increment_all_or_none <- consume_file_usage; without therate_limitsdeclaration the span name would be the bareredis.incr(read fromlitellm/integrations/otel/model/spans.py, not observed). With the default (V1) logger, no Redis service span is exported for this request at all, not for these counters and not for the parallel request limiter that runs in the same request, so nothing changes thereA file's count survives many other file ids, no Redis
One proxy process with
--num_workers 1and no Redis,max_file_downloads_per_minute: 3. The caller downloads file X to its limit, then 205 other file ids, then file X again. At5583f60542(this PR before the counter store got its own bound) the download counters shared the rate limiters' bounded in-memory cache, so the 205 other ids pushed file X's count out and it got a fresh allowance; atc74eb7d1c9the counters keep their own store of 20,000 entries and file X stays refused5583f60542, three downloads then a 429, 205 other ids, then file X is served againc74eb7d1c9, the same sequence in one minute window, and file X stays refusedSurprises from the run:
/key/updateerrors nest a stringified Python dict; pre-existing/user/new403 carries typeinternal_server_error, a pre-existing mapping/user/new403 says "on a key", meaning the auto-created one/config/field/updateanswers a 0 with "Invalid type"expires_after400 leaks theint()error text; pre-existing/config/updatesaved the 0 as{}Audit round 1 of 3
Merge base
50190134c3, product tipc74eb7d1c9, tests commits2085d71244and4ee2d9a9de. Every cell is a checked-intests/integration/test the audit wrote: the proxy (two workers), Postgres, and Redis run for real, the provider's Files API is the owned scripted upstream, and the span cell posts to a local OTLP sink. Nothing inside the proxy is mocked. The selection is the 134 cases (48 test functions) in the threemanagement/files and the oneobservability/file, run as node ids withtests/integration/run.py management ... --seed 43632 --order-seed 43632(the span cell through theextensionsgroup), once on the merge base and twice on the head with the same seeds: identical collected and passed selections both times, no skips, no retriesTwo runs went into the matrix. The whole selection ran at
2085d71244on 2026-10-05 and 2026-10-06 UTC (base23:46:28Z, head 100:12:34Z, head 200:47:35Z; local result dirs2085d71244/{base,head1,head2}_{management,extensions}). The merge base fails 127 of the 134 cases and passes the 7 controls, and the head passes all 134 both times. An earlier full run at the tests commitb7851f201ewas thrown out:make checkhad copied the worktree's.envback in before it, so every proxy in it booted with that file loaded (theEnvironment variables loaded from ../../.envlog line), and in this run none of the 31 proxies did. That discarded run also showed two chaos cells failing once each (test_a_download_cap_stored_through_the_config_api_reaches_every_worker_and_survives_a_restart,test_downloads_keep_answering_while_redis_hangs), both root-caused to the tests, not the product: the restart check ran three sequential downloads with no guard against straddling a UTC minute boundary, and the exact-cap probe ran while both workers' Redis circuit breakers were still open from the previous cell's outage (60 s default recovery), so it counted in per-process memory and the two workers split the cap.2085d71244fixes both with window-guarded bursts, a 2 s breaker recovery in the chaos fixture, and a shared-count check that drives a fresh key to its cap through a one-worker sibling proxy and then expects the two-worker candidate to refuse everything4ee2d9a9deis a tests-only commit on top of it (it releases the held upload handlers when the killed-worker chaos cell fails early, after a Greptile P2 at2085d71244), so per the rerun rule only the cell it changed,management/test_batch_file_usage_caps_chaos.py::test_a_killed_worker_keeps_its_upload_slots_used_and_the_sibling_serving, ran again at4ee2d9a9de(base01:43:26Z, head 102:06:23Z, head 202:26:16Zon 2026-10-06; result dirs4ee2d9a9de/{base,head1,head2}_management): red on the merge base, green on both head runs. Every other row keeps its2085d71244result, and the product code under every row is stillc74eb7d1c9Each row is one test function;
casescounts its parametrized cases, and a column shows how many of them passed or failed on that legmanagement/test_batch_file_usage_caps.py::test_a_file_id_with_a_slash_never_reaches_the_file_route_or_a_counter2085d71244management/test_batch_file_usage_caps.py::test_a_limit_changed_during_a_download_burst_only_answers_200_or_4292085d71244management/test_batch_file_usage_caps.py::test_a_lowered_limit_reaches_the_other_proxy_process2085d71244management/test_batch_file_usage_caps.py::test_async_openai_sdk_caller_sees_each_limit_as_a_status_error2085d71244management/test_batch_file_usage_caps.py::test_callers_without_a_valid_key_are_rejected_before_anything_is_counted2085d71244management/test_batch_file_usage_caps.py::test_concurrent_requests_across_processes_never_exceed_a_limit2085d71244management/test_batch_file_usage_caps.py::test_download_the_provider_cannot_find_still_uses_a_slot2085d71244management/test_batch_file_usage_caps.py::test_downloads_are_unlimited_when_no_limit_is_set2085d71244management/test_batch_file_usage_caps.py::test_every_download_route_shares_one_count_per_file2085d71244management/test_batch_file_usage_caps.py::test_every_upload_route_shares_one_daily_count2085d71244management/test_batch_file_usage_caps.py::test_hostile_file_ids_are_counted_as_the_provider_receives_them2085d71244management/test_batch_file_usage_caps.py::test_jwt_callers_are_counted_per_user_across_tokens2085d71244management/test_batch_file_usage_caps.py::test_key_daily_upload_limit_replaces_the_yaml_limit2085d71244management/test_batch_file_usage_caps.py::test_key_download_limit_counts_one_file_per_key_across_proxy_processes2085d71244management/test_batch_file_usage_caps.py::test_limited_key_still_reaches_chat_completions_and_file_metadata2085d71244management/test_batch_file_usage_caps.py::test_malformed_limits_are_ignored_and_the_yaml_limits_still_apply2085d71244management/test_batch_file_usage_caps.py::test_managed_files_are_capped_and_counted_under_their_unified_id2085d71244management/test_batch_file_usage_caps.py::test_model_routed_files_are_counted_under_the_id_the_caller_uses2085d71244management/test_batch_file_usage_caps.py::test_numeric_string_limits_are_honored2085d71244management/test_batch_file_usage_caps.py::test_only_batch_purpose_uploads_are_limited_and_counted2085d71244management/test_batch_file_usage_caps.py::test_openai_sdk_caller_sees_each_limit_as_a_status_error2085d71244management/test_batch_file_usage_caps.py::test_record_limit_counts_request_lines_not_blank_lines_or_line_endings2085d71244management/test_batch_file_usage_caps.py::test_record_limit_is_the_lower_of_the_key_level_and_team_limits2085d71244management/test_batch_file_usage_caps.py::test_rejected_uploads_do_not_use_a_daily_upload_slot2085d71244management/test_batch_file_usage_caps.py::test_team_download_limit_is_shared_by_every_key_on_the_team2085d71244management/test_batch_file_usage_caps.py::test_upload_rejected_by_the_key_limit_does_not_use_a_team_slot2085d71244management/test_batch_file_usage_caps.py::test_upload_the_provider_rejects_still_uses_a_daily_upload_slot2085d71244management/test_batch_file_usage_caps.py::test_yaml_daily_upload_limit_counts_one_key_across_proxy_processes2085d71244management/test_batch_file_usage_caps.py::test_yaml_record_limit_rejects_a_longer_batch_file_before_it_reaches_the_provider2085d71244management/test_batch_file_usage_caps_chaos.py::test_a_download_cap_stored_through_the_config_api_reaches_every_worker_and_survives_a_restart2085d71244management/test_batch_file_usage_caps_chaos.py::test_a_killed_worker_keeps_its_upload_slots_used_and_the_sibling_serving4ee2d9a9demanagement/test_batch_file_usage_caps_chaos.py::test_downloads_keep_answering_while_redis_hangs2085d71244management/test_batch_file_usage_caps_chaos.py::test_the_daily_count_survives_a_proxy_restart2085d71244management/test_batch_file_usage_caps_chaos.py::test_uploads_fall_back_to_per_process_counts_while_redis_is_down2085d71244management/test_batch_limit_metadata_admin_only.py::test_bulk_user_creation_refuses_only_the_row_that_puts_a_batch_limit_on_a_key2085d71244management/test_batch_limit_metadata_admin_only.py::test_config_field_update_refuses_a_malformed_file_limit2085d71244management/test_batch_limit_metadata_admin_only.py::test_config_list_shows_each_file_limit_as_an_unset_integer2085d71244management/test_batch_limit_metadata_admin_only.py::test_config_update_refuses_a_zero_file_limit2085d71244management/test_batch_limit_metadata_admin_only.py::test_org_admin_cannot_put_a_batch_limit_on_a_new_team2085d71244management/test_batch_limit_metadata_admin_only.py::test_org_admin_cannot_put_a_batch_limit_on_a_new_users_key2085d71244management/test_batch_limit_metadata_admin_only.py::test_org_admin_cannot_put_a_batch_limit_on_an_existing_team2085d71244management/test_batch_limit_metadata_admin_only.py::test_org_admin_may_resend_a_stored_team_limit_but_not_change_it2085d71244management/test_batch_limit_metadata_admin_only.py::test_proxy_admin_sets_every_batch_limit_on_every_route2085d71244management/test_batch_limit_metadata_admin_only.py::test_team_admin_cannot_put_a_batch_limit_on_a_new_key2085d71244management/test_batch_limit_metadata_admin_only.py::test_team_admin_cannot_put_a_batch_limit_on_a_regenerated_key2085d71244management/test_batch_limit_metadata_admin_only.py::test_team_admin_cannot_put_a_batch_limit_on_an_existing_key2085d71244management/test_batch_limit_metadata_admin_only.py::test_team_admin_may_resend_a_stored_key_limit_but_not_change_it2085d71244observability/test_file_usage_counter_span.py::test_a_counted_upload_exports_its_counter_increment_as_a_rate_limits_span2085d71244Inventory walk: every row of the surface inventory (records per file, daily uploads, downloads per minute, malformed and hostile values, limit changes under traffic, concurrency across workers, who may set the limits, the config endpoints, Redis stopped and Redis hanging mid-burst, a killed worker, a proxy restart, and the counter's OTLP span) has a cell above that ran at the head and matched its expected outcome, so no row is unverified. The seven cases that pass on the merge base are the controls whose behavior the PR does not change: an unauthenticated caller, no limit set anywhere, a slash in the file id (two routes), blank lines and CRLF around a file within the record limit (two shapes), and a proxy admin setting every limit
Type
🆕 New Feature
Caveats (if any)
Medium
metadataonPOST /user/new(when it creates a key) and on bulk user creationbatch_enqueued_token_limitbefore, so a provisioning script that set it as a non-admin breaks; key and team routes already refused itfail_closed_rate_limit_enforcementcovers the request rate limiter only, the same as the batch enqueued-token limiterREDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT, 60 s by default), and the counts taken in memory during the outage are not carried back to Redis/openai/...,/azure/...,/vertex_ai/...,/bedrock/...) are not cappedmax_batch_file_size_mband the enqueued-token limit haveallowed_routes: ["openai_routes"]on the key keeps/v1/filesand/v1/batchesopen and returns 403 on those routesLow
Retry-Aftercan state plainlybatch_enqueued_token_limitchanged wording/config/updatewith a 0 for a new setting is now a 422, not 200, and/config/field/updaterejects a 0 with an unclear "Invalid type" message/user/new403 saysinternal_server_error, a pre-existing mappingLive PR risk (c74eb7d)
Run at
c74eb7d1c9over the two product commits since the last pass atc13b47730d(the counter store's own 20,000-entry bound, and therate_limitsservice target on the counter increments), plus the merge of main at5583f60542; the earlier findings below were re-checked and standBreaking
batch_enqueued_token_limitinmetadataonPOST /user/new(with a key created) orPOST /management/v1/users/bulkgot a 200 and the value on the new key before, and now gets a 403. Read from the diff: neither route called the admin-only check at the merge base; the same shape was observed live formax_file_downloads_per_minutein case 6 (200 before, 403 after). Intended, since it closes the way around the rule the key and team routes already had. Shipping approved by: mateo-berri, in the approving review on this PRBackward incompatible
batch_enqueued_token_limiton key and team routes changed from "It replaces the standard rate limit checks for batch submissions." to "It limits what the holder can do with batches, so the holder cannot raise it." Status and type are unchanged. Shipping approved by: mateo-berri, in the approving review on this PRenforce_batch_enqueued_token_limit_is_admin_onlyand itsBatchEnqueuedTokenLimitRequestprotocol are renamed toenforce_batch_limits_are_admin_onlyandBatchLimitRequest. Every caller inlitellm/andenterprise/is updated; code outside the repo importing the old name would break. Shipping approved by: mateo-berri, in the approving review on this PRFILE_USAGE_MAX_TRACKED_COUNTERSis a new constant with no config key or env var behind it, andwith_service_targetonly names the counter's Redis calls for the service spansRegression risk
POST /management/v1/users/bulkwas not driven live. Its unit tests cover the new 403, and it shares the check/user/newran live in case 6Dependency graph
POST /v1/files(record and upload caps): unit tests plus live, cases 1, 2, 7, 8, and again on the merged treeGET /v1/files/{id}/content(download cap): unit tests plus live, cases 3 and 4, and again on the merged tree/v1/filesand managed files: unit tests, the Buildkite files and batches e2e, andtests/pass_through_unit_tests/test_passthrough_managed_ids.pyrun locallyPOST /user/newwith a created key: unit tests plus live, case 6POST /management/v1/users/bulk: unit tests onlytests/local_testing/test_auth_utils.pyrun locallytests/batches_tests/test_batch_rate_limits.pyrun locally/config/update,/config/field/update,/config/list, and the Router Settings page: live, cases 9 and 10 plus the User Flow screenshotsschema.d.ts: regenerated, and the sync check passesProxyLogging.file_usage_cacheand its 20,000-entry bound:update_valuesis its only other reader (it attaches Redis), no setting resizes it (update_in_memory_max_sizeis applied touser_api_key_cacheonly), unit test throughProxyLogging, and live in the no-Redis check above (a count survives 205 other file ids at the tip and did not at5583f60542)rate_limitsservice target: the service logger'sServiceLoggerPayload.target(OTel span name andlitellm.service.target, verified live under the V2 logger on the merged tree), the Redis pipeline's family label (not reached, the counters use single increments, unit tests intests/unit/caching/test_redis_batch.py), and the response-cache phase check inlitellm/caching/caching.py(compares tollm_response, unaffected);tests/unit/test_internal_context.pygates the declarationNot verified
tests/openai_endpoints_testsfiles and batches suites need a live proxy and were not run. They set no limit, so they take the path that returns early, which the Buildkite files and batches e2e covered at this tipredis.incr rate_limitsspan was read from a local OTLP receiver, not from a vendor's trace UI; the main checkout's.envcarries no OTel backend credentialFinal Attestation
Note
Medium Risk
Changes rate limiting and admin-gated metadata on
/v1/filesand key/team/user provisioning; behavior depends on Redis for cross-worker accuracy, but the change set is heavily tested.Overview
Adds three optional proxy caps on batch file traffic:
max_batch_file_records(413 on oversized.jsonl),max_batch_file_uploads_per_day(429 per UTC day forpurpose=batch), andmax_file_downloads_per_minute(429 per file withRetry-After). Limits come fromgeneral_settings, with key and team metadata overrides; record caps use the lower of key vs team.Enforcement is wired into
/v1/filesupload validation and a newfile_usage_capslayer that increments Redis-backed counters via a dedicatedfile_usage_cache(separate from RPM/TPM caches).enforce_batch_enqueued_token_limit_is_admin_onlyis generalized toenforce_batch_limits_are_admin_onlyfor all batch-limit metadata keys, including onPOST /user/newand bulk user creation whenauto_create_keyis true.Config types, Router Settings /
schema.d.ts, and broad unit plus integration tests cover precedence, JWT-per-user counting, multi-worker Redis, and admin-only rules.Reviewed by Cursor Bugbot for commit 4ee2d9a. Bugbot is set up for automated code reviews on this repo. Configure here.