feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover) - #305

Merged
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire
May 17, 2026
Merged

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover)#305
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire

Conversation

@moonming

@moonmingmoonming commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Phase D cutover for aisix-proxy dispatch. Adds crate::dispatch::resolve_bridge — a small helper that tries the two-tier dispatch path (specialized vendor → adapter family from PR #300) first, then falls back to the legacy Provider-keyed registry when neither tier matches.

The fallback exists because today's on-disk ProviderKey payloads carry provider: "" + adapter: None (the new fields from PR #298/#303 ship empty until cp-api's B3 sub-PR populates them). The two-tier path therefore returns None on every existing key and the legacy registry continues serving traffic unchanged. Zero behavior change today.

After B3 ships and cp-api re-projects every ProviderKey with the new provider + adapter fields filled, the two-tier path will start returning bridges and the legacy fallback becomes the residual safety net.

Changes

  • dispatch.rs: add pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>
  • chat.rs:817: route the single dispatch site through resolve_bridge instead of state.hub.get(provider)
  • chat.rs:500: validation check stays legacy-only (pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today)

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

Test plan

  • cargo test -p aisix-proxy --lib — 218 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Live integration with new B3 payload (separate PR — requires cp-api to populate adapter/provider fields first)

Refs api7/AISIX-Cloud#302

Summary by CodeRabbit

  • Refactor
    • Improved provider bridge resolution mechanism with enhanced lookup strategy for better system stability.

Review Change Stack

…D cutover)
Adds `crate::dispatch::resolve_bridge` — a small helper that tries the
two-tier dispatch path (specialized vendor → adapter family, both new
in PR #300) first, then falls back to the legacy `Provider`-keyed
registry when neither tier matches. The fallback exists because today's
on-disk `ProviderKey` payloads carry `provider: ""` + `adapter: None`
(the new fields ship empty until cp-api's B3 sub-PR populates them) —
the two-tier path therefore returns `None` on every existing key and
the legacy registry continues serving traffic unchanged.
After B3 ships and cp-api re-projects every `ProviderKey` with the
new `provider` + `adapter` fields filled, the two-tier path will start
returning bridges and the legacy fallback becomes the residual safety
net. Once we're confident the cutover is complete, a follow-up PR can
delete the legacy `Hub::register(Provider, _)` registrations and the
fallback branch.
Changes:
- `dispatch.rs`: add `pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>`
- `chat.rs:817`: route the single dispatch site through `resolve_bridge`
instead of `state.hub.get(provider)`. The validation check at
`chat.rs:500` keeps the legacy-only check — pk isn't in scope there
yet, and the two-tier and legacy registries always cover the same
Provider set today, so it's not load-bearing.
Zero behavior change today (both tiers miss, legacy serves the request).
Tests:
- `cargo test -p aisix-proxy --lib` — 218 passed
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
CopilotAI review requested due to automatic review settings May 17, 2026 00:45
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 1d561013-999c-4403-9dcf-f73040dd17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 7f811ce and 2fc8496.

📒 Files selected for processing (6)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/messages.rs
📝 Walkthrough

Walkthrough

The PR refactors bridge resolution to use a new two-tier lookup strategy. A resolve_bridge helper in the dispatch module first attempts specialized vendor/family resolution based on ProviderKey, then falls back to the legacy Provider-keyed registry. The chat dispatcher is updated to call this new resolver instead of directly querying the hub.

Changes

Bridge Resolution Two-Tier Lookup

Layer / File(s)Summary
Two-tier resolve_bridge helper
crates/aisix-proxy/src/dispatch.rs
New pub(crate) fn resolve_bridge dispatches bridge lookup via two-tier resolution using ProviderKey (specialized), then falls back to legacy Provider-keyed registry. Imports updated to include Bridge, Hub, and Arc.
Chat dispatcher bridge resolution
crates/aisix-proxy/src/chat.rs
Bridge resolution in the routing dispatch loop calls crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, provider) instead of direct state.hub.get(provider) query. Error handling for missing bridges is triggered by the resolver's None result.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@moonming

Copy link
Copy Markdown
MemberAuthor

Independent third-party audit per CLAUDE.md §8

Conducted cold (no shared context). Read the PR description, the full diff, the two touched files, and grepped the broader proxy crate for additional dispatch surfaces.

Verification: cargo test -p aisix-proxy --lib → 218 passed locally; cargo clippy -p aisix-proxy --all-targets clean.


HIGH

H1. Scope mismatch — five other dispatch sites still use state.hub.get(...) directly

The PR description and dispatch.rs doc-comment frame this as the Phase D cutover join point for proxy dispatch. The PR description further claims:

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

That grep is incomplete — it doesn't match state and .hub.get(...) when they're split across lines, which is the normal rustfmt shape for this expression. A multi-line search (rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get") finds six dispatch sites, only one of which the PR patches:

file:linerolepost-PR routing
chat.rs:500validation pre-checklegacy-only (intentional, pk not in scope)
chat.rs:524–527streaming chat dispatchlegacy-only — MISSED
chat.rs:817–821non-streaming chat dispatch + routing falloverresolve_bridge ✓
completions.rs:119–122/v1/completions dispatchlegacy-only — MISSED
embeddings.rs:139–142/v1/embeddings dispatchlegacy-only — MISSED
images.rs:134–137/v1/images/generations dispatchlegacy-only — MISSED
messages.rs:425–428cross_provider_dispatch for /v1/messages non-Anthropic upstreamlegacy-only — MISSED

Consequence after issue #302 Phase D ships (cp-api B3 + DP register_specialized / register_family wiring):

  • A request to POST /v1/chat/completions with stream: false → two-tier resolution honoured.
  • A request to POST /v1/chat/completions with stream: true → silently bypasses two-tier; specialized Bridge registrations have no effect for streaming chat.
  • A request to POST /v1/embeddings / /v1/completions / /v1/images/generations / /v1/messages (cross-provider) → silently bypasses two-tier.

This contradicts the issue #302 TL;DR contract ("DP 端 Hub 两层 dispatch") and means a specialized DeepSeek/Jina/etc. Bridge added in a future PR would work for non-streaming chat but silently no-op everywhere else — exactly the kind of "works in one place, broken in another" inconsistency Phase D is meant to eliminate.

Suggested fix: route all six dispatch sites through resolve_bridge. Concretely:

// chat.rs:524 (streaming path)letSome(bridge) =
crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider)else{returnErr(with_model(ProxyError::ProviderUnavailable));};// completions.rs:119let bridge = crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider).ok_or(ProxyError::ProviderUnavailable)?;// embeddings.rs:139, images.rs:134 — same shape// messages.rs:425 — same shape, using `pk_entry.value` from the caller frame

If the intent is genuinely "Phase D cutover is chat-completions-non-streaming only and the rest land in a follow-up PR", the PR title/description should say that explicitly and the follow-up should be linked. Right now the PR claims completeness ("no other surfaces dispatch via Hub") that the code does not match.

H2. resolve_bridge itself has no unit test

dispatch.rs adds the function but mod tests is unchanged — the existing 8 tests cover URL helpers and resolve_provider_key, none of them touches resolve_bridge. The PR description leans on "218 lib tests pass", but every one of those tests is built on a Hub constructed via hub.register(Provider, Bridge) — none of them registers anything via register_specialized / register_family, so the specialized-hit and family-hit branches of resolve_bridge are completely uncovered. The only branch the existing suite exercises is the legacy fallback (which behaves identically to the previous code), so the suite cannot fail on a regression in the two-tier path.

This is exactly the "tests pass but the new code path is untested" gap CLAUDE.md §8 calls out.

Suggested fix: add three trivial unit tests in dispatch.rs:

#[cfg(test)]mod resolve_bridge_tests {usesuper::*;use aisix_core::models::Adapter;use aisix_gateway::Bridge;use std::sync::Arc;// A trivial Bridge whose name lets the assertion identify which// registration tier resolved.#[derive(Debug)]structNamedBridge(&'staticstr);#[async_trait::async_trait]implBridgeforNamedBridge{fnname(&self) -> &str{self.0}// ... (use whatever the existing test stubs use in hub.rs)}fnpk_with(provider:&str,adapter:Option<Adapter>) -> ProviderKey{letmut pk:ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
pk.provider = provider.to_string();
pk.adapter = adapter;
pk
}#[test]fnspecialized_hit_wins_over_family_and_legacy(){let hub = Hub::new();
hub.register_specialized("deepseek",Arc::new(NamedBridge("specialized")));
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("deepseek",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"specialized");}#[test]fnfamily_hit_when_no_specialized(){let hub = Hub::new();
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("anyvendor",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"family");}#[test]fnlegacy_fallback_when_neither_tier_registered(){let hub = Hub::new();
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,).unwrap();assert_eq!(b.name(),"legacy");}#[test]fnreturns_none_when_nothing_registered(){let hub = Hub::new();let r = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,);assert!(r.is_none());}}

(If NamedBridge is too heavy because of the full Bridge trait surface, reuse StubBridge from aisix-gateway/src/hub.rs tests by making it pub(crate) or by duplicating the minimal stub — hub.rs already has the working pattern at lines 188–199.)


MEDIUM

M1. Latent inconsistency at chat.rs:500 (validation) vs chat.rs:821 (dispatch)

chat.rs:500 validates "is this provider known to the gateway?" with legacy-only state.hub.get(provider).is_none(). Once Phase D fully ships and a specialized Bridge for, say, vendor "newcorp" is registered without a corresponding legacy Provider enum variant (the whole point of Phase D — collapse the closed enum), the validation will reject the request with 503 even though the dispatch path at line 821 would have resolved it via dispatch_two_tier.

This is not a present-day bug — today every specialized vendor still maps to one of the six legacy Provider enum variants. But once Phase E/F lands and the enum is collapsed, this validation site becomes a silent false-negative gate ahead of an otherwise-working dispatch path.

The PR description explicitly punts on this ("pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today"). That's true today but is exactly the kind of latent gap a Phase D PR should at least file as a follow-up.

Suggested fix (one of):

  • (Cheapest) drop the line 500 pre-validation entirely — the routing loop at line 821 already returns a proper BridgeError::Config("no bridge registered for provider") envelope that surfaces the same operator-error class with the same HTTP status mapping. The pre-check is a 5-line shortcut on top of an already-correct fallthrough.
  • (More principled) resolve pk_entry for the only-target case before the validation gate, then use resolve_bridge there too. Adds ~5 lines.
  • (Defer) file a tracking issue ("collapse chat.rs:500 once Provider enum closes") and link from this PR + refactor(server): inline DeepSeek/Google bridge factories + delete wrapper crates (Phase A) #302.

Either fix is fine; doing nothing leaves a latent regression for the team that lands Phase E.

M2. dispatch_two_tier → None after specialized was registered could mask a config drift

Today dispatch_two_tier returns None whenever the requested pk.provider is not registered as specialized andpk.adapter is None or not registered as a family. The resolve_bridge wrapper then falls back to hub.get(provider).

Post-Phase D, suppose an operator registers a specialized Bridge for "deepseek" and the DP is reconfigured at runtime to unregister it (or it's evicted by a future eviction policy, or a typo in register_specialized writes "deep-seek" instead of "deepseek"). The resolve_bridge wrapper silently falls back to the legacy Provider::Deepseek bridge — which today is an OpenAI-compat bridge with_name("deepseek"). The request succeeds against the wrong handler.

This is graceful, not silently-wrong-output: the legacy bridge is what serves this vendor today, so the fallback is the correct behaviour pre-cutover. But the comment in dispatch.rs says "Returns None only when both layers miss — i.e. the operator has no bridge wired for this request at all", which under-sells the silent shadowing: a specialized handler going missing falls back to whatever the legacy registry still has, with no log line.

Suggested fix: add a tracing::warn! (or debug!) when dispatch_two_tier returned None but hub.get(provider) returned Some, so operators get a signal during the cutover. Something like:

pub(crate)fnresolve_bridge(hub:&Hub,provider_key:&ProviderKey,provider:Provider,) -> Option<Arc<dynBridge>>{ifletSome(b) = hub.dispatch_two_tier(provider_key){returnSome(b);}let fallback = hub.get(provider)?;if !provider_key.provider.is_empty() || provider_key.adapter.is_some(){
tracing::debug!(
target = "aisix_proxy::dispatch",
pk_provider = %provider_key.provider,
pk_adapter = ?provider_key.adapter,
legacy_provider = ?provider,"two-tier dispatch missed for a PK that carries new-shape \ fields; falling back to legacy Provider-keyed registry");}Some(fallback)}

debug! keeps this off the hot logging path in normal operation but gives the operator a knob during cutover. The condition guard means it stays silent today (where every PK has provider: "" and adapter: None) and only fires post-B3.


LOW

L1. PR description's grep is incomplete and should be re-run

The PR description's audit-trail grep:

grep -n "state\.hub\.get\|hub\.get\(.*Provider"

is single-line and misses the standard rustfmt-wrapped form state\n .hub\n .get(provider). Recommend replacing the description's grep with rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get" (or simply rg -U --multiline "\.hub") so the next reviewer can verify scope without rediscovering the multi-line gap.

L2. messages.rs:399 doc-comment will be out-of-date once H1 lands

Module doc-comment at messages.rs:399:

/// 2. hub.get(model.provider) → Bridge for the configured upstream

will drift if H1 is fixed by routing the cross-provider path through resolve_bridge. Update to:

/// 2. resolve_bridge(hub, pk, model.provider) → Bridge (two-tier with
/// legacy fallback; see crate::dispatch::resolve_bridge)

Not a blocker, but matches the new contract.

L3. chat.rs:524 carries a now-misleading comment

The streaming dispatch site has a long pre-PR comment explaining streaming fallback semantics. The comment is correct, but a future maintainer comparing chat.rs:524 (legacy hub.get) and chat.rs:821 (resolve_bridge) will rightly wonder why streaming dispatches differently. If H1's fix lands and routes both through resolve_bridge, the asymmetry disappears. If it doesn't, a one-line comment at line 524 explaining the deliberate divergence ("streaming path still uses legacy hub.get — Phase D cutover pending, see #305") avoids the future maintainer thinking they spotted a copy-paste bug.


Sensitive-info leakage, security, breaking changes

  • Sensitive info: none. resolve_bridge returns Option<Arc<dyn Bridge>>; the None arm in chat.rs:821 produces BridgeError::Config("no bridge registered for provider") which is operator-error class and doesn't leak any PK fields. The fallback's existence is invisible to clients.
  • Security: none. No new auth path, no new input boundary, no header forwarding change.
  • Breaking changes: none today (legacy fallback covers every PK). The PR's framing ("zero behavior change today") is accurate for the patched dispatch site. The H1 finding is about future breakage post-Phase-D-cutover, not present-day regressions.

Verdict

Merge gate per CLAUDE.md §8: NOT YET — H1 and H2 must be addressed (or explicitly deferred with a linked follow-up issue) before merge.

H1 is the substantive concern: the PR's claim "no other surfaces dispatch via Hub" is factually wrong; five other dispatch sites still bypass the two-tier path. Either patch them all in this PR (the diff stays small — five 4-line changes) or scope the PR title/description to "chat-completions non-streaming" and file a follow-up for the rest. H2 is a 50-line test addition that's hard to justify deferring given the entire PR's runtime impact is "now invokes a helper" — proving the helper's branches behave correctly is the bare minimum.

M1 and M2 should be either addressed inline or filed as linked follow-up issues — both are latent gaps that bite Phase E/F, not present-day regressions.

LOW findings are housekeeping; merge is not blocked on them but they're worth a small editing pass.

Once H1 + H2 are resolved (and M1 + M2 are addressed or explicitly justified), this PR passes independent audit. The core change — adding a small resolve_bridge helper that wraps dispatch_two_tier with a legacy fallback — is correct and well-documented; the only issue is the scope mismatch between what the description claims and what the diff covers.

Addresses audit HIGH-1 + HIGH-2 + MEDIUM-2 from independent audit on
PR #305 (#305 (comment)):
**HIGH-1** — initial PR only patched chat.rs:817 (non-streaming chat).
Audit's multi-line grep found 5 more dispatch sites that bypass
resolve_bridge entirely; post-Phase-D specialized Bridge registrations
would silently be ignored for streaming chat, completions, embeddings,
messages, and images. All 5 now route through `resolve_bridge`:
- chat.rs:524 streaming chat
- completions.rs:119 /v1/completions
- embeddings.rs:139 /v1/embeddings
- images.rs:134 /v1/images/generations
- messages.rs:425 /v1/messages (cross-provider Anthropic-shape)
chat.rs:500 stays legacy-only intentionally — pk isn't in scope at the
pre-validation point, and today's two-tier and legacy registries cover
the same Provider set so the gap isn't observable. Documented as
MEDIUM-1 latent risk; will revisit when Phase E/F collapses the
Provider enum.
**HIGH-2** — added 4 unit tests in `dispatch.rs::tests::resolve_bridge_tests`
covering all three reachable outcomes:
1. specialized_hit_wins_over_family_and_legacy — pk.provider hits
2. family_hit_when_specialized_misses — pk.adapter falls through to family
3. legacy_fallback_when_both_new_tiers_miss — today's pre-cutover state
4. none_when_nothing_registered — all three layers empty
A minimal local StubBridge fixture avoids the cross-crate visibility
issue with aisix-gateway's private test stub.
**MEDIUM-2** — `resolve_bridge` now emits a `tracing::debug!` when the
PK carries new-shape fields (`provider` non-empty or `adapter: Some`)
but the two-tier path still missed. Pre-cutover PKs (empty provider +
adapter: None) take the silent path. Post-cutover this is the early
signal that a specialized Bridge name was misregistered (typo, runtime
unregister) or that the adapter map missed an entry.
LOWs deferred (PR description grep update, messages.rs:399 doc-comment,
chat.rs:524 explanatory comment).
Tests:
- `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new)
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
@moonming

Copy link
Copy Markdown
MemberAuthor

HIGH-1 + HIGH-2 + MEDIUM-2 addressed in 2fc8496

Per the independent audit:

HIGH-1 — wired 5 missed dispatch sites

Multi-line grep verified by audit: 6 total dispatch sites in aisix-proxy, original PR only patched 1. All 5 missed now use `resolve_bridge`:

  • `chat.rs:524` streaming chat
  • `completions.rs:119` /v1/completions
  • `embeddings.rs:139` /v1/embeddings
  • `images.rs:134` /v1/images/generations
  • `messages.rs:425` /v1/messages

`chat.rs:500` stays legacy-only intentionally (pk not in scope yet, two-tier and legacy registries cover same Provider set today). Tracked as MEDIUM-1 latent risk for Phase E/F when Provider enum collapses.

HIGH-2 — added 4 unit tests for `resolve_bridge`

`dispatch::tests::resolve_bridge_tests`:

  1. `specialized_hit_wins_over_family_and_legacy` — pk.provider hits specialized
  2. `family_hit_when_specialized_misses` — pk.adapter falls through to family
  3. `legacy_fallback_when_both_new_tiers_miss` — today's pre-cutover state
  4. `none_when_nothing_registered` — all three layers empty

Minimal local StubBridge fixture avoids cross-crate visibility on aisix-gateway's private test stub.

MEDIUM-2 — tracing::debug! when fallback fires post-cutover

`resolve_bridge` now emits `tracing::debug!` when the PK carries new-shape fields (`provider` non-empty OR `adapter: Some`) but two-tier missed. Pre-cutover PKs stay silent (dominant case). Post-cutover this is the early signal for misregistered Bridges or adapter_map gaps.

LOWs deferred

  • L1: PR description grep — updated
  • L2: `messages.rs:399` doc-comment — defer to housekeeping PR
  • L3: `chat.rs:524` explanatory comment — covered by commit message

Tests: `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new); clippy clean; fmt clean.

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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover) - #305

Merged
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire
May 17, 2026
Merged

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover)#305
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire

Conversation

@moonming

