diff --git a/crates/aisix-core/src/models/model.rs b/crates/aisix-core/src/models/model.rs index 68b73635..be018e12 100644 --- a/crates/aisix-core/src/models/model.rs +++ b/crates/aisix-core/src/models/model.rs @@ -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. /// diff --git a/crates/aisix-core/src/models/provider_key.rs b/crates/aisix-core/src/models/provider_key.rs index a5ad8b2d..1530826f 100644 --- a/crates/aisix-core/src/models/provider_key.rs +++ b/crates/aisix-core/src/models/provider_key.rs @@ -56,26 +56,23 @@ pub struct ProviderKey { pub api_base: Option, /// 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, diff --git a/crates/aisix-proxy/src/background.rs b/crates/aisix-proxy/src/background.rs index eba45504..9762f828 100644 --- a/crates/aisix-proxy/src/background.rs +++ b/crates/aisix-proxy/src/background.rs @@ -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(), diff --git a/crates/aisix-proxy/src/chat.rs b/crates/aisix-proxy/src/chat.rs index e0628ec0..31029a2e 100644 --- a/crates/aisix-proxy/src/chat.rs +++ b/crates/aisix-proxy/src/chat.rs @@ -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)); } } @@ -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); @@ -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()); diff --git a/crates/aisix-proxy/src/completions.rs b/crates/aisix-proxy/src/completions.rs index bcaa1c78..b81532a0 100644 --- a/crates/aisix-proxy/src/completions.rs +++ b/crates/aisix-proxy/src/completions.rs @@ -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()); diff --git a/crates/aisix-proxy/src/dispatch.rs b/crates/aisix-proxy/src/dispatch.rs index 1ea68a4a..81864426 100644 --- a/crates/aisix-proxy/src/dispatch.rs +++ b/crates/aisix-proxy/src/dispatch.rs @@ -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> { - 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> { + hub.dispatch_two_tier(provider_key) } /// Look up the `ProviderKey` a given `Model` references. Returns a @@ -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. @@ -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", @@ -635,10 +598,9 @@ 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"); } @@ -646,69 +608,51 @@ mod tests { 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", @@ -716,16 +660,10 @@ mod tests { 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()); } } } diff --git a/crates/aisix-proxy/src/embeddings.rs b/crates/aisix-proxy/src/embeddings.rs index 05921432..b2d7c1c7 100644 --- a/crates/aisix-proxy/src/embeddings.rs +++ b/crates/aisix-proxy/src/embeddings.rs @@ -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); diff --git a/crates/aisix-proxy/src/images.rs b/crates/aisix-proxy/src/images.rs index eb4a667d..a8225453 100644 --- a/crates/aisix-proxy/src/images.rs +++ b/crates/aisix-proxy/src/images.rs @@ -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()); diff --git a/crates/aisix-proxy/src/messages.rs b/crates/aisix-proxy/src/messages.rs index edea18e3..e6fc8c1a 100644 --- a/crates/aisix-proxy/src/messages.rs +++ b/crates/aisix-proxy/src/messages.rs @@ -844,9 +844,8 @@ async fn cross_provider_dispatch( ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix")) })? .to_string(); - let bridge: Arc = - crate::dispatch::resolve_bridge(&state.hub, provider_key, model.provider.as_deref()) - .ok_or(ProxyError::ProviderUnavailable)?; + let bridge: Arc = 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 diff --git a/crates/aisix-server/src/main.rs b/crates/aisix-server/src/main.rs index be078444..8f34f1e8 100644 --- a/crates/aisix-server/src/main.rs +++ b/crates/aisix-server/src/main.rs @@ -836,20 +836,14 @@ fn build_hub() -> Hub { hub.register_family(Adapter::AzureOpenai, Arc::new(AzureOpenAiBridge::new())); hub.register_family(Adapter::Bedrock, Arc::new(BedrockBridge::new())); - // ─── Specialized vendor bridges (one-cycle compat shim) ────────── + // ─── Specialized vendor bridges ───────────────────────────────── // - // Pre-Phase-A ProviderKeys on disk may carry `provider` but no - // `adapter` field (cp-api started writing `adapter` at the same - // commit as Phase A — older rows haven't been resaved). Without - // these explicit specialized registrations, `dispatch_two_tier` - // would fall through to the None branch and 503 those PKs. - // - // Only `openai` and `anthropic` are registered — every other - // vendor only existed in the catalog post-Phase-A (Phase A added - // them as long-tail OpenAI-compat) and is guaranteed to have - // `adapter` populated, so the family bridge above already covers - // them. Once cp-api has resaved all pre-Phase-A rows these two - // entries become safe to delete. + // `openai` and `anthropic` are the two canonical vendors with a + // dedicated specialized bridge, so a ProviderKey whose `provider` + // is exactly `"openai"`/`"anthropic"` resolves through the + // specialized tier of `dispatch_two_tier`. Long-tail OpenAI-compat + // vendors (xai, openrouter, groq, deepseek, …) carry `adapter: + // openai` and resolve through the family tier above instead. hub.register_specialized("openai", Arc::new(OpenAiBridge::new())); hub.register_specialized("anthropic", Arc::new(AnthropicBridge::new())); @@ -1290,46 +1284,36 @@ mod tests { ); } - /// `build_hub()` MUST keep `register_specialized("openai", …)` so - /// `crates/aisix-proxy/src/dispatch.rs::resolve_bridge`'s - /// compat-shim fallback (`hub.get_specialized(Model.provider)`) - /// resolves a pre-Phase-A PK row (empty `provider`, no `adapter`). - /// A future PR that drops this registration prematurely — before - /// cp-api has re-saved every legacy PK — would silently 503 those - /// rows. This test pins the shim contract end-to-end against the + /// `build_hub()` MUST register the specialized `openai` vendor so a + /// ProviderKey with `provider: "openai"` dispatches to the dedicated + /// `OpenAiBridge`. This pins the registration end-to-end against the /// real `build_hub()` registry (not a stub Hub), so it fails the /// moment the registration disappears. #[test] - fn build_hub_compat_shim_resolves_pre_phase_a_openai_pk() { + fn build_hub_registers_specialized_openai_vendor() { let hub = build_hub(); let bridge = hub .get_specialized("openai") - .expect("openai compat shim must dispatch pre-Phase-A PK rows"); + .expect("openai vendor must be registered as specialized"); assert_eq!( bridge.name(), "openai", - "specialized 'openai' compat shim MUST be `OpenAiBridge::new()` \ - (returning bridge name 'openai') so pre-Phase-A PK rows with \ - empty `provider` + no `adapter` resolve via \ - `dispatch::resolve_bridge`'s `hub.get_specialized(Model.provider)` \ - fallback", + "specialized 'openai' MUST be `OpenAiBridge::new()` (bridge name 'openai')", ); } - /// Parallel of the openai compat-shim test, for the Anthropic side. + /// Parallel of the openai specialized-registration test, for the + /// Anthropic side. #[test] - fn build_hub_compat_shim_resolves_pre_phase_a_anthropic_pk() { + fn build_hub_registers_specialized_anthropic_vendor() { let hub = build_hub(); let bridge = hub .get_specialized("anthropic") - .expect("anthropic compat shim must dispatch pre-Phase-A PK rows"); + .expect("anthropic vendor must be registered as specialized"); assert_eq!( bridge.name(), "anthropic", - "specialized 'anthropic' compat shim MUST be `AnthropicBridge::new()` \ - so pre-Phase-A PK rows with empty `provider` + no `adapter` resolve \ - via `dispatch::resolve_bridge`'s `hub.get_specialized(Model.provider)` \ - fallback", + "specialized 'anthropic' MUST be `AnthropicBridge::new()` (bridge name 'anthropic')", ); } } diff --git a/schemas/resources/model.schema.json b/schemas/resources/model.schema.json index 5529687c..ec4016d3 100644 --- a/schemas/resources/model.schema.json +++ b/schemas/resources/model.schema.json @@ -51,7 +51,7 @@ ] }, "provider": { - "description": "Upstream vendor identity, free-form string (e.g. `\"openai\"`, `\"xai\"`, `\"openrouter\"`, any models.dev catalog id). Primary dispatch reads `ProviderKey.adapter` + `ProviderKey.provider` via `Hub::dispatch_two_tier`, so a new long-tail vendor admitted by cp-api works without a DP code change. `Model.provider` is additionally consumed by:\n\n1. Anti-misdispatch gates that reject cross-provider routing for endpoints whose wire shape is vendor-specific: - `/v1/messages` (`crates/aisix-proxy/src/messages.rs:290`) — non-anthropic Models go through `cross_provider_dispatch`. - `/v1/responses` (`crates/aisix-proxy/src/responses.rs:117`) — non-openai Models rejected with 400. - `/v1/images/generations` (`crates/aisix-proxy/src/images.rs:124`) — non-openai Models rejected with 400. 2. `/v1/rerank` vendor gate + access-log label (`crates/aisix-proxy/src/rerank.rs:125,145`); Cohere/Jina each have a native rerank surface that bypasses the Bridge trait, so this path stays keyed on `Model.provider`. 3. Telemetry / access-log labels via `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)`.\n\n`None` for routing models.\n\nCloses the schema-validation half of api7/AISIX-Cloud#417 and the dispatch half of api7/AISIX-Cloud#302 Phase A.", + "description": "Upstream vendor identity, free-form string (e.g. `\"openai\"`, `\"xai\"`, `\"openrouter\"`, any models.dev catalog id). Primary dispatch reads `ProviderKey.adapter` + `ProviderKey.provider` via `Hub::dispatch_two_tier`, so a new long-tail vendor admitted by cp-api works without a DP code change. `Model.provider` is additionally consumed by:\n\n1. Anti-misdispatch gates that reject cross-provider routing for endpoints whose wire shape is vendor-specific: - `/v1/messages` (`crates/aisix-proxy/src/messages.rs:290`) — non-anthropic Models go through `cross_provider_dispatch`. - `/v1/responses` (`crates/aisix-proxy/src/responses.rs:117`) — non-openai Models rejected with 400. - `/v1/images/generations` (`crates/aisix-proxy/src/images.rs:124`) — non-openai Models rejected with 400. 2. `/v1/rerank` vendor gate + access-log label (`crates/aisix-proxy/src/rerank.rs:125,145`); Cohere/Jina each have a native rerank surface that bypasses the Bridge trait, so this path stays keyed on `Model.provider`. 3. Telemetry / access-log labels via `dispatch::require_provider()` on every bridge-dispatching endpoint (chat, completions, embeddings, images, audio, rerank).\n\nBridge dispatch itself is keyed on the ProviderKey's `provider`/`adapter` (`Hub::dispatch_two_tier`), not on this field.\n\n`None` for routing models.\n\nCloses the schema-validation half of api7/AISIX-Cloud#417 and the dispatch half of api7/AISIX-Cloud#302 Phase A.", "type": [ "string", "null" diff --git a/schemas/resources/provider_key.schema.json b/schemas/resources/provider_key.schema.json index 43f4f2d8..0f2e99f9 100644 --- a/schemas/resources/provider_key.schema.json +++ b/schemas/resources/provider_key.schema.json @@ -8,7 +8,7 @@ ], "properties": { "adapter": { - "description": "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)]`.", + "description": "Wire-shape adapter (`openai` / `anthropic` / `bedrock` / `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.", "anyOf": [ { "$ref": "#/definitions/Adapter" @@ -30,7 +30,7 @@ "type": "string" }, "provider": { - "description": "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)]`.", + "description": "Vendor identity (e.g. `\"deepseek\"`, `\"openai\"`, any models.dev 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.", "default": "", "type": "string" }, diff --git a/tests/e2e/src/cases/anthropic-upstream-e2e.test.ts b/tests/e2e/src/cases/anthropic-upstream-e2e.test.ts index 0f0aafc5..f0cac6f0 100644 --- a/tests/e2e/src/cases/anthropic-upstream-e2e.test.ts +++ b/tests/e2e/src/cases/anthropic-upstream-e2e.test.ts @@ -67,6 +67,8 @@ describe("anthropic upstream e2e: OpenAI in, Anthropic out, OpenAI back to calle // these mixed up was the lesson from the unit-level matrix tests. const pk = await admin.createProviderKey({ display_name: "an-e2e-pk", + provider: "anthropic", + adapter: "anthropic", secret: "sk-ant-mock", api_base: upstream.baseUrl, }); diff --git a/tests/e2e/src/cases/api-base-tolerance-e2e.test.ts b/tests/e2e/src/cases/api-base-tolerance-e2e.test.ts index 68f5e6a9..e7e6521b 100644 --- a/tests/e2e/src/cases/api-base-tolerance-e2e.test.ts +++ b/tests/e2e/src/cases/api-base-tolerance-e2e.test.ts @@ -182,6 +182,8 @@ describe("api_base tolerance e2e: endpoint suffix is stripped before dispatch", // `${baseUrl}/v1/messages/v1/messages`. const pk = await admin.createProviderKey({ display_name: "anthropic-suffix-tolerance-pk", + provider: "anthropic", + adapter: "anthropic", secret: "sk-ant-mock", api_base: `${upstream.baseUrl}/v1/messages`, }); diff --git a/tests/e2e/src/cases/chat-anthropic-stream-input-tokens-e2e.test.ts b/tests/e2e/src/cases/chat-anthropic-stream-input-tokens-e2e.test.ts index 3324a7cc..a132be4e 100644 --- a/tests/e2e/src/cases/chat-anthropic-stream-input-tokens-e2e.test.ts +++ b/tests/e2e/src/cases/chat-anthropic-stream-input-tokens-e2e.test.ts @@ -60,6 +60,8 @@ describe("/v1/chat/completions anthropic streaming input tokens (#450)", () => { const admin = new AdminClient(app.adminUrl, app.adminKey); const pk = await admin.createProviderKey({ display_name: "chat-anth-stream-pk", + provider: "anthropic", + adapter: "anthropic", secret: "sk-anth-mock", api_base: upstream.baseUrl, }); diff --git a/tests/e2e/src/cases/messages-model-group-e2e.test.ts b/tests/e2e/src/cases/messages-model-group-e2e.test.ts index 1876167e..6dba6397 100644 --- a/tests/e2e/src/cases/messages-model-group-e2e.test.ts +++ b/tests/e2e/src/cases/messages-model-group-e2e.test.ts @@ -120,6 +120,8 @@ describe("model group via passthrough endpoints e2e (#471)", () => { if (!admin) throw new Error("admin client not initialized"); const pk = await admin.createProviderKey({ display_name: `${displayName}-pk`, + provider: "anthropic", + adapter: "anthropic", secret: "sk-ant-mock", // Anthropic bridge appends /v1/messages, so point at the bare host. api_base: upstream.baseUrl, diff --git a/tests/e2e/src/cases/tools-cross-provider-e2e.test.ts b/tests/e2e/src/cases/tools-cross-provider-e2e.test.ts index 11e96e7d..97423b5f 100644 --- a/tests/e2e/src/cases/tools-cross-provider-e2e.test.ts +++ b/tests/e2e/src/cases/tools-cross-provider-e2e.test.ts @@ -93,6 +93,8 @@ describe("tools cross-provider e2e: OpenAI tools → Anthropic upstream tool_use // (mirrors anthropic-upstream-e2e convention). const pk = await admin.createProviderKey({ display_name: "tools-xprov-pk", + provider: "anthropic", + adapter: "anthropic", secret: "sk-ant-mock", api_base: upstream.baseUrl, }); diff --git a/tests/e2e/src/harness/admin.ts b/tests/e2e/src/harness/admin.ts index 0de44c78..4cff8baa 100644 --- a/tests/e2e/src/harness/admin.ts +++ b/tests/e2e/src/harness/admin.ts @@ -25,7 +25,18 @@ export class AdminClient { async createProviderKey( pk: Record, ): Promise<{ id: string; value: Record }> { - return this.json("POST", "/admin/v1/provider_keys", pk); + // The DP dispatches a ProviderKey via its `provider` (specialized + // vendor) + `adapter` (protocol family) — cp-api always writes both + // in production, and the DP no longer carries a Model.provider + // fallback. So the harness mirrors cp-api and always sends them, + // defaulting to the OpenAI-compatible vendor/family that the bulk of + // the mock-upstream tests use. Tests against a non-OpenAI upstream + // (anthropic, etc.) pass `provider`/`adapter` explicitly. + return this.json("POST", "/admin/v1/provider_keys", { + provider: "openai", + adapter: "openai", + ...pk, + }); } async listModels(): Promise>> {