Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion crates/aisix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub use models::{
validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, Adapter,
AisixSnapshot, ApiKey, CachePolicy, CooldownConfig, ExporterKind, Guardrail,
GuardrailHookPoint, GuardrailKind, KeywordConfig, KeywordPattern, Model, ObservabilityExporter,
OnAllFilteredPolicy, ParamConstraints, Provider, ProviderKey, RateLimit, RateLimitPolicy,
OnAllFilteredPolicy, ParamConstraints, ProviderKey, RateLimit, RateLimitPolicy,
RequestOverrides, ResponseOverrides, Routing, RoutingStrategy, RoutingTarget, SchemaError,
StreamDoneMarker, TelemetryTags, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
};
Expand Down
3 changes: 1 addition & 2 deletions crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ pub use guardrail::{
GuardrailKind, KeywordConfig, KeywordPattern,
};
pub use model::{
Adapter, BackgroundModelCheck, CooldownConfig, Model, Provider,
DEFAULT_COOLDOWN_TRIGGER_STATUSES,
Adapter, BackgroundModelCheck, CooldownConfig, Model, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
};
pub use observability_exporter::{ExporterKind, ObservabilityExporter, OtlpHttpConfig};
pub use provider_key::{
Expand Down
109 changes: 22 additions & 87 deletions crates/aisix-core/src/models/model.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//! `Model` entity — the routing target users reference from API requests.
//!
//! A Model has a user-chosen unique `display_name`, an explicit
//! `provider` enum, an upstream `model_name` (e.g. "gpt-4o"), and a
//! `provider_key_id` referencing a [`ProviderKey`] entry that supplies
//! the secret + optional `api_base` override.
//! A Model has a user-chosen unique `display_name`, an open vendor
//! string `provider` (e.g. `"openai"`, `"xai"`), an upstream
//! `model_name` (e.g. `"gpt-4o"`), and a `provider_key_id` referencing
//! a [`ProviderKey`] entry that supplies the secret + optional
//! `api_base` override.
//!
//! Routing models — virtual routers that pick a target Model per request
//! — set `routing` instead of `provider`/`model_name`/`provider_key_id`.
Expand All @@ -17,71 +18,26 @@ use super::rate_limit::RateLimit;
use super::routing::Routing;
use crate::resource::Resource;

/// First-class upstream provider variants with specialized DP handling.
///
/// Kept narrow on purpose: only vendors whose code path diverges from
/// the wire-shape default (`Adapter`-family bridge) live here. Every
/// other catalog vendor cp-api admits (xai, openrouter, groq, mistral,
/// fireworks-ai, perplexity, together, moonshot, alibaba, zhipu,
/// baseten, huggingface, cerebras, …) flows through the `Adapter`
/// family bridge without a DP enum variant — closes api7/AISIX-Cloud#302
/// Phase A and api7/AISIX-Cloud#417.
///
/// Wire identity on `ProviderKey.provider` / `Model.provider` is a
/// free-form string (`Option<String>`); this enum is reserved for the
/// few code paths that still match on a fixed vendor set
/// (Cohere/Jina native rerank, Anthropic-shape `/v1/messages` cross-
/// provider routing).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Openai,
Anthropic,
Google,
Deepseek,
/// Cohere — `/v1/rerank` (native, via `aisix-proxy::rerank`) and
/// chat/completions / embeddings (via `OpenAiBridge::with_name("cohere")`
/// against `https://api.cohere.com/compatibility/v1`, per
/// <https://docs.cohere.com/reference/chat>).
Cohere,
/// Jina AI — currently exposed for `/v1/rerank` only (#213 Phase 2).
/// Jina's rerank wire shape is identity-mapped to the OpenAI-compat
/// shape (`{model, query, documents, top_n}` with Bearer auth at
/// `https://api.jina.ai/v1/rerank`), so the gateway forwards
/// verbatim with no transform.
Jina,
}

impl Provider {
/// Stable wire identity for this first-class variant. Matches the
/// models.dev catalog id cp-api stores on `ProviderKey.provider`.
pub const fn as_str(self) -> &'static str {
match self {
Self::Openai => "openai",
Self::Anthropic => "anthropic",
Self::Google => "google",
Self::Deepseek => "deepseek",
Self::Cohere => "cohere",
Self::Jina => "jina",
}
}
}
// `Provider` enum removed as part of #302 Phase A clean cut. Vendor
// identity is an open string on `ProviderKey.provider` /
// `Model.provider` — DP no longer enumerates vendors at compile time.
// Code paths that need vendor-aware dispatch (rerank, messages
// cross-provider routing) compare the string directly.

