diff --git a/crates/aisix-core/src/lib.rs b/crates/aisix-core/src/lib.rs index 1827fa02..7e4c6fea 100644 --- a/crates/aisix-core/src/lib.rs +++ b/crates/aisix-core/src/lib.rs @@ -31,7 +31,7 @@ pub use error::{ }; pub use models::{ validate_apikey, validate_cache_policy, validate_guardrail, validate_model, - validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, + validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, Adapter, AisixSnapshot, ApiKey, CachePolicy, CooldownConfig, ExporterKind, Guardrail, GuardrailHookPoint, GuardrailKind, KeywordConfig, KeywordPattern, Model, ObservabilityExporter, OnAllFilteredPolicy, Provider, ProviderKey, RateLimit, RateLimitPolicy, Routing, diff --git a/crates/aisix-core/src/models/mod.rs b/crates/aisix-core/src/models/mod.rs index 91dedc83..d627e614 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -34,7 +34,8 @@ pub use guardrail::{ GuardrailKind, KeywordConfig, KeywordPattern, }; pub use model::{ - BackgroundModelCheck, CooldownConfig, Model, Provider, DEFAULT_COOLDOWN_TRIGGER_STATUSES, + Adapter, BackgroundModelCheck, CooldownConfig, Model, Provider, + DEFAULT_COOLDOWN_TRIGGER_STATUSES, }; pub use observability_exporter::{ExporterKind, ObservabilityExporter, OtlpHttpConfig}; pub use provider_key::ProviderKey; diff --git a/crates/aisix-core/src/models/model.rs b/crates/aisix-core/src/models/model.rs index 28498d34..9657e0d2 100644 --- a/crates/aisix-core/src/models/model.rs +++ b/crates/aisix-core/src/models/model.rs @@ -62,6 +62,73 @@ impl Provider { } } +/// 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`). +/// +/// 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. +/// +/// 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Adapter { + Openai, + Anthropic, + Bedrock, + Vertex, + AzureOpenai, +} + +impl From for Adapter { + /// Mapping from the current `Provider` enum onto the `Adapter` + /// wire-shape set, for use during the issue #302 Phase A skeleton + /// stage. This mapping is **not** wired into dispatch in this PR; + /// it exists as a forward-looking reference that follow-up Phase A + /// PRs will consume when migrating entities, the Hub, and Bridges + /// onto `Adapter`. + /// + /// Rationale per variant: + /// + /// - `Openai` / `Anthropic`: direct equivalents. + /// - `Google` → `Vertex`: forward-looking choice for the Phase A + /// migration target. **Note:** this intentionally diverges from + /// the current runtime behavior — `Provider::Google` today calls + /// the Gemini Generative Language API via its OpenAI-compatible + /// endpoint (`/v1beta/openai`), which is an `openai`-shaped + /// wire. Any downstream PR that flips dispatch onto `Adapter` + /// MUST either also migrate the Google bridge to native Vertex + /// AI request encoding, or revisit this arm before merging — a + /// silent flip would change the wire shape sent to Google + /// upstreams. See issue #302 Phase A for the migration plan. + /// - `Deepseek` → `Openai`: DeepSeek exposes an OpenAI-compatible + /// chat completions endpoint, so the adapter shape is `openai`. + /// - `Cohere` → `Openai`: the gateway currently talks to Cohere's + /// OpenAI-compatible endpoints (#213 Phase 1 — rerank-only), + /// so the wire adapter is `openai`. A native Cohere adapter is + /// not part of this skeleton. + /// - `Jina` → `Openai`: Jina's rerank is identity-mapped to the + /// OpenAI-compat rerank shape (#213 Phase 2), so the wire + /// adapter is `openai`. + fn from(provider: Provider) -> Self { + match provider { + Provider::Openai => Adapter::Openai, + Provider::Anthropic => Adapter::Anthropic, + Provider::Google => Adapter::Vertex, + Provider::Deepseek => Adapter::Openai, + Provider::Cohere => Adapter::Openai, + Provider::Jina => Adapter::Openai, + } + } +} + /// Per-token cost for budget tracking. Both values are in USD per 1,000 tokens. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -436,6 +503,84 @@ mod tests { assert_eq!(bg.ignore_statuses, vec![408, 429]); } + #[test] + fn adapter_from_provider_covers_every_variant() { + // Every Provider variant must map to a defined Adapter. This + // test exists to fail the build if a new Provider is added + // without an explicit From arm — the match in `From` + // is exhaustive so the real safety net is the compiler, but + // pinning the chosen Adapter per variant guards against a + // future edit silently changing the mapping. + assert_eq!(Adapter::from(Provider::Openai), Adapter::Openai); + assert_eq!(Adapter::from(Provider::Anthropic), Adapter::Anthropic); + assert_eq!(Adapter::from(Provider::Google), Adapter::Vertex); + assert_eq!(Adapter::from(Provider::Deepseek), Adapter::Openai); + assert_eq!(Adapter::from(Provider::Cohere), Adapter::Openai); + assert_eq!(Adapter::from(Provider::Jina), Adapter::Openai); + } + + #[test] + fn adapter_serializes_to_kebab_case_wire_strings() { + // Pin each Adapter's wire form. AzureOpenai → "azure-openai" + // is the load-bearing case for the kebab-case choice; the + // others are pinned to lock the contract so a future + // rename_all change is surfaced as a test failure. + assert_eq!( + serde_json::to_string(&Adapter::Openai).unwrap(), + "\"openai\"" + ); + assert_eq!( + serde_json::to_string(&Adapter::Anthropic).unwrap(), + "\"anthropic\"" + ); + assert_eq!( + serde_json::to_string(&Adapter::Bedrock).unwrap(), + "\"bedrock\"" + ); + assert_eq!( + serde_json::to_string(&Adapter::Vertex).unwrap(), + "\"vertex\"" + ); + assert_eq!( + serde_json::to_string(&Adapter::AzureOpenai).unwrap(), + "\"azure-openai\"" + ); + } + + #[test] + fn adapter_deserializes_from_kebab_case_wire_strings() { + assert_eq!( + serde_json::from_str::("\"openai\"").unwrap(), + Adapter::Openai + ); + assert_eq!( + serde_json::from_str::("\"anthropic\"").unwrap(), + Adapter::Anthropic + ); + assert_eq!( + serde_json::from_str::("\"bedrock\"").unwrap(), + Adapter::Bedrock + ); + assert_eq!( + serde_json::from_str::("\"vertex\"").unwrap(), + Adapter::Vertex + ); + assert_eq!( + serde_json::from_str::("\"azure-openai\"").unwrap(), + Adapter::AzureOpenai + ); + } + + #[test] + fn adapter_rejects_unknown_variant_strings() { + // Closed enum — any string outside the kebab-case wire set + // must fail to deserialize so callers can't silently smuggle + // in a typo or a legacy provider name. + assert!(serde_json::from_str::("\"gemini\"").is_err()); + assert!(serde_json::from_str::("\"azureopenai\"").is_err()); + assert!(serde_json::from_str::("\"azure_openai\"").is_err()); + } + #[test] fn provider_default_urls_are_stable() { assert_eq!(