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 @@ -35,7 +35,7 @@ pub use models::{
AisixSnapshot, ApiKey, CachePolicy, CooldownConfig, ExporterKind, Guardrail,
GuardrailHookPoint, GuardrailKind, KeywordConfig, KeywordPattern, Model, ObservabilityExporter,
OnAllFilteredPolicy, Provider, ProviderKey, RateLimit, RateLimitPolicy, Routing,
RoutingStrategy, RoutingTarget, SchemaError, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
RoutingStrategy, RoutingTarget, SchemaError, TelemetryTags, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
};
pub use resource::{Resource, ResourceEntry};
pub use snapshot::{ResourceTable, SnapshotHandle};
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub use model::{
DEFAULT_COOLDOWN_TRIGGER_STATUSES,
};
pub use observability_exporter::{ExporterKind, ObservabilityExporter, OtlpHttpConfig};
pub use provider_key::ProviderKey;
pub use provider_key::{ProviderKey, TelemetryTags};
pub use rate_limit::RateLimit;
pub use rate_limit_policy::RateLimitPolicy;
pub use routing::{OnAllFilteredPolicy, Routing, RoutingStrategy, RoutingTarget};
Expand Down
181 changes: 181 additions & 0 deletions crates/aisix-core/src/models/provider_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use serde::{Deserialize, Serialize};

use crate::models::Adapter;
use crate::resource::Resource;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand All @@ -38,11 +39,78 @@ pub struct ProviderKey {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_base: Option<String>,

/// Vendor identity (e.g. `"deepseek"`, `"openai"`). Introduced as a
/// skeleton for issue #302 Phase A. Empty in this PR — no dispatch
/// path consumes it yet; the field exists so future Phase A sub-PRs
/// can populate it without an on-disk schema break. Old payloads
/// that omit `provider` continue to deserialize via
/// `#[serde(default)]`.
#[serde(default)]
pub provider: String,

/// Wire-shape adapter (`openai` / `anthropic` / `bedrock` /
/// `vertex` / `azure-openai`). Introduced as a skeleton for issue
/// #302 Phase A. `None` in this PR — no dispatch path consumes it
/// yet; the field exists so future Phase A sub-PRs can populate it
/// without an on-disk schema break. Old payloads that omit
/// `adapter` continue to deserialize via `#[serde(default)]`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adapter: Option<Adapter>,

/// Telemetry tags carried alongside the key for metric/log
/// emission. Introduced as a skeleton for issue #302 Phase A.
/// No metric path consumes these tags yet; the field exists so
/// future Phase A sub-PRs can attribute traffic without an
/// on-disk schema break. Old payloads that omit `telemetry_tags`
/// fall back to the `Default` impl via `#[serde(default)]`.
#[serde(default)]
pub telemetry_tags: TelemetryTags,

/// Filled in by the snapshot loader from the etcd key path.
#[serde(skip)]
pub(crate) runtime_id: String,
}

/// Telemetry attribution tags emitted alongside requests routed
/// through this `ProviderKey`. Introduced as a skeleton for issue
/// #302 Phase A — no metric/log path consumes these fields yet.
///
/// The `#[serde(default)]` on each field plus `#[derive(Default)]`
/// means an omitted block or omitted individual key both yield the
/// zero-value `TelemetryTags`, preserving backward compatibility
/// with existing `ProviderKey` payloads.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TelemetryTags {
/// `"catalog"` for first-party curated providers, `"byo"` for
/// bring-your-own. `None` until Phase A wires attribution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,

/// Whether this provider is surfaced in the featured list.
/// Defaults to `false`.
#[serde(default)]
pub featured: bool,

/// Branded provider slug for catalog entries (e.g. `"openai"`,
/// `"anthropic"`). `None` for byo or until Phase A wires
/// attribution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branded_provider: Option<String>,

/// Operator-defined label for this provider key (e.g.
/// `"production"`, `"shared-test"`). `None` until Phase A wires
/// attribution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pk_label: Option<String>,

/// Operator-defined label for bring-your-own entries (e.g. an
/// internal team name). `None` for catalog entries or until
/// Phase A wires attribution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub byo_label: Option<String>,
}