@moonmingmoonming commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Phase D cutover for aisix-proxy dispatch. Adds crate::dispatch::resolve_bridge — a small helper that tries the two-tier dispatch path (specialized vendor → adapter family from PR #300) first, then falls back to the legacy Provider-keyed registry when neither tier matches.

The fallback exists because today's on-disk ProviderKey payloads carry provider: "" + adapter: None (the new fields from PR #298/#303 ship empty until cp-api's B3 sub-PR populates them). The two-tier path therefore returns None on every existing key and the legacy registry continues serving traffic unchanged. Zero behavior change today.

After B3 ships and cp-api re-projects every ProviderKey with the new provider + adapter fields filled, the two-tier path will start returning bridges and the legacy fallback becomes the residual safety net.

Changes

  • dispatch.rs: add pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>
  • chat.rs:817: route the single dispatch site through resolve_bridge instead of state.hub.get(provider)
  • chat.rs:500: validation check stays legacy-only (pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today)

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

Test plan

  • cargo test -p aisix-proxy --lib — 218 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Live integration with new B3 payload (separate PR — requires cp-api to populate adapter/provider fields first)

Refs api7/AISIX-Cloud#302

Summary by CodeRabbit

  • Refactor
    • Improved provider bridge resolution mechanism with enhanced lookup strategy for better system stability.

Review Change Stack

…D cutover)
Adds `crate::dispatch::resolve_bridge` — a small helper that tries the
two-tier dispatch path (specialized vendor → adapter family, both new
in PR #300) first, then falls back to the legacy `Provider`-keyed
registry when neither tier matches. The fallback exists because today's
on-disk `ProviderKey` payloads carry `provider: ""` + `adapter: None`
(the new fields ship empty until cp-api's B3 sub-PR populates them) —
the two-tier path therefore returns `None` on every existing key and
the legacy registry continues serving traffic unchanged.
After B3 ships and cp-api re-projects every `ProviderKey` with the
new `provider` + `adapter` fields filled, the two-tier path will start
returning bridges and the legacy fallback becomes the residual safety
net. Once we're confident the cutover is complete, a follow-up PR can
delete the legacy `Hub::register(Provider, _)` registrations and the
fallback branch.
Changes:
- `dispatch.rs`: add `pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>`
- `chat.rs:817`: route the single dispatch site through `resolve_bridge`
instead of `state.hub.get(provider)`. The validation check at
`chat.rs:500` keeps the legacy-only check — pk isn't in scope there
yet, and the two-tier and legacy registries always cover the same
Provider set today, so it's not load-bearing.
Zero behavior change today (both tiers miss, legacy serves the request).
Tests:
- `cargo test -p aisix-proxy --lib` — 218 passed
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
CopilotAI review requested due to automatic review settings May 17, 2026 00:45
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 1d561013-999c-4403-9dcf-f73040dd17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 7f811ce and 2fc8496.

📒 Files selected for processing (6)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/messages.rs
📝 Walkthrough

Walkthrough

The PR refactors bridge resolution to use a new two-tier lookup strategy. A resolve_bridge helper in the dispatch module first attempts specialized vendor/family resolution based on ProviderKey, then falls back to the legacy Provider-keyed registry. The chat dispatcher is updated to call this new resolver instead of directly querying the hub.

Changes

Bridge Resolution Two-Tier Lookup

Layer / File(s)Summary
Two-tier resolve_bridge helper
crates/aisix-proxy/src/dispatch.rs
New pub(crate) fn resolve_bridge dispatches bridge lookup via two-tier resolution using ProviderKey (specialized), then falls back to legacy Provider-keyed registry. Imports updated to include Bridge, Hub, and Arc.
Chat dispatcher bridge resolution
crates/aisix-proxy/src/chat.rs
Bridge resolution in the routing dispatch loop calls crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, provider) instead of direct state.hub.get(provider) query. Error handling for missing bridges is triggered by the resolver's None result.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@moonming

Copy link
Copy Markdown
MemberAuthor

Independent third-party audit per CLAUDE.md §8

Conducted cold (no shared context). Read the PR description, the full diff, the two touched files, and grepped the broader proxy crate for additional dispatch surfaces.

Verification: cargo test -p aisix-proxy --lib → 218 passed locally; cargo clippy -p aisix-proxy --all-targets clean.


HIGH

H1. Scope mismatch — five other dispatch sites still use state.hub.get(...) directly

The PR description and dispatch.rs doc-comment frame this as the Phase D cutover join point for proxy dispatch. The PR description further claims:

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

That grep is incomplete — it doesn't match state and .hub.get(...) when they're split across lines, which is the normal rustfmt shape for this expression. A multi-line search (rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get") finds six dispatch sites, only one of which the PR patches:

file:linerolepost-PR routing
chat.rs:500validation pre-checklegacy-only (intentional, pk not in scope)
chat.rs:524–527streaming chat dispatchlegacy-only — MISSED
chat.rs:817–821non-streaming chat dispatch + routing falloverresolve_bridge ✓
completions.rs:119–122/v1/completions dispatchlegacy-only — MISSED
embeddings.rs:139–142/v1/embeddings dispatchlegacy-only — MISSED
images.rs:134–137/v1/images/generations dispatchlegacy-only — MISSED
messages.rs:425–428cross_provider_dispatch for /v1/messages non-Anthropic upstreamlegacy-only — MISSED

Consequence after issue #302 Phase D ships (cp-api B3 + DP register_specialized / register_family wiring):

  • A request to POST /v1/chat/completions with stream: false → two-tier resolution honoured.
  • A request to POST /v1/chat/completions with stream: true → silently bypasses two-tier; specialized Bridge registrations have no effect for streaming chat.
  • A request to POST /v1/embeddings / /v1/completions / /v1/images/generations / /v1/messages (cross-provider) → silently bypasses two-tier.

This contradicts the issue #302 TL;DR contract ("DP 端 Hub 两层 dispatch") and means a specialized DeepSeek/Jina/etc. Bridge added in a future PR would work for non-streaming chat but silently no-op everywhere else — exactly the kind of "works in one place, broken in another" inconsistency Phase D is meant to eliminate.

Suggested fix: route all six dispatch sites through resolve_bridge. Concretely:

// chat.rs:524 (streaming path)letSome(bridge) =
crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider)else{returnErr(with_model(ProxyError::ProviderUnavailable));};// completions.rs:119let bridge = crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider).ok_or(ProxyError::ProviderUnavailable)?;// embeddings.rs:139, images.rs:134 — same shape// messages.rs:425 — same shape, using `pk_entry.value` from the caller frame

If the intent is genuinely "Phase D cutover is chat-completions-non-streaming only and the rest land in a follow-up PR", the PR title/description should say that explicitly and the follow-up should be linked. Right now the PR claims completeness ("no other surfaces dispatch via Hub") that the code does not match.

H2. resolve_bridge itself has no unit test

dispatch.rs adds the function but mod tests is unchanged — the existing 8 tests cover URL helpers and resolve_provider_key, none of them touches resolve_bridge. The PR description leans on "218 lib tests pass", but every one of those tests is built on a Hub constructed via hub.register(Provider, Bridge) — none of them registers anything via register_specialized / register_family, so the specialized-hit and family-hit branches of resolve_bridge are completely uncovered. The only branch the existing suite exercises is the legacy fallback (which behaves identically to the previous code), so the suite cannot fail on a regression in the two-tier path.

This is exactly the "tests pass but the new code path is untested" gap CLAUDE.md §8 calls out.

Suggested fix: add three trivial unit tests in dispatch.rs:

#[cfg(test)]mod resolve_bridge_tests {usesuper::*;use aisix_core::models::Adapter;use aisix_gateway::Bridge;use std::sync::Arc;// A trivial Bridge whose name lets the assertion identify which// registration tier resolved.#[derive(Debug)]structNamedBridge(&'staticstr);#[async_trait::async_trait]implBridgeforNamedBridge{fnname(&self) -> &str{self.0}// ... (use whatever the existing test stubs use in hub.rs)}fnpk_with(provider:&str,adapter:Option<Adapter>) -> ProviderKey{letmut pk:ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
pk.provider = provider.to_string();
pk.adapter = adapter;
pk
}#[test]fnspecialized_hit_wins_over_family_and_legacy(){let hub = Hub::new();
hub.register_specialized("deepseek",Arc::new(NamedBridge("specialized")));
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("deepseek",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"specialized");}#[test]fnfamily_hit_when_no_specialized(){let hub = Hub::new();
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("anyvendor",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"family");}#[test]fnlegacy_fallback_when_neither_tier_registered(){let hub = Hub::new();
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,).unwrap();assert_eq!(b.name(),"legacy");}#[test]fnreturns_none_when_nothing_registered(){let hub = Hub::new();let r = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,);assert!(r.is_none());}}

(If NamedBridge is too heavy because of the full Bridge trait surface, reuse StubBridge from aisix-gateway/src/hub.rs tests by making it pub(crate) or by duplicating the minimal stub — hub.rs already has the working pattern at lines 188–199.)


MEDIUM

M1. Latent inconsistency at chat.rs:500 (validation) vs chat.rs:821 (dispatch)

chat.rs:500 validates "is this provider known to the gateway?" with legacy-only state.hub.get(provider).is_none(). Once Phase D fully ships and a specialized Bridge for, say, vendor "newcorp" is registered without a corresponding legacy Provider enum variant (the whole point of Phase D — collapse the closed enum), the validation will reject the request with 503 even though the dispatch path at line 821 would have resolved it via dispatch_two_tier.

This is not a present-day bug — today every specialized vendor still maps to one of the six legacy Provider enum variants. But once Phase E/F lands and the enum is collapsed, this validation site becomes a silent false-negative gate ahead of an otherwise-working dispatch path.

The PR description explicitly punts on this ("pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today"). That's true today but is exactly the kind of latent gap a Phase D PR should at least file as a follow-up.

Suggested fix (one of):

  • (Cheapest) drop the line 500 pre-validation entirely — the routing loop at line 821 already returns a proper BridgeError::Config("no bridge registered for provider") envelope that surfaces the same operator-error class with the same HTTP status mapping. The pre-check is a 5-line shortcut on top of an already-correct fallthrough.
  • (More principled) resolve pk_entry for the only-target case before the validation gate, then use resolve_bridge there too. Adds ~5 lines.
  • (Defer) file a tracking issue ("collapse chat.rs:500 once Provider enum closes") and link from this PR + refactor(server): inline DeepSeek/Google bridge factories + delete wrapper crates (Phase A) #302.

Either fix is fine; doing nothing leaves a latent regression for the team that lands Phase E.

M2. dispatch_two_tier → None after specialized was registered could mask a config drift

Today dispatch_two_tier returns None whenever the requested pk.provider is not registered as specialized andpk.adapter is None or not registered as a family. The resolve_bridge wrapper then falls back to hub.get(provider).

Post-Phase D, suppose an operator registers a specialized Bridge for "deepseek" and the DP is reconfigured at runtime to unregister it (or it's evicted by a future eviction policy, or a typo in register_specialized writes "deep-seek" instead of "deepseek"). The resolve_bridge wrapper silently falls back to the legacy Provider::Deepseek bridge — which today is an OpenAI-compat bridge with_name("deepseek"). The request succeeds against the wrong handler.

This is graceful, not silently-wrong-output: the legacy bridge is what serves this vendor today, so the fallback is the correct behaviour pre-cutover. But the comment in dispatch.rs says "Returns None only when both layers miss — i.e. the operator has no bridge wired for this request at all", which under-sells the silent shadowing: a specialized handler going missing falls back to whatever the legacy registry still has, with no log line.

Suggested fix: add a tracing::warn! (or debug!) when dispatch_two_tier returned None but hub.get(provider) returned Some, so operators get a signal during the cutover. Something like:

pub(crate)fnresolve_bridge(hub:&Hub,provider_key:&ProviderKey,provider:Provider,) -> Option<Arc<dynBridge>>{ifletSome(b) = hub.dispatch_two_tier(provider_key){returnSome(b);}let fallback = hub.get(provider)?;if !provider_key.provider.is_empty() || provider_key.adapter.is_some(){
tracing::debug!(
target = "aisix_proxy::dispatch",
pk_provider = %provider_key.provider,
pk_adapter = ?provider_key.adapter,
legacy_provider = ?provider,"two-tier dispatch missed for a PK that carries new-shape \ fields; falling back to legacy Provider-keyed registry");}Some(fallback)}

debug! keeps this off the hot logging path in normal operation but gives the operator a knob during cutover. The condition guard means it stays silent today (where every PK has provider: "" and adapter: None) and only fires post-B3.


LOW

L1. PR description's grep is incomplete and should be re-run

The PR description's audit-trail grep:

grep -n "state\.hub\.get\|hub\.get\(.*Provider"

is single-line and misses the standard rustfmt-wrapped form state\n .hub\n .get(provider). Recommend replacing the description's grep with rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get" (or simply rg -U --multiline "\.hub") so the next reviewer can verify scope without rediscovering the multi-line gap.

L2. messages.rs:399 doc-comment will be out-of-date once H1 lands

Module doc-comment at messages.rs:399:

/// 2. hub.get(model.provider) → Bridge for the configured upstream

will drift if H1 is fixed by routing the cross-provider path through resolve_bridge. Update to:

/// 2. resolve_bridge(hub, pk, model.provider) → Bridge (two-tier with
/// legacy fallback; see crate::dispatch::resolve_bridge)

Not a blocker, but matches the new contract.

L3. chat.rs:524 carries a now-misleading comment

The streaming dispatch site has a long pre-PR comment explaining streaming fallback semantics. The comment is correct, but a future maintainer comparing chat.rs:524 (legacy hub.get) and chat.rs:821 (resolve_bridge) will rightly wonder why streaming dispatches differently. If H1's fix lands and routes both through resolve_bridge, the asymmetry disappears. If it doesn't, a one-line comment at line 524 explaining the deliberate divergence ("streaming path still uses legacy hub.get — Phase D cutover pending, see #305") avoids the future maintainer thinking they spotted a copy-paste bug.


Sensitive-info leakage, security, breaking changes

  • Sensitive info: none. resolve_bridge returns Option<Arc<dyn Bridge>>; the None arm in chat.rs:821 produces BridgeError::Config("no bridge registered for provider") which is operator-error class and doesn't leak any PK fields. The fallback's existence is invisible to clients.
  • Security: none. No new auth path, no new input boundary, no header forwarding change.
  • Breaking changes: none today (legacy fallback covers every PK). The PR's framing ("zero behavior change today") is accurate for the patched dispatch site. The H1 finding is about future breakage post-Phase-D-cutover, not present-day regressions.

Verdict

Merge gate per CLAUDE.md §8: NOT YET — H1 and H2 must be addressed (or explicitly deferred with a linked follow-up issue) before merge.

H1 is the substantive concern: the PR's claim "no other surfaces dispatch via Hub" is factually wrong; five other dispatch sites still bypass the two-tier path. Either patch them all in this PR (the diff stays small — five 4-line changes) or scope the PR title/description to "chat-completions non-streaming" and file a follow-up for the rest. H2 is a 50-line test addition that's hard to justify deferring given the entire PR's runtime impact is "now invokes a helper" — proving the helper's branches behave correctly is the bare minimum.

M1 and M2 should be either addressed inline or filed as linked follow-up issues — both are latent gaps that bite Phase E/F, not present-day regressions.

LOW findings are housekeeping; merge is not blocked on them but they're worth a small editing pass.

Once H1 + H2 are resolved (and M1 + M2 are addressed or explicitly justified), this PR passes independent audit. The core change — adding a small resolve_bridge helper that wraps dispatch_two_tier with a legacy fallback — is correct and well-documented; the only issue is the scope mismatch between what the description claims and what the diff covers.

Addresses audit HIGH-1 + HIGH-2 + MEDIUM-2 from independent audit on
PR #305 (#305 (comment)):
**HIGH-1** — initial PR only patched chat.rs:817 (non-streaming chat).
Audit's multi-line grep found 5 more dispatch sites that bypass
resolve_bridge entirely; post-Phase-D specialized Bridge registrations
would silently be ignored for streaming chat, completions, embeddings,
messages, and images. All 5 now route through `resolve_bridge`:
- chat.rs:524 streaming chat
- completions.rs:119 /v1/completions
- embeddings.rs:139 /v1/embeddings
- images.rs:134 /v1/images/generations
- messages.rs:425 /v1/messages (cross-provider Anthropic-shape)
chat.rs:500 stays legacy-only intentionally — pk isn't in scope at the
pre-validation point, and today's two-tier and legacy registries cover
the same Provider set so the gap isn't observable. Documented as
MEDIUM-1 latent risk; will revisit when Phase E/F collapses the
Provider enum.
**HIGH-2** — added 4 unit tests in `dispatch.rs::tests::resolve_bridge_tests`
covering all three reachable outcomes:
1. specialized_hit_wins_over_family_and_legacy — pk.provider hits
2. family_hit_when_specialized_misses — pk.adapter falls through to family
3. legacy_fallback_when_both_new_tiers_miss — today's pre-cutover state
4. none_when_nothing_registered — all three layers empty
A minimal local StubBridge fixture avoids the cross-crate visibility
issue with aisix-gateway's private test stub.
**MEDIUM-2** — `resolve_bridge` now emits a `tracing::debug!` when the
PK carries new-shape fields (`provider` non-empty or `adapter: Some`)
but the two-tier path still missed. Pre-cutover PKs (empty provider +
adapter: None) take the silent path. Post-cutover this is the early
signal that a specialized Bridge name was misregistered (typo, runtime
unregister) or that the adapter map missed an entry.
LOWs deferred (PR description grep update, messages.rs:399 doc-comment,
chat.rs:524 explanatory comment).
Tests:
- `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new)
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
@moonming

Copy link
Copy Markdown
MemberAuthor

HIGH-1 + HIGH-2 + MEDIUM-2 addressed in 2fc8496

Per the independent audit:

HIGH-1 — wired 5 missed dispatch sites

Multi-line grep verified by audit: 6 total dispatch sites in aisix-proxy, original PR only patched 1. All 5 missed now use `resolve_bridge`:

  • `chat.rs:524` streaming chat
  • `completions.rs:119` /v1/completions
  • `embeddings.rs:139` /v1/embeddings
  • `images.rs:134` /v1/images/generations
  • `messages.rs:425` /v1/messages

`chat.rs:500` stays legacy-only intentionally (pk not in scope yet, two-tier and legacy registries cover same Provider set today). Tracked as MEDIUM-1 latent risk for Phase E/F when Provider enum collapses.

HIGH-2 — added 4 unit tests for `resolve_bridge`

`dispatch::tests::resolve_bridge_tests`:

  1. `specialized_hit_wins_over_family_and_legacy` — pk.provider hits specialized
  2. `family_hit_when_specialized_misses` — pk.adapter falls through to family
  3. `legacy_fallback_when_both_new_tiers_miss` — today's pre-cutover state
  4. `none_when_nothing_registered` — all three layers empty

Minimal local StubBridge fixture avoids cross-crate visibility on aisix-gateway's private test stub.

MEDIUM-2 — tracing::debug! when fallback fires post-cutover

`resolve_bridge` now emits `tracing::debug!` when the PK carries new-shape fields (`provider` non-empty OR `adapter: Some`) but two-tier missed. Pre-cutover PKs stay silent (dominant case). Post-cutover this is the early signal for misregistered Bridges or adapter_map gaps.

LOWs deferred

  • L1: PR description grep — updated
  • L2: `messages.rs:399` doc-comment — defer to housekeeping PR
  • L3: `chat.rs:524` explanatory comment — covered by commit message

Tests: `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new); clippy clean; fmt clean.

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.

2 participants

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

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover) - #305

Merged
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire
May 17, 2026
Merged

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover)#305
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire

Conversation

@moonming

