Skip to content

fix(passthrough): enforce the body model's rate limit on the raw tunnel - #805

Merged
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit
Jul 23, 2026
Merged

fix(passthrough): enforce the body model's rate limit on the raw tunnel#805
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit

Conversation

@moonming

@moonmingmoonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

/passthrough/:provider/*rest now enforces the model-level rate-limit layers (a Model's inline rate_limit plus model-scope rate-limit policies) for JSON bodies whose top-level model field names a configured Model of the addressed provider.

Tracking: api7/AISIX-Cloud#1116 (field report: a video model's configured rate limit was silently ignored on passthrough).

Why

Pre-fix, the tunnel called quota::enforce(&state, auth, None) — only the API-key / team / member layers applied. For provider endpoints with no typed surface (video generation being the reported case), the model cap configured in the dashboard was unenforceable anywhere in the product, and nothing surfaced that: the config was accepted and silently inert.

How

  • body_model_rate_limit() in passthrough.rs: best-effort parse of the buffered request body (already in memory for the input-guardrail scan), exact display_name lookup constrained to the addressed provider, then the existing ModelRateLimit::from_modelquota::enforce(Some(&model_rl)) path used by every typed endpoint. Same bucket keys as typed traffic, so passthrough and typed calls to the same model share one budget.
  • Fallbacks preserve pre-fix behavior exactly: non-JSON body, no model field, unregistered name, or a same-named Model of a different provider → request-level layers only, no error.
  • The tunnel still parses no usage (tokens stay 0), so only request-count dimensions (rps/rpm/rph) draw from the model buckets — token-per-minute caps remain inert on passthrough, consistent with raw tunnels generally (documented in the tracking issue; docs follow-up noted there).

Ecosystem comparison (per repo rule 7)

  • The provider-native envelope carries the target model in a top-level model field — the shape shared by OpenAI-compatible bodies and e.g. Alibaba Model Studio's synthesis APIs (https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference).
  • Established gateways that run a pre-call policy hook on their passthrough routes resolve the target model the same way — from the request body's model field — and apply per-model request caps when the name matches a registered model; identity-level (key/team) limits apply on all routes regardless. None skip the model layer wholesale for lack of a "resolved model".
  • Raw-tunnel token counting is universally degraded across gateways (post-hoc or next-request accounting at best); this PR deliberately does not attempt it.

Scope notes

  • a2a.rs / mcp.rs also pass None, but have no model concept — intentionally untouched.
  • The issue's optional step 4 (preferring the body-named Model as the credential-lending target) is deferred; tracked in api7/AISIX-Cloud#1116. This PR changes rate limiting only — credential resolution and the security: enforce model ACLs for routing targets and passthrough providers #449 ACL semantics are untouched.
  • No CP work needed: model rate_limit and model-scope policies are existing CP surfaces; this is a DP enforcement gap only.

Audit remediation (cold audit: MERGE-WITH-CHANGES, 3 MEDIUM / 0 HIGH)

  • MEDIUM-1 fixed — body model now also matches the provider-native model_name within the addressed provider (exact display_name first); the bucket is keyed by the entry's display_name either way, so tunnel and typed traffic share one budget. min_by_key(id) keeps duplicate-model_name picks deterministic.
  • MEDIUM-2 fixed — full serde_json::Value DOM replaced by a #[derive(Deserialize)] field probe; unknown fields skip without allocation.
  • MEDIUM-3 fixed — new unit test pins a model-scope RateLimitPolicy row gating the tunnel (no inline rate_limit).
  • LOW-1 fixed (cross-provider same-name test), LOW-3(c) fixed (e2e asserts the 429 produced no upstream round-trip). LOW-2 (typed+tunnel shared-bucket cross-call test) not added: the sharing is now pinned structurally — both surfaces key by display_name through the same ModelRateLimit::from_model — and the alias test covers the bucket-key path.

Accepted residuals (by design, per the fallback contract): (a) a caller that omits or misnames the body model field draws only the key/team layers — model caps on the raw tunnel bind well-behaved clients; the polling leg of async APIs is intentionally exempt. (b) A key not ACL'd for model X can still drain X's shared bucket through the tunnel, because credential borrowing picks the first accessible model and the body model is not ACL-checked — pre-existing behavior, tracked with the deferred credential-resolution step in api7/AISIX-Cloud#1116.

Tests

  • Unit (crates/aisix-proxy/src/lib.rs):
    • passthrough_enforces_model_rate_limit_from_body_model_field — rpm=1 model, second tunnel call 429 rate_limit_exceeded.
    • passthrough_unregistered_or_absent_body_model_keeps_key_layer_only — unregistered name and non-JSON body pass; key-level cap still gates (pre-fix behavior pinned).
  • E2E (tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts, source-blind, real gateway binary + etcd + mock upstream):
    • registered body model: 2nd call 429 with Retry-After and standard envelope;
    • unregistered body model: repeated calls keep flowing.
  • Full aisix-proxy suite: 660 passed. Existing passthrough e2e family: green (one unrelated /v1/responses least-busy case flaked with ECONNRESET in a parallel run and passes alone; it does not traverse the changed code).

Pre-fix the passthrough handler passed None to quota::enforce, so a
Model's inline rate_limit and model-scope policies never applied to
passthrough traffic. For provider endpoints with no typed surface
(e.g. video generation) the configured model cap was unenforceable
anywhere in the product while the dashboard accepted it silently.
Post-fix the top-level `model` field of a JSON passthrough body is
matched (exact display_name, same provider) against the configured
Models and its limits are reserved through the same quota::enforce
layers as the typed endpoints. Non-JSON bodies, bodies without a
model field, and unregistered names keep the previous behavior:
request-level layers only. The tunnel still parses no usage, so only
request-count dimensions (rps/rpm/rph) draw from the model buckets.
api7/AISIX-Cloud#1116
@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in:17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f74310-d62e-4204-af3d-13c1de66eaef

📥 Commits

Reviewing files that changed from the base of the PR and between dc7a435 and f8daa04.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/passthrough.rs
  • tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-model-rate-limit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The provider's async journey is submit -> poll -> fetch. Polling is a
bodyless GET with no model field, so it must keep flowing after the
submit cap is hit — a client that submitted right before the cap must
still be able to poll its task to completion.
…t full DOM
Audit follow-ups on the body-model rate-limit fix:
- The tunnel forwards bodies verbatim, so callers typically name the
provider-native id (model_name), not the gateway alias. Match
display_name first, then model_name within the addressed provider
(min_by_key(id) keeps duplicate-model_name picks deterministic);
key the bucket by the entry's display_name either way so tunnel and
typed traffic share one budget.
- Replace the full serde_json::Value parse with a #[derive(Deserialize)]
field probe — unknown fields skip without allocating, so a large
body no longer costs a DOM copy of itself.
- New tests: model-scope policy row gates the tunnel; provider-native
model_name resolves the alias's cap; a same-named model of another
provider is never charged; e2e asserts the 429 produced no upstream
round-trip.
@moonming

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming
moonming merged commit 47e6252 into mainJul 23, 2026
12 checks passed
@moonming
moonming deleted the fix/passthrough-model-rate-limit branch July 23, 2026 04:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(passthrough): enforce the body model's rate limit on the raw tunnel by moonming · Pull Request #805 · api7/aisix · GitHub
Skip to content

fix(passthrough): enforce the body model's rate limit on the raw tunnel - #805

Merged
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit
Jul 23, 2026
Merged

fix(passthrough): enforce the body model's rate limit on the raw tunnel#805
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit

Conversation

@moonming

@moonmingmoonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

/passthrough/:provider/*rest now enforces the model-level rate-limit layers (a Model's inline rate_limit plus model-scope rate-limit policies) for JSON bodies whose top-level model field names a configured Model of the addressed provider.

Tracking: api7/AISIX-Cloud#1116 (field report: a video model's configured rate limit was silently ignored on passthrough).

Why

Pre-fix, the tunnel called quota::enforce(&state, auth, None) — only the API-key / team / member layers applied. For provider endpoints with no typed surface (video generation being the reported case), the model cap configured in the dashboard was unenforceable anywhere in the product, and nothing surfaced that: the config was accepted and silently inert.

How

  • body_model_rate_limit() in passthrough.rs: best-effort parse of the buffered request body (already in memory for the input-guardrail scan), exact display_name lookup constrained to the addressed provider, then the existing ModelRateLimit::from_modelquota::enforce(Some(&model_rl)) path used by every typed endpoint. Same bucket keys as typed traffic, so passthrough and typed calls to the same model share one budget.
  • Fallbacks preserve pre-fix behavior exactly: non-JSON body, no model field, unregistered name, or a same-named Model of a different provider → request-level layers only, no error.
  • The tunnel still parses no usage (tokens stay 0), so only request-count dimensions (rps/rpm/rph) draw from the model buckets — token-per-minute caps remain inert on passthrough, consistent with raw tunnels generally (documented in the tracking issue; docs follow-up noted there).

Ecosystem comparison (per repo rule 7)

  • The provider-native envelope carries the target model in a top-level model field — the shape shared by OpenAI-compatible bodies and e.g. Alibaba Model Studio's synthesis APIs (https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference).
  • Established gateways that run a pre-call policy hook on their passthrough routes resolve the target model the same way — from the request body's model field — and apply per-model request caps when the name matches a registered model; identity-level (key/team) limits apply on all routes regardless. None skip the model layer wholesale for lack of a "resolved model".
  • Raw-tunnel token counting is universally degraded across gateways (post-hoc or next-request accounting at best); this PR deliberately does not attempt it.

Scope notes

  • a2a.rs / mcp.rs also pass None, but have no model concept — intentionally untouched.
  • The issue's optional step 4 (preferring the body-named Model as the credential-lending target) is deferred; tracked in api7/AISIX-Cloud#1116. This PR changes rate limiting only — credential resolution and the security: enforce model ACLs for routing targets and passthrough providers #449 ACL semantics are untouched.
  • No CP work needed: model rate_limit and model-scope policies are existing CP surfaces; this is a DP enforcement gap only.

Audit remediation (cold audit: MERGE-WITH-CHANGES, 3 MEDIUM / 0 HIGH)

  • MEDIUM-1 fixed — body model now also matches the provider-native model_name within the addressed provider (exact display_name first); the bucket is keyed by the entry's display_name either way, so tunnel and typed traffic share one budget. min_by_key(id) keeps duplicate-model_name picks deterministic.
  • MEDIUM-2 fixed — full serde_json::Value DOM replaced by a #[derive(Deserialize)] field probe; unknown fields skip without allocation.
  • MEDIUM-3 fixed — new unit test pins a model-scope RateLimitPolicy row gating the tunnel (no inline rate_limit).
  • LOW-1 fixed (cross-provider same-name test), LOW-3(c) fixed (e2e asserts the 429 produced no upstream round-trip). LOW-2 (typed+tunnel shared-bucket cross-call test) not added: the sharing is now pinned structurally — both surfaces key by display_name through the same ModelRateLimit::from_model — and the alias test covers the bucket-key path.

Accepted residuals (by design, per the fallback contract): (a) a caller that omits or misnames the body model field draws only the key/team layers — model caps on the raw tunnel bind well-behaved clients; the polling leg of async APIs is intentionally exempt. (b) A key not ACL'd for model X can still drain X's shared bucket through the tunnel, because credential borrowing picks the first accessible model and the body model is not ACL-checked — pre-existing behavior, tracked with the deferred credential-resolution step in api7/AISIX-Cloud#1116.

Tests

  • Unit (crates/aisix-proxy/src/lib.rs):
    • passthrough_enforces_model_rate_limit_from_body_model_field — rpm=1 model, second tunnel call 429 rate_limit_exceeded.
    • passthrough_unregistered_or_absent_body_model_keeps_key_layer_only — unregistered name and non-JSON body pass; key-level cap still gates (pre-fix behavior pinned).
  • E2E (tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts, source-blind, real gateway binary + etcd + mock upstream):
    • registered body model: 2nd call 429 with Retry-After and standard envelope;
    • unregistered body model: repeated calls keep flowing.
  • Full aisix-proxy suite: 660 passed. Existing passthrough e2e family: green (one unrelated /v1/responses least-busy case flaked with ECONNRESET in a parallel run and passes alone; it does not traverse the changed code).

Pre-fix the passthrough handler passed None to quota::enforce, so a
Model's inline rate_limit and model-scope policies never applied to
passthrough traffic. For provider endpoints with no typed surface
(e.g. video generation) the configured model cap was unenforceable
anywhere in the product while the dashboard accepted it silently.
Post-fix the top-level `model` field of a JSON passthrough body is
matched (exact display_name, same provider) against the configured
Models and its limits are reserved through the same quota::enforce
layers as the typed endpoints. Non-JSON bodies, bodies without a
model field, and unregistered names keep the previous behavior:
request-level layers only. The tunnel still parses no usage, so only
request-count dimensions (rps/rpm/rph) draw from the model buckets.
api7/AISIX-Cloud#1116
@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in:17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f74310-d62e-4204-af3d-13c1de66eaef

📥 Commits

Reviewing files that changed from the base of the PR and between dc7a435 and f8daa04.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/passthrough.rs
  • tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-model-rate-limit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The provider's async journey is submit -> poll -> fetch. Polling is a
bodyless GET with no model field, so it must keep flowing after the
submit cap is hit — a client that submitted right before the cap must
still be able to poll its task to completion.
…t full DOM
Audit follow-ups on the body-model rate-limit fix:
- The tunnel forwards bodies verbatim, so callers typically name the
provider-native id (model_name), not the gateway alias. Match
display_name first, then model_name within the addressed provider
(min_by_key(id) keeps duplicate-model_name picks deterministic);
key the bucket by the entry's display_name either way so tunnel and
typed traffic share one budget.
- Replace the full serde_json::Value parse with a #[derive(Deserialize)]
field probe — unknown fields skip without allocating, so a large
body no longer costs a DOM copy of itself.
- New tests: model-scope policy row gates the tunnel; provider-native
model_name resolves the alias's cap; a same-named model of another
provider is never charged; e2e asserts the 429 produced no upstream
round-trip.
@moonming

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming
moonming merged commit 47e6252 into mainJul 23, 2026
12 checks passed
@moonming
moonming deleted the fix/passthrough-model-rate-limit branch July 23, 2026 04:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(passthrough): enforce the body model's rate limit on the raw tunnel by moonming · Pull Request #805 · api7/aisix · GitHub
Skip to content

fix(passthrough): enforce the body model's rate limit on the raw tunnel - #805

Merged
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit
Jul 23, 2026
Merged

fix(passthrough): enforce the body model's rate limit on the raw tunnel#805
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit

Conversation

@moonming

@moonmingmoonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

/passthrough/:provider/*rest now enforces the model-level rate-limit layers (a Model's inline rate_limit plus model-scope rate-limit policies) for JSON bodies whose top-level model field names a configured Model of the addressed provider.

Tracking: api7/AISIX-Cloud#1116 (field report: a video model's configured rate limit was silently ignored on passthrough).

Why

Pre-fix, the tunnel called quota::enforce(&state, auth, None) — only the API-key / team / member layers applied. For provider endpoints with no typed surface (video generation being the reported case), the model cap configured in the dashboard was unenforceable anywhere in the product, and nothing surfaced that: the config was accepted and silently inert.

How

  • body_model_rate_limit() in passthrough.rs: best-effort parse of the buffered request body (already in memory for the input-guardrail scan), exact display_name lookup constrained to the addressed provider, then the existing ModelRateLimit::from_modelquota::enforce(Some(&model_rl)) path used by every typed endpoint. Same bucket keys as typed traffic, so passthrough and typed calls to the same model share one budget.
  • Fallbacks preserve pre-fix behavior exactly: non-JSON body, no model field, unregistered name, or a same-named Model of a different provider → request-level layers only, no error.
  • The tunnel still parses no usage (tokens stay 0), so only request-count dimensions (rps/rpm/rph) draw from the model buckets — token-per-minute caps remain inert on passthrough, consistent with raw tunnels generally (documented in the tracking issue; docs follow-up noted there).

Ecosystem comparison (per repo rule 7)

  • The provider-native envelope carries the target model in a top-level model field — the shape shared by OpenAI-compatible bodies and e.g. Alibaba Model Studio's synthesis APIs (https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference).
  • Established gateways that run a pre-call policy hook on their passthrough routes resolve the target model the same way — from the request body's model field — and apply per-model request caps when the name matches a registered model; identity-level (key/team) limits apply on all routes regardless. None skip the model layer wholesale for lack of a "resolved model".
  • Raw-tunnel token counting is universally degraded across gateways (post-hoc or next-request accounting at best); this PR deliberately does not attempt it.

Scope notes

  • a2a.rs / mcp.rs also pass None, but have no model concept — intentionally untouched.
  • The issue's optional step 4 (preferring the body-named Model as the credential-lending target) is deferred; tracked in api7/AISIX-Cloud#1116. This PR changes rate limiting only — credential resolution and the security: enforce model ACLs for routing targets and passthrough providers #449 ACL semantics are untouched.
  • No CP work needed: model rate_limit and model-scope policies are existing CP surfaces; this is a DP enforcement gap only.

Audit remediation (cold audit: MERGE-WITH-CHANGES, 3 MEDIUM / 0 HIGH)

  • MEDIUM-1 fixed — body model now also matches the provider-native model_name within the addressed provider (exact display_name first); the bucket is keyed by the entry's display_name either way, so tunnel and typed traffic share one budget. min_by_key(id) keeps duplicate-model_name picks deterministic.
  • MEDIUM-2 fixed — full serde_json::Value DOM replaced by a #[derive(Deserialize)] field probe; unknown fields skip without allocation.
  • MEDIUM-3 fixed — new unit test pins a model-scope RateLimitPolicy row gating the tunnel (no inline rate_limit).
  • LOW-1 fixed (cross-provider same-name test), LOW-3(c) fixed (e2e asserts the 429 produced no upstream round-trip). LOW-2 (typed+tunnel shared-bucket cross-call test) not added: the sharing is now pinned structurally — both surfaces key by display_name through the same ModelRateLimit::from_model — and the alias test covers the bucket-key path.

Accepted residuals (by design, per the fallback contract): (a) a caller that omits or misnames the body model field draws only the key/team layers — model caps on the raw tunnel bind well-behaved clients; the polling leg of async APIs is intentionally exempt. (b) A key not ACL'd for model X can still drain X's shared bucket through the tunnel, because credential borrowing picks the first accessible model and the body model is not ACL-checked — pre-existing behavior, tracked with the deferred credential-resolution step in api7/AISIX-Cloud#1116.

Tests

  • Unit (crates/aisix-proxy/src/lib.rs):
    • passthrough_enforces_model_rate_limit_from_body_model_field — rpm=1 model, second tunnel call 429 rate_limit_exceeded.
    • passthrough_unregistered_or_absent_body_model_keeps_key_layer_only — unregistered name and non-JSON body pass; key-level cap still gates (pre-fix behavior pinned).
  • E2E (tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts, source-blind, real gateway binary + etcd + mock upstream):
    • registered body model: 2nd call 429 with Retry-After and standard envelope;
    • unregistered body model: repeated calls keep flowing.
  • Full aisix-proxy suite: 660 passed. Existing passthrough e2e family: green (one unrelated /v1/responses least-busy case flaked with ECONNRESET in a parallel run and passes alone; it does not traverse the changed code).

Pre-fix the passthrough handler passed None to quota::enforce, so a
Model's inline rate_limit and model-scope policies never applied to
passthrough traffic. For provider endpoints with no typed surface
(e.g. video generation) the configured model cap was unenforceable
anywhere in the product while the dashboard accepted it silently.
Post-fix the top-level `model` field of a JSON passthrough body is
matched (exact display_name, same provider) against the configured
Models and its limits are reserved through the same quota::enforce
layers as the typed endpoints. Non-JSON bodies, bodies without a
model field, and unregistered names keep the previous behavior:
request-level layers only. The tunnel still parses no usage, so only
request-count dimensions (rps/rpm/rph) draw from the model buckets.
api7/AISIX-Cloud#1116
@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in:17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f74310-d62e-4204-af3d-13c1de66eaef

📥 Commits

Reviewing files that changed from the base of the PR and between dc7a435 and f8daa04.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/passthrough.rs
  • tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-model-rate-limit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The provider's async journey is submit -> poll -> fetch. Polling is a
bodyless GET with no model field, so it must keep flowing after the
submit cap is hit — a client that submitted right before the cap must
still be able to poll its task to completion.
…t full DOM
Audit follow-ups on the body-model rate-limit fix:
- The tunnel forwards bodies verbatim, so callers typically name the
provider-native id (model_name), not the gateway alias. Match
display_name first, then model_name within the addressed provider
(min_by_key(id) keeps duplicate-model_name picks deterministic);
key the bucket by the entry's display_name either way so tunnel and
typed traffic share one budget.
- Replace the full serde_json::Value parse with a #[derive(Deserialize)]
field probe — unknown fields skip without allocating, so a large
body no longer costs a DOM copy of itself.
- New tests: model-scope policy row gates the tunnel; provider-native
model_name resolves the alias's cap; a same-named model of another
provider is never charged; e2e asserts the 429 produced no upstream
round-trip.
@moonming

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming
moonming merged commit 47e6252 into mainJul 23, 2026
12 checks passed
@moonming
moonming deleted the fix/passthrough-model-rate-limit branch July 23, 2026 04:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(passthrough): enforce the body model's rate limit on the raw tunnel by moonming · Pull Request #805 · api7/aisix · GitHub
Skip to content

fix(passthrough): enforce the body model's rate limit on the raw tunnel - #805

Merged
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit
Jul 23, 2026
Merged

fix(passthrough): enforce the body model's rate limit on the raw tunnel#805
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit

Conversation

@moonming

@moonmingmoonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

/passthrough/:provider/*rest now enforces the model-level rate-limit layers (a Model's inline rate_limit plus model-scope rate-limit policies) for JSON bodies whose top-level model field names a configured Model of the addressed provider.

Tracking: api7/AISIX-Cloud#1116 (field report: a video model's configured rate limit was silently ignored on passthrough).

Why

Pre-fix, the tunnel called quota::enforce(&state, auth, None) — only the API-key / team / member layers applied. For provider endpoints with no typed surface (video generation being the reported case), the model cap configured in the dashboard was unenforceable anywhere in the product, and nothing surfaced that: the config was accepted and silently inert.

How

  • body_model_rate_limit() in passthrough.rs: best-effort parse of the buffered request body (already in memory for the input-guardrail scan), exact display_name lookup constrained to the addressed provider, then the existing ModelRateLimit::from_modelquota::enforce(Some(&model_rl)) path used by every typed endpoint. Same bucket keys as typed traffic, so passthrough and typed calls to the same model share one budget.
  • Fallbacks preserve pre-fix behavior exactly: non-JSON body, no model field, unregistered name, or a same-named Model of a different provider → request-level layers only, no error.
  • The tunnel still parses no usage (tokens stay 0), so only request-count dimensions (rps/rpm/rph) draw from the model buckets — token-per-minute caps remain inert on passthrough, consistent with raw tunnels generally (documented in the tracking issue; docs follow-up noted there).

Ecosystem comparison (per repo rule 7)

  • The provider-native envelope carries the target model in a top-level model field — the shape shared by OpenAI-compatible bodies and e.g. Alibaba Model Studio's synthesis APIs (https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference).
  • Established gateways that run a pre-call policy hook on their passthrough routes resolve the target model the same way — from the request body's model field — and apply per-model request caps when the name matches a registered model; identity-level (key/team) limits apply on all routes regardless. None skip the model layer wholesale for lack of a "resolved model".
  • Raw-tunnel token counting is universally degraded across gateways (post-hoc or next-request accounting at best); this PR deliberately does not attempt it.

Scope notes

  • a2a.rs / mcp.rs also pass None, but have no model concept — intentionally untouched.
  • The issue's optional step 4 (preferring the body-named Model as the credential-lending target) is deferred; tracked in api7/AISIX-Cloud#1116. This PR changes rate limiting only — credential resolution and the security: enforce model ACLs for routing targets and passthrough providers #449 ACL semantics are untouched.
  • No CP work needed: model rate_limit and model-scope policies are existing CP surfaces; this is a DP enforcement gap only.

Audit remediation (cold audit: MERGE-WITH-CHANGES, 3 MEDIUM / 0 HIGH)

  • MEDIUM-1 fixed — body model now also matches the provider-native model_name within the addressed provider (exact display_name first); the bucket is keyed by the entry's display_name either way, so tunnel and typed traffic share one budget. min_by_key(id) keeps duplicate-model_name picks deterministic.
  • MEDIUM-2 fixed — full serde_json::Value DOM replaced by a #[derive(Deserialize)] field probe; unknown fields skip without allocation.
  • MEDIUM-3 fixed — new unit test pins a model-scope RateLimitPolicy row gating the tunnel (no inline rate_limit).
  • LOW-1 fixed (cross-provider same-name test), LOW-3(c) fixed (e2e asserts the 429 produced no upstream round-trip). LOW-2 (typed+tunnel shared-bucket cross-call test) not added: the sharing is now pinned structurally — both surfaces key by display_name through the same ModelRateLimit::from_model — and the alias test covers the bucket-key path.

Accepted residuals (by design, per the fallback contract): (a) a caller that omits or misnames the body model field draws only the key/team layers — model caps on the raw tunnel bind well-behaved clients; the polling leg of async APIs is intentionally exempt. (b) A key not ACL'd for model X can still drain X's shared bucket through the tunnel, because credential borrowing picks the first accessible model and the body model is not ACL-checked — pre-existing behavior, tracked with the deferred credential-resolution step in api7/AISIX-Cloud#1116.

Tests

  • Unit (crates/aisix-proxy/src/lib.rs):
    • passthrough_enforces_model_rate_limit_from_body_model_field — rpm=1 model, second tunnel call 429 rate_limit_exceeded.
    • passthrough_unregistered_or_absent_body_model_keeps_key_layer_only — unregistered name and non-JSON body pass; key-level cap still gates (pre-fix behavior pinned).
  • E2E (tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts, source-blind, real gateway binary + etcd + mock upstream):
    • registered body model: 2nd call 429 with Retry-After and standard envelope;
    • unregistered body model: repeated calls keep flowing.
  • Full aisix-proxy suite: 660 passed. Existing passthrough e2e family: green (one unrelated /v1/responses least-busy case flaked with ECONNRESET in a parallel run and passes alone; it does not traverse the changed code).

Pre-fix the passthrough handler passed None to quota::enforce, so a
Model's inline rate_limit and model-scope policies never applied to
passthrough traffic. For provider endpoints with no typed surface
(e.g. video generation) the configured model cap was unenforceable
anywhere in the product while the dashboard accepted it silently.
Post-fix the top-level `model` field of a JSON passthrough body is
matched (exact display_name, same provider) against the configured
Models and its limits are reserved through the same quota::enforce
layers as the typed endpoints. Non-JSON bodies, bodies without a
model field, and unregistered names keep the previous behavior:
request-level layers only. The tunnel still parses no usage, so only
request-count dimensions (rps/rpm/rph) draw from the model buckets.
api7/AISIX-Cloud#1116
@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in:17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f74310-d62e-4204-af3d-13c1de66eaef

📥 Commits

Reviewing files that changed from the base of the PR and between dc7a435 and f8daa04.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/passthrough.rs
  • tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-model-rate-limit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The provider's async journey is submit -> poll -> fetch. Polling is a
bodyless GET with no model field, so it must keep flowing after the
submit cap is hit — a client that submitted right before the cap must
still be able to poll its task to completion.
…t full DOM
Audit follow-ups on the body-model rate-limit fix:
- The tunnel forwards bodies verbatim, so callers typically name the
provider-native id (model_name), not the gateway alias. Match
display_name first, then model_name within the addressed provider
(min_by_key(id) keeps duplicate-model_name picks deterministic);
key the bucket by the entry's display_name either way so tunnel and
typed traffic share one budget.
- Replace the full serde_json::Value parse with a #[derive(Deserialize)]
field probe — unknown fields skip without allocating, so a large
body no longer costs a DOM copy of itself.
- New tests: model-scope policy row gates the tunnel; provider-native
model_name resolves the alias's cap; a same-named model of another
provider is never charged; e2e asserts the 429 produced no upstream
round-trip.
@moonming

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming
moonming merged commit 47e6252 into mainJul 23, 2026
12 checks passed
@moonming
moonming deleted the fix/passthrough-model-rate-limit branch July 23, 2026 04:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(passthrough): enforce the body model's rate limit on the raw tunnel by moonming · Pull Request #805 · api7/aisix · GitHub
Skip to content

fix(passthrough): enforce the body model's rate limit on the raw tunnel - #805

Merged
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit
Jul 23, 2026
Merged

fix(passthrough): enforce the body model's rate limit on the raw tunnel#805
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit

Conversation

@moonming

@moonmingmoonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

/passthrough/:provider/*rest now enforces the model-level rate-limit layers (a Model's inline rate_limit plus model-scope rate-limit policies) for JSON bodies whose top-level model field names a configured Model of the addressed provider.

Tracking: api7/AISIX-Cloud#1116 (field report: a video model's configured rate limit was silently ignored on passthrough).

Why

Pre-fix, the tunnel called quota::enforce(&state, auth, None) — only the API-key / team / member layers applied. For provider endpoints with no typed surface (video generation being the reported case), the model cap configured in the dashboard was unenforceable anywhere in the product, and nothing surfaced that: the config was accepted and silently inert.

How

  • body_model_rate_limit() in passthrough.rs: best-effort parse of the buffered request body (already in memory for the input-guardrail scan), exact display_name lookup constrained to the addressed provider, then the existing ModelRateLimit::from_modelquota::enforce(Some(&model_rl)) path used by every typed endpoint. Same bucket keys as typed traffic, so passthrough and typed calls to the same model share one budget.
  • Fallbacks preserve pre-fix behavior exactly: non-JSON body, no model field, unregistered name, or a same-named Model of a different provider → request-level layers only, no error.
  • The tunnel still parses no usage (tokens stay 0), so only request-count dimensions (rps/rpm/rph) draw from the model buckets — token-per-minute caps remain inert on passthrough, consistent with raw tunnels generally (documented in the tracking issue; docs follow-up noted there).

Ecosystem comparison (per repo rule 7)

  • The provider-native envelope carries the target model in a top-level model field — the shape shared by OpenAI-compatible bodies and e.g. Alibaba Model Studio's synthesis APIs (https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference).
  • Established gateways that run a pre-call policy hook on their passthrough routes resolve the target model the same way — from the request body's model field — and apply per-model request caps when the name matches a registered model; identity-level (key/team) limits apply on all routes regardless. None skip the model layer wholesale for lack of a "resolved model".
  • Raw-tunnel token counting is universally degraded across gateways (post-hoc or next-request accounting at best); this PR deliberately does not attempt it.

Scope notes

  • a2a.rs / mcp.rs also pass None, but have no model concept — intentionally untouched.
  • The issue's optional step 4 (preferring the body-named Model as the credential-lending target) is deferred; tracked in api7/AISIX-Cloud#1116. This PR changes rate limiting only — credential resolution and the security: enforce model ACLs for routing targets and passthrough providers #449 ACL semantics are untouched.
  • No CP work needed: model rate_limit and model-scope policies are existing CP surfaces; this is a DP enforcement gap only.

Audit remediation (cold audit: MERGE-WITH-CHANGES, 3 MEDIUM / 0 HIGH)

  • MEDIUM-1 fixed — body model now also matches the provider-native model_name within the addressed provider (exact display_name first); the bucket is keyed by the entry's display_name either way, so tunnel and typed traffic share one budget. min_by_key(id) keeps duplicate-model_name picks deterministic.
  • MEDIUM-2 fixed — full serde_json::Value DOM replaced by a #[derive(Deserialize)] field probe; unknown fields skip without allocation.
  • MEDIUM-3 fixed — new unit test pins a model-scope RateLimitPolicy row gating the tunnel (no inline rate_limit).
  • LOW-1 fixed (cross-provider same-name test), LOW-3(c) fixed (e2e asserts the 429 produced no upstream round-trip). LOW-2 (typed+tunnel shared-bucket cross-call test) not added: the sharing is now pinned structurally — both surfaces key by display_name through the same ModelRateLimit::from_model — and the alias test covers the bucket-key path.

Accepted residuals (by design, per the fallback contract): (a) a caller that omits or misnames the body model field draws only the key/team layers — model caps on the raw tunnel bind well-behaved clients; the polling leg of async APIs is intentionally exempt. (b) A key not ACL'd for model X can still drain X's shared bucket through the tunnel, because credential borrowing picks the first accessible model and the body model is not ACL-checked — pre-existing behavior, tracked with the deferred credential-resolution step in api7/AISIX-Cloud#1116.

Tests

  • Unit (crates/aisix-proxy/src/lib.rs):
    • passthrough_enforces_model_rate_limit_from_body_model_field — rpm=1 model, second tunnel call 429 rate_limit_exceeded.
    • passthrough_unregistered_or_absent_body_model_keeps_key_layer_only — unregistered name and non-JSON body pass; key-level cap still gates (pre-fix behavior pinned).
  • E2E (tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts, source-blind, real gateway binary + etcd + mock upstream):
    • registered body model: 2nd call 429 with Retry-After and standard envelope;
    • unregistered body model: repeated calls keep flowing.
  • Full aisix-proxy suite: 660 passed. Existing passthrough e2e family: green (one unrelated /v1/responses least-busy case flaked with ECONNRESET in a parallel run and passes alone; it does not traverse the changed code).

Pre-fix the passthrough handler passed None to quota::enforce, so a
Model's inline rate_limit and model-scope policies never applied to
passthrough traffic. For provider endpoints with no typed surface
(e.g. video generation) the configured model cap was unenforceable
anywhere in the product while the dashboard accepted it silently.
Post-fix the top-level `model` field of a JSON passthrough body is
matched (exact display_name, same provider) against the configured
Models and its limits are reserved through the same quota::enforce
layers as the typed endpoints. Non-JSON bodies, bodies without a
model field, and unregistered names keep the previous behavior:
request-level layers only. The tunnel still parses no usage, so only
request-count dimensions (rps/rpm/rph) draw from the model buckets.
api7/AISIX-Cloud#1116
@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in:17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f74310-d62e-4204-af3d-13c1de66eaef

📥 Commits

Reviewing files that changed from the base of the PR and between dc7a435 and f8daa04.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/passthrough.rs
  • tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-model-rate-limit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The provider's async journey is submit -> poll -> fetch. Polling is a
bodyless GET with no model field, so it must keep flowing after the
submit cap is hit — a client that submitted right before the cap must
still be able to poll its task to completion.
…t full DOM
Audit follow-ups on the body-model rate-limit fix:
- The tunnel forwards bodies verbatim, so callers typically name the
provider-native id (model_name), not the gateway alias. Match
display_name first, then model_name within the addressed provider
(min_by_key(id) keeps duplicate-model_name picks deterministic);
key the bucket by the entry's display_name either way so tunnel and
typed traffic share one budget.
- Replace the full serde_json::Value parse with a #[derive(Deserialize)]
field probe — unknown fields skip without allocating, so a large
body no longer costs a DOM copy of itself.
- New tests: model-scope policy row gates the tunnel; provider-native
model_name resolves the alias's cap; a same-named model of another
provider is never charged; e2e asserts the 429 produced no upstream
round-trip.
@moonming

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming
moonming merged commit 47e6252 into mainJul 23, 2026
12 checks passed
@moonming
moonming deleted the fix/passthrough-model-rate-limit branch July 23, 2026 04:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(passthrough): enforce the body model's rate limit on the raw tunnel by moonming · Pull Request #805 · api7/aisix · GitHub
Skip to content

fix(passthrough): enforce the body model's rate limit on the raw tunnel - #805

Merged
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit
Jul 23, 2026
Merged

fix(passthrough): enforce the body model's rate limit on the raw tunnel#805
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit

Conversation

@moonming

@moonmingmoonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

/passthrough/:provider/*rest now enforces the model-level rate-limit layers (a Model's inline rate_limit plus model-scope rate-limit policies) for JSON bodies whose top-level model field names a configured Model of the addressed provider.

Tracking: api7/AISIX-Cloud#1116 (field report: a video model's configured rate limit was silently ignored on passthrough).

Why

Pre-fix, the tunnel called quota::enforce(&state, auth, None) — only the API-key / team / member layers applied. For provider endpoints with no typed surface (video generation being the reported case), the model cap configured in the dashboard was unenforceable anywhere in the product, and nothing surfaced that: the config was accepted and silently inert.

How

  • body_model_rate_limit() in passthrough.rs: best-effort parse of the buffered request body (already in memory for the input-guardrail scan), exact display_name lookup constrained to the addressed provider, then the existing ModelRateLimit::from_modelquota::enforce(Some(&model_rl)) path used by every typed endpoint. Same bucket keys as typed traffic, so passthrough and typed calls to the same model share one budget.
  • Fallbacks preserve pre-fix behavior exactly: non-JSON body, no model field, unregistered name, or a same-named Model of a different provider → request-level layers only, no error.
  • The tunnel still parses no usage (tokens stay 0), so only request-count dimensions (rps/rpm/rph) draw from the model buckets — token-per-minute caps remain inert on passthrough, consistent with raw tunnels generally (documented in the tracking issue; docs follow-up noted there).

Ecosystem comparison (per repo rule 7)

  • The provider-native envelope carries the target model in a top-level model field — the shape shared by OpenAI-compatible bodies and e.g. Alibaba Model Studio's synthesis APIs (https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference).
  • Established gateways that run a pre-call policy hook on their passthrough routes resolve the target model the same way — from the request body's model field — and apply per-model request caps when the name matches a registered model; identity-level (key/team) limits apply on all routes regardless. None skip the model layer wholesale for lack of a "resolved model".
  • Raw-tunnel token counting is universally degraded across gateways (post-hoc or next-request accounting at best); this PR deliberately does not attempt it.

Scope notes

  • a2a.rs / mcp.rs also pass None, but have no model concept — intentionally untouched.
  • The issue's optional step 4 (preferring the body-named Model as the credential-lending target) is deferred; tracked in api7/AISIX-Cloud#1116. This PR changes rate limiting only — credential resolution and the security: enforce model ACLs for routing targets and passthrough providers #449 ACL semantics are untouched.
  • No CP work needed: model rate_limit and model-scope policies are existing CP surfaces; this is a DP enforcement gap only.

Audit remediation (cold audit: MERGE-WITH-CHANGES, 3 MEDIUM / 0 HIGH)

  • MEDIUM-1 fixed — body model now also matches the provider-native model_name within the addressed provider (exact display_name first); the bucket is keyed by the entry's display_name either way, so tunnel and typed traffic share one budget. min_by_key(id) keeps duplicate-model_name picks deterministic.
  • MEDIUM-2 fixed — full serde_json::Value DOM replaced by a #[derive(Deserialize)] field probe; unknown fields skip without allocation.
  • MEDIUM-3 fixed — new unit test pins a model-scope RateLimitPolicy row gating the tunnel (no inline rate_limit).
  • LOW-1 fixed (cross-provider same-name test), LOW-3(c) fixed (e2e asserts the 429 produced no upstream round-trip). LOW-2 (typed+tunnel shared-bucket cross-call test) not added: the sharing is now pinned structurally — both surfaces key by display_name through the same ModelRateLimit::from_model — and the alias test covers the bucket-key path.

Accepted residuals (by design, per the fallback contract): (a) a caller that omits or misnames the body model field draws only the key/team layers — model caps on the raw tunnel bind well-behaved clients; the polling leg of async APIs is intentionally exempt. (b) A key not ACL'd for model X can still drain X's shared bucket through the tunnel, because credential borrowing picks the first accessible model and the body model is not ACL-checked — pre-existing behavior, tracked with the deferred credential-resolution step in api7/AISIX-Cloud#1116.

Tests

  • Unit (crates/aisix-proxy/src/lib.rs):
    • passthrough_enforces_model_rate_limit_from_body_model_field — rpm=1 model, second tunnel call 429 rate_limit_exceeded.
    • passthrough_unregistered_or_absent_body_model_keeps_key_layer_only — unregistered name and non-JSON body pass; key-level cap still gates (pre-fix behavior pinned).
  • E2E (tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts, source-blind, real gateway binary + etcd + mock upstream):
    • registered body model: 2nd call 429 with Retry-After and standard envelope;
    • unregistered body model: repeated calls keep flowing.
  • Full aisix-proxy suite: 660 passed. Existing passthrough e2e family: green (one unrelated /v1/responses least-busy case flaked with ECONNRESET in a parallel run and passes alone; it does not traverse the changed code).

Pre-fix the passthrough handler passed None to quota::enforce, so a
Model's inline rate_limit and model-scope policies never applied to
passthrough traffic. For provider endpoints with no typed surface
(e.g. video generation) the configured model cap was unenforceable
anywhere in the product while the dashboard accepted it silently.
Post-fix the top-level `model` field of a JSON passthrough body is
matched (exact display_name, same provider) against the configured
Models and its limits are reserved through the same quota::enforce
layers as the typed endpoints. Non-JSON bodies, bodies without a
model field, and unregistered names keep the previous behavior:
request-level layers only. The tunnel still parses no usage, so only
request-count dimensions (rps/rpm/rph) draw from the model buckets.
api7/AISIX-Cloud#1116
@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in:17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f74310-d62e-4204-af3d-13c1de66eaef

📥 Commits

Reviewing files that changed from the base of the PR and between dc7a435 and f8daa04.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/passthrough.rs
  • tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-model-rate-limit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The provider's async journey is submit -> poll -> fetch. Polling is a
bodyless GET with no model field, so it must keep flowing after the
submit cap is hit — a client that submitted right before the cap must
still be able to poll its task to completion.
…t full DOM
Audit follow-ups on the body-model rate-limit fix:
- The tunnel forwards bodies verbatim, so callers typically name the
provider-native id (model_name), not the gateway alias. Match
display_name first, then model_name within the addressed provider
(min_by_key(id) keeps duplicate-model_name picks deterministic);
key the bucket by the entry's display_name either way so tunnel and
typed traffic share one budget.
- Replace the full serde_json::Value parse with a #[derive(Deserialize)]
field probe — unknown fields skip without allocating, so a large
body no longer costs a DOM copy of itself.
- New tests: model-scope policy row gates the tunnel; provider-native
model_name resolves the alias's cap; a same-named model of another
provider is never charged; e2e asserts the 429 produced no upstream
round-trip.
@moonming

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming
moonming merged commit 47e6252 into mainJul 23, 2026
12 checks passed
@moonming
moonming deleted the fix/passthrough-model-rate-limit branch July 23, 2026 04:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(passthrough): enforce the body model's rate limit on the raw tunnel by moonming · Pull Request #805 · api7/aisix · GitHub
Skip to content

fix(passthrough): enforce the body model's rate limit on the raw tunnel - #805

Merged
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit
Jul 23, 2026
Merged

fix(passthrough): enforce the body model's rate limit on the raw tunnel#805
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit

Conversation

@moonming

@moonmingmoonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

/passthrough/:provider/*rest now enforces the model-level rate-limit layers (a Model's inline rate_limit plus model-scope rate-limit policies) for JSON bodies whose top-level model field names a configured Model of the addressed provider.

Tracking: api7/AISIX-Cloud#1116 (field report: a video model's configured rate limit was silently ignored on passthrough).

Why

Pre-fix, the tunnel called quota::enforce(&state, auth, None) — only the API-key / team / member layers applied. For provider endpoints with no typed surface (video generation being the reported case), the model cap configured in the dashboard was unenforceable anywhere in the product, and nothing surfaced that: the config was accepted and silently inert.

How

  • body_model_rate_limit() in passthrough.rs: best-effort parse of the buffered request body (already in memory for the input-guardrail scan), exact display_name lookup constrained to the addressed provider, then the existing ModelRateLimit::from_modelquota::enforce(Some(&model_rl)) path used by every typed endpoint. Same bucket keys as typed traffic, so passthrough and typed calls to the same model share one budget.
  • Fallbacks preserve pre-fix behavior exactly: non-JSON body, no model field, unregistered name, or a same-named Model of a different provider → request-level layers only, no error.
  • The tunnel still parses no usage (tokens stay 0), so only request-count dimensions (rps/rpm/rph) draw from the model buckets — token-per-minute caps remain inert on passthrough, consistent with raw tunnels generally (documented in the tracking issue; docs follow-up noted there).

Ecosystem comparison (per repo rule 7)

  • The provider-native envelope carries the target model in a top-level model field — the shape shared by OpenAI-compatible bodies and e.g. Alibaba Model Studio's synthesis APIs (https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference).
  • Established gateways that run a pre-call policy hook on their passthrough routes resolve the target model the same way — from the request body's model field — and apply per-model request caps when the name matches a registered model; identity-level (key/team) limits apply on all routes regardless. None skip the model layer wholesale for lack of a "resolved model".
  • Raw-tunnel token counting is universally degraded across gateways (post-hoc or next-request accounting at best); this PR deliberately does not attempt it.

Scope notes

  • a2a.rs / mcp.rs also pass None, but have no model concept — intentionally untouched.
  • The issue's optional step 4 (preferring the body-named Model as the credential-lending target) is deferred; tracked in api7/AISIX-Cloud#1116. This PR changes rate limiting only — credential resolution and the security: enforce model ACLs for routing targets and passthrough providers #449 ACL semantics are untouched.
  • No CP work needed: model rate_limit and model-scope policies are existing CP surfaces; this is a DP enforcement gap only.

Audit remediation (cold audit: MERGE-WITH-CHANGES, 3 MEDIUM / 0 HIGH)

  • MEDIUM-1 fixed — body model now also matches the provider-native model_name within the addressed provider (exact display_name first); the bucket is keyed by the entry's display_name either way, so tunnel and typed traffic share one budget. min_by_key(id) keeps duplicate-model_name picks deterministic.
  • MEDIUM-2 fixed — full serde_json::Value DOM replaced by a #[derive(Deserialize)] field probe; unknown fields skip without allocation.
  • MEDIUM-3 fixed — new unit test pins a model-scope RateLimitPolicy row gating the tunnel (no inline rate_limit).
  • LOW-1 fixed (cross-provider same-name test), LOW-3(c) fixed (e2e asserts the 429 produced no upstream round-trip). LOW-2 (typed+tunnel shared-bucket cross-call test) not added: the sharing is now pinned structurally — both surfaces key by display_name through the same ModelRateLimit::from_model — and the alias test covers the bucket-key path.

Accepted residuals (by design, per the fallback contract): (a) a caller that omits or misnames the body model field draws only the key/team layers — model caps on the raw tunnel bind well-behaved clients; the polling leg of async APIs is intentionally exempt. (b) A key not ACL'd for model X can still drain X's shared bucket through the tunnel, because credential borrowing picks the first accessible model and the body model is not ACL-checked — pre-existing behavior, tracked with the deferred credential-resolution step in api7/AISIX-Cloud#1116.

Tests

  • Unit (crates/aisix-proxy/src/lib.rs):
    • passthrough_enforces_model_rate_limit_from_body_model_field — rpm=1 model, second tunnel call 429 rate_limit_exceeded.
    • passthrough_unregistered_or_absent_body_model_keeps_key_layer_only — unregistered name and non-JSON body pass; key-level cap still gates (pre-fix behavior pinned).
  • E2E (tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts, source-blind, real gateway binary + etcd + mock upstream):
    • registered body model: 2nd call 429 with Retry-After and standard envelope;
    • unregistered body model: repeated calls keep flowing.
  • Full aisix-proxy suite: 660 passed. Existing passthrough e2e family: green (one unrelated /v1/responses least-busy case flaked with ECONNRESET in a parallel run and passes alone; it does not traverse the changed code).

Pre-fix the passthrough handler passed None to quota::enforce, so a
Model's inline rate_limit and model-scope policies never applied to
passthrough traffic. For provider endpoints with no typed surface
(e.g. video generation) the configured model cap was unenforceable
anywhere in the product while the dashboard accepted it silently.
Post-fix the top-level `model` field of a JSON passthrough body is
matched (exact display_name, same provider) against the configured
Models and its limits are reserved through the same quota::enforce
layers as the typed endpoints. Non-JSON bodies, bodies without a
model field, and unregistered names keep the previous behavior:
request-level layers only. The tunnel still parses no usage, so only
request-count dimensions (rps/rpm/rph) draw from the model buckets.
api7/AISIX-Cloud#1116
@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in:17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f74310-d62e-4204-af3d-13c1de66eaef

📥 Commits

Reviewing files that changed from the base of the PR and between dc7a435 and f8daa04.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/passthrough.rs
  • tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-model-rate-limit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The provider's async journey is submit -> poll -> fetch. Polling is a
bodyless GET with no model field, so it must keep flowing after the
submit cap is hit — a client that submitted right before the cap must
still be able to poll its task to completion.
…t full DOM
Audit follow-ups on the body-model rate-limit fix:
- The tunnel forwards bodies verbatim, so callers typically name the
provider-native id (model_name), not the gateway alias. Match
display_name first, then model_name within the addressed provider
(min_by_key(id) keeps duplicate-model_name picks deterministic);
key the bucket by the entry's display_name either way so tunnel and
typed traffic share one budget.
- Replace the full serde_json::Value parse with a #[derive(Deserialize)]
field probe — unknown fields skip without allocating, so a large
body no longer costs a DOM copy of itself.
- New tests: model-scope policy row gates the tunnel; provider-native
model_name resolves the alias's cap; a same-named model of another
provider is never charged; e2e asserts the 429 produced no upstream
round-trip.
@moonming

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming
moonming merged commit 47e6252 into mainJul 23, 2026
12 checks passed
@moonming
moonming deleted the fix/passthrough-model-rate-limit branch July 23, 2026 04:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(passthrough): enforce the body model's rate limit on the raw tunnel by moonming · Pull Request #805 · api7/aisix · GitHub
Skip to content

fix(passthrough): enforce the body model's rate limit on the raw tunnel - #805

Merged
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit
Jul 23, 2026
Merged

fix(passthrough): enforce the body model's rate limit on the raw tunnel#805
moonming merged 3 commits into
mainfrom
fix/passthrough-model-rate-limit

Conversation

@moonming

@moonmingmoonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

/passthrough/:provider/*rest now enforces the model-level rate-limit layers (a Model's inline rate_limit plus model-scope rate-limit policies) for JSON bodies whose top-level model field names a configured Model of the addressed provider.

Tracking: api7/AISIX-Cloud#1116 (field report: a video model's configured rate limit was silently ignored on passthrough).

Why

Pre-fix, the tunnel called quota::enforce(&state, auth, None) — only the API-key / team / member layers applied. For provider endpoints with no typed surface (video generation being the reported case), the model cap configured in the dashboard was unenforceable anywhere in the product, and nothing surfaced that: the config was accepted and silently inert.

How

  • body_model_rate_limit() in passthrough.rs: best-effort parse of the buffered request body (already in memory for the input-guardrail scan), exact display_name lookup constrained to the addressed provider, then the existing ModelRateLimit::from_modelquota::enforce(Some(&model_rl)) path used by every typed endpoint. Same bucket keys as typed traffic, so passthrough and typed calls to the same model share one budget.
  • Fallbacks preserve pre-fix behavior exactly: non-JSON body, no model field, unregistered name, or a same-named Model of a different provider → request-level layers only, no error.
  • The tunnel still parses no usage (tokens stay 0), so only request-count dimensions (rps/rpm/rph) draw from the model buckets — token-per-minute caps remain inert on passthrough, consistent with raw tunnels generally (documented in the tracking issue; docs follow-up noted there).

Ecosystem comparison (per repo rule 7)

  • The provider-native envelope carries the target model in a top-level model field — the shape shared by OpenAI-compatible bodies and e.g. Alibaba Model Studio's synthesis APIs (https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference).
  • Established gateways that run a pre-call policy hook on their passthrough routes resolve the target model the same way — from the request body's model field — and apply per-model request caps when the name matches a registered model; identity-level (key/team) limits apply on all routes regardless. None skip the model layer wholesale for lack of a "resolved model".
  • Raw-tunnel token counting is universally degraded across gateways (post-hoc or next-request accounting at best); this PR deliberately does not attempt it.

Scope notes

  • a2a.rs / mcp.rs also pass None, but have no model concept — intentionally untouched.
  • The issue's optional step 4 (preferring the body-named Model as the credential-lending target) is deferred; tracked in api7/AISIX-Cloud#1116. This PR changes rate limiting only — credential resolution and the security: enforce model ACLs for routing targets and passthrough providers #449 ACL semantics are untouched.
  • No CP work needed: model rate_limit and model-scope policies are existing CP surfaces; this is a DP enforcement gap only.

Audit remediation (cold audit: MERGE-WITH-CHANGES, 3 MEDIUM / 0 HIGH)

  • MEDIUM-1 fixed — body model now also matches the provider-native model_name within the addressed provider (exact display_name first); the bucket is keyed by the entry's display_name either way, so tunnel and typed traffic share one budget. min_by_key(id) keeps duplicate-model_name picks deterministic.
  • MEDIUM-2 fixed — full serde_json::Value DOM replaced by a #[derive(Deserialize)] field probe; unknown fields skip without allocation.
  • MEDIUM-3 fixed — new unit test pins a model-scope RateLimitPolicy row gating the tunnel (no inline rate_limit).
  • LOW-1 fixed (cross-provider same-name test), LOW-3(c) fixed (e2e asserts the 429 produced no upstream round-trip). LOW-2 (typed+tunnel shared-bucket cross-call test) not added: the sharing is now pinned structurally — both surfaces key by display_name through the same ModelRateLimit::from_model — and the alias test covers the bucket-key path.

Accepted residuals (by design, per the fallback contract): (a) a caller that omits or misnames the body model field draws only the key/team layers — model caps on the raw tunnel bind well-behaved clients; the polling leg of async APIs is intentionally exempt. (b) A key not ACL'd for model X can still drain X's shared bucket through the tunnel, because credential borrowing picks the first accessible model and the body model is not ACL-checked — pre-existing behavior, tracked with the deferred credential-resolution step in api7/AISIX-Cloud#1116.

Tests

  • Unit (crates/aisix-proxy/src/lib.rs):
    • passthrough_enforces_model_rate_limit_from_body_model_field — rpm=1 model, second tunnel call 429 rate_limit_exceeded.
    • passthrough_unregistered_or_absent_body_model_keeps_key_layer_only — unregistered name and non-JSON body pass; key-level cap still gates (pre-fix behavior pinned).
  • E2E (tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts, source-blind, real gateway binary + etcd + mock upstream):
    • registered body model: 2nd call 429 with Retry-After and standard envelope;
    • unregistered body model: repeated calls keep flowing.
  • Full aisix-proxy suite: 660 passed. Existing passthrough e2e family: green (one unrelated /v1/responses least-busy case flaked with ECONNRESET in a parallel run and passes alone; it does not traverse the changed code).

Pre-fix the passthrough handler passed None to quota::enforce, so a
Model's inline rate_limit and model-scope policies never applied to
passthrough traffic. For provider endpoints with no typed surface
(e.g. video generation) the configured model cap was unenforceable
anywhere in the product while the dashboard accepted it silently.
Post-fix the top-level `model` field of a JSON passthrough body is
matched (exact display_name, same provider) against the configured
Models and its limits are reserved through the same quota::enforce
layers as the typed endpoints. Non-JSON bodies, bodies without a
model field, and unregistered names keep the previous behavior:
request-level layers only. The tunnel still parses no usage, so only
request-count dimensions (rps/rpm/rph) draw from the model buckets.
api7/AISIX-Cloud#1116
@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in:17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f74310-d62e-4204-af3d-13c1de66eaef

📥 Commits

Reviewing files that changed from the base of the PR and between dc7a435 and f8daa04.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/passthrough.rs
  • tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-model-rate-limit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The provider's async journey is submit -> poll -> fetch. Polling is a
bodyless GET with no model field, so it must keep flowing after the
submit cap is hit — a client that submitted right before the cap must
still be able to poll its task to completion.
…t full DOM
Audit follow-ups on the body-model rate-limit fix:
- The tunnel forwards bodies verbatim, so callers typically name the
provider-native id (model_name), not the gateway alias. Match
display_name first, then model_name within the addressed provider
(min_by_key(id) keeps duplicate-model_name picks deterministic);
key the bucket by the entry's display_name either way so tunnel and
typed traffic share one budget.
- Replace the full serde_json::Value parse with a #[derive(Deserialize)]
field probe — unknown fields skip without allocating, so a large
body no longer costs a DOM copy of itself.
- New tests: model-scope policy row gates the tunnel; provider-native
model_name resolves the alias's cap; a same-named model of another
provider is never charged; e2e asserts the 429 produced no upstream
round-trip.
@moonming

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming
moonming merged commit 47e6252 into mainJul 23, 2026
12 checks passed
@moonming
moonming deleted the fix/passthrough-model-rate-limit branch July 23, 2026 04:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@moonming