/// Wire-shape adapter used to talk to an upstream. This is the closed
/// set of upstream protocols the gateway knows how to encode against —
/// distinct from a vendor identity (which is captured separately on
/// `ProviderKey`).
/// `ProviderKey.provider` as an open string).
///
/// Introduced as a skeleton for issue #302 Phase A. This type is purely
/// additive in this PR: nothing in the gateway dispatches off `Adapter`
/// yet, no entity field is changed, and `Provider` continues to drive
/// all runtime behavior. Follow-up PRs in Phase A migrate entities and
/// the Hub to consume `Adapter` directly.
/// Post-#302 Phase A clean cut, dispatch is two-tier: the Hub looks up
/// a specialized bridge by the open `ProviderKey.provider` string
/// first, then falls back to the closed `Adapter` family bridge. Any
/// new long-tail OpenAI-compat vendor cp-api admits (xai, openrouter,
/// cerebras, …) routes through the `Adapter::Openai` family bridge
/// without a DP code change.
///
/// Note on serde casing: `Adapter` uses `kebab-case` so the `AzureOpenai`
/// variant serializes as `"azure-openai"`. This intentionally differs
/// from `Provider`'s `lowercase` casing, which produced no hyphens
/// because all current `Provider` names are single tokens.
/// variant serializes as `"azure-openai"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Adapter {
Expand All @@ -92,11 +48,6 @@ pub enum Adapter {
AzureOpenai,
}

// `From<Provider> for Adapter` removed: post-#302 Phase A nothing in
// production reads it. Adapter identity lives on `ProviderKey.adapter`
// directly, projected by cp-api per adapter_map.yaml. The legacy
// Model.provider → Adapter mapping has no caller.

/// Per-token cost for budget tracking. Both values are in USD per 1,000 tokens.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -544,24 +495,8 @@ mod tests {
assert!(serde_json::from_str::<Adapter>("\"azure_openai\"").is_err());
}