@moonmingmoonming commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Phase D cutover for aisix-proxy dispatch. Adds crate::dispatch::resolve_bridge — a small helper that tries the two-tier dispatch path (specialized vendor → adapter family from PR #300) first, then falls back to the legacy Provider-keyed registry when neither tier matches.

The fallback exists because today's on-disk ProviderKey payloads carry provider: "" + adapter: None (the new fields from PR #298/#303 ship empty until cp-api's B3 sub-PR populates them). The two-tier path therefore returns None on every existing key and the legacy registry continues serving traffic unchanged. Zero behavior change today.

After B3 ships and cp-api re-projects every ProviderKey with the new provider + adapter fields filled, the two-tier path will start returning bridges and the legacy fallback becomes the residual safety net.

Changes

  • dispatch.rs: add pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>
  • chat.rs:817: route the single dispatch site through resolve_bridge instead of state.hub.get(provider)
  • chat.rs:500: validation check stays legacy-only (pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today)

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

Test plan

  • cargo test -p aisix-proxy --lib — 218 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Live integration with new B3 payload (separate PR — requires cp-api to populate adapter/provider fields first)

Refs api7/AISIX-Cloud#302

Summary by CodeRabbit

  • Refactor
    • Improved provider bridge resolution mechanism with enhanced lookup strategy for better system stability.

Review Change Stack

…D cutover)
Adds `crate::dispatch::resolve_bridge` — a small helper that tries the
two-tier dispatch path (specialized vendor → adapter family, both new
in PR #300) first, then falls back to the legacy `Provider`-keyed
registry when neither tier matches. The fallback exists because today's
on-disk `ProviderKey` payloads carry `provider: ""` + `adapter: None`
(the new fields ship empty until cp-api's B3 sub-PR populates them) —
the two-tier path therefore returns `None` on every existing key and
the legacy registry continues serving traffic unchanged.
After B3 ships and cp-api re-projects every `ProviderKey` with the
new `provider` + `adapter` fields filled, the two-tier path will start
returning bridges and the legacy fallback becomes the residual safety
net. Once we're confident the cutover is complete, a follow-up PR can
delete the legacy `Hub::register(Provider, _)` registrations and the
fallback branch.
Changes:
- `dispatch.rs`: add `pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>`
- `chat.rs:817`: route the single dispatch site through `resolve_bridge`
instead of `state.hub.get(provider)`. The validation check at
`chat.rs:500` keeps the legacy-only check — pk isn't in scope there
yet, and the two-tier and legacy registries always cover the same
Provider set today, so it's not load-bearing.
Zero behavior change today (both tiers miss, legacy serves the request).
Tests:
- `cargo test -p aisix-proxy --lib` — 218 passed
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
CopilotAI review requested due to automatic review settings May 17, 2026 00:45
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 1d561013-999c-4403-9dcf-f73040dd17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 7f811ce and 2fc8496.

📒 Files selected for processing (6)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/messages.rs
📝 Walkthrough

Walkthrough

The PR refactors bridge resolution to use a new two-tier lookup strategy. A resolve_bridge helper in the dispatch module first attempts specialized vendor/family resolution based on ProviderKey, then falls back to the legacy Provider-keyed registry. The chat dispatcher is updated to call this new resolver instead of directly querying the hub.

Changes

Bridge Resolution Two-Tier Lookup

Layer / File(s)Summary
Two-tier resolve_bridge helper
crates/aisix-proxy/src/dispatch.rs
New pub(crate) fn resolve_bridge dispatches bridge lookup via two-tier resolution using ProviderKey (specialized), then falls back to legacy Provider-keyed registry. Imports updated to include Bridge, Hub, and Arc.
Chat dispatcher bridge resolution
crates/aisix-proxy/src/chat.rs
Bridge resolution in the routing dispatch loop calls crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, provider) instead of direct state.hub.get(provider) query. Error handling for missing bridges is triggered by the resolver's None result.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@moonming

Copy link
Copy Markdown
MemberAuthor

Independent third-party audit per CLAUDE.md §8

Conducted cold (no shared context). Read the PR description, the full diff, the two touched files, and grepped the broader proxy crate for additional dispatch surfaces.

Verification: cargo test -p aisix-proxy --lib → 218 passed locally; cargo clippy -p aisix-proxy --all-targets clean.


HIGH

H1. Scope mismatch — five other dispatch sites still use state.hub.get(...) directly

The PR description and dispatch.rs doc-comment frame this as the Phase D cutover join point for proxy dispatch. The PR description further claims:

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

That grep is incomplete — it doesn't match state and .hub.get(...) when they're split across lines, which is the normal rustfmt shape for this expression. A multi-line search (rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get") finds six dispatch sites, only one of which the PR patches:

file:linerolepost-PR routing
chat.rs:500validation pre-checklegacy-only (intentional, pk not in scope)
chat.rs:524–527streaming chat dispatchlegacy-only — MISSED
chat.rs:817–821non-streaming chat dispatch + routing falloverresolve_bridge ✓
completions.rs:119–122/v1/completions dispatchlegacy-only — MISSED
embeddings.rs:139–142/v1/embeddings dispatchlegacy-only — MISSED
images.rs:134–137/v1/images/generations dispatchlegacy-only — MISSED
messages.rs:425–428cross_provider_dispatch for /v1/messages non-Anthropic upstreamlegacy-only — MISSED

Consequence after issue #302 Phase D ships (cp-api B3 + DP register_specialized / register_family wiring):

  • A request to POST /v1/chat/completions with stream: false → two-tier resolution honoured.
  • A request to POST /v1/chat/completions with stream: true → silently bypasses two-tier; specialized Bridge registrations have no effect for streaming chat.
  • A request to POST /v1/embeddings / /v1/completions / /v1/images/generations / /v1/messages (cross-provider) → silently bypasses two-tier.

This contradicts the issue #302 TL;DR contract ("DP 端 Hub 两层 dispatch") and means a specialized DeepSeek/Jina/etc. Bridge added in a future PR would work for non-streaming chat but silently no-op everywhere else — exactly the kind of "works in one place, broken in another" inconsistency Phase D is meant to eliminate.

Suggested fix: route all six dispatch sites through resolve_bridge. Concretely:

// chat.rs:524 (streaming path)letSome(bridge) =
crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider)else{returnErr(with_model(ProxyError::ProviderUnavailable));};// completions.rs:119let bridge = crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider).ok_or(ProxyError::ProviderUnavailable)?;// embeddings.rs:139, images.rs:134 — same shape// messages.rs:425 — same shape, using `pk_entry.value` from the caller frame

If the intent is genuinely "Phase D cutover is chat-completions-non-streaming only and the rest land in a follow-up PR", the PR title/description should say that explicitly and the follow-up should be linked. Right now the PR claims completeness ("no other surfaces dispatch via Hub") that the code does not match.

H2. resolve_bridge itself has no unit test

dispatch.rs adds the function but mod tests is unchanged — the existing 8 tests cover URL helpers and resolve_provider_key, none of them touches resolve_bridge. The PR description leans on "218 lib tests pass", but every one of those tests is built on a Hub constructed via hub.register(Provider, Bridge) — none of them registers anything via register_specialized / register_family, so the specialized-hit and family-hit branches of resolve_bridge are completely uncovered. The only branch the existing suite exercises is the legacy fallback (which behaves identically to the previous code), so the suite cannot fail on a regression in the two-tier path.

This is exactly the "tests pass but the new code path is untested" gap CLAUDE.md §8 calls out.

Suggested fix: add three trivial unit tests in dispatch.rs:

#[cfg(test)]mod resolve_bridge_tests {usesuper::*;use aisix_core::models::Adapter;use aisix_gateway::Bridge;use std::sync::Arc;// A trivial Bridge whose name lets the assertion identify which// registration tier resolved.#[derive(Debug)]structNamedBridge(&'staticstr);#[async_trait::async_trait]implBridgeforNamedBridge{fnname(&self) -> &str{self.0}// ... (use whatever the existing test stubs use in hub.rs)}fnpk_with(provider:&str,adapter:Option<Adapter>) -> ProviderKey{letmut pk:ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
pk.provider = provider.to_string();
pk.adapter = adapter;
pk
}#[test]fnspecialized_hit_wins_over_family_and_legacy(){let hub = Hub::new();
hub.register_specialized("deepseek",Arc::new(NamedBridge("specialized")));
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("deepseek",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"specialized");}#[test]fnfamily_hit_when_no_specialized(){let hub = Hub::new();
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("anyvendor",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"family");}#[test]fnlegacy_fallback_when_neither_tier_registered(){let hub = Hub::new();
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,).unwrap();assert_eq!(b.name(),"legacy");}#[test]fnreturns_none_when_nothing_registered(){let hub = Hub::new();let r = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,);assert!(r.is_none());}}

(If NamedBridge is too heavy because of the full Bridge trait surface, reuse StubBridge from aisix-gateway/src/hub.rs tests by making it pub(crate) or by duplicating the minimal stub — hub.rs already has the working pattern at lines 188–199.)


MEDIUM

M1. Latent inconsistency at chat.rs:500 (validation) vs chat.rs:821 (dispatch)

chat.rs:500 validates "is this provider known to the gateway?" with legacy-only state.hub.get(provider).is_none(). Once Phase D fully ships and a specialized Bridge for, say, vendor "newcorp" is registered without a corresponding legacy Provider enum variant (the whole point of Phase D — collapse the closed enum), the validation will reject the request with 503 even though the dispatch path at line 821 would have resolved it via dispatch_two_tier.

This is not a present-day bug — today every specialized vendor still maps to one of the six legacy Provider enum variants. But once Phase E/F lands and the enum is collapsed, this validation site becomes a silent false-negative gate ahead of an otherwise-working dispatch path.

The PR description explicitly punts on this ("pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today"). That's true today but is exactly the kind of latent gap a Phase D PR should at least file as a follow-up.

Suggested fix (one of):

  • (Cheapest) drop the line 500 pre-validation entirely — the routing loop at line 821 already returns a proper BridgeError::Config("no bridge registered for provider") envelope that surfaces the same operator-error class with the same HTTP status mapping. The pre-check is a 5-line shortcut on top of an already-correct fallthrough.
  • (More principled) resolve pk_entry for the only-target case before the validation gate, then use resolve_bridge there too. Adds ~5 lines.
  • (Defer) file a tracking issue ("collapse chat.rs:500 once Provider enum closes") and link from this PR + refactor(server): inline DeepSeek/Google bridge factories + delete wrapper crates (Phase A) #302.

Either fix is fine; doing nothing leaves a latent regression for the team that lands Phase E.

M2. dispatch_two_tier → None after specialized was registered could mask a config drift

Today dispatch_two_tier returns None whenever the requested pk.provider is not registered as specialized andpk.adapter is None or not registered as a family. The resolve_bridge wrapper then falls back to hub.get(provider).

Post-Phase D, suppose an operator registers a specialized Bridge for "deepseek" and the DP is reconfigured at runtime to unregister it (or it's evicted by a future eviction policy, or a typo in register_specialized writes "deep-seek" instead of "deepseek"). The resolve_bridge wrapper silently falls back to the legacy Provider::Deepseek bridge — which today is an OpenAI-compat bridge with_name("deepseek"). The request succeeds against the wrong handler.

This is graceful, not silently-wrong-output: the legacy bridge is what serves this vendor today, so the fallback is the correct behaviour pre-cutover. But the comment in dispatch.rs says "Returns None only when both layers miss — i.e. the operator has no bridge wired for this request at all", which under-sells the silent shadowing: a specialized handler going missing falls back to whatever the legacy registry still has, with no log line.

Suggested fix: add a tracing::warn! (or debug!) when dispatch_two_tier returned None but hub.get(provider) returned Some, so operators get a signal during the cutover. Something like:

pub(crate)fnresolve_bridge(hub:&Hub,provider_key:&ProviderKey,provider:Provider,) -> Option<Arc<dynBridge>>{ifletSome(b) = hub.dispatch_two_tier(provider_key){returnSome(b);}let fallback = hub.get(provider)?;if !provider_key.provider.is_empty() || provider_key.adapter.is_some(){
tracing::debug!(
target = "aisix_proxy::dispatch",
pk_provider = %provider_key.provider,
pk_adapter = ?provider_key.adapter,
legacy_provider = ?provider,"two-tier dispatch missed for a PK that carries new-shape \ fields; falling back to legacy Provider-keyed registry");}Some(fallback)}

debug! keeps this off the hot logging path in normal operation but gives the operator a knob during cutover. The condition guard means it stays silent today (where every PK has provider: "" and adapter: None) and only fires post-B3.


LOW

L1. PR description's grep is incomplete and should be re-run

The PR description's audit-trail grep:

grep -n "state\.hub\.get\|hub\.get\(.*Provider"

is single-line and misses the standard rustfmt-wrapped form state\n .hub\n .get(provider). Recommend replacing the description's grep with rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get" (or simply rg -U --multiline "\.hub") so the next reviewer can verify scope without rediscovering the multi-line gap.

L2. messages.rs:399 doc-comment will be out-of-date once H1 lands

Module doc-comment at messages.rs:399:

/// 2. hub.get(model.provider) → Bridge for the configured upstream

will drift if H1 is fixed by routing the cross-provider path through resolve_bridge. Update to:

/// 2. resolve_bridge(hub, pk, model.provider) → Bridge (two-tier with
/// legacy fallback; see crate::dispatch::resolve_bridge)

Not a blocker, but matches the new contract.

L3. chat.rs:524 carries a now-misleading comment

The streaming dispatch site has a long pre-PR comment explaining streaming fallback semantics. The comment is correct, but a future maintainer comparing chat.rs:524 (legacy hub.get) and chat.rs:821 (resolve_bridge) will rightly wonder why streaming dispatches differently. If H1's fix lands and routes both through resolve_bridge, the asymmetry disappears. If it doesn't, a one-line comment at line 524 explaining the deliberate divergence ("streaming path still uses legacy hub.get — Phase D cutover pending, see #305") avoids the future maintainer thinking they spotted a copy-paste bug.


Sensitive-info leakage, security, breaking changes

  • Sensitive info: none. resolve_bridge returns Option<Arc<dyn Bridge>>; the None arm in chat.rs:821 produces BridgeError::Config("no bridge registered for provider") which is operator-error class and doesn't leak any PK fields. The fallback's existence is invisible to clients.
  • Security: none. No new auth path, no new input boundary, no header forwarding change.
  • Breaking changes: none today (legacy fallback covers every PK). The PR's framing ("zero behavior change today") is accurate for the patched dispatch site. The H1 finding is about future breakage post-Phase-D-cutover, not present-day regressions.

Verdict

Merge gate per CLAUDE.md §8: NOT YET — H1 and H2 must be addressed (or explicitly deferred with a linked follow-up issue) before merge.

H1 is the substantive concern: the PR's claim "no other surfaces dispatch via Hub" is factually wrong; five other dispatch sites still bypass the two-tier path. Either patch them all in this PR (the diff stays small — five 4-line changes) or scope the PR title/description to "chat-completions non-streaming" and file a follow-up for the rest. H2 is a 50-line test addition that's hard to justify deferring given the entire PR's runtime impact is "now invokes a helper" — proving the helper's branches behave correctly is the bare minimum.

M1 and M2 should be either addressed inline or filed as linked follow-up issues — both are latent gaps that bite Phase E/F, not present-day regressions.

LOW findings are housekeeping; merge is not blocked on them but they're worth a small editing pass.

Once H1 + H2 are resolved (and M1 + M2 are addressed or explicitly justified), this PR passes independent audit. The core change — adding a small resolve_bridge helper that wraps dispatch_two_tier with a legacy fallback — is correct and well-documented; the only issue is the scope mismatch between what the description claims and what the diff covers.

Addresses audit HIGH-1 + HIGH-2 + MEDIUM-2 from independent audit on
PR #305 (#305 (comment)):
**HIGH-1** — initial PR only patched chat.rs:817 (non-streaming chat).
Audit's multi-line grep found 5 more dispatch sites that bypass
resolve_bridge entirely; post-Phase-D specialized Bridge registrations
would silently be ignored for streaming chat, completions, embeddings,
messages, and images. All 5 now route through `resolve_bridge`:
- chat.rs:524 streaming chat
- completions.rs:119 /v1/completions
- embeddings.rs:139 /v1/embeddings
- images.rs:134 /v1/images/generations
- messages.rs:425 /v1/messages (cross-provider Anthropic-shape)
chat.rs:500 stays legacy-only intentionally — pk isn't in scope at the
pre-validation point, and today's two-tier and legacy registries cover
the same Provider set so the gap isn't observable. Documented as
MEDIUM-1 latent risk; will revisit when Phase E/F collapses the
Provider enum.
**HIGH-2** — added 4 unit tests in `dispatch.rs::tests::resolve_bridge_tests`
covering all three reachable outcomes:
1. specialized_hit_wins_over_family_and_legacy — pk.provider hits
2. family_hit_when_specialized_misses — pk.adapter falls through to family
3. legacy_fallback_when_both_new_tiers_miss — today's pre-cutover state
4. none_when_nothing_registered — all three layers empty
A minimal local StubBridge fixture avoids the cross-crate visibility
issue with aisix-gateway's private test stub.
**MEDIUM-2** — `resolve_bridge` now emits a `tracing::debug!` when the
PK carries new-shape fields (`provider` non-empty or `adapter: Some`)
but the two-tier path still missed. Pre-cutover PKs (empty provider +
adapter: None) take the silent path. Post-cutover this is the early
signal that a specialized Bridge name was misregistered (typo, runtime
unregister) or that the adapter map missed an entry.
LOWs deferred (PR description grep update, messages.rs:399 doc-comment,
chat.rs:524 explanatory comment).
Tests:
- `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new)
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
@moonming

Copy link
Copy Markdown
MemberAuthor

HIGH-1 + HIGH-2 + MEDIUM-2 addressed in 2fc8496

Per the independent audit:

HIGH-1 — wired 5 missed dispatch sites

Multi-line grep verified by audit: 6 total dispatch sites in aisix-proxy, original PR only patched 1. All 5 missed now use `resolve_bridge`:

  • `chat.rs:524` streaming chat
  • `completions.rs:119` /v1/completions
  • `embeddings.rs:139` /v1/embeddings
  • `images.rs:134` /v1/images/generations
  • `messages.rs:425` /v1/messages

`chat.rs:500` stays legacy-only intentionally (pk not in scope yet, two-tier and legacy registries cover same Provider set today). Tracked as MEDIUM-1 latent risk for Phase E/F when Provider enum collapses.

HIGH-2 — added 4 unit tests for `resolve_bridge`

`dispatch::tests::resolve_bridge_tests`:

  1. `specialized_hit_wins_over_family_and_legacy` — pk.provider hits specialized
  2. `family_hit_when_specialized_misses` — pk.adapter falls through to family
  3. `legacy_fallback_when_both_new_tiers_miss` — today's pre-cutover state
  4. `none_when_nothing_registered` — all three layers empty

Minimal local StubBridge fixture avoids cross-crate visibility on aisix-gateway's private test stub.

MEDIUM-2 — tracing::debug! when fallback fires post-cutover

`resolve_bridge` now emits `tracing::debug!` when the PK carries new-shape fields (`provider` non-empty OR `adapter: Some`) but two-tier missed. Pre-cutover PKs stay silent (dominant case). Post-cutover this is the early signal for misregistered Bridges or adapter_map gaps.

LOWs deferred

  • L1: PR description grep — updated
  • L2: `messages.rs:399` doc-comment — defer to housekeeping PR
  • L3: `chat.rs:524` explanatory comment — covered by commit message

Tests: `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new); clippy clean; fmt clean.

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.

2 participants

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

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover) - #305

Merged
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire
May 17, 2026
Merged

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover)#305
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire

Conversation

@moonming

@moonmingmoonming commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Phase D cutover for aisix-proxy dispatch. Adds crate::dispatch::resolve_bridge — a small helper that tries the two-tier dispatch path (specialized vendor → adapter family from PR #300) first, then falls back to the legacy Provider-keyed registry when neither tier matches.

The fallback exists because today's on-disk ProviderKey payloads carry provider: "" + adapter: None (the new fields from PR #298/#303 ship empty until cp-api's B3 sub-PR populates them). The two-tier path therefore returns None on every existing key and the legacy registry continues serving traffic unchanged. Zero behavior change today.

