feat(grpc): wire tenant token rate limiting into gRPC router - #2016
feat(grpc): wire tenant token rate limiting into gRPC router#2016XinyueZhang369 wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
Thorough review of the tenant rate limiting wiring into the gRPC router. The "guarded reserve-once" mechanism via RateLimitCell / RateLimitReserveStage is clean: reserves exactly once per logical request across retry attempts, settles with real backend-reported usage (not just the reserve estimate), and has proper RAII safety nets for every lifecycle edge (preemption/Drop, streaming handoff, non-success close, disconnect via ReservationAttachment).
Key correctness points verified:
n>1handling:max()(not sum) for shared prompt/cached tokens,sum()for per-choice completion tokens, and expected-choices guard before settling streaming usage- Harmony PD mode tracks
decode_completed_indicesseparately from prefill-populatedprompt_tokensto avoid masking a decode phase that never finished - CAS-guarded resolution on
SharedReservationHandleensures first-resolver-wins across all paths (inline settle, streaming settle,ReservationAttachmentabandon, cell Drop) - Retry loop correctly short-circuits on
Denied— never re-reserves or retries a rate-limit denial - Integration tests (real mock gRPC backend) prove settle_success runs with real usage by choosing budget numbers that distinguish reserve-only from reserve+settle
0 issues found.
791d787 to
bfe9a8a
Compare
bfe9a8a to
9f78090
Compare
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (23)
🚧 Files skipped from review as they are similar to previous changes (22)
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe change exposes ChangesTenant rate limiting
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Router
participant RequestPipeline
participant RateLimitManager
participant StreamingProcessor
participant ResponseBody
Router->>RequestPipeline: execute request with shared reservation state
RequestPipeline->>RateLimitManager: reserve tenant input-token budget
RateLimitManager-->>RequestPipeline: admitted reservation or denial
RequestPipeline->>StreamingProcessor: process response with reservation
StreamingProcessor->>ResponseBody: attach reservation lifecycle
ResponseBody-->>RateLimitManager: settle complete usage or abandon unresolved reservation
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (7)
model_gateway/src/routers/grpc/regular/stages/chat/response_processing.rs (1)
125-134: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win🟡 Nit: Five response-processing stages repeat the same four-arm attachment match. Each stage combines optional load guards with an optional
ReservationAttachmentusing the samematch (guards, reservation)block. No shared helper exists for that combination, so the pattern was copied once per stage. A future third attachment kind would require eight arms in five places.Add one helper, for example
AttachedBody::wrap_with_optional(response, guards, reservation), and call it from each site.
model_gateway/src/routers/grpc/regular/stages/chat/response_processing.rs#L125-L134: replace the four-arm match with the shared helper call.model_gateway/src/routers/grpc/regular/stages/completion/response_processing.rs#L106-L115: replace the four-arm match with the shared helper call.model_gateway/src/routers/grpc/regular/stages/generate/response_processing.rs#L117-L126: replace the four-arm match with the shared helper call.model_gateway/src/routers/grpc/regular/stages/messages/response_processing.rs#L112-L121: replace the four-arm match with the shared helper call.model_gateway/src/routers/grpc/harmony/stages/response_processing.rs#L100-L110: replace the four-arm match with the shared helper call.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/src/routers/grpc/regular/stages/chat/response_processing.rs` around lines 125 - 134, Factor the repeated optional-attachment logic into an AttachedBody helper such as wrap_with_optional(response, guards, reservation), preserving the existing behavior for every Some/None combination. Replace the four-arm match at model_gateway/src/routers/grpc/regular/stages/chat/response_processing.rs:125-134, completion/response_processing.rs:106-115, generate/response_processing.rs:117-126, messages/response_processing.rs:112-121, and model_gateway/src/routers/grpc/harmony/stages/response_processing.rs:100-110 with calls to the shared helper.model_gateway/src/routers/grpc/pipeline.rs (1)
1613-1755: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win🟡 Nit: Reservation settlement has no unit coverage.
The tests cover the reserve stage well: single admission across retries, cached denial, disabled manager, and a missing cell. They do not cover
settle_reservation. The zero-usage case flagged at Lines 508-514 would be caught by a test that asserts the settled amount for a response withoutusage.Do you want me to add those settlement tests?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/src/routers/grpc/pipeline.rs` around lines 1613 - 1755, Add unit coverage for settle_reservation, including a response without usage that settles a zero token amount. Extend the existing rate_limit_reserve_tests module with setup using the visible RateLimitManager and reservation context, then assert the manager records the expected settled amount for zero usage while preserving the existing reserve-stage tests.Source: Coding guidelines
model_gateway/src/routers/grpc/regular/streaming.rs (1)
2992-3006: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value🟡 Nit: The completion settle gate reads a metrics map, and the field doc no longer matches the field.
Two small issues in the same computation:
saw_completeis derived fromreasoning_tokens.len(). That map exists to aggregate reasoning-token metrics. It happens to receive exactly one entry per completed index today, so the count is correct. It is not a completion counter, and nothing enforces that it stays one. A later change that skips the insert for zero reasoning tokens, or moves it into theChunkarm, would silently break the reservation gate. Track completed indices in a dedicatedHashSet<u32>, asprocess_generate_streamingalready does at Line 907.The field doc at Lines 70-73 states that
saw_completerecords whether aCompletemessage "was ever seen". Line 2998 sets it only when every expected choice completed. Align the doc with the stricter meaning, or rename the field toall_choices_completed.♻️ Proposed change: dedicated counter
let mut reasoning_tokens: HashMap<u32, u32> = HashMap::new(); + let mut completed_indices: HashSet<u32> = HashSet::new();let index = index_offset + complete.index(); + completed_indices.insert(index); total_prompt = total_prompt.max(complete.prompt_tokens());- let expected_choices = completion_request.n.unwrap_or(1).max(1); - let saw_complete = reasoning_tokens.len() as u32 >= expected_choices; + let expected_choices = completion_request.n.unwrap_or(1).max(1); + let saw_complete = completed_indices.len() as u32 >= expected_choices;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/src/routers/grpc/regular/streaming.rs` around lines 2992 - 3006, Track completed choice indices in a dedicated HashSet<u32> within the streaming completion flow, following the existing process_generate_streaming pattern, and derive saw_complete from that set reaching expected_choices instead of reasoning_tokens.len(). Update the saw_complete field documentation to describe that all expected choices completed, or rename the field consistently if that better matches the established API.model_gateway/src/routers/grpc/router.rs (1)
550-614: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value🟡 Nit: Four dispatch methods now repeat the same reservation scaffolding.
route_chat_impl,route_generate_impl,route_messages_impl, androute_completion_impleach repeat six steps: canonicalize the model ID, rewrite the bodymodel, clone the retry inputs, create theRateLimitCell, suppress retries onDenied, and close the reservation after the retry loop. Any future change to the reservation lifecycle must land in four places.Two smaller cleanups are available without a large refactor:
- Extract the denial predicate, for example
fn reservation_denied(cell: &RateLimitCell) -> bool, and call it from all fourshould_retryclosures.resolve_retry_configresolves the alias itself, and it now receives an already-canonical ID at Line 567. The second resolve is redundant. Consider aretry_config_for_canonicalvariant that skips it.A full generic extraction over the four request and response types is likely not worth the type gymnastics here.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/src/routers/grpc/router.rs` around lines 550 - 614, Reduce duplicated reservation logic across route_chat_impl, route_generate_impl, route_messages_impl, and route_completion_impl by extracting a shared reservation_denied(&RateLimitCell) predicate and using it in each should_retry closure. Add a retry configuration helper for already-canonical model IDs, then use it after resolve_canonical_model_id so retry configuration does not resolve the alias again; leave the broader dispatch flow unchanged.model_gateway/tests/tenant_rate_limiting_grpc_test.rs (1)
94-174: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift🟡 Nit: Confirm equivalent end-to-end coverage exists for other pipelines and PD dispatch.
build_routeronly wiresregular_mode(vec![])with aGrpc/TokenSpeedworker, and every test in this file drivesroute_generate. The PR wires tenant rate limiting into Chat, Messages, Completion, Generate, and Harmony pipelines, and PD disaggregation introduces a distinct dual-dispatch path (separate prefill/decode reservation and settlement) alongside regular routing.Confirm whether equivalent end-to-end reservation/settlement coverage exists elsewhere for the other pipelines and for PD mode, since Harmony's multi-choice completion gating and PD's split dispatch could plausibly diverge from the single-dispatch Generate behavior asserted here.
As per coding guidelines, "Account for dual-dispatch complexity introduced by PD disaggregation in addition to regular routing" and "Run the pr-test-analyzer agent to verify that tests adequately cover new or changed functionality."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/tests/tenant_rate_limiting_grpc_test.rs` around lines 94 - 174, Extend the tenant rate-limiting integration coverage beyond build_router’s regular TokenSpeed route_generate path: add equivalent reservation/settlement tests for Chat, Messages, Completion, and Harmony, including Harmony multi-choice behavior, and add PD disaggregation tests covering separate prefill/decode dispatch reservations and settlement. Reuse the existing router setup and assertions where possible, and verify the resulting test suite with the pr-test-analyzer agent.Source: Coding guidelines
model_gateway/tests/common/mod.rs (1)
328-473: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win🟡 Nit: Extract shared
AppContextconstruction logic instead of duplicating it a third time.
create_test_context_with_tokenizer_registry(Lines 328-473) repeats nearly all of the ~140-line tail already present in the unchangedcreate_test_context_with_parsers(Lines 481-620): registries, storage backends, worker monitor, job queue, workflow engines, OpenAI-mode worker registration, and MCP orchestrator setup. The only real differences are the tokenizer registry source, the parser factories, and now the rate-limit manager.Consider factoring the shared tail into a private helper that accepts these three pieces as parameters, so future
AppContextfield additions (like this PR'srate_limit_manager) only need to be wired in one place instead of drifting across multiple near-identical test helpers.♻️ Sketch of a shared helper
+fn build_test_app_context( + config: RouterConfig, + client: reqwest::Client, + tokenizer_registry: Arc<TokenizerRegistry>, + reasoning_parser_factory: Option<ReasoningParserFactory>, + tool_parser_factory: Option<ToolParserFactory>, + rate_limiter: Option<Arc<TokenBucket>>, + rate_limit_manager: Option<Arc<smg::rate_limit::RateLimitManager>>, +) -> Pin<Box<dyn Future<Output = Arc<AppContext>> + Send>> { + // ... shared registries/storage/monitor/job-queue/workflow/MCP init, + // moved out of create_test_context_with_tokenizer_registry and + // create_test_context_with_parsers. +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/tests/common/mod.rs` around lines 328 - 473, Extract the shared AppContext setup from create_test_context_with_tokenizer_registry and create_test_context_with_parsers into one private helper. Parameterize the helper with the tokenizer registry, reasoning parser factory, and tool parser factory, and centralize rate_limit_manager plus all shared registries, storage, monitor, queue, workflow, worker, and MCP initialization there. Keep both public helpers as thin wrappers that supply their respective inputs and preserve their existing behavior.model_gateway/src/routers/grpc/common/stages/rate_limit.rs (1)
111-114: 🗄️ Data Integrity & Integration | 🔵 Trivial🟡 Nit — Track HTTP/gRPC rate-limit parity as a follow-up.
The doc comment states Responses, embeddings, and classify have not opted into
rate_limit_cell, and the PR scope also excludes non-gRPC routers. This is disclosed, intentional, phased scope, so it is not a defect in this change. Track closing this gap in a follow-up so the HTTP and gRPC routers eventually enforce the same tenant rate-limit contract.As per coding guidelines, "Ensure HTTP and gRPC routers implement the same API contract across both code paths."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/src/routers/grpc/common/stages/rate_limit.rs` around lines 111 - 114, Track a follow-up to align rate-limit enforcement between HTTP and gRPC routers, including Responses, embeddings, and classify endpoints that currently lack rate_limit_cell opt-in. Preserve the current scoped behavior in the rate-limit reservation stage and document the follow-up using the project’s established issue or TODO tracking mechanism.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@model_gateway/src/routers/grpc/common/stages/rate_limit.rs`:
- Around line 144-149: In the missing-tenant-metadata branch of the rate-limit
`execute()` flow, add a `tracing::warn!` describing that rate limiting is
failing open because `ctx.input.tenant_request_meta` is absent, then preserve
the existing `Ok(None)` return.
---
Nitpick comments:
In `@model_gateway/src/routers/grpc/common/stages/rate_limit.rs`:
- Around line 111-114: Track a follow-up to align rate-limit enforcement between
HTTP and gRPC routers, including Responses, embeddings, and classify endpoints
that currently lack rate_limit_cell opt-in. Preserve the current scoped behavior
in the rate-limit reservation stage and document the follow-up using the
project’s established issue or TODO tracking mechanism.
In `@model_gateway/src/routers/grpc/pipeline.rs`:
- Around line 1613-1755: Add unit coverage for settle_reservation, including a
response without usage that settles a zero token amount. Extend the existing
rate_limit_reserve_tests module with setup using the visible RateLimitManager
and reservation context, then assert the manager records the expected settled
amount for zero usage while preserving the existing reserve-stage tests.
In `@model_gateway/src/routers/grpc/regular/stages/chat/response_processing.rs`:
- Around line 125-134: Factor the repeated optional-attachment logic into an
AttachedBody helper such as wrap_with_optional(response, guards, reservation),
preserving the existing behavior for every Some/None combination. Replace the
four-arm match at
model_gateway/src/routers/grpc/regular/stages/chat/response_processing.rs:125-134,
completion/response_processing.rs:106-115,
generate/response_processing.rs:117-126,
messages/response_processing.rs:112-121, and
model_gateway/src/routers/grpc/harmony/stages/response_processing.rs:100-110
with calls to the shared helper.
In `@model_gateway/src/routers/grpc/regular/streaming.rs`:
- Around line 2992-3006: Track completed choice indices in a dedicated
HashSet<u32> within the streaming completion flow, following the existing
process_generate_streaming pattern, and derive saw_complete from that set
reaching expected_choices instead of reasoning_tokens.len(). Update the
saw_complete field documentation to describe that all expected choices
completed, or rename the field consistently if that better matches the
established API.
In `@model_gateway/src/routers/grpc/router.rs`:
- Around line 550-614: Reduce duplicated reservation logic across
route_chat_impl, route_generate_impl, route_messages_impl, and
route_completion_impl by extracting a shared reservation_denied(&RateLimitCell)
predicate and using it in each should_retry closure. Add a retry configuration
helper for already-canonical model IDs, then use it after
resolve_canonical_model_id so retry configuration does not resolve the alias
again; leave the broader dispatch flow unchanged.
In `@model_gateway/tests/common/mod.rs`:
- Around line 328-473: Extract the shared AppContext setup from
create_test_context_with_tokenizer_registry and create_test_context_with_parsers
into one private helper. Parameterize the helper with the tokenizer registry,
reasoning parser factory, and tool parser factory, and centralize
rate_limit_manager plus all shared registries, storage, monitor, queue,
workflow, worker, and MCP initialization there. Keep both public helpers as thin
wrappers that supply their respective inputs and preserve their existing
behavior.
In `@model_gateway/tests/tenant_rate_limiting_grpc_test.rs`:
- Around line 94-174: Extend the tenant rate-limiting integration coverage
beyond build_router’s regular TokenSpeed route_generate path: add equivalent
reservation/settlement tests for Chat, Messages, Completion, and Harmony,
including Harmony multi-choice behavior, and add PD disaggregation tests
covering separate prefill/decode dispatch reservations and settlement. Reuse the
existing router setup and assertions where possible, and verify the resulting
test suite with the pr-test-analyzer agent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 5a8f2db8-4007-4989-93fc-0cd14799b003
📒 Files selected for processing (22)
crates/mock_worker/Cargo.tomlcrates/mock_worker/src/lib.rscrates/mock_worker/src/main.rsmodel_gateway/Cargo.tomlmodel_gateway/src/app_context.rsmodel_gateway/src/rate_limit/rejection.rsmodel_gateway/src/routers/grpc/common/response_formatting.rsmodel_gateway/src/routers/grpc/common/stages/mod.rsmodel_gateway/src/routers/grpc/common/stages/rate_limit.rsmodel_gateway/src/routers/grpc/context.rsmodel_gateway/src/routers/grpc/harmony/stages/response_processing.rsmodel_gateway/src/routers/grpc/harmony/streaming.rsmodel_gateway/src/routers/grpc/pipeline.rsmodel_gateway/src/routers/grpc/regular/responses/streaming.rsmodel_gateway/src/routers/grpc/regular/stages/chat/response_processing.rsmodel_gateway/src/routers/grpc/regular/stages/completion/response_processing.rsmodel_gateway/src/routers/grpc/regular/stages/generate/response_processing.rsmodel_gateway/src/routers/grpc/regular/stages/messages/response_processing.rsmodel_gateway/src/routers/grpc/regular/streaming.rsmodel_gateway/src/routers/grpc/router.rsmodel_gateway/tests/common/mod.rsmodel_gateway/tests/tenant_rate_limiting_grpc_test.rs
There was a problem hiding this comment.
🧹 Nitpick comments (2)
model_gateway/tests/common/mod.rs (1)
506-518: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win🟡 Nit:
create_test_context_with_parsershardcodesNonefor the rate-limit manager.
create_test_context_with_tokenizer_registryderives the manager fromconfig, but this helper ignores tenant rate-limit configuration. A test that enables tenant rate limits through this helper gets no manager, so the request path silently skips reservation and the test passes for the wrong reason. Derive the manager fromconfighere as well, or document that this helper intentionally disables tenant rate limiting.♻️ Proposed change
pub fn create_test_context_with_parsers( config: RouterConfig, ) -> Pin<Box<dyn Future<Output = Arc<AppContext>> + Send>> { + // Tenant rate limiting stays disabled here on purpose: callers of this + // helper do not exercise the reservation path. Box::pin(build_test_app_context( config, Arc::new(TokenizerRegistry::new()), Some(ReasoningParserFactory::new()), Some(ToolParserFactory::new()), None, empty_mcp_config(), )) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/tests/common/mod.rs` around lines 506 - 518, Update create_test_context_with_parsers to derive and pass the rate-limit manager from config, matching create_test_context_with_tokenizer_registry, instead of hardcoding None; preserve the helper’s existing parser factory initialization.model_gateway/src/routers/grpc/common/stages/helpers.rs (1)
84-105: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win🟡 Nit — Add focused tests for combined response guards.
attach_response_guards()exercises(Some, Some),(Some, None),(None, Some), and(None, None)via the streaming response paths, but the existing tests exercise load guards and reservation handles through separate RAII/unit tests rather than throughAttachedBody::wrap_responsewith the shared(guards, ReservationAttachment)combo. Add a small focused test that drops the combined wrapped body and asserts both the load guard and unresolved reservation are cleaned up.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model_gateway/src/routers/grpc/common/stages/helpers.rs` around lines 84 - 105, Add a focused test for attach_response_guards covering the (Some(LoadGuards), Some(ReservationAttachment)) case, wrapping a response through AttachedBody::wrap_response, then dropping the body and asserting both the load guard and unresolved reservation are released. Reuse the existing RAII test fixtures and assertions, keeping the test scoped to combined guard cleanup.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@model_gateway/src/routers/grpc/common/stages/helpers.rs`:
- Around line 84-105: Add a focused test for attach_response_guards covering the
(Some(LoadGuards), Some(ReservationAttachment)) case, wrapping a response
through AttachedBody::wrap_response, then dropping the body and asserting both
the load guard and unresolved reservation are released. Reuse the existing RAII
test fixtures and assertions, keeping the test scoped to combined guard cleanup.
In `@model_gateway/tests/common/mod.rs`:
- Around line 506-518: Update create_test_context_with_parsers to derive and
pass the rate-limit manager from config, matching
create_test_context_with_tokenizer_registry, instead of hardcoding None;
preserve the helper’s existing parser factory initialization.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3f524414-635f-477d-ad22-b6ad059d6762
📒 Files selected for processing (12)
model_gateway/src/routers/grpc/common/stages/helpers.rsmodel_gateway/src/routers/grpc/common/stages/rate_limit.rsmodel_gateway/src/routers/grpc/harmony/stages/response_processing.rsmodel_gateway/src/routers/grpc/pipeline.rsmodel_gateway/src/routers/grpc/regular/stages/chat/response_processing.rsmodel_gateway/src/routers/grpc/regular/stages/completion/response_processing.rsmodel_gateway/src/routers/grpc/regular/stages/generate/response_processing.rsmodel_gateway/src/routers/grpc/regular/stages/messages/response_processing.rsmodel_gateway/src/routers/grpc/regular/streaming.rsmodel_gateway/src/routers/grpc/router.rsmodel_gateway/tests/common/mod.rsmodel_gateway/tests/tenant_rate_limiting_grpc_test.rs
🚧 Files skipped from review as they are similar to previous changes (10)
- model_gateway/src/routers/grpc/harmony/stages/response_processing.rs
- model_gateway/src/routers/grpc/router.rs
- model_gateway/src/routers/grpc/regular/stages/chat/response_processing.rs
- model_gateway/src/routers/grpc/regular/stages/completion/response_processing.rs
- model_gateway/src/routers/grpc/common/stages/rate_limit.rs
- model_gateway/src/routers/grpc/regular/stages/generate/response_processing.rs
- model_gateway/src/routers/grpc/regular/stages/messages/response_processing.rs
- model_gateway/tests/tenant_rate_limiting_grpc_test.rs
- model_gateway/src/routers/grpc/pipeline.rs
- model_gateway/src/routers/grpc/regular/streaming.rs
…essages Wires RateLimitManager::reserve/settle_success/close_reserved_only (Phase 1, merged via #1958/#1966/#1969 -- nothing called it yet) into the gRPC router, via a guarded reserve-once mechanism rather than hoisting preparation out of the retry loop. A prior attempt at this (splitting prep from dispatch into a PreparedRequest replayed across retry attempts) was implemented, reviewed, and reverted -- see branch xz/grpc-pipeline-prepare-execute-split-archived. It introduced real bugs (alias-handover mismatch, a multimodal double-clone) because prep and dispatch became independently-constructed states that had to be kept in sync by hand. This approach avoids that entirely: every execute_* method stays exactly as it is today (full prep + dispatch, fused, rerun independently per retry attempt), and a small RateLimitCell shared across attempts of one logical request tracks whether reserve() has already run. - New RateLimitReserveStage, inserted after preparation (before worker selection) in the chat/messages/completion/harmony stage lists. Checks the cell: already Admitted -> skip; already Denied -> reject (defensive, should_retry stops the loop first); otherwise reserve() using the exact token count preparation just produced. - router.rs's should_retry now also checks the cell for a cached Denied outcome and stops retrying immediately -- a rate-limit denial is not a transient worker failure, and retrying it defeats retry_after_secs. - Non-streaming success settles inline using the response's real usage. Streaming settles with real accumulated totals at each existing mark_completed() site in streaming.rs and harmony/streaming.rs (~10 sites across chat/generate/messages/completion, both regular and prefill-decode); ReservationAttachment is layered onto the response body's AttachedBody as a Drop-based safety net for early disconnect/error before that point is reached. - router.rs closes any still-open reservation after the retry loop for any non-2xx final response (repeated prep failure, exhausted retries) -- safe to call unconditionally since settle/close/abandon are all CAS-guarded to only the first resolution. Responses endpoint, embeddings, and classify are left unwired (no cell passed) for now, matching the original Phase 2 scope cut -- none of them use RetryExecutor today. Signed-off-by: XinyueZhang369 <zoeyzhang369@gmail.com>
Tier 2 of the tenant rate limiting test plan: verify the gRPC router's reserve/settle wiring against a real (mock) gRPC backend, rather than only at the RateLimitReserveStage unit level. - crates/mock_worker: add a [lib] target so its HTTP/gRPC simulators (previously private modules of the standalone binary) are importable as a dev-dependency. main.rs now depends on the lib instead of declaring its own modules; behavior unchanged. - model_gateway/tests/common/mod.rs: add create_test_context_with_tokenizer_registry, a variant of create_test_context that takes a caller-supplied tokenizer registry (needed since mock-worker's gRPC service deliberately has no tokenizer artifacts to autoload) and real parser factories (GrpcRouter::new requires both Some, unlike the HTTP-only default). The original create_test_context is now a thin wrapper, unchanged for existing callers. - app_context.rs: add AppContextBuilder::rate_limit_manager, a direct setter mirroring the existing rate_limiter() one -- the config-driven maybe_rate_limit_manager is private to this module, unreachable for test harnesses that build AppContext piecemeal instead of through from_config. - New test file spawns mock_worker::grpc::serve in-process against a picked port, registers it as a real gRPC worker, and drives RouterTrait::route_generate directly (mirroring routers/grpc/router.rs's own pd_tests module) rather than through the lightweight HTTP test app, which doesn't wire tenant-resolution middleware. Three tests: - denial_returns_429_with_retry_after: a budget too small for one reservation denies immediately, before ever reaching the worker. - non_streaming_settle_uses_real_usage_not_just_the_estimate: proves settle_success runs with the backend's real reported usage rather than just the reserve-time estimate. mock-worker's canned mode always reports prompt_tokens=1 regardless of real input length while completion_tokens is exactly the configured output_tokens; a budget sized between the reserve estimate and the true settled cost makes a second identical request's admit/deny outcome a direct, deterministic proof that settle used the backend's real numbers. - feature_disabled_is_a_no_op: baseline, no interference when tenant rate limiting isn't configured. Full verification gate green: cargo test -p smg --lib (1339 passed) + the new integration test (3 passed, stable across repeated runs), cargo +nightly fmt --all, cargo clippy -p smg --all-targets -D warnings, cargo clippy -p mock-worker --all-targets -D warnings, and cargo clippy --workspace --all-targets -D warnings all clean. Signed-off-by: XinyueZhang369 <zoeyzhang369@gmail.com>
…ias handover, and impossible-request Retry-After Four review findings on the tenant rate-limit reserve-guard wiring, all verified against current code before fixing: - [P1] Batched completions reserved only the first prompt's tokens. PreparationOutput::token_ids() is deliberately a single-item routing proxy (unchanged); RateLimitReserveStage now uses a new total_input_token_count(), which sums every item in a batched Completion request instead of delegating to the routing proxy. A batch with a small first prompt and large later prompts was passing admission without reserving their real input cost. - [P1] Preemption/cancellation leaked active reservations. The route future can be dropped (priority-scheduler preemption, or any other cancellation) before any response -- streaming or not -- is ever produced, at which point neither the inline non-streaming settle nor the streaming ReservationAttachment ever gets the chance to resolve the handle, and SharedReservationHandle has no cleanup-on-drop of its own. RateLimitCell now does: a Drop impl abandons any still-open reservation it holds, guarded by a new handed_off flag set only when a streaming response takes over via the new take_for_streaming_handoff (replaces the old peek-then-match in all 5 response_processing.rs call sites) -- without that flag, Drop would race and always win against the streaming path's later, correctly-timed real-usage settle, since route_*_impl returns the initial SSE response long before the stream itself finishes. - [P2] Retries could dispatch a different canonical model than the one reserved. Each attempt re-resolved the raw alias fresh (via RequestContext::new), while the cached reservation stayed bound to whichever model the first attempt resolved to. An alias repointed mid-retry could dispatch to model B while the reservation -- and its eventual settle -- stayed against model A's budget, bypassing model B's own policy entirely. Fixed by resolving the canonical model once, before the retry loop, in all four route_*_impl functions (new resolve_canonical_model_id), reusing the same value for both the reservation and every dispatch attempt. - [P2] An impossible request (estimated cost exceeding the tenant's total capacity) exposed the backend's u64::MAX "can never fit, no matter how long you wait" sentinel as a literal Retry-After header. rejection_response now omits the header for that sentinel too, matching the existing retry_after_secs > 0 omission. New tests: total_input_token_count_sums_every_batched_completion_item (context.rs); three RateLimitCell::drop tests using an in-crate counting fake backend (drop-without-handoff abandons, drop-after-handoff does not, drop-after-denial does not); rejection_response's u64::MAX case; two new end-to-end integration tests (impossible_request_denies_without_retry_after, and denial_returns_429_with_retry_after rewritten to a genuine finite-wait denial rather than an impossible one, which the Retry-After fix now correctly treats differently). Full verification gate green: cargo test -p smg --lib (1344 passed), the gRPC integration test (13 passed, stable across repeated runs), cargo +nightly fmt --all, cargo clippy -p smg --all-targets -D warnings, cargo clippy --workspace --all-targets -D warnings all clean. Signed-off-by: XinyueZhang369 <zoeyzhang369@gmail.com>
…and stop double-charging n>1 prompts - router.rs: rewrite the cloned request body's model field to the resolved canonical model_id, not just model_id_cloned itself -- RequestContext::new's own alias resolve no-ops on an already-canonical id, so without this the body (and therefore response metadata and parser selection) kept reporting the client's alias. - regular/streaming.rs, harmony/streaming.rs: a clean stream EOF that never produced an authoritative Complete message was settling with actual_input_tokens=0, incorrectly refunding the reservation's estimate. Track whether Complete was ever seen and close_reserved_only instead when it wasn't, across chat/generate/messages/completion (PD variants delegate to the same functions) and Harmony. - response_formatting.rs: build_usage summed prompt_tokens across every n>1 choice instead of taking the max, double- (or n-times-) charging the one prompt those choices share. Completion's own usage computation already does this correctly; mirrored here for Chat/Harmony. Signed-off-by: XinyueZhang369 <zoeyzhang369@gmail.com>
…ing usage - regular/streaming.rs, harmony/streaming.rs: a clean EOF partway through an n>1 stream (some choices finished, others didn't) was treated as fully authoritative usage as long as at least one Complete had arrived, understating actual_input_tokens/completion_tokens instead of closing as no-usage. Track completed choices explicitly (a HashSet of indices, or an existing per-index map's length where that map is only ever populated from Complete) and require it to cover every expected choice before settling; close_reserved_only otherwise. - harmony/streaming.rs: in PD mode the prefill phase pre-populates the same prompt_tokens map the decode phase later reads, so checking that map alone could treat a prefill-only Complete as proof decode finished when it never did. Track decode's own Complete messages separately. - regular/streaming.rs, harmony/streaming.rs: streaming settle (and, for chat, the client-visible usage SSE chunk) still summed prompt_tokens across n>1 choices instead of taking the max, double-charging the one prompt they share -- same bug already fixed for the non-streaming path in the previous commit, now applied to streaming too. Signed-off-by: XinyueZhang369 <zoeyzhang369@gmail.com>
cached_tokens is a property of the shared prompt (how much of it hit the KV cache), same as prompt_tokens, but was still summed across every n>1 choice instead of taking the max -- same bug as prompt_tokens, just missed in the earlier fix. Fixed in build_usage (non-streaming Chat/Harmony), regular chat streaming's usage chunk, and Harmony's decode settle. Completion was already correct (its own per-prompt max already covered cached_tokens). Signed-off-by: XinyueZhang369 <zoeyzhang369@gmail.com>
…guard - rate_limit.rs: log a warn when tenant_request_meta is missing so the fail-open path (no reservation made) is observable instead of silent. - regular/streaming.rs: Completion's streaming saw_complete no longer piggybacks on reasoning_tokens.len() (an unrelated map that only incidentally gets one insert per completed index today) -- tracks a dedicated completed_indices: HashSet<u32>, matching the pattern already used by process_generate_streaming and Harmony's decode_completed_indices. Also fixed the CompletionStreamOutcome::saw_complete field doc, which described the old "any Complete seen" semantics instead of the current "every expected choice completed" one. - common/stages/helpers.rs, 5x response_processing.rs: extracted the 4-arm (load_guards, reservation) AttachedBody match repeated identically across chat/completion/generate/messages/harmony into one shared helpers::attach_response_guards, removing ~30 duplicated lines. - pipeline.rs: added a unit test proving settle_reservation(0, 0) (the no-`usage` response case) refunds the full reserved estimate rather than keeping it or leaving the reservation stuck open. - router.rs: extracted Self::reservation_denied(&RateLimitCell) instead of repeating the same denial-check closure body in all four route_*_impl's should_retry. Also split resolve_retry_config into a canonical-only fast path (resolve_retry_config_for_canonical, what all four call sites actually need now that they pre-canonicalize) and removed the now-dead alias-resolving original, rewriting its two tests to exercise the real resolve_canonical_model_id + resolve_retry_config_for_canonical sequence instead. - tests/tenant_rate_limiting_grpc_test.rs: added completion_denial_reaches_ the_client, proving RateLimitReserveStage is live on a second wired pipeline (not just Generate) end-to-end against a real mock backend. Chat/Messages coverage was attempted the same way but hits a pre-existing, unrelated gap -- MockTokenizer doesn't implement apply_chat_template, so those requests 400 in preparation before ever reaching the reserve stage; documented in the test file rather than worked around here. Harmony and PD dispatch remain uncovered by this suite (would need a harmony-flavored model and a separate prefill+decode worker pair respectively); the reserve/settle mechanics they'd exercise are identical to what's already proven here and in pipeline.rs's own rate_limit_reserve_tests. - tests/common/mod.rs: create_test_context_with_tokenizer_registry, create_test_context_with_parsers, and create_test_context_with_mcp_config shared nearly all of a ~140-line AppContext construction tail (registries, storage, worker monitor, job queue, workflow engines, OpenAI-mode worker registration, MCP orchestrator startup), duplicated three times with only the tokenizer registry, parser factories, rate-limit manager, and MCP config actually varying. Extracted that tail into build_test_app_context; all three (plus create_test_context) are now thin wrappers supplying just their differing inputs. No behavior change -- verified against every test binary that uses this file (api_tests, otel_tracing_test, routing_tests, tenant_rate_limiting_grpc_test), all passing unchanged. Full verification gate: 1377 lib tests, 14 tenant-rate-limit integration tests, 106 api_tests, 11 otel_tracing_test, 102 routing_tests, fmt clean, scoped + workspace clippy -D warnings clean. Signed-off-by: XinyueZhang369 <zoeyzhang369@gmail.com>
43779b4 to
f47310d
Compare
Description
Problem
RateLimitManager::reserve()/settle_success()/close_reserved_only()(Phase 1, already merged onmain) aren't called anywhere yet — nothing enforces tenant token-per-minute limits on the gRPC router's chat/generate/completion/messages endpoints.Solution
Wire tenant rate limiting into the gRPC pipeline with a "guarded reserve-once" mechanism: a small
RateLimitCellshared across all retry attempts of one logical request, checked by a newRateLimitReserveStageinserted right after preparation. The first attempt to reach the stage reserves (or is denied) and caches the outcome; every later retry attempt sees the cached outcome and skips straight through.RetryExecutorreruns the full pipeline per attempt unchanged — no prepare/dispatch split, no independently-constructed state that has to be kept in sync by hand.Changes
RateLimitCell/RateLimitOutcome/RateLimitReserveStage(new,routers/grpc/common/stages/rate_limit.rs), inserted into the Chat/Messages/Completion/Harmony pipelines right after preparation.router.rs: eachroute_*_implconstructs the cell, canonicalizes the model once before the retry loop (reused for both the reservation and every dispatch attempt, including the request body's ownmodelfield), stops retrying on a cached denial, and closes any reservation a non-2xx final response never got to settle.reservation: Option<Arc<SharedReservationHandle>>parameter threaded throughregular/streaming.rsandharmony/streaming.rs, with aReservationAttachmentlayered ontoAttachedBodyas the Drop-based safety net for disconnect/preemption before that point.crates/mock_workergained a[lib]target so its real (mock) gRPC server is spawnable in-process frommodel_gateway/tests/, enabling real end-to-end integration coverage (tests/tenant_rate_limiting_grpc_test.rs) rather than only unit tests against the pipeline stage in isolation.Retry-After: 18446744073709551615for impossible requests.Completeframe was settling with 0 input tokens instead of keeping the reservation;n>1requests were charging the shared prompt once per choice instead of once per request.n>1stream (some choices finished, others didn't) was accepted as fully authoritative instead of requiring every expected choice'sComplete; Harmony PD mode's prefill-populated map could mask a decode phase that never actually finished.cached_tokenshad the same per-choice multiplication bug asprompt_tokens.RetryExecutortoday); OpenAI/Anthropic/Gemini routers (Phase 3).Test Plan
model_gateway/tests/tenant_rate_limiting_grpc_test.rs), driven against a real (mock) gRPC backend: a finite-wait 429 withRetry-After, an impossible-request 429 withoutRetry-After, non-streaming settle using the backend's real reported usage (not just the reserve-time estimate), and the feature-disabled no-op path.RateLimitCell::dropsafety-net behavior (abandons on preemption, doesn't double-resolve after streaming handoff or denial),total_input_token_countbatching, theu64::MAXRetry-Aftersentinel, andbuild_usage's per-choiceprompt_tokens/cached_tokensmax-not-sum behavior.Checklist
cargo +nightly fmtpassescargo clippy --all-targets --all-features -- -D warningspasses