Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions crates/aisix-core/src/models/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,10 @@ pub struct Model {
/// `dispatch::require_provider()` on every bridge-dispatching
/// endpoint (chat, completions, embeddings, images, audio,
/// rerank).
/// 4. The one-cycle compat shim in
/// `crates/aisix-proxy/src/dispatch.rs::resolve_bridge` (called
/// by every bridge-dispatching endpoint) that rescues pre-
/// Phase-A on-disk PK rows (empty `provider` + `None` `adapter`)
/// by falling back to `hub.get_specialized(Model.provider)`.
///
/// Bridge dispatch itself is keyed on the ProviderKey's
/// `provider`/`adapter` (`Hub::dispatch_two_tier`), not on this
/// field.
///
/// `None` for routing models.
///
Expand Down
29 changes: 13 additions & 16 deletions crates/aisix-core/src/models/provider_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,23 @@ pub struct ProviderKey {
pub api_base: Option<String>,

/// Vendor identity (e.g. `"deepseek"`, `"openai"`, any models.dev
/// catalog id). Post-#302 Phase A this is the primary specialized-
/// dispatch key consumed by `Hub::dispatch_two_tier` (specialized
/// lookup tier) and by both family bridges' `resolve_base` safety
/// guard (the guard rejects an empty `api_base` for any vendor
/// whose identity doesn't match the family's canonical vendor).
/// Empty for pre-Phase-A on-disk rows still in etcd — those route
/// via the compat shim in `aisix-proxy::dispatch::resolve_bridge`
/// that falls back to `Model.provider`. Old payloads that omit
/// `provider` continue to deserialize via `#[serde(default)]`.
/// catalog id). The primary specialized-dispatch key consumed by
/// `Hub::dispatch_two_tier` (specialized lookup tier) and by both
/// family bridges' `resolve_base` safety guard (the guard rejects an
/// empty `api_base` for any vendor whose identity doesn't match the
/// family's canonical vendor). cp-api always writes it; the
/// `#[serde(default)]` only covers in-memory test fixtures.
#[serde(default)]
pub provider: String,

/// Wire-shape adapter (`openai` / `anthropic` / `bedrock` /
/// `vertex` / `azure-openai`). Post-#302 Phase A this is the
/// family-fallback dispatch key for `Hub::dispatch_two_tier` when
/// the specialized lookup misses; long-tail OpenAI-compat vendors
/// (xai, openrouter, groq, …) reach the right bridge through this
/// path without a DP code change. `None` for pre-Phase-A on-disk
/// rows — those dispatch via the compat shim. Old payloads that
/// omit `adapter` continue to deserialize via `#[serde(default)]`.
/// `vertex` / `azure-openai`). The family-fallback dispatch key for
/// `Hub::dispatch_two_tier` when the specialized lookup misses;
/// long-tail OpenAI-compat vendors (xai, openrouter, groq, …) reach
/// the right bridge through this path without a DP code change.
/// cp-api always writes it; a `ProviderKey` that resolves to neither
/// a specialized `provider` nor a registered `adapter` family is a
/// misconfiguration and surfaces as 503.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adapter: Option<Adapter>,

Expand Down
8 changes: 6 additions & 2 deletions crates/aisix-proxy/src/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,12 @@ async fn check_direct_model(
dispatch::require_provider(model).map_err(|e| BridgeError::Config(e.to_string()))?;
let pk_entry = dispatch::resolve_provider_key(snapshot, model)
.map_err(|e| BridgeError::Config(e.to_string()))?;
let bridge = dispatch::resolve_bridge(hub, &pk_entry.value, model.provider.as_deref())
.ok_or_else(|| BridgeError::Config("no bridge registered for provider".into()))?;
let bridge = dispatch::resolve_bridge(hub, &pk_entry.value).ok_or_else(|| {
BridgeError::Config(format!(
"no bridge registered for provider_key provider={:?} adapter={:?}",
pk_entry.value.provider, pk_entry.value.adapter
))
})?;

let req = ChatFormat {
model: model.display_name.clone(),
Expand Down
27 changes: 10 additions & 17 deletions crates/aisix-proxy/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -730,9 +730,7 @@ async fn dispatch(
// we commit to a long upstream call.
let pk_entry =
crate::dispatch::resolve_provider_key(&snapshot, only).map_err(with_model)?;
if crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, only.provider.as_deref())
.is_none()
{
if crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value).is_none() {
return Err(with_model(ProxyError::ProviderUnavailable));
}
}
Expand All @@ -756,9 +754,8 @@ async fn dispatch(
let provider = crate::dispatch::require_provider(model).map_err(with_model)?;
let pk_entry =
crate::dispatch::resolve_provider_key(&snapshot, model).map_err(with_model)?;
let bridge =
crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, model.provider.as_deref())
.ok_or_else(|| with_model(ProxyError::ProviderUnavailable))?;
let bridge = crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value)
.ok_or_else(|| with_model(ProxyError::ProviderUnavailable))?;
let model_arc = Arc::new(model.clone());
let pk_arc = Arc::new(pk_entry.value.clone());
let ctx = BridgeContext::new(request_id, model_arc, pk_arc);
Expand Down Expand Up @@ -1207,17 +1204,13 @@ async fn dispatch(
// Two-tier dispatch via `Hub::dispatch_two_tier`: specialized
// vendor (ProviderKey.provider) first, then adapter family
// (ProviderKey.adapter). The legacy `Provider`-keyed registry
// is gone after #302 Phase A. `resolve_bridge` carries a
// one-cycle compat shim for pre-Phase-A PK rows that still
// have empty `provider` and `adapter: None` on disk — those
// resolve via `Model.provider` (passed below). Once cp-api
// has backfilled every PK, the shim becomes unreachable.
let Some(bridge) =
crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, model.provider.as_deref())
else {
last_err = Some(BridgeError::Config(
"no bridge registered for provider".into(),
));
// is gone after #302 Phase A; a PK that matches neither tier is
// a misconfiguration and surfaces as 503.
let Some(bridge) = crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value) else {
last_err = Some(BridgeError::Config(format!(
"no bridge registered for provider_key provider={:?} adapter={:?}",
pk_entry.value.provider, pk_entry.value.adapter
)));
continue;
};
let model_arc = Arc::new(model.clone());
Expand Down
5 changes: 2 additions & 3 deletions crates/aisix-proxy/src/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,8 @@ async fn dispatch(
let provider = crate::dispatch::require_provider(model)?;
let pk_entry = crate::dispatch::resolve_provider_key(&snapshot, model)?;

let bridge =
crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, model.provider.as_deref())
.ok_or(ProxyError::ProviderUnavailable)?;
let bridge = crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value)
.ok_or(ProxyError::ProviderUnavailable)?;

let model_arc = Arc::new(model.clone());
let pk_arc = Arc::new(pk_entry.value.clone());
Expand Down
148 changes: 43 additions & 105 deletions crates/aisix-proxy/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,55 +27,20 @@ use crate::error::ProxyError;

/// Resolve the Bridge to dispatch this request through.
///
/// Primary path: `Hub::dispatch_two_tier` — specialized vendor first
/// (keyed on `ProviderKey.provider`), then adapter family (keyed on
/// `ProviderKey.adapter`). Vendor identity is an open string;
/// adapter is the closed 5-value enum. Any catalog vendor cp-api
/// admits (xai, openrouter, future long-tail) resolves through the
/// family fallthrough without a DP code change.
/// `Hub::dispatch_two_tier` — specialized vendor first (keyed on
/// `ProviderKey.provider`), then adapter family (keyed on
/// `ProviderKey.adapter`). Vendor identity is an open string; adapter
/// is the closed 5-value enum. Any catalog vendor cp-api admits (xai,
/// openrouter, future long-tail) resolves through the family
/// fallthrough without a DP code change.
///
/// Compat shim: pre-Phase-A `ProviderKey` rows still on disk have
/// empty `provider` AND `adapter: None`. For those, fall back to
/// the specialized registry keyed on `Model.provider`. This keeps
/// existing on-disk data routable through the upgrade cycle. Once
/// cp-api has backfilled every PK with `provider` + `adapter`,
/// the fallback path becomes unreachable and can be removed.
///
/// Returns `None` when the two-tier path AND the compat fallback
/// both miss — caller surfaces this as 503 "no dispatch path".
pub(crate) fn resolve_bridge(
hub: &Hub,
provider_key: &ProviderKey,
model_provider: Option<&str>,
) -> Option<Arc<dyn Bridge>> {
if let Some(b) = hub.dispatch_two_tier(provider_key) {
return Some(b);
}
// One-cycle compat for pre-Phase-A PK rows. cp-api now writes
// `provider` + `adapter` for every PK row; once any pre-cutover
// rows have been re-saved (or the operator's etcd has been wiped
// and reseeded), this `if` body becomes unreachable.
if provider_key.provider.is_empty() && provider_key.adapter.is_none() {
if let Some(mp) = model_provider {
if !mp.is_empty() {
// Emit a tracing::warn! so an operator (or SREs reading
// logs) can detect un-migrated PK rows still in the
// wild. The "one-cycle" deprecation promise is only
// enforceable if dispatching through the shim is
// observable.
tracing::warn!(
target: "aisix_proxy::dispatch",
pk_display_name = %provider_key.display_name,
model_provider = %mp,
"compat shim: pre-Phase-A PK row (empty `provider` + `adapter: None`) \
dispatched via Model.provider fallback — re-save this PK to remove the \
legacy code path"
);
return hub.get_specialized(mp);
}
}
}
None
/// Returns `None` when both tiers miss (the PK carries neither a
/// registered `provider` nor a registered `adapter`) — caller surfaces
/// this as 503 "no dispatch path". cp-api writes `provider` + `adapter`
/// on every PK, so a miss means a genuine misconfiguration, not a
/// migration gap.
pub(crate) fn resolve_bridge(hub: &Hub, provider_key: &ProviderKey) -> Option<Arc<dyn Bridge>> {
hub.dispatch_two_tier(provider_key)
}

/// Look up the `ProviderKey` a given `Model` references. Returns a
Expand Down Expand Up @@ -545,13 +510,11 @@ mod tests {

// --- resolve_bridge tests -------------------------------------
//
// Cover the three reachable outcomes of resolve_bridge:
// Cover the reachable outcomes of resolve_bridge:
// 1. specialized hit — pk.provider matches a specialized entry
// 2. family hit — pk.adapter matches a family entry,
// specialized misses
// 3. legacy fallback — both new-tier maps miss, legacy hub.get
// serves the bridge
// 4. none miss — nothing registered at all
// 3. none miss — neither tier matches (misconfigured PK)
//
// A minimal Bridge stub is used so the test doesn't need reqwest
// or a real upstream.
Expand Down Expand Up @@ -626,7 +589,7 @@ mod tests {
}

#[test]
fn specialized_hit_wins_over_family_and_legacy() {
fn specialized_hit_wins_over_family() {
let hub = Hub::new();
hub.register_specialized(
"deepseek",
Expand All @@ -635,97 +598,72 @@ mod tests {
}),
);
hub.register_family(Adapter::Openai, Arc::new(StubBridge { name: "family" }));
hub.register_specialized("openai", Arc::new(StubBridge { name: "legacy" }));

let pk = pk_with_provider_and_adapter("deepseek", Some("openai"));
let bridge = resolve_bridge(&hub, &pk, None).unwrap();
let bridge = resolve_bridge(&hub, &pk).unwrap();
assert_eq!(bridge.name(), "specialized");
}

#[test]
fn family_hit_when_specialized_misses() {
let hub = Hub::new();
hub.register_family(Adapter::Openai, Arc::new(StubBridge { name: "family" }));
hub.register_specialized("openai", Arc::new(StubBridge { name: "legacy" }));

// pk.provider = "unknown-vendor" → no specialized; pk.adapter
// = Openai → family hit.
let pk = pk_with_provider_and_adapter("unknown-vendor", Some("openai"));
let bridge = resolve_bridge(&hub, &pk, None).unwrap();
let bridge = resolve_bridge(&hub, &pk).unwrap();
assert_eq!(bridge.name(), "family");
}

/// A PK with empty `provider` AND `adapter: None` plus no
/// `Model.provider` to fall back on has nothing to dispatch
/// on — caller surfaces 503.
/// A PK whose `provider` matches no specialized entry and whose
/// `adapter` matches no family entry has nothing to dispatch on
/// — caller surfaces 503. cp-api always writes both fields, so
/// this is a genuine misconfiguration, not a migration gap.
#[test]
fn none_when_neither_tier_matches_and_no_model_compat() {
fn none_when_neither_tier_matches() {
let hub = Hub::new();
hub.register_specialized("openai", Arc::new(StubBridge { name: "vendor" }));
let pk = pk_with_provider_and_adapter("", None);
assert!(resolve_bridge(&hub, &pk, None).is_none());
let pk = pk_with_provider_and_adapter("unknown-vendor", Some("anthropic"));
assert!(resolve_bridge(&hub, &pk).is_none());
}

/// A PK with empty `provider` AND no `adapter` (the malformed
/// shape the removed compat shim used to rescue) now resolves to
/// nothing — 503.
#[test]
fn none_when_nothing_registered() {
fn none_when_provider_and_adapter_both_empty() {
let hub = Hub::new();
hub.register_specialized("openai", Arc::new(StubBridge { name: "vendor" }));
let pk = pk_with_provider_and_adapter("", None);
assert!(resolve_bridge(&hub, &pk, None).is_none());
assert!(resolve_bridge(&hub, &pk).is_none());
}

/// One-cycle compat for pre-Phase-A PK rows. A PK with empty
/// `provider` AND `adapter: None` (on-disk shape pre-cutover)
/// resolves through the specialized registry keyed on the
/// Model's vendor string, so existing data stays routable
/// across the upgrade without forcing operators to re-save
/// every PK first.
#[test]
fn legacy_pk_with_empty_fields_falls_back_to_model_provider() {
fn none_when_nothing_registered() {
let hub = Hub::new();
hub.register_specialized(
"openai",
Arc::new(StubBridge {
name: "specialized-openai",
}),
);
// Pre-Phase-A PK shape: no `provider`, no `adapter`.
let pk = pk_with_provider_and_adapter("", None);
let bridge = resolve_bridge(&hub, &pk, Some("openai")).unwrap();
assert_eq!(bridge.name(), "specialized-openai");
let pk = pk_with_provider_and_adapter("openai", Some("openai"));
assert!(resolve_bridge(&hub, &pk).is_none());
}

/// Compat shim must NOT fire when the PK carries either
/// `provider` or `adapter` — those rows go through the
/// two-tier path and miss authoritatively if nothing matches.
/// A future PR that drops `Adapter::Openai` family must FAIL
/// the test below, not get rescued by the compat shim.
///
/// This test pins the regression vector exactly: PK has
/// `provider:"vendor-without-specialized"` + `adapter:Some(Openai)`,
/// hub has NO `Adapter::Openai` family registered. The
/// two-tier path returns None on both layers. The compat
/// shim must NOT rescue this because `provider` is non-empty.
/// If a future PR drops `Adapter::Openai` family registration
/// in `build_hub()`, this test fires.
/// A PK with a non-empty `provider` and an `adapter` whose
/// family isn't registered misses both tiers authoritatively —
/// it is NOT rescued by any fallback. If a future PR drops the
/// `Adapter::Openai` family registration in `build_hub()`, this
/// fires instead of silently routing elsewhere.
#[test]
fn compat_shim_does_not_rescue_missing_family_for_post_phase_a_pk() {
fn none_when_adapter_family_not_registered() {
let hub = Hub::new();
hub.register_specialized(
"openai",
Arc::new(StubBridge {
name: "specialized-openai",
}),
);
// `vendor-without-specialized` is not registered as
// specialized; `Adapter::Openai` is not registered as
// family. Compat shim must not fire because `provider`
// is non-empty.
// `vendor-without-specialized` has no specialized entry;
// `Adapter::Openai` has no family entry → None.
let pk = pk_with_provider_and_adapter("vendor-without-specialized", Some("openai"));
assert!(
resolve_bridge(&hub, &pk, Some("openai")).is_none(),
"compat shim MUST NOT rescue a missing Adapter::Openai family — \
provider is non-empty, so post-Phase-A path is authoritative",
);
assert!(resolve_bridge(&hub, &pk).is_none());
}
}
}
5 changes: 2 additions & 3 deletions crates/aisix-proxy/src/embeddings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,8 @@ async fn dispatch(
let provider = crate::dispatch::require_provider(model)?;
let pk_entry = crate::dispatch::resolve_provider_key(&snapshot, model)?;

let bridge =
crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, model.provider.as_deref())
.ok_or(ProxyError::ProviderUnavailable)?;
let bridge = crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value)
.ok_or(ProxyError::ProviderUnavailable)?;

let model_rl =
crate::quota::ModelRateLimit::from_model(&body.model, &model_entry.id, &model_entry.value);
Expand Down
5 changes: 2 additions & 3 deletions crates/aisix-proxy/src/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,8 @@ async fn dispatch(
let provider = crate::dispatch::require_provider(model)?.to_string();
let pk_entry = crate::dispatch::resolve_provider_key(&snapshot, model)?;

let bridge =
crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value, model.provider.as_deref())
.ok_or(ProxyError::ProviderUnavailable)?;
let bridge = crate::dispatch::resolve_bridge(&state.hub, &pk_entry.value)
.ok_or(ProxyError::ProviderUnavailable)?;

let model_arc = Arc::new(model.clone());
let pk_arc = Arc::new(pk_entry.value.clone());
Expand Down
5 changes: 2 additions & 3 deletions crates/aisix-proxy/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -844,9 +844,8 @@ async fn cross_provider_dispatch(
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
})?
.to_string();
let bridge: Arc<dyn Bridge> =
crate::dispatch::resolve_bridge(&state.hub, provider_key, model.provider.as_deref())
.ok_or(ProxyError::ProviderUnavailable)?;
let bridge: Arc<dyn Bridge> = crate::dispatch::resolve_bridge(&state.hub, provider_key)
.ok_or(ProxyError::ProviderUnavailable)?;

// Parse the Anthropic-shape body into the gateway's normalised
// ChatFormat. Errors here are 400 — the request is malformed
Expand Down
Loading
Loading