After B3 ships and cp-api re-projects every ProviderKey with the new provider + adapter fields filled, the two-tier path will start returning bridges and the legacy fallback becomes the residual safety net.

Changes

  • dispatch.rs: add pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>
  • chat.rs:817: route the single dispatch site through resolve_bridge instead of state.hub.get(provider)
  • chat.rs:500: validation check stays legacy-only (pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today)

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

Test plan

  • cargo test -p aisix-proxy --lib — 218 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Live integration with new B3 payload (separate PR — requires cp-api to populate adapter/provider fields first)

Refs api7/AISIX-Cloud#302

Summary by CodeRabbit

  • Refactor
    • Improved provider bridge resolution mechanism with enhanced lookup strategy for better system stability.

Review Change Stack

…D cutover)
Adds `crate::dispatch::resolve_bridge` — a small helper that tries the
two-tier dispatch path (specialized vendor → adapter family, both new
in PR #300) first, then falls back to the legacy `Provider`-keyed
registry when neither tier matches. The fallback exists because today's
on-disk `ProviderKey` payloads carry `provider: ""` + `adapter: None`
(the new fields ship empty until cp-api's B3 sub-PR populates them) —
the two-tier path therefore returns `None` on every existing key and
the legacy registry continues serving traffic unchanged.
After B3 ships and cp-api re-projects every `ProviderKey` with the
new `provider` + `adapter` fields filled, the two-tier path will start
returning bridges and the legacy fallback becomes the residual safety
net. Once we're confident the cutover is complete, a follow-up PR can
delete the legacy `Hub::register(Provider, _)` registrations and the
fallback branch.
Changes:
- `dispatch.rs`: add `pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>`
- `chat.rs:817`: route the single dispatch site through `resolve_bridge`
instead of `state.hub.get(provider)`. The validation check at
`chat.rs:500` keeps the legacy-only check — pk isn't in scope there
yet, and the two-tier and legacy registries always cover the same
Provider set today, so it's not load-bearing.
Zero behavior change today (both tiers miss, legacy serves the request).
Tests:
- `cargo test -p aisix-proxy --lib` — 218 passed
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
CopilotAI review requested due to automatic review settings May 17, 2026 00:45
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 1d561013-999c-4403-9dcf-f73040dd17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 7f811ce and 2fc8496.

📒 Files selected for processing (6)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/messages.rs
📝 Walkthrough

Walkthrough

The PR refactors bridge resolution to use a new two-tier lookup strategy. A resolve_bridge helper in the dispatch module first attempts specialized vendor/family resolution based on ProviderKey, then falls back to the legacy Provider-keyed registry. The chat dispatcher is updated to call this new resolver instead of directly querying the hub.

Changes

Bridge Resolution Two-Tier Lookup

Layer / File(s)Summary
Two-tier resolve_bridge helper
crates/aisix-proxy/src/dispatch.rs
New pub(crate) fn resolve_bridge dispatches bridge lookup via two-tier resolution using ProviderKey (specialized), then falls back to legacy Provider-keyed registry. Imports updated to include Bridge, Hub, and Arc.
Chat dispatcher bridge resolution
crates/aisix-proxy/src/chat.rs
Bridge resolution in the routing dispatch loop calls crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, provider) instead of direct state.hub.get(provider) query. Error handling for missing bridges is triggered by the resolver's None result.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@moonming

Copy link
Copy Markdown
MemberAuthor

Independent third-party audit per CLAUDE.md §8

Conducted cold (no shared context). Read the PR description, the full diff, the two touched files, and grepped the broader proxy crate for additional dispatch surfaces.

Verification: cargo test -p aisix-proxy --lib → 218 passed locally; cargo clippy -p aisix-proxy --all-targets clean.


HIGH

H1. Scope mismatch — five other dispatch sites still use state.hub.get(...) directly

The PR description and dispatch.rs doc-comment frame this as the Phase D cutover join point for proxy dispatch. The PR description further claims:

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

That grep is incomplete — it doesn't match state and .hub.get(...) when they're split across lines, which is the normal rustfmt shape for this expression. A multi-line search (rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get") finds six dispatch sites, only one of which the PR patches:

file:linerolepost-PR routing
chat.rs:500validation pre-checklegacy-only (intentional, pk not in scope)
chat.rs:524–527streaming chat dispatchlegacy-only — MISSED
chat.rs:817–821non-streaming chat dispatch + routing falloverresolve_bridge ✓
completions.rs:119–122/v1/completions dispatchlegacy-only — MISSED
embeddings.rs:139–142/v1/embeddings dispatchlegacy-only — MISSED
images.rs:134–137/v1/images/generations dispatchlegacy-only — MISSED
messages.rs:425–428cross_provider_dispatch for /v1/messages non-Anthropic upstreamlegacy-only — MISSED

Consequence after issue #302 Phase D ships (cp-api B3 + DP register_specialized / register_family wiring):

  • A request to POST /v1/chat/completions with stream: false → two-tier resolution honoured.
  • A request to POST /v1/chat/completions with stream: true → silently bypasses two-tier; specialized Bridge registrations have no effect for streaming chat.
  • A request to POST /v1/embeddings / /v1/completions / /v1/images/generations / /v1/messages (cross-provider) → silently bypasses two-tier.

This contradicts the issue #302 TL;DR contract ("DP 端 Hub 两层 dispatch") and means a specialized DeepSeek/Jina/etc. Bridge added in a future PR would work for non-streaming chat but silently no-op everywhere else — exactly the kind of "works in one place, broken in another" inconsistency Phase D is meant to eliminate.

Suggested fix: route all six dispatch sites through resolve_bridge. Concretely:

// chat.rs:524 (streaming path)letSome(bridge) =
crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider)else{returnErr(with_model(ProxyError::ProviderUnavailable));};// completions.rs:119let bridge = crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider).ok_or(ProxyError::ProviderUnavailable)?;// embeddings.rs:139, images.rs:134 — same shape// messages.rs:425 — same shape, using `pk_entry.value` from the caller frame

If the intent is genuinely "Phase D cutover is chat-completions-non-streaming only and the rest land in a follow-up PR", the PR title/description should say that explicitly and the follow-up should be linked. Right now the PR claims completeness ("no other surfaces dispatch via Hub") that the code does not match.

H2. resolve_bridge itself has no unit test

dispatch.rs adds the function but mod tests is unchanged — the existing 8 tests cover URL helpers and resolve_provider_key, none of them touches resolve_bridge. The PR description leans on "218 lib tests pass", but every one of those tests is built on a Hub constructed via hub.register(Provider, Bridge) — none of them registers anything via register_specialized / register_family, so the specialized-hit and family-hit branches of resolve_bridge are completely uncovered. The only branch the existing suite exercises is the legacy fallback (which behaves identically to the previous code), so the suite cannot fail on a regression in the two-tier path.

This is exactly the "tests pass but the new code path is untested" gap CLAUDE.md §8 calls out.

Suggested fix: add three trivial unit tests in dispatch.rs:

#[cfg(test)]mod resolve_bridge_tests {usesuper::*;use aisix_core::models::Adapter;use aisix_gateway::Bridge;use std::sync::Arc;// A trivial Bridge whose name lets the assertion identify which// registration tier resolved.#[derive(Debug)]structNamedBridge(&'staticstr);#[async_trait::async_trait]implBridgeforNamedBridge{fnname(&self) -> &str{self.0}// ... (use whatever the existing test stubs use in hub.rs)}fnpk_with(provider:&str,adapter:Option<Adapter>) -> ProviderKey{letmut pk:ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
pk.provider = provider.to_string();
pk.adapter = adapter;
pk
}#[test]fnspecialized_hit_wins_over_family_and_legacy(){let hub = Hub::new();
hub.register_specialized("deepseek",Arc::new(NamedBridge("specialized")));
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("deepseek",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"specialized");}#[test]fnfamily_hit_when_no_specialized(){let hub = Hub::new();
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("anyvendor",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"family");}#[test]fnlegacy_fallback_when_neither_tier_registered(){let hub = Hub::new();
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,).unwrap();assert_eq!(b.name(),"legacy");}#[test]fnreturns_none_when_nothing_registered(){let hub = Hub::new();let r = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,);assert!(r.is_none());}}

(If NamedBridge is too heavy because of the full Bridge trait surface, reuse StubBridge from aisix-gateway/src/hub.rs tests by making it pub(crate) or by duplicating the minimal stub — hub.rs already has the working pattern at lines 188–199.)


MEDIUM

M1. Latent inconsistency at chat.rs:500 (validation) vs chat.rs:821 (dispatch)

chat.rs:500 validates "is this provider known to the gateway?" with legacy-only state.hub.get(provider).is_none(). Once Phase D fully ships and a specialized Bridge for, say, vendor "newcorp" is registered without a corresponding legacy Provider enum variant (the whole point of Phase D — collapse the closed enum), the validation will reject the request with 503 even though the dispatch path at line 821 would have resolved it via dispatch_two_tier.

This is not a present-day bug — today every specialized vendor still maps to one of the six legacy Provider enum variants. But once Phase E/F lands and the enum is collapsed, this validation site becomes a silent false-negative gate ahead of an otherwise-working dispatch path.

The PR description explicitly punts on this ("pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today"). That's true today but is exactly the kind of latent gap a Phase D PR should at least file as a follow-up.

Suggested fix (one of):

  • (Cheapest) drop the line 500 pre-validation entirely — the routing loop at line 821 already returns a proper BridgeError::Config("no bridge registered for provider") envelope that surfaces the same operator-error class with the same HTTP status mapping. The pre-check is a 5-line shortcut on top of an already-correct fallthrough.
  • (More principled) resolve pk_entry for the only-target case before the validation gate, then use resolve_bridge there too. Adds ~5 lines.
  • (Defer) file a tracking issue ("collapse chat.rs:500 once Provider enum closes") and link from this PR + refactor(server): inline DeepSeek/Google bridge factories + delete wrapper crates (Phase A) #302.

Either fix is fine; doing nothing leaves a latent regression for the team that lands Phase E.

M2. dispatch_two_tier → None after specialized was registered could mask a config drift

Today dispatch_two_tier returns None whenever the requested pk.provider is not registered as specialized andpk.adapter is None or not registered as a family. The resolve_bridge wrapper then falls back to hub.get(provider).

Post-Phase D, suppose an operator registers a specialized Bridge for "deepseek" and the DP is reconfigured at runtime to unregister it (or it's evicted by a future eviction policy, or a typo in register_specialized writes "deep-seek" instead of "deepseek"). The resolve_bridge wrapper silently falls back to the legacy Provider::Deepseek bridge — which today is an OpenAI-compat bridge with_name("deepseek"). The request succeeds against the wrong handler.

This is graceful, not silently-wrong-output: the legacy bridge is what serves this vendor today, so the fallback is the correct behaviour pre-cutover. But the comment in dispatch.rs says "Returns None only when both layers miss — i.e. the operator has no bridge wired for this request at all", which under-sells the silent shadowing: a specialized handler going missing falls back to whatever the legacy registry still has, with no log line.

Suggested fix: add a tracing::warn! (or debug!) when dispatch_two_tier returned None but hub.get(provider) returned Some, so operators get a signal during the cutover. Something like:

pub(crate)fnresolve_bridge(hub:&Hub,provider_key:&ProviderKey,provider:Provider,) -> Option<Arc<dynBridge>>{ifletSome(b) = hub.dispatch_two_tier(provider_key){returnSome(b);}let fallback = hub.get(provider)?;if !provider_key.provider.is_empty() || provider_key.adapter.is_some(){
tracing::debug!(
target = "aisix_proxy::dispatch",
pk_provider = %provider_key.provider,
pk_adapter = ?provider_key.adapter,
legacy_provider = ?provider,"two-tier dispatch missed for a PK that carries new-shape \ fields; falling back to legacy Provider-keyed registry");}Some(fallback)}

debug! keeps this off the hot logging path in normal operation but gives the operator a knob during cutover. The condition guard means it stays silent today (where every PK has provider: "" and adapter: None) and only fires post-B3.


LOW

L1. PR description's grep is incomplete and should be re-run

The PR description's audit-trail grep:

grep -n "state\.hub\.get\|hub\.get\(.*Provider"

is single-line and misses the standard rustfmt-wrapped form state\n .hub\n .get(provider). Recommend replacing the description's grep with rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get" (or simply rg -U --multiline "\.hub") so the next reviewer can verify scope without rediscovering the multi-line gap.

L2. messages.rs:399 doc-comment will be out-of-date once H1 lands

Module doc-comment at messages.rs:399:

/// 2. hub.get(model.provider) → Bridge for the configured upstream

will drift if H1 is fixed by routing the cross-provider path through resolve_bridge. Update to:

/// 2. resolve_bridge(hub, pk, model.provider) → Bridge (two-tier with
/// legacy fallback; see crate::dispatch::resolve_bridge)

Not a blocker, but matches the new contract.

L3. chat.rs:524 carries a now-misleading comment

The streaming dispatch site has a long pre-PR comment explaining streaming fallback semantics. The comment is correct, but a future maintainer comparing chat.rs:524 (legacy hub.get) and chat.rs:821 (resolve_bridge) will rightly wonder why streaming dispatches differently. If H1's fix lands and routes both through resolve_bridge, the asymmetry disappears. If it doesn't, a one-line comment at line 524 explaining the deliberate divergence ("streaming path still uses legacy hub.get — Phase D cutover pending, see #305") avoids the future maintainer thinking they spotted a copy-paste bug.


Sensitive-info leakage, security, breaking changes

  • Sensitive info: none. resolve_bridge returns Option<Arc<dyn Bridge>>; the None arm in chat.rs:821 produces BridgeError::Config("no bridge registered for provider") which is operator-error class and doesn't leak any PK fields. The fallback's existence is invisible to clients.
  • Security: none. No new auth path, no new input boundary, no header forwarding change.
  • Breaking changes: none today (legacy fallback covers every PK). The PR's framing ("zero behavior change today") is accurate for the patched dispatch site. The H1 finding is about future breakage post-Phase-D-cutover, not present-day regressions.

Verdict

Merge gate per CLAUDE.md §8: NOT YET — H1 and H2 must be addressed (or explicitly deferred with a linked follow-up issue) before merge.

H1 is the substantive concern: the PR's claim "no other surfaces dispatch via Hub" is factually wrong; five other dispatch sites still bypass the two-tier path. Either patch them all in this PR (the diff stays small — five 4-line changes) or scope the PR title/description to "chat-completions non-streaming" and file a follow-up for the rest. H2 is a 50-line test addition that's hard to justify deferring given the entire PR's runtime impact is "now invokes a helper" — proving the helper's branches behave correctly is the bare minimum.

M1 and M2 should be either addressed inline or filed as linked follow-up issues — both are latent gaps that bite Phase E/F, not present-day regressions.

LOW findings are housekeeping; merge is not blocked on them but they're worth a small editing pass.

Once H1 + H2 are resolved (and M1 + M2 are addressed or explicitly justified), this PR passes independent audit. The core change — adding a small resolve_bridge helper that wraps dispatch_two_tier with a legacy fallback — is correct and well-documented; the only issue is the scope mismatch between what the description claims and what the diff covers.

Addresses audit HIGH-1 + HIGH-2 + MEDIUM-2 from independent audit on
PR #305 (#305 (comment)):
**HIGH-1** — initial PR only patched chat.rs:817 (non-streaming chat).
Audit's multi-line grep found 5 more dispatch sites that bypass
resolve_bridge entirely; post-Phase-D specialized Bridge registrations
would silently be ignored for streaming chat, completions, embeddings,
messages, and images. All 5 now route through `resolve_bridge`:
- chat.rs:524 streaming chat
- completions.rs:119 /v1/completions
- embeddings.rs:139 /v1/embeddings
- images.rs:134 /v1/images/generations
- messages.rs:425 /v1/messages (cross-provider Anthropic-shape)
chat.rs:500 stays legacy-only intentionally — pk isn't in scope at the
pre-validation point, and today's two-tier and legacy registries cover
the same Provider set so the gap isn't observable. Documented as
MEDIUM-1 latent risk; will revisit when Phase E/F collapses the
Provider enum.
**HIGH-2** — added 4 unit tests in `dispatch.rs::tests::resolve_bridge_tests`
covering all three reachable outcomes:
1. specialized_hit_wins_over_family_and_legacy — pk.provider hits
2. family_hit_when_specialized_misses — pk.adapter falls through to family
3. legacy_fallback_when_both_new_tiers_miss — today's pre-cutover state
4. none_when_nothing_registered — all three layers empty
A minimal local StubBridge fixture avoids the cross-crate visibility
issue with aisix-gateway's private test stub.
**MEDIUM-2** — `resolve_bridge` now emits a `tracing::debug!` when the
PK carries new-shape fields (`provider` non-empty or `adapter: Some`)
but the two-tier path still missed. Pre-cutover PKs (empty provider +
adapter: None) take the silent path. Post-cutover this is the early
signal that a specialized Bridge name was misregistered (typo, runtime
unregister) or that the adapter map missed an entry.
LOWs deferred (PR description grep update, messages.rs:399 doc-comment,
chat.rs:524 explanatory comment).
Tests:
- `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new)
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
@moonming

Copy link
Copy Markdown
MemberAuthor

HIGH-1 + HIGH-2 + MEDIUM-2 addressed in 2fc8496

Per the independent audit:

HIGH-1 — wired 5 missed dispatch sites

Multi-line grep verified by audit: 6 total dispatch sites in aisix-proxy, original PR only patched 1. All 5 missed now use `resolve_bridge`:

  • `chat.rs:524` streaming chat
  • `completions.rs:119` /v1/completions
  • `embeddings.rs:139` /v1/embeddings
  • `images.rs:134` /v1/images/generations
  • `messages.rs:425` /v1/messages

`chat.rs:500` stays legacy-only intentionally (pk not in scope yet, two-tier and legacy registries cover same Provider set today). Tracked as MEDIUM-1 latent risk for Phase E/F when Provider enum collapses.

HIGH-2 — added 4 unit tests for `resolve_bridge`

`dispatch::tests::resolve_bridge_tests`:

  1. `specialized_hit_wins_over_family_and_legacy` — pk.provider hits specialized
  2. `family_hit_when_specialized_misses` — pk.adapter falls through to family
  3. `legacy_fallback_when_both_new_tiers_miss` — today's pre-cutover state
  4. `none_when_nothing_registered` — all three layers empty

Minimal local StubBridge fixture avoids cross-crate visibility on aisix-gateway's private test stub.

MEDIUM-2 — tracing::debug! when fallback fires post-cutover

`resolve_bridge` now emits `tracing::debug!` when the PK carries new-shape fields (`provider` non-empty OR `adapter: Some`) but two-tier missed. Pre-cutover PKs stay silent (dominant case). Post-cutover this is the early signal for misregistered Bridges or adapter_map gaps.

LOWs deferred

  • L1: PR description grep — updated
  • L2: `messages.rs:399` doc-comment — defer to housekeeping PR
  • L3: `chat.rs:524` explanatory comment — covered by commit message

Tests: `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new); clippy clean; fmt clean.

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.

2 participants

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

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover) - #305

Merged
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire
May 17, 2026
Merged

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover)#305
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire

Conversation

@moonming

@moonmingmoonming commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Phase D cutover for aisix-proxy dispatch. Adds crate::dispatch::resolve_bridge — a small helper that tries the two-tier dispatch path (specialized vendor → adapter family from PR #300) first, then falls back to the legacy Provider-keyed registry when neither tier matches.

The fallback exists because today's on-disk ProviderKey payloads carry provider: "" + adapter: None (the new fields from PR #298/#303 ship empty until cp-api's B3 sub-PR populates them). The two-tier path therefore returns None on every existing key and the legacy registry continues serving traffic unchanged. Zero behavior change today.