impl Resource for ProviderKey {
fn id(&self) -> &str {
&self.runtime_id
Expand Down Expand Up @@ -96,4 +164,117 @@ mod tests {
assert_eq!(p.id(), "uuid-pk-1");
assert_eq!(p.name(), "openai-prod");
}

// ---- issue #302 Phase A skeleton ----

#[test]
fn legacy_payload_without_phase_a_fields_deserialises_with_defaults() {
// Wire-shape proof for the on-disk compatibility contract: an
// existing payload from before Phase A (no `provider`, no
// `adapter`, no `telemetry_tags`) must still deserialize, and
// the new fields must land at their zero values.
let p: ProviderKey = serde_json::from_str(
r#"{"display_name":"openai-prod","secret":"sk-x","api_base":"https://api.openai.com/v1"}"#,
)
.unwrap();
assert_eq!(p.provider, "");
assert_eq!(p.adapter, None);
assert_eq!(p.telemetry_tags, TelemetryTags::default());
}

#[test]
fn payload_with_all_phase_a_fields_deserialises() {
let p: ProviderKey = serde_json::from_str(
r#"{
"display_name": "deepseek-prod",
"secret": "sk-x",
"api_base": "https://api.deepseek.com/v1",
"provider": "deepseek",
"adapter": "openai",
"telemetry_tags": {
"kind": "catalog",
"featured": true,
"branded_provider": "deepseek",
"pk_label": "production"
}
}"#,
)
.unwrap();
assert_eq!(p.provider, "deepseek");
assert_eq!(p.adapter, Some(Adapter::Openai));
assert_eq!(p.telemetry_tags.kind.as_deref(), Some("catalog"));
assert!(p.telemetry_tags.featured);
assert_eq!(
p.telemetry_tags.branded_provider.as_deref(),
Some("deepseek")
);
assert_eq!(p.telemetry_tags.pk_label.as_deref(), Some("production"));
assert_eq!(p.telemetry_tags.byo_label, None);
}

#[test]
fn byo_telemetry_shape_deserialises() {
// BYO entries have null branded_provider and a non-null
// byo_label — the dual-label shape Phase A introduces.
let p: ProviderKey = serde_json::from_str(
r#"{
"display_name": "internal-llm",
"secret": "sk-x",
"telemetry_tags": {
"kind": "byo",
"branded_provider": null,
"byo_label": "platform-team"
}
}"#,
)
.unwrap();
assert_eq!(p.telemetry_tags.kind.as_deref(), Some("byo"));
assert!(!p.telemetry_tags.featured);
assert_eq!(p.telemetry_tags.branded_provider, None);
assert_eq!(p.telemetry_tags.byo_label.as_deref(), Some("platform-team"));
}

#[test]
fn telemetry_tags_rejects_unknown_field() {
// TelemetryTags is `deny_unknown_fields` — stops cp-api from
// silently shipping a new tag the DP can't see.
let r: Result<ProviderKey, _> = serde_json::from_str(
r#"{
"display_name": "x",
"secret": "k",
"telemetry_tags": { "unknown_tag": "v" }
}"#,
);
assert!(r.is_err());
}

#[test]
fn adapter_rejects_unknown_string() {
// `adapter` is the closed `Adapter` enum — unknown shape
// strings must fail loudly rather than silently fall through.
let r: Result<ProviderKey, _> = serde_json::from_str(
r#"{"display_name":"x","secret":"k","adapter":"not-a-real-adapter"}"#,
);
assert!(r.is_err());
}

#[test]
fn round_trip_omits_default_phase_a_fields() {
// A ProviderKey built without setting the Phase A fields
// serializes with `provider:""` and `telemetry_tags` defaulted,
// and `adapter` absent (skipped because None). Re-deserializing
// must reproduce the original struct.
let original = ProviderKey {
display_name: "openai-prod".into(),
secret: "sk-x".into(),
api_base: None,
provider: String::new(),
adapter: None,
telemetry_tags: TelemetryTags::default(),
runtime_id: String::new(),
};
let s = serde_json::to_string(&original).unwrap();
let back: ProviderKey = serde_json::from_str(&s).unwrap();
assert_eq!(original, back);
}
}
123 changes: 122 additions & 1 deletion crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,11 @@ fn apikey_schema() -> Value {
}