/// Every first-class `Provider` variant must have a non-empty
/// `as_str` wire id and a working `Adapter::from` arm. A
/// regression that added a new variant but forgot to update
/// either would compile fine but silently break dispatch
/// downstream.
#[test]
fn every_provider_variant_has_as_str_and_adapter() {
let variants = [
Provider::Openai,
Provider::Anthropic,
Provider::Google,
Provider::Deepseek,
Provider::Cohere,
Provider::Jina,
];
for v in variants {
let id = v.as_str();
assert!(!id.is_empty(), "{v:?}: as_str must be non-empty");
}
}
// `every_provider_variant_has_as_str_and_adapter` removed —
// the `Provider` enum it pinned no longer exists post-#302
// Phase A. Vendor identity is now a free-form string on
// `ProviderKey.provider` / `Model.provider`.
}
12 changes: 10 additions & 2 deletions crates/aisix-core/src/models/provider_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,16 @@ pub struct ProviderKey {
/// plaintext here.
pub secret: String,

/// Override for the upstream base URL. Empty/None means the
/// provider default applies (see `Provider::default_base_url`).
/// Override for the upstream base URL. Empty/None is rejected by
/// every family bridge whose canonical-vendor identity doesn't
/// match the PK's `provider`: the OpenAI-family bridge refuses
/// to fall back to `api.openai.com` for a vendor other than
/// `"openai"`, and the Anthropic-family bridge refuses to fall
Comment on lines +45 to +49
/// back to `api.anthropic.com` for a vendor other than
/// `"anthropic"`. See `OpenAiBridge::resolve_base` /
/// `AnthropicBridge::resolve_base` for the safety guards. cp-api
/// populates this from `adapter_map.yaml`'s `default_base_url` /
/// `provider_metadata.api_base_url`.
Comment on lines +45 to +54
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_base: Option<String>,

Expand Down
98 changes: 85 additions & 13 deletions crates/aisix-provider-anthropic/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ pub const ANTHROPIC_DEFAULT_BASE: &str = "https://api.anthropic.com";

pub struct AnthropicBridge {
client: Client,
name: &'static str,
api_version: &'static str,
}

Expand All @@ -52,16 +51,10 @@ impl AnthropicBridge {
pub fn with_client(client: Client) -> Self {
Self {
client,
name: "anthropic",
api_version: ANTHROPIC_VERSION,
}
}

pub fn with_name(mut self, name: &'static str) -> Self {
self.name = name;
self
}

pub fn with_api_version(mut self, v: &'static str) -> Self {
self.api_version = v;
self
Expand Down Expand Up @@ -126,12 +119,25 @@ fn resolve_base(ctx: &BridgeContext) -> Result<String, BridgeError> {
let pk_vendor_raw = ctx.provider_key.provider.as_str();
let pk_vendor_normalized = pk_vendor_raw.trim().to_ascii_lowercase();
if !pk_vendor_normalized.is_empty() && pk_vendor_normalized != "anthropic" {
// Operator-facing detail (route, provider topology,
// remediation steps) goes to logs only — keep the
// customer-visible 500 body short and free of
// internal-product taxonomy (cp-api / adapter_map /
// provider_metadata field names are not part of any
// wire contract a customer should depend on).
tracing::error!(
target: "aisix_provider_anthropic::bridge",
pk_display_name = %ctx.provider_key.display_name,
pk_vendor = %pk_vendor_raw,
"provider_key has no api_base; family bridge refusing fallback to \
api.anthropic.com. Operator action: populate `api_base` on the \
ProviderKey resource (managed deployments: via adapter_map / \
provider_metadata.api_base_url on the control plane; standalone: \
directly on the resource)."
Comment on lines +128 to +136
);
return Err(BridgeError::Config(format!(
"provider_key for vendor {pk_vendor_raw:?} has no api_base set; \
the Anthropic-family bridge refuses to fall back to api.anthropic.com \
to avoid routing {pk_vendor_raw:?}'s API key to Anthropic. cp-api \
must populate api_base for every catalog vendor via adapter_map / \
provider_metadata.api_base_url."
"provider_key for vendor {pk_vendor_raw:?} has no upstream base URL \
configured"
)));
}
Ok(ANTHROPIC_DEFAULT_BASE.to_string())
Expand Down Expand Up @@ -218,7 +224,7 @@ where
#[async_trait]
impl Bridge for AnthropicBridge {
fn name(&self) -> &'static str {
self.name
"anthropic"
}

async fn chat(
Expand Down Expand Up @@ -655,4 +661,70 @@ data: {\"type\":\"message_stop\"}\n\n";
assert_eq!(resolve_base(&ctx).unwrap(), canonical);
}
}

/// Family-bridge safety: when `AnthropicBridge` serves a non-anthropic
/// vendor with empty `api_base` it MUST refuse rather than fall back
/// to `ANTHROPIC_DEFAULT_BASE` — that fallback would silently route
/// the vendor's API key to `api.anthropic.com`. Mirror of the OpenAI
/// guard in `crates/aisix-provider-openai/src/bridge.rs`.
#[test]
fn family_bridge_refuses_non_anthropic_vendor_with_empty_api_base() {
for vendor in [
"bedrock-anthropic",
"vertex-anthropic",
"BedrockAnthropic",
" bedrock-anthropic ",
] {
let pk: ProviderKey = serde_json::from_str(&format!(
r#"{{"display_name":"x","secret":"k","provider":"{vendor}","adapter":"anthropic"}}"#
))
.unwrap();
let ctx = BridgeContext::new("rid", sample_model(), Arc::new(pk));
let err = resolve_base(&ctx).unwrap_err();
match err {
BridgeError::Config(msg) => {
assert!(
msg.contains("base URL") && msg.contains(vendor.trim()),
"vendor {vendor:?}: error must name vendor + base URL; got: {msg}",
);
// Sensitive-info-leakage guard: internal product
// taxonomy must not leak into the customer-visible
// 500 body. Those identifiers go to tracing only.
for forbidden in ["cp-api", "adapter_map", "provider_metadata"] {
assert!(
!msg.contains(forbidden),
"vendor {vendor:?}: error body must not leak \
internal-product taxonomy {forbidden:?}; got: {msg}",
);
}
}
other => panic!("vendor {vendor:?}: expected BridgeError::Config, got {other:?}"),
}
}
}

/// Pure-anthropic PK without `api_base` falls back to
/// `ANTHROPIC_DEFAULT_BASE` — the historical legacy behavior. The
/// safety check above only fires for non-anthropic vendors.
#[test]
fn family_bridge_allows_anthropic_vendor_with_empty_api_base() {
let pk: ProviderKey = serde_json::from_str(
r#"{"display_name":"a","secret":"sk-a","provider":"anthropic","adapter":"anthropic"}"#,
)
.unwrap();
let ctx = BridgeContext::new("rid", sample_model(), Arc::new(pk));
assert_eq!(resolve_base(&ctx).unwrap(), ANTHROPIC_DEFAULT_BASE);
}

/// Pre-Phase-A PK carries an empty `provider` string. The safety
/// check must NOT fire here — those rows route via the compat
/// shim in `crates/aisix-proxy/src/dispatch.rs::resolve_bridge`
/// to the specialized "anthropic" bridge. The bridge itself must
/// tolerate the legacy shape so the compat path doesn't 500.
#[test]
fn family_bridge_allows_legacy_empty_provider_with_empty_api_base() {
let pk: ProviderKey = serde_json::from_str(r#"{"display_name":"x","secret":"k"}"#).unwrap();
let ctx = BridgeContext::new("rid", sample_model(), Arc::new(pk));
assert_eq!(resolve_base(&ctx).unwrap(), ANTHROPIC_DEFAULT_BASE);
}
}
2 changes: 1 addition & 1 deletion crates/aisix-provider-azure-openai/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
//! bridge supports api-key only (the common case). AAD support will
//! land alongside the cp-api `auth_scheme` field becoming routable.
//!
//! # Why Azure-OpenAI is a separate bridge (not OpenAiBridge::with_name)
//! # Why Azure-OpenAI is a separate `Adapter::AzureOpenai` family bridge
//!
//! 1. **Auth header differs** — Azure uses `api-key: <key>`, not
//! `Authorization: Bearer <key>`. The OpenAiBridge's header-builder
Expand Down
Loading