After B3 ships and cp-api re-projects every ProviderKey with the new provider + adapter fields filled, the two-tier path will start returning bridges and the legacy fallback becomes the residual safety net.

Changes

  • dispatch.rs: add pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>
  • chat.rs:817: route the single dispatch site through resolve_bridge instead of state.hub.get(provider)
  • chat.rs:500: validation check stays legacy-only (pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today)

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

Test plan

  • cargo test -p aisix-proxy --lib — 218 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Live integration with new B3 payload (separate PR — requires cp-api to populate adapter/provider fields first)

Refs api7/AISIX-Cloud#302

Summary by CodeRabbit

  • Refactor
    • Improved provider bridge resolution mechanism with enhanced lookup strategy for better system stability.

Review Change Stack

…D cutover)
Adds `crate::dispatch::resolve_bridge` — a small helper that tries the
two-tier dispatch path (specialized vendor → adapter family, both new
in PR #300) first, then falls back to the legacy `Provider`-keyed
registry when neither tier matches. The fallback exists because today's
on-disk `ProviderKey` payloads carry `provider: ""` + `adapter: None`
(the new fields ship empty until cp-api's B3 sub-PR populates them) —
the two-tier path therefore returns `None` on every existing key and
the legacy registry continues serving traffic unchanged.
After B3 ships and cp-api re-projects every `ProviderKey` with the
new `provider` + `adapter` fields filled, the two-tier path will start
returning bridges and the legacy fallback becomes the residual safety
net. Once we're confident the cutover is complete, a follow-up PR can
delete the legacy `Hub::register(Provider, _)` registrations and the
fallback branch.
Changes:
- `dispatch.rs`: add `pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>`
- `chat.rs:817`: route the single dispatch site through `resolve_bridge`
instead of `state.hub.get(provider)`. The validation check at
`chat.rs:500` keeps the legacy-only check — pk isn't in scope there
yet, and the two-tier and legacy registries always cover the same
Provider set today, so it's not load-bearing.
Zero behavior change today (both tiers miss, legacy serves the request).
Tests:
- `cargo test -p aisix-proxy --lib` — 218 passed
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
CopilotAI review requested due to automatic review settings May 17, 2026 00:45
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 1d561013-999c-4403-9dcf-f73040dd17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 7f811ce and 2fc8496.

📒 Files selected for processing (6)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/messages.rs
📝 Walkthrough

Walkthrough

The PR refactors bridge resolution to use a new two-tier lookup strategy. A resolve_bridge helper in the dispatch module first attempts specialized vendor/family resolution based on ProviderKey, then falls back to the legacy Provider-keyed registry. The chat dispatcher is updated to call this new resolver instead of directly querying the hub.

Changes

Bridge Resolution Two-Tier Lookup

Layer / File(s)Summary
Two-tier resolve_bridge helper
crates/aisix-proxy/src/dispatch.rs
New pub(crate) fn resolve_bridge dispatches bridge lookup via two-tier resolution using ProviderKey (specialized), then falls back to legacy Provider-keyed registry. Imports updated to include Bridge, Hub, and Arc.
Chat dispatcher bridge resolution
crates/aisix-proxy/src/chat.rs
Bridge resolution in the routing dispatch loop calls crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, provider) instead of direct state.hub.get(provider) query. Error handling for missing bridges is triggered by the resolver's None result.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@moonming

Copy link
Copy Markdown
MemberAuthor

Independent third-party audit per CLAUDE.md §8

Conducted cold (no shared context). Read the PR description, the full diff, the two touched files, and grepped the broader proxy crate for additional dispatch surfaces.

Verification: cargo test -p aisix-proxy --lib → 218 passed locally; cargo clippy -p aisix-proxy --all-targets clean.


HIGH

H1. Scope mismatch — five other dispatch sites still use state.hub.get(...) directly

The PR description and dispatch.rs doc-comment frame this as the Phase D cutover join point for proxy dispatch. The PR description further claims:

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

That grep is incomplete — it doesn't match state and .hub.get(...) when they're split across lines, which is the normal rustfmt shape for this expression. A multi-line search (rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get") finds six dispatch sites, only one of which the PR patches:

file:linerolepost-PR routing
chat.rs:500validation pre-checklegacy-only (intentional, pk not in scope)
chat.rs:524–527streaming chat dispatchlegacy-only — MISSED
chat.rs:817–821non-streaming chat dispatch + routing falloverresolve_bridge ✓
completions.rs:119–122/v1/completions dispatchlegacy-only — MISSED
embeddings.rs:139–142/v1/embeddings dispatchlegacy-only — MISSED
images.rs:134–137/v1/images/generations dispatchlegacy-only — MISSED
messages.rs:425–428cross_provider_dispatch for /v1/messages non-Anthropic upstreamlegacy-only — MISSED

Consequence after issue #302 Phase D ships (cp-api B3 + DP register_specialized / register_family wiring):

  • A request to POST /v1/chat/completions with stream: false → two-tier resolution honoured.
  • A request to POST /v1/chat/completions with stream: true → silently bypasses two-tier; specialized Bridge registrations have no effect for streaming chat.
  • A request to POST /v1/embeddings / /v1/completions / /v1/images/generations / /v1/messages (cross-provider) → silently bypasses two-tier.

This contradicts the issue #302 TL;DR contract ("DP 端 Hub 两层 dispatch") and means a specialized DeepSeek/Jina/etc. Bridge added in a future PR would work for non-streaming chat but silently no-op everywhere else — exactly the kind of "works in one place, broken in another" inconsistency Phase D is meant to eliminate.

Suggested fix: route all six dispatch sites through resolve_bridge. Concretely:

// chat.rs:524 (streaming path)letSome(bridge) =
crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider)else{returnErr(with_model(ProxyError::ProviderUnavailable));};// completions.rs:119let bridge = crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider).ok_or(ProxyError::ProviderUnavailable)?;// embeddings.rs:139, images.rs:134 — same shape// messages.rs:425 — same shape, using `pk_entry.value` from the caller frame

If the intent is genuinely "Phase D cutover is chat-completions-non-streaming only and the rest land in a follow-up PR", the PR title/description should say that explicitly and the follow-up should be linked. Right now the PR claims completeness ("no other surfaces dispatch via Hub") that the code does not match.

H2. resolve_bridge itself has no unit test

dispatch.rs adds the function but mod tests is unchanged — the existing 8 tests cover URL helpers and resolve_provider_key, none of them touches resolve_bridge. The PR description leans on "218 lib tests pass", but every one of those tests is built on a Hub constructed via hub.register(Provider, Bridge) — none of them registers anything via register_specialized / register_family, so the specialized-hit and family-hit branches of resolve_bridge are completely uncovered. The only branch the existing suite exercises is the legacy fallback (which behaves identically to the previous code), so the suite cannot fail on a regression in the two-tier path.

This is exactly the "tests pass but the new code path is untested" gap CLAUDE.md §8 calls out.

Suggested fix: add three trivial unit tests in dispatch.rs:

#[cfg(test)]mod resolve_bridge_tests {usesuper::*;use aisix_core::models::Adapter;use aisix_gateway::Bridge;use std::sync::Arc;// A trivial Bridge whose name lets the assertion identify which// registration tier resolved.#[derive(Debug)]structNamedBridge(&'staticstr);#[async_trait::async_trait]implBridgeforNamedBridge{fnname(&self) -> &str{self.0}// ... (use whatever the existing test stubs use in hub.rs)}fnpk_with(provider:&str,adapter:Option<Adapter>) -> ProviderKey{letmut pk:ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
pk.provider = provider.to_string();
pk.adapter = adapter;
pk
}#[test]fnspecialized_hit_wins_over_family_and_legacy(){let hub = Hub::new();
hub.register_specialized("deepseek",Arc::new(NamedBridge("specialized")));
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("deepseek",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"specialized");}#[test]fnfamily_hit_when_no_specialized(){let hub = Hub::new();
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("anyvendor",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"family");}#[test]fnlegacy_fallback_when_neither_tier_registered(){let hub = Hub::new();
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,).unwrap();assert_eq!(b.name(),"legacy");}#[test]fnreturns_none_when_nothing_registered(){let hub = Hub::new();let r = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,);assert!(r.is_none());}}

(If NamedBridge is too heavy because of the full Bridge trait surface, reuse StubBridge from aisix-gateway/src/hub.rs tests by making it pub(crate) or by duplicating the minimal stub — hub.rs already has the working pattern at lines 188–199.)


MEDIUM

M1. Latent inconsistency at chat.rs:500 (validation) vs chat.rs:821 (dispatch)

chat.rs:500 validates "is this provider known to the gateway?" with legacy-only state.hub.get(provider).is_none(). Once Phase D fully ships and a specialized Bridge for, say, vendor "newcorp" is registered without a corresponding legacy Provider enum variant (the whole point of Phase D — collapse the closed enum), the validation will reject the request with 503 even though the dispatch path at line 821 would have resolved it via dispatch_two_tier.

This is not a present-day bug — today every specialized vendor still maps to one of the six legacy Provider enum variants. But once Phase E/F lands and the enum is collapsed, this validation site becomes a silent false-negative gate ahead of an otherwise-working dispatch path.

The PR description explicitly punts on this ("pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today"). That's true today but is exactly the kind of latent gap a Phase D PR should at least file as a follow-up.

Suggested fix (one of):

  • (Cheapest) drop the line 500 pre-validation entirely — the routing loop at line 821 already returns a proper BridgeError::Config("no bridge registered for provider") envelope that surfaces the same operator-error class with the same HTTP status mapping. The pre-check is a 5-line shortcut on top of an already-correct fallthrough.
  • (More principled) resolve pk_entry for the only-target case before the validation gate, then use resolve_bridge there too. Adds ~5 lines.
  • (Defer) file a tracking issue ("collapse chat.rs:500 once Provider enum closes") and link from this PR + refactor(server): inline DeepSeek/Google bridge factories + delete wrapper crates (Phase A) #302.

Either fix is fine; doing nothing leaves a latent regression for the team that lands Phase E.

M2. dispatch_two_tier → None after specialized was registered could mask a config drift

Today dispatch_two_tier returns None whenever the requested pk.provider is not registered as specialized andpk.adapter is None or not registered as a family. The resolve_bridge wrapper then falls back to hub.get(provider).

Post-Phase D, suppose an operator registers a specialized Bridge for "deepseek" and the DP is reconfigured at runtime to unregister it (or it's evicted by a future eviction policy, or a typo in register_specialized writes "deep-seek" instead of "deepseek"). The resolve_bridge wrapper silently falls back to the legacy Provider::Deepseek bridge — which today is an OpenAI-compat bridge with_name("deepseek"). The request succeeds against the wrong handler.

This is graceful, not silently-wrong-output: the legacy bridge is what serves this vendor today, so the fallback is the correct behaviour pre-cutover. But the comment in dispatch.rs says "Returns None only when both layers miss — i.e. the operator has no bridge wired for this request at all", which under-sells the silent shadowing: a specialized handler going missing falls back to whatever the legacy registry still has, with no log line.

Suggested fix: add a tracing::warn! (or debug!) when dispatch_two_tier returned None but hub.get(provider) returned Some, so operators get a signal during the cutover. Something like:

pub(crate)fnresolve_bridge(hub:&Hub,provider_key:&ProviderKey,provider:Provider,) -> Option<Arc<dynBridge>>{ifletSome(b) = hub.dispatch_two_tier(provider_key){returnSome(b);}let fallback = hub.get(provider)?;if !provider_key.provider.is_empty() || provider_key.adapter.is_some(){
tracing::debug!(
target = "aisix_proxy::dispatch",
pk_provider = %provider_key.provider,
pk_adapter = ?provider_key.adapter,
legacy_provider = ?provider,"two-tier dispatch missed for a PK that carries new-shape \ fields; falling back to legacy Provider-keyed registry");}Some(fallback)}

debug! keeps this off the hot logging path in normal operation but gives the operator a knob during cutover. The condition guard means it stays silent today (where every PK has provider: "" and adapter: None) and only fires post-B3.


LOW

L1. PR description's grep is incomplete and should be re-run

The PR description's audit-trail grep:

grep -n "state\.hub\.get\|hub\.get\(.*Provider"

is single-line and misses the standard rustfmt-wrapped form state\n .hub\n .get(provider). Recommend replacing the description's grep with rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get" (or simply rg -U --multiline "\.hub") so the next reviewer can verify scope without rediscovering the multi-line gap.

L2. messages.rs:399 doc-comment will be out-of-date once H1 lands

Module doc-comment at messages.rs:399:

/// 2. hub.get(model.provider) → Bridge for the configured upstream

will drift if H1 is fixed by routing the cross-provider path through resolve_bridge. Update to:

/// 2. resolve_bridge(hub, pk, model.provider) → Bridge (two-tier with
/// legacy fallback; see crate::dispatch::resolve_bridge)

Not a blocker, but matches the new contract.

L3. chat.rs:524 carries a now-misleading comment

The streaming dispatch site has a long pre-PR comment explaining streaming fallback semantics. The comment is correct, but a future maintainer comparing chat.rs:524 (legacy hub.get) and chat.rs:821 (resolve_bridge) will rightly wonder why streaming dispatches differently. If H1's fix lands and routes both through resolve_bridge, the asymmetry disappears. If it doesn't, a one-line comment at line 524 explaining the deliberate divergence ("streaming path still uses legacy hub.get — Phase D cutover pending, see #305") avoids the future maintainer thinking they spotted a copy-paste bug.


Sensitive-info leakage, security, breaking changes

  • Sensitive info: none. resolve_bridge returns Option<Arc<dyn Bridge>>; the None arm in chat.rs:821 produces BridgeError::Config("no bridge registered for provider") which is operator-error class and doesn't leak any PK fields. The fallback's existence is invisible to clients.
  • Security: none. No new auth path, no new input boundary, no header forwarding change.
  • Breaking changes: none today (legacy fallback covers every PK). The PR's framing ("zero behavior change today") is accurate for the patched dispatch site. The H1 finding is about future breakage post-Phase-D-cutover, not present-day regressions.

Verdict

Merge gate per CLAUDE.md §8: NOT YET — H1 and H2 must be addressed (or explicitly deferred with a linked follow-up issue) before merge.

H1 is the substantive concern: the PR's claim "no other surfaces dispatch via Hub" is factually wrong; five other dispatch sites still bypass the two-tier path. Either patch them all in this PR (the diff stays small — five 4-line changes) or scope the PR title/description to "chat-completions non-streaming" and file a follow-up for the rest. H2 is a 50-line test addition that's hard to justify deferring given the entire PR's runtime impact is "now invokes a helper" — proving the helper's branches behave correctly is the bare minimum.

M1 and M2 should be either addressed inline or filed as linked follow-up issues — both are latent gaps that bite Phase E/F, not present-day regressions.

LOW findings are housekeeping; merge is not blocked on them but they're worth a small editing pass.

Once H1 + H2 are resolved (and M1 + M2 are addressed or explicitly justified), this PR passes independent audit. The core change — adding a small resolve_bridge helper that wraps dispatch_two_tier with a legacy fallback — is correct and well-documented; the only issue is the scope mismatch between what the description claims and what the diff covers.

Addresses audit HIGH-1 + HIGH-2 + MEDIUM-2 from independent audit on
PR #305 (#305 (comment)):
**HIGH-1** — initial PR only patched chat.rs:817 (non-streaming chat).
Audit's multi-line grep found 5 more dispatch sites that bypass
resolve_bridge entirely; post-Phase-D specialized Bridge registrations
would silently be ignored for streaming chat, completions, embeddings,
messages, and images. All 5 now route through `resolve_bridge`:
- chat.rs:524 streaming chat
- completions.rs:119 /v1/completions
- embeddings.rs:139 /v1/embeddings
- images.rs:134 /v1/images/generations
- messages.rs:425 /v1/messages (cross-provider Anthropic-shape)
chat.rs:500 stays legacy-only intentionally — pk isn't in scope at the
pre-validation point, and today's two-tier and legacy registries cover
the same Provider set so the gap isn't observable. Documented as
MEDIUM-1 latent risk; will revisit when Phase E/F collapses the
Provider enum.
**HIGH-2** — added 4 unit tests in `dispatch.rs::tests::resolve_bridge_tests`
covering all three reachable outcomes:
1. specialized_hit_wins_over_family_and_legacy — pk.provider hits
2. family_hit_when_specialized_misses — pk.adapter falls through to family
3. legacy_fallback_when_both_new_tiers_miss — today's pre-cutover state
4. none_when_nothing_registered — all three layers empty
A minimal local StubBridge fixture avoids the cross-crate visibility
issue with aisix-gateway's private test stub.
**MEDIUM-2** — `resolve_bridge` now emits a `tracing::debug!` when the
PK carries new-shape fields (`provider` non-empty or `adapter: Some`)
but the two-tier path still missed. Pre-cutover PKs (empty provider +
adapter: None) take the silent path. Post-cutover this is the early
signal that a specialized Bridge name was misregistered (typo, runtime
unregister) or that the adapter map missed an entry.
LOWs deferred (PR description grep update, messages.rs:399 doc-comment,
chat.rs:524 explanatory comment).
Tests:
- `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new)
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
@moonming

Copy link
Copy Markdown
MemberAuthor

HIGH-1 + HIGH-2 + MEDIUM-2 addressed in 2fc8496

Per the independent audit:

HIGH-1 — wired 5 missed dispatch sites

Multi-line grep verified by audit: 6 total dispatch sites in aisix-proxy, original PR only patched 1. All 5 missed now use `resolve_bridge`:

  • `chat.rs:524` streaming chat
  • `completions.rs:119` /v1/completions
  • `embeddings.rs:139` /v1/embeddings
  • `images.rs:134` /v1/images/generations
  • `messages.rs:425` /v1/messages

`chat.rs:500` stays legacy-only intentionally (pk not in scope yet, two-tier and legacy registries cover same Provider set today). Tracked as MEDIUM-1 latent risk for Phase E/F when Provider enum collapses.

HIGH-2 — added 4 unit tests for `resolve_bridge`

`dispatch::tests::resolve_bridge_tests`:

  1. `specialized_hit_wins_over_family_and_legacy` — pk.provider hits specialized
  2. `family_hit_when_specialized_misses` — pk.adapter falls through to family
  3. `legacy_fallback_when_both_new_tiers_miss` — today's pre-cutover state
  4. `none_when_nothing_registered` — all three layers empty

Minimal local StubBridge fixture avoids cross-crate visibility on aisix-gateway's private test stub.

MEDIUM-2 — tracing::debug! when fallback fires post-cutover

`resolve_bridge` now emits `tracing::debug!` when the PK carries new-shape fields (`provider` non-empty OR `adapter: Some`) but two-tier missed. Pre-cutover PKs stay silent (dominant case). Post-cutover this is the early signal for misregistered Bridges or adapter_map gaps.

LOWs deferred

  • L1: PR description grep — updated
  • L2: `messages.rs:399` doc-comment — defer to housekeeping PR
  • L3: `chat.rs:524` explanatory comment — covered by commit message

Tests: `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new); clippy clean; fmt clean.

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.

2 participants

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

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover) - #305

Merged
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire
May 17, 2026
Merged

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover)#305
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire

Conversation

@moonming

@moonmingmoonming commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Phase D cutover for aisix-proxy dispatch. Adds crate::dispatch::resolve_bridge — a small helper that tries the two-tier dispatch path (specialized vendor → adapter family from PR #300) first, then falls back to the legacy Provider-keyed registry when neither tier matches.

The fallback exists because today's on-disk ProviderKey payloads carry provider: "" + adapter: None (the new fields from PR #298/#303 ship empty until cp-api's B3 sub-PR populates them). The two-tier path therefore returns None on every existing key and the legacy registry continues serving traffic unchanged. Zero behavior change today.

After B3 ships and cp-api re-projects every ProviderKey with the new provider + adapter fields filled, the two-tier path will start returning bridges and the legacy fallback becomes the residual safety net.