fn provider_key_schema() -> Value {
// `provider`, `adapter`, and `telemetry_tags` were added as a
// skeleton for issue #302 Phase A. They are optional on the wire
// (matching `#[serde(default)]` on the Rust side) so existing
// ProviderKey payloads without these fields keep validating. No
// dispatch path reads them in this PR.
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
Expand All @@ -283,7 +288,25 @@ fn provider_key_schema() -> Value {
"properties": {
"display_name": { "type": "string", "minLength": 1 },
"secret": { "type": "string", "minLength": 1 },
"api_base": { "type": "string" }
"api_base": { "type": "string" },
// Phase A skeleton — vendor identity, free-form string.
// Closed-set validation is deferred to a follow-up Phase A
// PR that wires dispatch onto `provider`.
"provider": { "type": "string" },
// Phase A skeleton — wire-shape adapter. Pinned to the
// closed Adapter enum.
"adapter": { "type": "string", "enum": ["openai", "anthropic", "bedrock", "vertex", "azure-openai"] },
"telemetry_tags": {
"type": "object",
"additionalProperties": false,
"properties": {
"kind": { "type": "string", "enum": ["catalog", "byo"] },
"featured": { "type": "boolean" },
"branded_provider": { "type": ["string", "null"] },
"pk_label": { "type": ["string", "null"] },
"byo_label": { "type": ["string", "null"] }
}
}
}
})
}
Expand Down Expand Up @@ -948,4 +971,102 @@ mod tests {
});
assert!(validate_rate_limit_policy(&v).is_err());
}

// ---- provider_key schema (issue #302 Phase A skeleton) ----

#[test]
fn provider_key_minimal_passes() {
let v = json!({
"display_name": "openai-prod",
"secret": "sk-x"
});
validate_provider_key(&v).unwrap();
}

#[test]
fn provider_key_legacy_payload_without_phase_a_fields_passes() {
// Pre-#302 payload — no provider / adapter / telemetry_tags.
// Must still validate so existing on-disk rows keep loading.
let v = json!({
"display_name": "openai-prod",
"secret": "sk-x",
"api_base": "https://api.openai.com/v1"
});
validate_provider_key(&v).unwrap();
}

#[test]
fn provider_key_with_phase_a_fields_passes() {
let v = json!({
"display_name": "deepseek-prod",
"secret": "sk-x",
"api_base": "https://api.deepseek.com/v1",
"provider": "deepseek",
"adapter": "openai",
"telemetry_tags": {
"kind": "catalog",
"featured": true,
"branded_provider": "deepseek",
"pk_label": "production"
}
});
validate_provider_key(&v).unwrap();
}

#[test]
fn provider_key_with_byo_telemetry_shape_passes() {
let v = json!({
"display_name": "internal-llm",
"secret": "sk-x",
"telemetry_tags": {
"kind": "byo",
"branded_provider": null,
"byo_label": "platform-team"
}
});
validate_provider_key(&v).unwrap();
}

#[test]
fn provider_key_rejects_unknown_adapter_value() {
let v = json!({
"display_name": "x",
"secret": "k",
"adapter": "not-a-real-adapter"
});
assert!(validate_provider_key(&v).is_err());
}

#[test]
fn provider_key_rejects_unknown_telemetry_field() {
let v = json!({
"display_name": "x",
"secret": "k",
"telemetry_tags": { "unknown_tag": "v" }
});
assert!(validate_provider_key(&v).is_err());
}

#[test]
fn provider_key_rejects_unknown_top_level_field() {
// Top-level additionalProperties=false still applies — only
// the explicitly-listed Phase A fields are accepted.
let v = json!({
"display_name": "x",
"secret": "k",
"rogue": 1
});
assert!(validate_provider_key(&v).is_err());
}

#[test]
fn provider_key_rejects_unknown_telemetry_kind() {
// `kind` is the closed `"catalog" | "byo"` set.
let v = json!({
"display_name": "x",
"secret": "k",
"telemetry_tags": { "kind": "third-party" }
});
assert!(validate_provider_key(&v).is_err());
}
}
Loading