diff --git a/crates/aisix-core/src/lib.rs b/crates/aisix-core/src/lib.rs index 7e4c6fea..c061e0e3 100644 --- a/crates/aisix-core/src/lib.rs +++ b/crates/aisix-core/src/lib.rs @@ -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}; diff --git a/crates/aisix-core/src/models/mod.rs b/crates/aisix-core/src/models/mod.rs index d627e614..064c11e0 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -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}; diff --git a/crates/aisix-core/src/models/provider_key.rs b/crates/aisix-core/src/models/provider_key.rs index 4ff22b32..4d148970 100644 --- a/crates/aisix-core/src/models/provider_key.rs +++ b/crates/aisix-core/src/models/provider_key.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; +use crate::models::Adapter; use crate::resource::Resource; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -38,11 +39,78 @@ pub struct ProviderKey { #[serde(default, skip_serializing_if = "Option::is_none")] pub api_base: Option, + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, +} + impl Resource for ProviderKey { fn id(&self) -> &str { &self.runtime_id @@ -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 = 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 = 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); + } } diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index ed2e287d..de043bf0 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -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", @@ -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"] } + } + } } }) } @@ -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()); + } }