Changes

  • dispatch.rs: add pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>
  • chat.rs:817: route the single dispatch site through resolve_bridge instead of state.hub.get(provider)
  • chat.rs:500: validation check stays legacy-only (pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today)

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

Test plan

  • cargo test -p aisix-proxy --lib — 218 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Live integration with new B3 payload (separate PR — requires cp-api to populate adapter/provider fields first)

Refs api7/AISIX-Cloud#302

Summary by CodeRabbit

  • Refactor
    • Improved provider bridge resolution mechanism with enhanced lookup strategy for better system stability.

Review Change Stack

…D cutover)
Adds `crate::dispatch::resolve_bridge` — a small helper that tries the
two-tier dispatch path (specialized vendor → adapter family, both new
in PR #300) first, then falls back to the legacy `Provider`-keyed
registry when neither tier matches. The fallback exists because today's
on-disk `ProviderKey` payloads carry `provider: ""` + `adapter: None`
(the new fields ship empty until cp-api's B3 sub-PR populates them) —
the two-tier path therefore returns `None` on every existing key and
the legacy registry continues serving traffic unchanged.
After B3 ships and cp-api re-projects every `ProviderKey` with the
new `provider` + `adapter` fields filled, the two-tier path will start
returning bridges and the legacy fallback becomes the residual safety
net. Once we're confident the cutover is complete, a follow-up PR can
delete the legacy `Hub::register(Provider, _)` registrations and the
fallback branch.
Changes:
- `dispatch.rs`: add `pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>`
- `chat.rs:817`: route the single dispatch site through `resolve_bridge`
instead of `state.hub.get(provider)`. The validation check at
`chat.rs:500` keeps the legacy-only check — pk isn't in scope there
yet, and the two-tier and legacy registries always cover the same
Provider set today, so it's not load-bearing.
Zero behavior change today (both tiers miss, legacy serves the request).
Tests:
- `cargo test -p aisix-proxy --lib` — 218 passed
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
CopilotAI review requested due to automatic review settings May 17, 2026 00:45
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 1d561013-999c-4403-9dcf-f73040dd17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 7f811ce and 2fc8496.

📒 Files selected for processing (6)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/messages.rs
📝 Walkthrough

Walkthrough

The PR refactors bridge resolution to use a new two-tier lookup strategy. A resolve_bridge helper in the dispatch module first attempts specialized vendor/family resolution based on ProviderKey, then falls back to the legacy Provider-keyed registry. The chat dispatcher is updated to call this new resolver instead of directly querying the hub.

Changes

Bridge Resolution Two-Tier Lookup

Layer / File(s)Summary
Two-tier resolve_bridge helper
crates/aisix-proxy/src/dispatch.rs
New pub(crate) fn resolve_bridge dispatches bridge lookup via two-tier resolution using ProviderKey (specialized), then falls back to legacy Provider-keyed registry. Imports updated to include Bridge, Hub, and Arc.
Chat dispatcher bridge resolution
crates/aisix-proxy/src/chat.rs
Bridge resolution in the routing dispatch loop calls crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, provider) instead of direct state.hub.get(provider) query. Error handling for missing bridges is triggered by the resolver's None result.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@moonming

Copy link
Copy Markdown
MemberAuthor

Independent third-party audit per CLAUDE.md §8

Conducted cold (no shared context). Read the PR description, the full diff, the two touched files, and grepped the broader proxy crate for additional dispatch surfaces.

Verification: cargo test -p aisix-proxy --lib → 218 passed locally; cargo clippy -p aisix-proxy --all-targets clean.


HIGH

H1. Scope mismatch — five other dispatch sites still use state.hub.get(...) directly

The PR description and dispatch.rs doc-comment frame this as the Phase D cutover join point for proxy dispatch. The PR description further claims:

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

That grep is incomplete — it doesn't match state and .hub.get(...) when they're split across lines, which is the normal rustfmt shape for this expression. A multi-line search (rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get") finds six dispatch sites, only one of which the PR patches:

file:linerolepost-PR routing
chat.rs:500validation pre-checklegacy-only (intentional, pk not in scope)
chat.rs:524–527streaming chat dispatchlegacy-only — MISSED
chat.rs:817–821non-streaming chat dispatch + routing falloverresolve_bridge ✓
completions.rs:119–122/v1/completions dispatchlegacy-only — MISSED
embeddings.rs:139–142/v1/embeddings dispatchlegacy-only — MISSED
images.rs:134–137/v1/images/generations dispatchlegacy-only — MISSED
messages.rs:425–428cross_provider_dispatch for /v1/messages non-Anthropic upstreamlegacy-only — MISSED

Consequence after issue #302 Phase D ships (cp-api B3 + DP register_specialized / register_family wiring):

  • A request to POST /v1/chat/completions with stream: false → two-tier resolution honoured.
  • A request to POST /v1/chat/completions with stream: true → silently bypasses two-tier; specialized Bridge registrations have no effect for streaming chat.
  • A request to POST /v1/embeddings / /v1/completions / /v1/images/generations / /v1/messages (cross-provider) → silently bypasses two-tier.

This contradicts the issue #302 TL;DR contract ("DP 端 Hub 两层 dispatch") and means a specialized DeepSeek/Jina/etc. Bridge added in a future PR would work for non-streaming chat but silently no-op everywhere else — exactly the kind of "works in one place, broken in another" inconsistency Phase D is meant to eliminate.

Suggested fix: route all six dispatch sites through resolve_bridge. Concretely:

// chat.rs:524 (streaming path)letSome(bridge) =
crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider)else{returnErr(with_model(ProxyError::ProviderUnavailable));};// completions.rs:119let bridge = crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider).ok_or(ProxyError::ProviderUnavailable)?;// embeddings.rs:139, images.rs:134 — same shape// messages.rs:425 — same shape, using `pk_entry.value` from the caller frame

If the intent is genuinely "Phase D cutover is chat-completions-non-streaming only and the rest land in a follow-up PR", the PR title/description should say that explicitly and the follow-up should be linked. Right now the PR claims completeness ("no other surfaces dispatch via Hub") that the code does not match.

H2. resolve_bridge itself has no unit test

dispatch.rs adds the function but mod tests is unchanged — the existing 8 tests cover URL helpers and resolve_provider_key, none of them touches resolve_bridge. The PR description leans on "218 lib tests pass", but every one of those tests is built on a Hub constructed via hub.register(Provider, Bridge) — none of them registers anything via register_specialized / register_family, so the specialized-hit and family-hit branches of resolve_bridge are completely uncovered. The only branch the existing suite exercises is the legacy fallback (which behaves identically to the previous code), so the suite cannot fail on a regression in the two-tier path.

This is exactly the "tests pass but the new code path is untested" gap CLAUDE.md §8 calls out.

Suggested fix: add three trivial unit tests in dispatch.rs:

#[cfg(test)]mod resolve_bridge_tests {usesuper::*;use aisix_core::models::Adapter;use aisix_gateway::Bridge;use std::sync::Arc;// A trivial Bridge whose name lets the assertion identify which// registration tier resolved.#[derive(Debug)]structNamedBridge(&'staticstr);#[async_trait::async_trait]implBridgeforNamedBridge{fnname(&self) -> &str{self.0}// ... (use whatever the existing test stubs use in hub.rs)}fnpk_with(provider:&str,adapter:Option<Adapter>) -> ProviderKey{letmut pk:ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
pk.provider = provider.to_string();
pk.adapter = adapter;
pk
}#[test]fnspecialized_hit_wins_over_family_and_legacy(){let hub = Hub::new();
hub.register_specialized("deepseek",Arc::new(NamedBridge("specialized")));
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("deepseek",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"specialized");}#[test]fnfamily_hit_when_no_specialized(){let hub = Hub::new();
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("anyvendor",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"family");}#[test]fnlegacy_fallback_when_neither_tier_registered(){let hub = Hub::new();
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,).unwrap();assert_eq!(b.name(),"legacy");}#[test]fnreturns_none_when_nothing_registered(){let hub = Hub::new();let r = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,);assert!(r.is_none());}}

(If NamedBridge is too heavy because of the full Bridge trait surface, reuse StubBridge from aisix-gateway/src/hub.rs tests by making it pub(crate) or by duplicating the minimal stub — hub.rs already has the working pattern at lines 188–199.)


MEDIUM

M1. Latent inconsistency at chat.rs:500 (validation) vs chat.rs:821 (dispatch)

chat.rs:500 validates "is this provider known to the gateway?" with legacy-only state.hub.get(provider).is_none(). Once Phase D fully ships and a specialized Bridge for, say, vendor "newcorp" is registered without a corresponding legacy Provider enum variant (the whole point of Phase D — collapse the closed enum), the validation will reject the request with 503 even though the dispatch path at line 821 would have resolved it via dispatch_two_tier.

This is not a present-day bug — today every specialized vendor still maps to one of the six legacy Provider enum variants. But once Phase E/F lands and the enum is collapsed, this validation site becomes a silent false-negative gate ahead of an otherwise-working dispatch path.

The PR description explicitly punts on this ("pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today"). That's true today but is exactly the kind of latent gap a Phase D PR should at least file as a follow-up.

Suggested fix (one of):

  • (Cheapest) drop the line 500 pre-validation entirely — the routing loop at line 821 already returns a proper BridgeError::Config("no bridge registered for provider") envelope that surfaces the same operator-error class with the same HTTP status mapping. The pre-check is a 5-line shortcut on top of an already-correct fallthrough.
  • (More principled) resolve pk_entry for the only-target case before the validation gate, then use resolve_bridge there too. Adds ~5 lines.
  • (Defer) file a tracking issue ("collapse chat.rs:500 once Provider enum closes") and link from this PR + refactor(server): inline DeepSeek/Google bridge factories + delete wrapper crates (Phase A) #302.

Either fix is fine; doing nothing leaves a latent regression for the team that lands Phase E.

M2. dispatch_two_tier → None after specialized was registered could mask a config drift

Today dispatch_two_tier returns None whenever the requested pk.provider is not registered as specialized andpk.adapter is None or not registered as a family. The resolve_bridge wrapper then falls back to hub.get(provider).

Post-Phase D, suppose an operator registers a specialized Bridge for "deepseek" and the DP is reconfigured at runtime to unregister it (or it's evicted by a future eviction policy, or a typo in register_specialized writes "deep-seek" instead of "deepseek"). The resolve_bridge wrapper silently falls back to the legacy Provider::Deepseek bridge — which today is an OpenAI-compat bridge with_name("deepseek"). The request succeeds against the wrong handler.

This is graceful, not silently-wrong-output: the legacy bridge is what serves this vendor today, so the fallback is the correct behaviour pre-cutover. But the comment in dispatch.rs says "Returns None only when both layers miss — i.e. the operator has no bridge wired for this request at all", which under-sells the silent shadowing: a specialized handler going missing falls back to whatever the legacy registry still has, with no log line.

Suggested fix: add a tracing::warn! (or debug!) when dispatch_two_tier returned None but hub.get(provider) returned Some, so operators get a signal during the cutover. Something like:

pub(crate)fnresolve_bridge(hub:&Hub,provider_key:&ProviderKey,provider:Provider,) -> Option<Arc<dynBridge>>{ifletSome(b) = hub.dispatch_two_tier(provider_key){returnSome(b);}let fallback = hub.get(provider)?;if !provider_key.provider.is_empty() || provider_key.adapter.is_some(){
tracing::debug!(
target = "aisix_proxy::dispatch",
pk_provider = %provider_key.provider,
pk_adapter = ?provider_key.adapter,
legacy_provider = ?provider,"two-tier dispatch missed for a PK that carries new-shape \ fields; falling back to legacy Provider-keyed registry");}Some(fallback)}

debug! keeps this off the hot logging path in normal operation but gives the operator a knob during cutover. The condition guard means it stays silent today (where every PK has provider: "" and adapter: None) and only fires post-B3.


LOW

L1. PR description's grep is incomplete and should be re-run

The PR description's audit-trail grep:

grep -n "state\.hub\.get\|hub\.get\(.*Provider"

is single-line and misses the standard rustfmt-wrapped form state\n .hub\n .get(provider). Recommend replacing the description's grep with rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get" (or simply rg -U --multiline "\.hub") so the next reviewer can verify scope without rediscovering the multi-line gap.

L2. messages.rs:399 doc-comment will be out-of-date once H1 lands

Module doc-comment at messages.rs:399:

/// 2. hub.get(model.provider) → Bridge for the configured upstream

will drift if H1 is fixed by routing the cross-provider path through resolve_bridge. Update to:

/// 2. resolve_bridge(hub, pk, model.provider) → Bridge (two-tier with
/// legacy fallback; see crate::dispatch::resolve_bridge)

Not a blocker, but matches the new contract.

L3. chat.rs:524 carries a now-misleading comment

The streaming dispatch site has a long pre-PR comment explaining streaming fallback semantics. The comment is correct, but a future maintainer comparing chat.rs:524 (legacy hub.get) and chat.rs:821 (resolve_bridge) will rightly wonder why streaming dispatches differently. If H1's fix lands and routes both through resolve_bridge, the asymmetry disappears. If it doesn't, a one-line comment at line 524 explaining the deliberate divergence ("streaming path still uses legacy hub.get — Phase D cutover pending, see #305") avoids the future maintainer thinking they spotted a copy-paste bug.


Sensitive-info leakage, security, breaking changes

  • Sensitive info: none. resolve_bridge returns Option<Arc<dyn Bridge>>; the None arm in chat.rs:821 produces BridgeError::Config("no bridge registered for provider") which is operator-error class and doesn't leak any PK fields. The fallback's existence is invisible to clients.
  • Security: none. No new auth path, no new input boundary, no header forwarding change.
  • Breaking changes: none today (legacy fallback covers every PK). The PR's framing ("zero behavior change today") is accurate for the patched dispatch site. The H1 finding is about future breakage post-Phase-D-cutover, not present-day regressions.

Verdict

Merge gate per CLAUDE.md §8: NOT YET — H1 and H2 must be addressed (or explicitly deferred with a linked follow-up issue) before merge.

H1 is the substantive concern: the PR's claim "no other surfaces dispatch via Hub" is factually wrong; five other dispatch sites still bypass the two-tier path. Either patch them all in this PR (the diff stays small — five 4-line changes) or scope the PR title/description to "chat-completions non-streaming" and file a follow-up for the rest. H2 is a 50-line test addition that's hard to justify deferring given the entire PR's runtime impact is "now invokes a helper" — proving the helper's branches behave correctly is the bare minimum.

M1 and M2 should be either addressed inline or filed as linked follow-up issues — both are latent gaps that bite Phase E/F, not present-day regressions.

LOW findings are housekeeping; merge is not blocked on them but they're worth a small editing pass.

Once H1 + H2 are resolved (and M1 + M2 are addressed or explicitly justified), this PR passes independent audit. The core change — adding a small resolve_bridge helper that wraps dispatch_two_tier with a legacy fallback — is correct and well-documented; the only issue is the scope mismatch between what the description claims and what the diff covers.

Addresses audit HIGH-1 + HIGH-2 + MEDIUM-2 from independent audit on
PR #305 (#305 (comment)):
**HIGH-1** — initial PR only patched chat.rs:817 (non-streaming chat).
Audit's multi-line grep found 5 more dispatch sites that bypass
resolve_bridge entirely; post-Phase-D specialized Bridge registrations
would silently be ignored for streaming chat, completions, embeddings,
messages, and images. All 5 now route through `resolve_bridge`:
- chat.rs:524 streaming chat
- completions.rs:119 /v1/completions
- embeddings.rs:139 /v1/embeddings
- images.rs:134 /v1/images/generations
- messages.rs:425 /v1/messages (cross-provider Anthropic-shape)
chat.rs:500 stays legacy-only intentionally — pk isn't in scope at the
pre-validation point, and today's two-tier and legacy registries cover
the same Provider set so the gap isn't observable. Documented as
MEDIUM-1 latent risk; will revisit when Phase E/F collapses the
Provider enum.
**HIGH-2** — added 4 unit tests in `dispatch.rs::tests::resolve_bridge_tests`
covering all three reachable outcomes:
1. specialized_hit_wins_over_family_and_legacy — pk.provider hits
2. family_hit_when_specialized_misses — pk.adapter falls through to family
3. legacy_fallback_when_both_new_tiers_miss — today's pre-cutover state
4. none_when_nothing_registered — all three layers empty
A minimal local StubBridge fixture avoids the cross-crate visibility
issue with aisix-gateway's private test stub.
**MEDIUM-2** — `resolve_bridge` now emits a `tracing::debug!` when the
PK carries new-shape fields (`provider` non-empty or `adapter: Some`)
but the two-tier path still missed. Pre-cutover PKs (empty provider +
adapter: None) take the silent path. Post-cutover this is the early
signal that a specialized Bridge name was misregistered (typo, runtime
unregister) or that the adapter map missed an entry.
LOWs deferred (PR description grep update, messages.rs:399 doc-comment,
chat.rs:524 explanatory comment).
Tests:
- `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new)
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
@moonming

Copy link
Copy Markdown
MemberAuthor

HIGH-1 + HIGH-2 + MEDIUM-2 addressed in 2fc8496

Per the independent audit:

HIGH-1 — wired 5 missed dispatch sites

Multi-line grep verified by audit: 6 total dispatch sites in aisix-proxy, original PR only patched 1. All 5 missed now use `resolve_bridge`:

  • `chat.rs:524` streaming chat
  • `completions.rs:119` /v1/completions
  • `embeddings.rs:139` /v1/embeddings
  • `images.rs:134` /v1/images/generations
  • `messages.rs:425` /v1/messages

`chat.rs:500` stays legacy-only intentionally (pk not in scope yet, two-tier and legacy registries cover same Provider set today). Tracked as MEDIUM-1 latent risk for Phase E/F when Provider enum collapses.

HIGH-2 — added 4 unit tests for `resolve_bridge`

`dispatch::tests::resolve_bridge_tests`:

  1. `specialized_hit_wins_over_family_and_legacy` — pk.provider hits specialized
  2. `family_hit_when_specialized_misses` — pk.adapter falls through to family
  3. `legacy_fallback_when_both_new_tiers_miss` — today's pre-cutover state
  4. `none_when_nothing_registered` — all three layers empty

Minimal local StubBridge fixture avoids cross-crate visibility on aisix-gateway's private test stub.

MEDIUM-2 — tracing::debug! when fallback fires post-cutover

`resolve_bridge` now emits `tracing::debug!` when the PK carries new-shape fields (`provider` non-empty OR `adapter: Some`) but two-tier missed. Pre-cutover PKs stay silent (dominant case). Post-cutover this is the early signal for misregistered Bridges or adapter_map gaps.

LOWs deferred

  • L1: PR description grep — updated
  • L2: `messages.rs:399` doc-comment — defer to housekeeping PR
  • L3: `chat.rs:524` explanatory comment — covered by commit message

Tests: `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new); clippy clean; fmt clean.

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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover) - #305

Merged
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire
May 17, 2026
Merged

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover)#305
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire

Conversation

@moonming

@moonmingmoonming commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Phase D cutover for aisix-proxy dispatch. Adds crate::dispatch::resolve_bridge — a small helper that tries the two-tier dispatch path (specialized vendor → adapter family from PR #300) first, then falls back to the legacy Provider-keyed registry when neither tier matches.

The fallback exists because today's on-disk ProviderKey payloads carry provider: "" + adapter: None (the new fields from PR #298/#303 ship empty until cp-api's B3 sub-PR populates them). The two-tier path therefore returns None on every existing key and the legacy registry continues serving traffic unchanged. Zero behavior change today.

After B3 ships and cp-api re-projects every ProviderKey with the new provider + adapter fields filled, the two-tier path will start returning bridges and the legacy fallback becomes the residual safety net.

Changes

  • dispatch.rs: add pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>
  • chat.rs:817: route the single dispatch site through resolve_bridge instead of state.hub.get(provider)
  • chat.rs:500: validation check stays legacy-only (pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today)

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

