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
2 changes: 1 addition & 1 deletion crates/aisix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
145 changes: 145 additions & 0 deletions crates/aisix-core/src/models/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Provider> 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)]
Expand Down Expand Up @@ -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<Provider>`
// 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::<Adapter>("\"openai\"").unwrap(),
Adapter::Openai
);
assert_eq!(
serde_json::from_str::<Adapter>("\"anthropic\"").unwrap(),
Adapter::Anthropic
);
assert_eq!(
serde_json::from_str::<Adapter>("\"bedrock\"").unwrap(),
Adapter::Bedrock
);
assert_eq!(
serde_json::from_str::<Adapter>("\"vertex\"").unwrap(),
Adapter::Vertex
);
assert_eq!(
serde_json::from_str::<Adapter>("\"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::<Adapter>("\"gemini\"").is_err());
assert!(serde_json::from_str::<Adapter>("\"azureopenai\"").is_err());
assert!(serde_json::from_str::<Adapter>("\"azure_openai\"").is_err());
}

#[test]
fn provider_default_urls_are_stable() {
assert_eq!(
Expand Down
Loading