feat(rate_limit): support per-tag rpm limiting on a single key - #31502
Conversation
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR adds per-tag RPM limiting on a single API key, letting callers scope independent rate-limit counters to request tags (e.g.
Confidence Score: 4/5Safe to merge for the RPM enforcement path; two open issues in the post-call TPM tracking path (flagged in previous review rounds) remain unaddressed and should be resolved before the feature is considered complete. The pre-call RPM enforcement is correct and well-tested. The open issues from previous rounds — unconditional litellm/proxy/hooks/parallel_request_limiter_v3.py — specifically the
|
| Filename | Overview |
|---|---|
| litellm/proxy/_types.py | Adds tag_rpm_limit: Optional[dict[str, int]] to GenerateRequestBase and appends "tag_rpm_limit" to LiteLLM_ManagementEndpoint_MetadataFields so the field is automatically folded into key metadata on create/update. Clean, minimal change consistent with how mcp_rpm_limit was added. |
| litellm/proxy/auth/auth_utils.py | Adds get_key_tag_rpm_limit helper that reads tag_rpm_limit from key metadata. Pattern is identical to the existing get_key_mcp_rpm_limit helper. |
| litellm/proxy/hooks/parallel_request_limiter_v3.py | Adds _add_tag_per_key_rate_limit_descriptor for pre-call RPM enforcement; the pre-call path correctly short-circuits when no tag limits are configured. However, the post-call _collect_tpm_scope_targets emits tag_per_key TPM entries unconditionally for all tagged requests (flagged in previous review round), and per-tag TPM enforcement is silently absent when LITELLM_TPM_TOKEN_RESERVATION_ENABLED=false (also previously flagged). |
| litellm/proxy/management_endpoints/key_management_endpoints.py | Adds tag_rpm_limit parameter to generate_key_helper_fn and stores it in metadata when provided, mirroring the existing mcp_rpm_limit handling. Docstrings for generate_key_fn and update_key_fn are updated accordingly. |
| ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx | New reusable editor component for per-tag RPM rows. Uses stable id field (not array index) for React list keys, and exposes tagRowsToLimits / tagLimitsToRows converters. Module-level nextRowId counter provides unique IDs across remounts. |
| ui/litellm-dashboard/src/components/templates/key_edit_view.tsx | Integrates TagRateLimitEditor into the key edit form. Always submits tag_rpm_limit (even {}) so clearing all rows removes the stored limits. State is initialized from keyData.metadata.tag_rpm_limit at mount; the useEffect that re-syncs Ant Design form fields when keyData changes does not re-sync tagRateLimits, but this is benign because setIsEditing(false) unmounts the component after every successful save. |
| tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py | Four new tests cover independent tag counters, descriptor creation, absence of descriptors without config, and untagged fallback to key-level limit — all meaningful behavioral assertions. |
Reviews (12): Last reviewed commit: "feat(rate_limit): support per-tag rpm li..." | Re-trigger Greptile
0a80fdf to
87e4a01
Compare
| if not tag_rpm_limit and not tag_tpm_limit: | ||
| return | ||
|
|
||
| for tag in dict.fromkeys(get_tags_from_request_body(data)): |
There was a problem hiding this comment.
Medium: Caller-controlled tags bypass tag limits
The tag descriptor is only added for tags supplied in the request body. A client using a key with tag_rpm_limit={"cell-1": 2} can omit metadata.tags or send an unconfigured tag and avoid the per-tag counter entirely, falling back to the broader key limit. If tag limits are intended as an enforcement boundary, derive the tag from trusted key/team configuration or fail closed when a key has tag limits but the request has no authorized matching tag.
There was a problem hiding this comment.
Per-tag limits are designed as opt-in sub-limits layered under the key-level rpm/tpm ceiling, not as a standalone enforcement boundary. A tag with no configured limit, or a request that carries no tag at all, is still governed by the key-level rpm_limit/tpm_limit, which stays the hard ceiling for the key. Dropping or changing the tag cannot lift a caller above the key's overall budget; it only forfeits the finer per-tag bucket and falls back to the broader key limit. That fallback is the behavior the feature is built around (the proof-of-fix shows an untagged request returning 200 under the key limit), and a fail-closed-on-missing-tag rule would reject legitimate untagged traffic the key is entitled to send
If a deployment wants tags to act as a hard boundary, it sets a key-level rpm_limit/tpm_limit as the ceiling and the per-tag limits subdivide it. I added a regression test, test_per_tag_untagged_request_governed_by_key_limit_v3, that pins this: an untagged or unconfigured-tag request is bounded by the key-level limit and is never rejected by a tag counter, so a future fail-closed change fails the test instead of silently breaking the documented behavior
There was a problem hiding this comment.
Confirmed by design, and unchanged now that this PR is narrowed to per-tag RPM only. Per-tag limits are opt-in sub-limits beneath the key-level rpm_limit ceiling, not a standalone enforcement boundary. Omitting or changing a tag only forfeits the finer per-tag bucket and falls back to the key-level limit, which stays the hard ceiling and cannot be exceeded; it cannot lift a caller above the key overall budget. Failing closed on a missing tag would reject legitimate untagged traffic the key is entitled to send (the live proof shows an untagged request returning 200 under the key limit). test_per_tag_untagged_request_governed_by_key_limit_v3 pins this so a future fail-closed change fails the test instead of silently changing the documented behavior
| model_rpm_limit: Optional[dict] = None | ||
| model_tpm_limit: Optional[dict] = None | ||
| mcp_rpm_limit: Optional[Dict[str, int]] = None | ||
| tag_rpm_limit: Optional[Dict[str, int]] = None |
There was a problem hiding this comment.
Medium: Key owners can clear tag limits through key updates
UpdateKeyRequest inherits these fields, and the update path treats metadata/rate-limit metadata as a non-budget change for key owners and authorized team members. A non-admin who can update their own key can send tag_rpm_limit: {} / tag_tpm_limit: {} (or the same keys inside metadata) to remove an admin-assigned per-tag throttle, then exceed that limit. Gate changes to these fields like other rate-limit/budget controls and validate them against team/org ceilings before writing them to key metadata.
There was a problem hiding this comment.
tag_rpm_limit/tag_tpm_limit are rate-limit fields and follow the same /key/update authorization path as the existing rpm_limit/tpm_limit. In _validate_update_key_data only max_budget, spend, and budget_limits are admin-gated; rpm_limit/tpm_limit and the other non-budget fields are already editable by the key owner or an authorized team member without the admin check. A key owner who can clear tag_rpm_limit can equally clear their own rpm_limit today, so gating the tag maps more tightly than the key-level limit they subdivide would be inconsistent. /key/update also requires key-management route permission, which a plain inference key does not carry, so this is not reachable by the data-plane key that the rate limit applies to
If admin-only rate-limit ceilings are wanted, that belongs in the shared budget/upperbound machinery for rpm_limit/tpm_limit in general rather than special-casing the tag maps in this PR
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
There was a problem hiding this comment.
tag_rpm_limit follows the same /key/update authorization path as the existing rpm_limit. /key/update requires key-management route permission, which a plain data-plane inference key (the one the rate limit actually applies to) does not carry, so this is not reachable by that key. For a caller who can reach /key/update, only max_budget, spend, and budget_limits are admin-gated in _validate_update_key_data; rpm_limit/tpm_limit and the other non-budget fields are already editable by the key owner or an authorized team member. A key owner who can clear tag_rpm_limit can equally clear their own rpm_limit today, so gating the tag map more tightly than the key-level limit it subdivides would be inconsistent. An admin-only ceiling for rate limits belongs in the shared upperbound machinery for rpm_limit/tpm_limit in general rather than a special case for the tag map in this PR
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
There was a problem hiding this comment.
Following up with the reachability and consistency picture, since I think both point the same way.
Reachability: /key/update requires key-management route permission. The key a tag rate limit actually governs is a data-plane inference key, which does not carry that permission, so the credential the throttle applies to cannot reach this endpoint to clear its own limit. Changing the limit requires a separately privileged management credential, not the rate-limited key itself.
Consistency: in _validate_update_key_data only max_budget, spend, and budget_limits are admin-gated. rpm_limit, tpm_limit, and the other non-budget fields are already editable by the key owner or an authorized team member, and tag_rpm_limit is a rate-limit field that behaves identically. A key owner who can clear tag_rpm_limit can equally clear their own rpm_limit today, so this PR does not add a new class of bypass; it adds one more field to an existing, uniformly applied set.
I do think there is a fair product question underneath this: should rate-limit ceilings be admin-only at all. That is a general decision about rpm_limit/tpm_limit, and the consistent place to make it is the shared budget/upperbound machinery applied uniformly to every rate-limit field, rather than special-casing the tag map in this feature PR. If a maintainer wants that boundary, I am happy to open a follow-up that gates all rate-limit fields together so the behavior stays consistent across the board
PR overviewThis PR adds support for applying per-tag rate limits to a single API key, including new request/type fields and enforcement logic in the parallel request limiter. It appears to let keys carry tag-specific RPM/TPM limits alongside existing broader key-level limits. Two security issues remain open around enforcement of the new tag-based limits. Requests can avoid the tag-specific counter by omitting or changing caller-supplied tags, and non-admin key owners may be able to clear admin-assigned tag limits through key updates. No issues have been addressed yet, so the current posture still allows practical bypass of the new throttling controls. Open issues (2)
Fixed/addressed: 0 · PR risk: 6/10 |
87e4a01 to
d913553
Compare
1 similar comment
d913553 to
c877830
Compare
c877830 to
48bb643
Compare
b06d7b7 to
2bbc943
Compare
1 similar comment
2bbc943 to
ae521f2
Compare
ae521f2 to
959025a
Compare
|
CI note: the only red check is The ceiling is also stale on the base branch: |
|
Documentation PR for this feature: #31855 Includes a docs page with API examples ( The docs files ( |
Add a tag_rpm_limit field to virtual keys so each request tag gets its own independent RPM counter on the v3 rate limiter. A key configured with per-tag limits tracks each tag/group separately, and requests whose tag has no configured limit fall back to the key-level limit. Includes the dashboard UI to manage per-tag limits on key create and edit. Resolves LIT-3147
959025a to
8e2ed6c
Compare
…itellm_lit-3147-per-tag-rate-limit # Conflicts: # ui/litellm-dashboard/src/components/organisms/create_key_button.tsx # ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
…itellm_lit-3147-per-tag-rate-limit # Conflicts: # litellm/proxy/hooks/parallel_request_limiter_v3.py # ui/litellm-dashboard/src/components/organisms/create_key_button.tsx # ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
Merging this PR will improve performance by 11.46%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
…AI#31502) Add a tag_rpm_limit field to virtual keys so each request tag gets its own independent RPM counter on the v3 rate limiter. A key configured with per-tag limits tracks each tag/group separately, and requests whose tag has no configured limit fall back to the key-level limit. Includes the dashboard UI to manage per-tag limits on key create and edit. Resolves LIT-3147
Relevant issues
Resolves LIT-3147
Linear ticket
LIT-3147
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewScreenshots / Proof of Fix
Run against a live proxy (real Postgres, real OpenAI
gpt-4o-minicalls) on this branchCreate a key with a per-tag RPM limit of 2 on
cell-1and a generous key-level limit of 50Each tag is tracked independently.
cell-1returns 429 once it hits its own limit, whilecell-2(no configured tag limit) keeps flowing, and an untagged request falls back to the key-level limitResponse headers on the
cell-1429Updating the limits through
/key/update(the path the edit UI uses) works too, and the new limit is enforced on the next requestUI: open the key create modal (Advanced Settings) or the key edit view, add a "Per-Tag Rate Limits" row with a tag name plus RPM, and save. The key info view then shows the configured Tag RPM Limits
End-to-end UI demo
Recorded against the freshly built dashboard on this branch. The flow creates a key
per-tag-demo-keywith a per-tag limit ofcell-1 = 2RPM through the create-key form, opens the key's Settings tab (which showsTag RPM Limits: {"cell-1":2}and thetag_rpm_limitmetadata), then drives live requests through the proxy.cell-1gets two 200s and a 429 on the third; a different tagcell-2stays at 200 for all three, confirming the counters are independentType
🆕 New Feature
Changes
A customer asked whether a single key's rate limit could be divided per request tag so each cell/group it operates is tracked independently instead of sharing one key-wide limit. Imagine ten cells that should each get 1000 RPM: today that means one key at 10000 RPM shared across all cells (a burst in one cell starves the rest) or ten separate keys to manage. This adds a per-tag RPM limit on a single key instead
A new field,
tag_rpm_limit, can be set on a key. It maps a request tag to an RPM limit and is stored in the key'smetadataJSON, mirroring howmodel_rpm_limitandmcp_rpm_limitalready work, so there is no schema migration. At request time the v3 rate limiter reads the request's tags and, for any tag that has a configured limit, enforces an independent counter keyed bytag_per_key/{api_key}:{tag}. Tags without a configured limit, and untagged requests, are governed only by the existing key-level limit, so behavior is unchanged for keys that do not opt inOn the backend the work is three small pieces: the field on
GenerateRequestBase(added toLiteLLM_ManagementEndpoint_MetadataFieldsso it folds into metadata), aget_key_tag_rpm_limithelper inauth_utils, and atag_per_keydescriptor inparallel_request_limiter_v3that reuses the existing per-scope RPM machinery, so enforcement needs no bespoke logicThe dashboard gets a "Per-Tag Rate Limits" row editor on key create and edit, and the key info view displays the configured map
Link to Devin session: https://app.devin.ai/sessions/60c6ad134652487687282119acff22a0
Documentation
Documented in BerriAI/litellm-docs#458.