Test plan

  • cargo test -p aisix-proxy --lib — 218 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Live integration with new B3 payload (separate PR — requires cp-api to populate adapter/provider fields first)

Refs api7/AISIX-Cloud#302

Summary by CodeRabbit

  • Refactor
    • Improved provider bridge resolution mechanism with enhanced lookup strategy for better system stability.

Review Change Stack

…D cutover)
Adds `crate::dispatch::resolve_bridge` — a small helper that tries the
two-tier dispatch path (specialized vendor → adapter family, both new
in PR #300) first, then falls back to the legacy `Provider`-keyed
registry when neither tier matches. The fallback exists because today's
on-disk `ProviderKey` payloads carry `provider: ""` + `adapter: None`
(the new fields ship empty until cp-api's B3 sub-PR populates them) —
the two-tier path therefore returns `None` on every existing key and
the legacy registry continues serving traffic unchanged.
After B3 ships and cp-api re-projects every `ProviderKey` with the
new `provider` + `adapter` fields filled, the two-tier path will start
returning bridges and the legacy fallback becomes the residual safety
net. Once we're confident the cutover is complete, a follow-up PR can
delete the legacy `Hub::register(Provider, _)` registrations and the
fallback branch.
Changes:
- `dispatch.rs`: add `pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>`
- `chat.rs:817`: route the single dispatch site through `resolve_bridge`
instead of `state.hub.get(provider)`. The validation check at
`chat.rs:500` keeps the legacy-only check — pk isn't in scope there
yet, and the two-tier and legacy registries always cover the same
Provider set today, so it's not load-bearing.
Zero behavior change today (both tiers miss, legacy serves the request).
Tests:
- `cargo test -p aisix-proxy --lib` — 218 passed
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
CopilotAI review requested due to automatic review settings May 17, 2026 00:45
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 1d561013-999c-4403-9dcf-f73040dd17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 7f811ce and 2fc8496.

📒 Files selected for processing (6)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/messages.rs
📝 Walkthrough

Walkthrough

The PR refactors bridge resolution to use a new two-tier lookup strategy. A resolve_bridge helper in the dispatch module first attempts specialized vendor/family resolution based on ProviderKey, then falls back to the legacy Provider-keyed registry. The chat dispatcher is updated to call this new resolver instead of directly querying the hub.

Changes

Bridge Resolution Two-Tier Lookup

Layer / File(s)Summary
Two-tier resolve_bridge helper
crates/aisix-proxy/src/dispatch.rs
New pub(crate) fn resolve_bridge dispatches bridge lookup via two-tier resolution using ProviderKey (specialized), then falls back to legacy Provider-keyed registry. Imports updated to include Bridge, Hub, and Arc.
Chat dispatcher bridge resolution
crates/aisix-proxy/src/chat.rs
Bridge resolution in the routing dispatch loop calls crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, provider) instead of direct state.hub.get(provider) query. Error handling for missing bridges is triggered by the resolver's None result.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@moonming

Copy link
Copy Markdown
MemberAuthor

Independent third-party audit per CLAUDE.md §8

Conducted cold (no shared context). Read the PR description, the full diff, the two touched files, and grepped the broader proxy crate for additional dispatch surfaces.

Verification: cargo test -p aisix-proxy --lib → 218 passed locally; cargo clippy -p aisix-proxy --all-targets clean.


HIGH

H1. Scope mismatch — five other dispatch sites still use state.hub.get(...) directly

The PR description and dispatch.rs doc-comment frame this as the Phase D cutover join point for proxy dispatch. The PR description further claims:

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

That grep is incomplete — it doesn't match state and .hub.get(...) when they're split across lines, which is the normal rustfmt shape for this expression. A multi-line search (rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get") finds six dispatch sites, only one of which the PR patches:

file:linerolepost-PR routing
chat.rs:500validation pre-checklegacy-only (intentional, pk not in scope)
chat.rs:524–527streaming chat dispatchlegacy-only — MISSED
chat.rs:817–821non-streaming chat dispatch + routing falloverresolve_bridge ✓
completions.rs:119–122/v1/completions dispatchlegacy-only — MISSED
embeddings.rs:139–142/v1/embeddings dispatchlegacy-only — MISSED
images.rs:134–137/v1/images/generations dispatchlegacy-only — MISSED
messages.rs:425–428cross_provider_dispatch for /v1/messages non-Anthropic upstreamlegacy-only — MISSED

Consequence after issue #302 Phase D ships (cp-api B3 + DP register_specialized / register_family wiring):

  • A request to POST /v1/chat/completions with stream: false → two-tier resolution honoured.
  • A request to POST /v1/chat/completions with stream: true → silently bypasses two-tier; specialized Bridge registrations have no effect for streaming chat.
  • A request to POST /v1/embeddings / /v1/completions / /v1/images/generations / /v1/messages (cross-provider) → silently bypasses two-tier.

This contradicts the issue #302 TL;DR contract ("DP 端 Hub 两层 dispatch") and means a specialized DeepSeek/Jina/etc. Bridge added in a future PR would work for non-streaming chat but silently no-op everywhere else — exactly the kind of "works in one place, broken in another" inconsistency Phase D is meant to eliminate.

Suggested fix: route all six dispatch sites through resolve_bridge. Concretely:

// chat.rs:524 (streaming path)letSome(bridge) =
crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider)else{returnErr(with_model(ProxyError::ProviderUnavailable));};// completions.rs:119let bridge = crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider).ok_or(ProxyError::ProviderUnavailable)?;// embeddings.rs:139, images.rs:134 — same shape// messages.rs:425 — same shape, using `pk_entry.value` from the caller frame

If the intent is genuinely "Phase D cutover is chat-completions-non-streaming only and the rest land in a follow-up PR", the PR title/description should say that explicitly and the follow-up should be linked. Right now the PR claims completeness ("no other surfaces dispatch via Hub") that the code does not match.

H2. resolve_bridge itself has no unit test

dispatch.rs adds the function but mod tests is unchanged — the existing 8 tests cover URL helpers and resolve_provider_key, none of them touches resolve_bridge. The PR description leans on "218 lib tests pass", but every one of those tests is built on a Hub constructed via hub.register(Provider, Bridge) — none of them registers anything via register_specialized / register_family, so the specialized-hit and family-hit branches of resolve_bridge are completely uncovered. The only branch the existing suite exercises is the legacy fallback (which behaves identically to the previous code), so the suite cannot fail on a regression in the two-tier path.

This is exactly the "tests pass but the new code path is untested" gap CLAUDE.md §8 calls out.

Suggested fix: add three trivial unit tests in dispatch.rs:

#[cfg(test)]mod resolve_bridge_tests {usesuper::*;use aisix_core::models::Adapter;use aisix_gateway::Bridge;use std::sync::Arc;// A trivial Bridge whose name lets the assertion identify which// registration tier resolved.#[derive(Debug)]structNamedBridge(&'staticstr);#[async_trait::async_trait]implBridgeforNamedBridge{fnname(&self) -> &str{self.0}// ... (use whatever the existing test stubs use in hub.rs)}fnpk_with(provider:&str,adapter:Option<Adapter>) -> ProviderKey{letmut pk:ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
pk.provider = provider.to_string();
pk.adapter = adapter;
pk
}#[test]fnspecialized_hit_wins_over_family_and_legacy(){let hub = Hub::new();
hub.register_specialized("deepseek",Arc::new(NamedBridge("specialized")));
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("deepseek",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"specialized");}#[test]fnfamily_hit_when_no_specialized(){let hub = Hub::new();
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("anyvendor",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"family");}#[test]fnlegacy_fallback_when_neither_tier_registered(){let hub = Hub::new();
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,).unwrap();assert_eq!(b.name(),"legacy");}#[test]fnreturns_none_when_nothing_registered(){let hub = Hub::new();let r = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,);assert!(r.is_none());}}

(If NamedBridge is too heavy because of the full Bridge trait surface, reuse StubBridge from aisix-gateway/src/hub.rs tests by making it pub(crate) or by duplicating the minimal stub — hub.rs already has the working pattern at lines 188–199.)


MEDIUM

M1. Latent inconsistency at chat.rs:500 (validation) vs chat.rs:821 (dispatch)

chat.rs:500 validates "is this provider known to the gateway?" with legacy-only state.hub.get(provider).is_none(). Once Phase D fully ships and a specialized Bridge for, say, vendor "newcorp" is registered without a corresponding legacy Provider enum variant (the whole point of Phase D — collapse the closed enum), the validation will reject the request with 503 even though the dispatch path at line 821 would have resolved it via dispatch_two_tier.

This is not a present-day bug — today every specialized vendor still maps to one of the six legacy Provider enum variants. But once Phase E/F lands and the enum is collapsed, this validation site becomes a silent false-negative gate ahead of an otherwise-working dispatch path.

The PR description explicitly punts on this ("pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today"). That's true today but is exactly the kind of latent gap a Phase D PR should at least file as a follow-up.

Suggested fix (one of):

  • (Cheapest) drop the line 500 pre-validation entirely — the routing loop at line 821 already returns a proper BridgeError::Config("no bridge registered for provider") envelope that surfaces the same operator-error class with the same HTTP status mapping. The pre-check is a 5-line shortcut on top of an already-correct fallthrough.
  • (More principled) resolve pk_entry for the only-target case before the validation gate, then use resolve_bridge there too. Adds ~5 lines.
  • (Defer) file a tracking issue ("collapse chat.rs:500 once Provider enum closes") and link from this PR + refactor(server): inline DeepSeek/Google bridge factories + delete wrapper crates (Phase A) #302.

Either fix is fine; doing nothing leaves a latent regression for the team that lands Phase E.

M2. dispatch_two_tier → None after specialized was registered could mask a config drift

Today dispatch_two_tier returns None whenever the requested pk.provider is not registered as specialized andpk.adapter is None or not registered as a family. The resolve_bridge wrapper then falls back to hub.get(provider).

Post-Phase D, suppose an operator registers a specialized Bridge for "deepseek" and the DP is reconfigured at runtime to unregister it (or it's evicted by a future eviction policy, or a typo in register_specialized writes "deep-seek" instead of "deepseek"). The resolve_bridge wrapper silently falls back to the legacy Provider::Deepseek bridge — which today is an OpenAI-compat bridge with_name("deepseek"). The request succeeds against the wrong handler.

This is graceful, not silently-wrong-output: the legacy bridge is what serves this vendor today, so the fallback is the correct behaviour pre-cutover. But the comment in dispatch.rs says "Returns None only when both layers miss — i.e. the operator has no bridge wired for this request at all", which under-sells the silent shadowing: a specialized handler going missing falls back to whatever the legacy registry still has, with no log line.

Suggested fix: add a tracing::warn! (or debug!) when dispatch_two_tier returned None but hub.get(provider) returned Some, so operators get a signal during the cutover. Something like:

pub(crate)fnresolve_bridge(hub:&Hub,provider_key:&ProviderKey,provider:Provider,) -> Option<Arc<dynBridge>>{ifletSome(b) = hub.dispatch_two_tier(provider_key){returnSome(b);}let fallback = hub.get(provider)?;if !provider_key.provider.is_empty() || provider_key.adapter.is_some(){
tracing::debug!(
target = "aisix_proxy::dispatch",
pk_provider = %provider_key.provider,
pk_adapter = ?provider_key.adapter,
legacy_provider = ?provider,"two-tier dispatch missed for a PK that carries new-shape \ fields; falling back to legacy Provider-keyed registry");}Some(fallback)}

debug! keeps this off the hot logging path in normal operation but gives the operator a knob during cutover. The condition guard means it stays silent today (where every PK has provider: "" and adapter: None) and only fires post-B3.


LOW

L1. PR description's grep is incomplete and should be re-run

The PR description's audit-trail grep:

grep -n "state\.hub\.get\|hub\.get\(.*Provider"

is single-line and misses the standard rustfmt-wrapped form state\n .hub\n .get(provider). Recommend replacing the description's grep with rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get" (or simply rg -U --multiline "\.hub") so the next reviewer can verify scope without rediscovering the multi-line gap.

L2. messages.rs:399 doc-comment will be out-of-date once H1 lands

Module doc-comment at messages.rs:399:

/// 2. hub.get(model.provider) → Bridge for the configured upstream

will drift if H1 is fixed by routing the cross-provider path through resolve_bridge. Update to:

/// 2. resolve_bridge(hub, pk, model.provider) → Bridge (two-tier with
/// legacy fallback; see crate::dispatch::resolve_bridge)

Not a blocker, but matches the new contract.

L3. chat.rs:524 carries a now-misleading comment

The streaming dispatch site has a long pre-PR comment explaining streaming fallback semantics. The comment is correct, but a future maintainer comparing chat.rs:524 (legacy hub.get) and chat.rs:821 (resolve_bridge) will rightly wonder why streaming dispatches differently. If H1's fix lands and routes both through resolve_bridge, the asymmetry disappears. If it doesn't, a one-line comment at line 524 explaining the deliberate divergence ("streaming path still uses legacy hub.get — Phase D cutover pending, see #305") avoids the future maintainer thinking they spotted a copy-paste bug.


Sensitive-info leakage, security, breaking changes

  • Sensitive info: none. resolve_bridge returns Option<Arc<dyn Bridge>>; the None arm in chat.rs:821 produces BridgeError::Config("no bridge registered for provider") which is operator-error class and doesn't leak any PK fields. The fallback's existence is invisible to clients.
  • Security: none. No new auth path, no new input boundary, no header forwarding change.
  • Breaking changes: none today (legacy fallback covers every PK). The PR's framing ("zero behavior change today") is accurate for the patched dispatch site. The H1 finding is about future breakage post-Phase-D-cutover, not present-day regressions.

Verdict

Merge gate per CLAUDE.md §8: NOT YET — H1 and H2 must be addressed (or explicitly deferred with a linked follow-up issue) before merge.

H1 is the substantive concern: the PR's claim "no other surfaces dispatch via Hub" is factually wrong; five other dispatch sites still bypass the two-tier path. Either patch them all in this PR (the diff stays small — five 4-line changes) or scope the PR title/description to "chat-completions non-streaming" and file a follow-up for the rest. H2 is a 50-line test addition that's hard to justify deferring given the entire PR's runtime impact is "now invokes a helper" — proving the helper's branches behave correctly is the bare minimum.

M1 and M2 should be either addressed inline or filed as linked follow-up issues — both are latent gaps that bite Phase E/F, not present-day regressions.

LOW findings are housekeeping; merge is not blocked on them but they're worth a small editing pass.

Once H1 + H2 are resolved (and M1 + M2 are addressed or explicitly justified), this PR passes independent audit. The core change — adding a small resolve_bridge helper that wraps dispatch_two_tier with a legacy fallback — is correct and well-documented; the only issue is the scope mismatch between what the description claims and what the diff covers.

Addresses audit HIGH-1 + HIGH-2 + MEDIUM-2 from independent audit on
PR #305 (#305 (comment)):
**HIGH-1** — initial PR only patched chat.rs:817 (non-streaming chat).
Audit's multi-line grep found 5 more dispatch sites that bypass
resolve_bridge entirely; post-Phase-D specialized Bridge registrations
would silently be ignored for streaming chat, completions, embeddings,
messages, and images. All 5 now route through `resolve_bridge`:
- chat.rs:524 streaming chat
- completions.rs:119 /v1/completions
- embeddings.rs:139 /v1/embeddings
- images.rs:134 /v1/images/generations
- messages.rs:425 /v1/messages (cross-provider Anthropic-shape)
chat.rs:500 stays legacy-only intentionally — pk isn't in scope at the
pre-validation point, and today's two-tier and legacy registries cover
the same Provider set so the gap isn't observable. Documented as
MEDIUM-1 latent risk; will revisit when Phase E/F collapses the
Provider enum.
**HIGH-2** — added 4 unit tests in `dispatch.rs::tests::resolve_bridge_tests`
covering all three reachable outcomes:
1. specialized_hit_wins_over_family_and_legacy — pk.provider hits
2. family_hit_when_specialized_misses — pk.adapter falls through to family
3. legacy_fallback_when_both_new_tiers_miss — today's pre-cutover state
4. none_when_nothing_registered — all three layers empty
A minimal local StubBridge fixture avoids the cross-crate visibility
issue with aisix-gateway's private test stub.
**MEDIUM-2** — `resolve_bridge` now emits a `tracing::debug!` when the
PK carries new-shape fields (`provider` non-empty or `adapter: Some`)
but the two-tier path still missed. Pre-cutover PKs (empty provider +
adapter: None) take the silent path. Post-cutover this is the early
signal that a specialized Bridge name was misregistered (typo, runtime
unregister) or that the adapter map missed an entry.
LOWs deferred (PR description grep update, messages.rs:399 doc-comment,
chat.rs:524 explanatory comment).
Tests:
- `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new)
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
@moonming

Copy link
Copy Markdown
MemberAuthor

HIGH-1 + HIGH-2 + MEDIUM-2 addressed in 2fc8496

Per the independent audit:

HIGH-1 — wired 5 missed dispatch sites

Multi-line grep verified by audit: 6 total dispatch sites in aisix-proxy, original PR only patched 1. All 5 missed now use `resolve_bridge`:

  • `chat.rs:524` streaming chat
  • `completions.rs:119` /v1/completions
  • `embeddings.rs:139` /v1/embeddings
  • `images.rs:134` /v1/images/generations
  • `messages.rs:425` /v1/messages

`chat.rs:500` stays legacy-only intentionally (pk not in scope yet, two-tier and legacy registries cover same Provider set today). Tracked as MEDIUM-1 latent risk for Phase E/F when Provider enum collapses.

HIGH-2 — added 4 unit tests for `resolve_bridge`

`dispatch::tests::resolve_bridge_tests`:

  1. `specialized_hit_wins_over_family_and_legacy` — pk.provider hits specialized
  2. `family_hit_when_specialized_misses` — pk.adapter falls through to family
  3. `legacy_fallback_when_both_new_tiers_miss` — today's pre-cutover state
  4. `none_when_nothing_registered` — all three layers empty

Minimal local StubBridge fixture avoids cross-crate visibility on aisix-gateway's private test stub.

MEDIUM-2 — tracing::debug! when fallback fires post-cutover

`resolve_bridge` now emits `tracing::debug!` when the PK carries new-shape fields (`provider` non-empty OR `adapter: Some`) but two-tier missed. Pre-cutover PKs stay silent (dominant case). Post-cutover this is the early signal for misregistered Bridges or adapter_map gaps.

LOWs deferred

  • L1: PR description grep — updated
  • L2: `messages.rs:399` doc-comment — defer to housekeeping PR
  • L3: `chat.rs:524` explanatory comment — covered by commit message

Tests: `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new); clippy clean; fmt clean.

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.

2 participants

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

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover) - #305

Merged
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire
May 17, 2026
Merged

feat(proxy): wire Hub::dispatch_two_tier with legacy fallback (Phase D cutover)#305
moonming merged 2 commits into
mainfrom
feat/dispatch-two-tier-wire

Conversation

@moonming

@moonmingmoonming commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Phase D cutover for aisix-proxy dispatch. Adds crate::dispatch::resolve_bridge — a small helper that tries the two-tier dispatch path (specialized vendor → adapter family from PR #300) first, then falls back to the legacy Provider-keyed registry when neither tier matches.

The fallback exists because today's on-disk ProviderKey payloads carry provider: "" + adapter: None (the new fields from PR #298/#303 ship empty until cp-api's B3 sub-PR populates them). The two-tier path therefore returns None on every existing key and the legacy registry continues serving traffic unchanged. Zero behavior change today.

After B3 ships and cp-api re-projects every ProviderKey with the new provider + adapter fields filled, the two-tier path will start returning bridges and the legacy fallback becomes the residual safety net.

Changes

  • dispatch.rs: add pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>
  • chat.rs:817: route the single dispatch site through resolve_bridge instead of state.hub.get(provider)
  • chat.rs:500: validation check stays legacy-only (pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today)

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

Test plan

  • cargo test -p aisix-proxy --lib — 218 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Live integration with new B3 payload (separate PR — requires cp-api to populate adapter/provider fields first)

Refs api7/AISIX-Cloud#302

Summary by CodeRabbit

  • Refactor
    • Improved provider bridge resolution mechanism with enhanced lookup strategy for better system stability.

Review Change Stack

…D cutover)
Adds `crate::dispatch::resolve_bridge` — a small helper that tries the
two-tier dispatch path (specialized vendor → adapter family, both new
in PR #300) first, then falls back to the legacy `Provider`-keyed
registry when neither tier matches. The fallback exists because today's
on-disk `ProviderKey` payloads carry `provider: ""` + `adapter: None`
(the new fields ship empty until cp-api's B3 sub-PR populates them) —
the two-tier path therefore returns `None` on every existing key and
the legacy registry continues serving traffic unchanged.
After B3 ships and cp-api re-projects every `ProviderKey` with the
new `provider` + `adapter` fields filled, the two-tier path will start
returning bridges and the legacy fallback becomes the residual safety
net. Once we're confident the cutover is complete, a follow-up PR can
delete the legacy `Hub::register(Provider, _)` registrations and the
fallback branch.
Changes:
- `dispatch.rs`: add `pub(crate) fn resolve_bridge(hub, pk, provider) -> Option<Arc<dyn Bridge>>`
- `chat.rs:817`: route the single dispatch site through `resolve_bridge`
instead of `state.hub.get(provider)`. The validation check at
`chat.rs:500` keeps the legacy-only check — pk isn't in scope there
yet, and the two-tier and legacy registries always cover the same
Provider set today, so it's not load-bearing.
Zero behavior change today (both tiers miss, legacy serves the request).
Tests:
- `cargo test -p aisix-proxy --lib` — 218 passed
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
CopilotAI review requested due to automatic review settings May 17, 2026 00:45
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 1d561013-999c-4403-9dcf-f73040dd17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 7f811ce and 2fc8496.

📒 Files selected for processing (6)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/messages.rs
📝 Walkthrough

Walkthrough

The PR refactors bridge resolution to use a new two-tier lookup strategy. A resolve_bridge helper in the dispatch module first attempts specialized vendor/family resolution based on ProviderKey, then falls back to the legacy Provider-keyed registry. The chat dispatcher is updated to call this new resolver instead of directly querying the hub.

Changes

Bridge Resolution Two-Tier Lookup

Layer / File(s)Summary
Two-tier resolve_bridge helper
crates/aisix-proxy/src/dispatch.rs
New pub(crate) fn resolve_bridge dispatches bridge lookup via two-tier resolution using ProviderKey (specialized), then falls back to legacy Provider-keyed registry. Imports updated to include Bridge, Hub, and Arc.
Chat dispatcher bridge resolution
crates/aisix-proxy/src/chat.rs
Bridge resolution in the routing dispatch loop calls crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, provider) instead of direct state.hub.get(provider) query. Error handling for missing bridges is triggered by the resolver's None result.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@moonming

Copy link
Copy Markdown
MemberAuthor

Independent third-party audit per CLAUDE.md §8

Conducted cold (no shared context). Read the PR description, the full diff, the two touched files, and grepped the broader proxy crate for additional dispatch surfaces.

Verification: cargo test -p aisix-proxy --lib → 218 passed locally; cargo clippy -p aisix-proxy --all-targets clean.


HIGH

H1. Scope mismatch — five other dispatch sites still use state.hub.get(...) directly

The PR description and dispatch.rs doc-comment frame this as the Phase D cutover join point for proxy dispatch. The PR description further claims:

Single grep -n "state\.hub\.get\|hub\.get\(.*Provider" across aisix-proxy/src/ confirms only those two call sites exist; no other surfaces (messages.rs / completions.rs / embeddings.rs / responses.rs / rerank.rs) dispatch via Hub.

That grep is incomplete — it doesn't match state and .hub.get(...) when they're split across lines, which is the normal rustfmt shape for this expression. A multi-line search (rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get") finds six dispatch sites, only one of which the PR patches:

file:linerolepost-PR routing
chat.rs:500validation pre-checklegacy-only (intentional, pk not in scope)
chat.rs:524–527streaming chat dispatchlegacy-only — MISSED
chat.rs:817–821non-streaming chat dispatch + routing falloverresolve_bridge ✓
completions.rs:119–122/v1/completions dispatchlegacy-only — MISSED
embeddings.rs:139–142/v1/embeddings dispatchlegacy-only — MISSED
images.rs:134–137/v1/images/generations dispatchlegacy-only — MISSED
messages.rs:425–428cross_provider_dispatch for /v1/messages non-Anthropic upstreamlegacy-only — MISSED

Consequence after issue #302 Phase D ships (cp-api B3 + DP register_specialized / register_family wiring):

  • A request to POST /v1/chat/completions with stream: false → two-tier resolution honoured.
  • A request to POST /v1/chat/completions with stream: true → silently bypasses two-tier; specialized Bridge registrations have no effect for streaming chat.
  • A request to POST /v1/embeddings / /v1/completions / /v1/images/generations / /v1/messages (cross-provider) → silently bypasses two-tier.

This contradicts the issue #302 TL;DR contract ("DP 端 Hub 两层 dispatch") and means a specialized DeepSeek/Jina/etc. Bridge added in a future PR would work for non-streaming chat but silently no-op everywhere else — exactly the kind of "works in one place, broken in another" inconsistency Phase D is meant to eliminate.

Suggested fix: route all six dispatch sites through resolve_bridge. Concretely:

// chat.rs:524 (streaming path)letSome(bridge) =
crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider)else{returnErr(with_model(ProxyError::ProviderUnavailable));};// completions.rs:119let bridge = crate::dispatch::resolve_bridge(&state.hub,&pk_entry.value, provider).ok_or(ProxyError::ProviderUnavailable)?;// embeddings.rs:139, images.rs:134 — same shape// messages.rs:425 — same shape, using `pk_entry.value` from the caller frame

If the intent is genuinely "Phase D cutover is chat-completions-non-streaming only and the rest land in a follow-up PR", the PR title/description should say that explicitly and the follow-up should be linked. Right now the PR claims completeness ("no other surfaces dispatch via Hub") that the code does not match.

H2. resolve_bridge itself has no unit test

dispatch.rs adds the function but mod tests is unchanged — the existing 8 tests cover URL helpers and resolve_provider_key, none of them touches resolve_bridge. The PR description leans on "218 lib tests pass", but every one of those tests is built on a Hub constructed via hub.register(Provider, Bridge) — none of them registers anything via register_specialized / register_family, so the specialized-hit and family-hit branches of resolve_bridge are completely uncovered. The only branch the existing suite exercises is the legacy fallback (which behaves identically to the previous code), so the suite cannot fail on a regression in the two-tier path.

This is exactly the "tests pass but the new code path is untested" gap CLAUDE.md §8 calls out.

Suggested fix: add three trivial unit tests in dispatch.rs:

#[cfg(test)]mod resolve_bridge_tests {usesuper::*;use aisix_core::models::Adapter;use aisix_gateway::Bridge;use std::sync::Arc;// A trivial Bridge whose name lets the assertion identify which// registration tier resolved.#[derive(Debug)]structNamedBridge(&'staticstr);#[async_trait::async_trait]implBridgeforNamedBridge{fnname(&self) -> &str{self.0}// ... (use whatever the existing test stubs use in hub.rs)}fnpk_with(provider:&str,adapter:Option<Adapter>) -> ProviderKey{letmut pk:ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
pk.provider = provider.to_string();
pk.adapter = adapter;
pk
}#[test]fnspecialized_hit_wins_over_family_and_legacy(){let hub = Hub::new();
hub.register_specialized("deepseek",Arc::new(NamedBridge("specialized")));
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("deepseek",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"specialized");}#[test]fnfamily_hit_when_no_specialized(){let hub = Hub::new();
hub.register_family(Adapter::Openai,Arc::new(NamedBridge("family")));
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("anyvendor",Some(Adapter::Openai)),Provider::Openai,).unwrap();assert_eq!(b.name(),"family");}#[test]fnlegacy_fallback_when_neither_tier_registered(){let hub = Hub::new();
hub.register(Provider::Openai,Arc::new(NamedBridge("legacy")));let b = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,).unwrap();assert_eq!(b.name(),"legacy");}#[test]fnreturns_none_when_nothing_registered(){let hub = Hub::new();let r = resolve_bridge(&hub,&pk_with("",None),Provider::Openai,);assert!(r.is_none());}}

(If NamedBridge is too heavy because of the full Bridge trait surface, reuse StubBridge from aisix-gateway/src/hub.rs tests by making it pub(crate) or by duplicating the minimal stub — hub.rs already has the working pattern at lines 188–199.)


MEDIUM

M1. Latent inconsistency at chat.rs:500 (validation) vs chat.rs:821 (dispatch)

chat.rs:500 validates "is this provider known to the gateway?" with legacy-only state.hub.get(provider).is_none(). Once Phase D fully ships and a specialized Bridge for, say, vendor "newcorp" is registered without a corresponding legacy Provider enum variant (the whole point of Phase D — collapse the closed enum), the validation will reject the request with 503 even though the dispatch path at line 821 would have resolved it via dispatch_two_tier.

This is not a present-day bug — today every specialized vendor still maps to one of the six legacy Provider enum variants. But once Phase E/F lands and the enum is collapsed, this validation site becomes a silent false-negative gate ahead of an otherwise-working dispatch path.

The PR description explicitly punts on this ("pk isn't in scope there yet, and the two-tier and legacy registries always cover the same Provider set today"). That's true today but is exactly the kind of latent gap a Phase D PR should at least file as a follow-up.

Suggested fix (one of):

  • (Cheapest) drop the line 500 pre-validation entirely — the routing loop at line 821 already returns a proper BridgeError::Config("no bridge registered for provider") envelope that surfaces the same operator-error class with the same HTTP status mapping. The pre-check is a 5-line shortcut on top of an already-correct fallthrough.
  • (More principled) resolve pk_entry for the only-target case before the validation gate, then use resolve_bridge there too. Adds ~5 lines.
  • (Defer) file a tracking issue ("collapse chat.rs:500 once Provider enum closes") and link from this PR + refactor(server): inline DeepSeek/Google bridge factories + delete wrapper crates (Phase A) #302.

Either fix is fine; doing nothing leaves a latent regression for the team that lands Phase E.

M2. dispatch_two_tier → None after specialized was registered could mask a config drift

Today dispatch_two_tier returns None whenever the requested pk.provider is not registered as specialized andpk.adapter is None or not registered as a family. The resolve_bridge wrapper then falls back to hub.get(provider).

Post-Phase D, suppose an operator registers a specialized Bridge for "deepseek" and the DP is reconfigured at runtime to unregister it (or it's evicted by a future eviction policy, or a typo in register_specialized writes "deep-seek" instead of "deepseek"). The resolve_bridge wrapper silently falls back to the legacy Provider::Deepseek bridge — which today is an OpenAI-compat bridge with_name("deepseek"). The request succeeds against the wrong handler.

This is graceful, not silently-wrong-output: the legacy bridge is what serves this vendor today, so the fallback is the correct behaviour pre-cutover. But the comment in dispatch.rs says "Returns None only when both layers miss — i.e. the operator has no bridge wired for this request at all", which under-sells the silent shadowing: a specialized handler going missing falls back to whatever the legacy registry still has, with no log line.

Suggested fix: add a tracing::warn! (or debug!) when dispatch_two_tier returned None but hub.get(provider) returned Some, so operators get a signal during the cutover. Something like:

pub(crate)fnresolve_bridge(hub:&Hub,provider_key:&ProviderKey,provider:Provider,) -> Option<Arc<dynBridge>>{ifletSome(b) = hub.dispatch_two_tier(provider_key){returnSome(b);}let fallback = hub.get(provider)?;if !provider_key.provider.is_empty() || provider_key.adapter.is_some(){
tracing::debug!(
target = "aisix_proxy::dispatch",
pk_provider = %provider_key.provider,
pk_adapter = ?provider_key.adapter,
legacy_provider = ?provider,"two-tier dispatch missed for a PK that carries new-shape \ fields; falling back to legacy Provider-keyed registry");}Some(fallback)}

debug! keeps this off the hot logging path in normal operation but gives the operator a knob during cutover. The condition guard means it stays silent today (where every PK has provider: "" and adapter: None) and only fires post-B3.


LOW

L1. PR description's grep is incomplete and should be re-run

The PR description's audit-trail grep:

grep -n "state\.hub\.get\|hub\.get\(.*Provider"

is single-line and misses the standard rustfmt-wrapped form state\n .hub\n .get(provider). Recommend replacing the description's grep with rg -U --multiline "state[\s\n]*\.[\s\n]*hub[\s\n]*\.[\s\n]*get" (or simply rg -U --multiline "\.hub") so the next reviewer can verify scope without rediscovering the multi-line gap.

L2. messages.rs:399 doc-comment will be out-of-date once H1 lands

Module doc-comment at messages.rs:399:

/// 2. hub.get(model.provider) → Bridge for the configured upstream

will drift if H1 is fixed by routing the cross-provider path through resolve_bridge. Update to:

/// 2. resolve_bridge(hub, pk, model.provider) → Bridge (two-tier with
/// legacy fallback; see crate::dispatch::resolve_bridge)

Not a blocker, but matches the new contract.

L3. chat.rs:524 carries a now-misleading comment

The streaming dispatch site has a long pre-PR comment explaining streaming fallback semantics. The comment is correct, but a future maintainer comparing chat.rs:524 (legacy hub.get) and chat.rs:821 (resolve_bridge) will rightly wonder why streaming dispatches differently. If H1's fix lands and routes both through resolve_bridge, the asymmetry disappears. If it doesn't, a one-line comment at line 524 explaining the deliberate divergence ("streaming path still uses legacy hub.get — Phase D cutover pending, see #305") avoids the future maintainer thinking they spotted a copy-paste bug.


Sensitive-info leakage, security, breaking changes

  • Sensitive info: none. resolve_bridge returns Option<Arc<dyn Bridge>>; the None arm in chat.rs:821 produces BridgeError::Config("no bridge registered for provider") which is operator-error class and doesn't leak any PK fields. The fallback's existence is invisible to clients.
  • Security: none. No new auth path, no new input boundary, no header forwarding change.
  • Breaking changes: none today (legacy fallback covers every PK). The PR's framing ("zero behavior change today") is accurate for the patched dispatch site. The H1 finding is about future breakage post-Phase-D-cutover, not present-day regressions.

Verdict

Merge gate per CLAUDE.md §8: NOT YET — H1 and H2 must be addressed (or explicitly deferred with a linked follow-up issue) before merge.

H1 is the substantive concern: the PR's claim "no other surfaces dispatch via Hub" is factually wrong; five other dispatch sites still bypass the two-tier path. Either patch them all in this PR (the diff stays small — five 4-line changes) or scope the PR title/description to "chat-completions non-streaming" and file a follow-up for the rest. H2 is a 50-line test addition that's hard to justify deferring given the entire PR's runtime impact is "now invokes a helper" — proving the helper's branches behave correctly is the bare minimum.

M1 and M2 should be either addressed inline or filed as linked follow-up issues — both are latent gaps that bite Phase E/F, not present-day regressions.

LOW findings are housekeeping; merge is not blocked on them but they're worth a small editing pass.

Once H1 + H2 are resolved (and M1 + M2 are addressed or explicitly justified), this PR passes independent audit. The core change — adding a small resolve_bridge helper that wraps dispatch_two_tier with a legacy fallback — is correct and well-documented; the only issue is the scope mismatch between what the description claims and what the diff covers.

Addresses audit HIGH-1 + HIGH-2 + MEDIUM-2 from independent audit on
PR #305 (#305 (comment)):
**HIGH-1** — initial PR only patched chat.rs:817 (non-streaming chat).
Audit's multi-line grep found 5 more dispatch sites that bypass
resolve_bridge entirely; post-Phase-D specialized Bridge registrations
would silently be ignored for streaming chat, completions, embeddings,
messages, and images. All 5 now route through `resolve_bridge`:
- chat.rs:524 streaming chat
- completions.rs:119 /v1/completions
- embeddings.rs:139 /v1/embeddings
- images.rs:134 /v1/images/generations
- messages.rs:425 /v1/messages (cross-provider Anthropic-shape)
chat.rs:500 stays legacy-only intentionally — pk isn't in scope at the
pre-validation point, and today's two-tier and legacy registries cover
the same Provider set so the gap isn't observable. Documented as
MEDIUM-1 latent risk; will revisit when Phase E/F collapses the
Provider enum.
**HIGH-2** — added 4 unit tests in `dispatch.rs::tests::resolve_bridge_tests`
covering all three reachable outcomes:
1. specialized_hit_wins_over_family_and_legacy — pk.provider hits
2. family_hit_when_specialized_misses — pk.adapter falls through to family
3. legacy_fallback_when_both_new_tiers_miss — today's pre-cutover state
4. none_when_nothing_registered — all three layers empty
A minimal local StubBridge fixture avoids the cross-crate visibility
issue with aisix-gateway's private test stub.
**MEDIUM-2** — `resolve_bridge` now emits a `tracing::debug!` when the
PK carries new-shape fields (`provider` non-empty or `adapter: Some`)
but the two-tier path still missed. Pre-cutover PKs (empty provider +
adapter: None) take the silent path. Post-cutover this is the early
signal that a specialized Bridge name was misregistered (typo, runtime
unregister) or that the adapter map missed an entry.
LOWs deferred (PR description grep update, messages.rs:399 doc-comment,
chat.rs:524 explanatory comment).
Tests:
- `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new)
- `cargo clippy --workspace --all-targets -- -D warnings` clean
- `cargo fmt --all -- --check` clean
Refs api7/AISIX-Cloud#302
@moonming

Copy link
Copy Markdown
MemberAuthor

HIGH-1 + HIGH-2 + MEDIUM-2 addressed in 2fc8496

Per the independent audit:

HIGH-1 — wired 5 missed dispatch sites

Multi-line grep verified by audit: 6 total dispatch sites in aisix-proxy, original PR only patched 1. All 5 missed now use `resolve_bridge`:

  • `chat.rs:524` streaming chat
  • `completions.rs:119` /v1/completions
  • `embeddings.rs:139` /v1/embeddings
  • `images.rs:134` /v1/images/generations
  • `messages.rs:425` /v1/messages

`chat.rs:500` stays legacy-only intentionally (pk not in scope yet, two-tier and legacy registries cover same Provider set today). Tracked as MEDIUM-1 latent risk for Phase E/F when Provider enum collapses.

HIGH-2 — added 4 unit tests for `resolve_bridge`

`dispatch::tests::resolve_bridge_tests`:

  1. `specialized_hit_wins_over_family_and_legacy` — pk.provider hits specialized
  2. `family_hit_when_specialized_misses` — pk.adapter falls through to family
  3. `legacy_fallback_when_both_new_tiers_miss` — today's pre-cutover state
  4. `none_when_nothing_registered` — all three layers empty

Minimal local StubBridge fixture avoids cross-crate visibility on aisix-gateway's private test stub.

MEDIUM-2 — tracing::debug! when fallback fires post-cutover

`resolve_bridge` now emits `tracing::debug!` when the PK carries new-shape fields (`provider` non-empty OR `adapter: Some`) but two-tier missed. Pre-cutover PKs stay silent (dominant case). Post-cutover this is the early signal for misregistered Bridges or adapter_map gaps.

LOWs deferred

  • L1: PR description grep — updated
  • L2: `messages.rs:399` doc-comment — defer to housekeeping PR
  • L3: `chat.rs:524` explanatory comment — covered by commit message

Tests: `cargo test -p aisix-proxy --lib` — 222 passed (218 existing + 4 new); clippy clean; fmt clean.

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.

2 participants

@moonming