From bec294a3c651b28bd00cc33f9da9c732c674c387 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 22 Jun 2026 21:35:07 +0800 Subject: [PATCH 1/8] test(core): characterize model validator before single-source refactor Pins the exact accept/reject behavior of validate_model across every constraint the hand-written model_schema() enforces (oneOf mutual exclusion, provider pattern/length, nested object bounds, additional- Properties). This is the guardrail for collapsing the runtime validator onto the Model struct + schemars so the refactor can prove it did not widen or narrow the config contract. The rate_limit.rps/rph cases encode today's (buggy) rejection and will flip to accept in the refactor. --- .../tests/model_schema_characterization.rs | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 crates/aisix-core/tests/model_schema_characterization.rs diff --git a/crates/aisix-core/tests/model_schema_characterization.rs b/crates/aisix-core/tests/model_schema_characterization.rs new file mode 100644 index 00000000..c23b7927 --- /dev/null +++ b/crates/aisix-core/tests/model_schema_characterization.rs @@ -0,0 +1,508 @@ +//! Characterization (golden-corpus) test for the `model` resource validator. +//! +//! This pins the EXACT accept/reject behavior of `validate_model` so that the +//! single-source-of-truth refactor (deriving the runtime validator from the +//! `Model` struct + schemars instead of a hand-written `json!` schema) can +//! prove it did not silently widen or narrow the config contract. +//! +//! Every case below is a constraint that the hand-written `model_schema()` +//! enforces today. After the refactor, this corpus must stay green — with one +//! deliberate, documented exception: the `rate_limit.rps` / `rate_limit.rph` +//! cases (see `INTENTIONALLY_FLIPPED` below), which the hand-written validator +//! wrongly rejects even though the `RateLimit` struct and the dispatch path +//! support them. The refactor flips those to ACCEPT and the assertions move +//! accordingly, surfacing the behavior change as a visible diff. + +use aisix_core::models::schema::validate_model; +use serde_json::{json, Value}; + +/// Assert that the current `validate_model` ACCEPTS `value`. +#[track_caller] +fn accept(label: &str, value: Value) { + if let Err(e) = validate_model(&value) { + panic!("expected ACCEPT for `{label}`, got reject: {e}"); + } +} + +/// Assert that the current `validate_model` REJECTS `value`. +#[track_caller] +fn reject(label: &str, value: Value) { + if validate_model(&value).is_ok() { + panic!("expected REJECT for `{label}`, but it was accepted"); + } +} + +// --------------------------------------------------------------------------- +// ACCEPT — the three valid model shapes and their optional fields. +// --------------------------------------------------------------------------- + +#[test] +fn accept_direct_minimal() { + accept( + "direct minimal", + json!({ + "display_name": "m", + "provider": "openai", + "model_name": "gpt-4o", + "provider_key_id": "pk-1" + }), + ); +} + +#[test] +fn accept_direct_full() { + accept( + "direct with every optional field", + json!({ + "display_name": "m", + "provider": "openai", + "model_name": "gpt-4o", + "provider_key_id": "pk-1", + "timeout": 30000, + "stream_timeout": 2500, + "rate_limit": {"tpm": 1, "tpd": 1, "rpm": 1, "rpd": 1, "concurrency": 1}, + "allowed_cidrs": ["10.0.0.0/8"], + "cost": {"input_per_1k": 0.0, "output_per_1k": 1.5}, + "background_model_check": { + "enabled": true, + "interval_seconds": 5, + "timeout_seconds": 1, + "prompt": "ok", + "max_tokens": 1, + "ignore_statuses": [408, 429], + "stale_after_seconds": 1 + }, + "cooldown": { + "enabled": true, + "default_seconds": 0, + "max_seconds": 1, + "honor_retry_after": true, + "trigger_statuses": [429, 503], + "trigger_on_timeout": true, + "trigger_on_transport": true + } + }), + ); +} + +#[test] +fn accept_provider_with_dot() { + // #417 regression guard: real models.dev ids like `wafer.ai` contain a dot. + accept( + "provider wafer.ai", + json!({ + "display_name": "m", + "provider": "wafer.ai", + "model_name": "x", + "provider_key_id": "pk-1" + }), + ); +} + +#[test] +fn accept_routing_minimal() { + accept( + "routing minimal", + json!({ + "display_name": "r", + "routing": {"targets": [{"model": "m"}]} + }), + ); +} + +#[test] +fn accept_routing_full() { + accept( + "routing with all knobs + shared optionals", + json!({ + "display_name": "r", + "routing": { + "strategy": "weighted", + "targets": [{"model": "a", "weight": 3}, {"model": "b", "weight": 1}], + "retries": 2, + "max_fallbacks": 1, + "retry_on_429": true, + "on_all_filtered": "original_order" + }, + "timeout": 1000, + "rate_limit": {"rpm": 10}, + "allowed_cidrs": ["10.0.0.0/8"], + "cost": {"input_per_1k": 0.0, "output_per_1k": 0.0} + }), + ); +} + +#[test] +fn accept_ensemble_minimal() { + accept( + "ensemble minimal", + json!({ + "display_name": "e", + "ensemble": {"panel": [{"model": "m"}], "judge": {"model": "j"}} + }), + ); +} + +#[test] +fn accept_ensemble_full() { + accept( + "ensemble with panel + judge knobs", + json!({ + "display_name": "e", + "ensemble": { + "panel": [{"model": "a", "temperature": 0.0, "seed": 0, "weight": 1}], + "judge": {"model": "j", "synthesis_prompt": "synthesize"}, + "min_responses": 1, + "timeout_ms": 0 + } + }), + ); +} + +#[test] +fn accept_direct_with_cooldown_and_background_check() { + // The direct branch permits cooldown + background_model_check; routing and + // ensemble do not. Locks the asymmetry. + accept( + "direct + cooldown + background_model_check", + json!({ + "display_name": "m", + "provider": "openai", + "model_name": "gpt-4o", + "provider_key_id": "pk-1", + "cooldown": {"enabled": true}, + "background_model_check": { + "enabled": true, + "interval_seconds": 5, + "timeout_seconds": 1, + "prompt": "ok", + "max_tokens": 1, + "stale_after_seconds": 1 + } + }), + ); +} + +// --------------------------------------------------------------------------- +// REJECT — top-level shape. +// --------------------------------------------------------------------------- + +#[test] +fn reject_missing_display_name() { + reject( + "missing display_name", + json!({"provider": "openai", "model_name": "g", "provider_key_id": "pk-1"}), + ); +} + +#[test] +fn reject_empty_display_name() { + reject( + "empty display_name", + json!({"display_name": "", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1"}), + ); +} + +#[test] +fn reject_unknown_top_level_field() { + reject( + "unknown top-level field", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "foo": 1}), + ); +} + +// --------------------------------------------------------------------------- +// REJECT — provider pattern / length (these constraints exist ONLY in the +// hand-written validator today; they must survive the refactor). +// --------------------------------------------------------------------------- + +#[test] +fn reject_provider_uppercase() { + reject( + "provider uppercase", + json!({"display_name": "m", "provider": "OpenAI", "model_name": "g", "provider_key_id": "pk-1"}), + ); +} + +#[test] +fn reject_provider_leading_punctuation() { + reject( + "provider leading dot", + json!({"display_name": "m", "provider": ".openai", "model_name": "g", "provider_key_id": "pk-1"}), + ); + reject( + "provider leading dash", + json!({"display_name": "m", "provider": "-openai", "model_name": "g", "provider_key_id": "pk-1"}), + ); +} + +#[test] +fn reject_provider_empty() { + reject( + "provider empty string", + json!({"display_name": "m", "provider": "", "model_name": "g", "provider_key_id": "pk-1"}), + ); +} + +#[test] +fn reject_provider_too_long() { + reject( + "provider > 64 chars", + json!({"display_name": "m", "provider": "a".repeat(65), "model_name": "g", "provider_key_id": "pk-1"}), + ); +} + +// --------------------------------------------------------------------------- +// REJECT — numeric / type bounds. +// --------------------------------------------------------------------------- + +#[test] +fn reject_negative_timeout() { + reject( + "negative timeout", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "timeout": -1}), + ); +} + +#[test] +fn reject_non_integer_timeout() { + reject( + "fractional timeout", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "timeout": 1.5}), + ); +} + +#[test] +fn reject_empty_allowed_cidr_entry() { + reject( + "allowed_cidrs with empty entry", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "allowed_cidrs": [""]}), + ); +} + +// --------------------------------------------------------------------------- +// REJECT — oneOf mutual exclusion (the constraint schemars cannot express from +// a flat struct; the refactor re-adds it via `#[schemars(extend(...))]`). +// --------------------------------------------------------------------------- + +#[test] +fn reject_no_shape_at_all() { + reject("display_name only — no direct/routing/ensemble", json!({"display_name": "m"})); +} + +#[test] +fn reject_partial_direct() { + reject( + "provider without model_name/provider_key_id", + json!({"display_name": "m", "provider": "openai"}), + ); +} + +#[test] +fn reject_routing_plus_provider() { + reject( + "routing + provider", + json!({"display_name": "m", "provider": "openai", "routing": {"targets": [{"model": "x"}]}}), + ); +} + +#[test] +fn reject_routing_plus_ensemble() { + reject( + "routing + ensemble", + json!({ + "display_name": "m", + "routing": {"targets": [{"model": "x"}]}, + "ensemble": {"panel": [{"model": "a"}], "judge": {"model": "j"}} + }), + ); +} + +#[test] +fn reject_routing_plus_cooldown() { + reject( + "routing + cooldown (cooldown is direct-only)", + json!({"display_name": "m", "routing": {"targets": [{"model": "x"}]}, "cooldown": {"enabled": true}}), + ); +} + +#[test] +fn reject_routing_plus_background_check() { + reject( + "routing + background_model_check (direct-only)", + json!({ + "display_name": "m", + "routing": {"targets": [{"model": "x"}]}, + "background_model_check": { + "enabled": true, "interval_seconds": 5, "timeout_seconds": 1, + "prompt": "ok", "max_tokens": 1, "stale_after_seconds": 1 + } + }), + ); +} + +#[test] +fn reject_ensemble_plus_provider() { + reject( + "ensemble + provider", + json!({ + "display_name": "m", + "provider": "openai", + "ensemble": {"panel": [{"model": "a"}], "judge": {"model": "j"}} + }), + ); +} + +// --------------------------------------------------------------------------- +// REJECT — nested object constraints. +// --------------------------------------------------------------------------- + +#[test] +fn reject_routing_empty_targets() { + reject( + "routing targets empty", + json!({"display_name": "r", "routing": {"targets": []}}), + ); +} + +#[test] +fn reject_routing_target_missing_model() { + reject( + "routing target without model", + json!({"display_name": "r", "routing": {"targets": [{"weight": 1}]}}), + ); +} + +#[test] +fn reject_routing_unknown_field() { + reject( + "routing unknown field", + json!({"display_name": "r", "routing": {"targets": [{"model": "x"}], "bogus": 1}}), + ); +} + +#[test] +fn reject_routing_bad_strategy() { + reject( + "routing invalid strategy", + json!({"display_name": "r", "routing": {"strategy": "random", "targets": [{"model": "x"}]}}), + ); +} + +#[test] +fn reject_routing_bad_on_all_filtered() { + reject( + "routing invalid on_all_filtered", + json!({"display_name": "r", "routing": {"targets": [{"model": "x"}], "on_all_filtered": "shrug"}}), + ); +} + +#[test] +fn reject_ensemble_missing_judge() { + reject( + "ensemble without judge", + json!({"display_name": "e", "ensemble": {"panel": [{"model": "a"}]}}), + ); +} + +#[test] +fn reject_ensemble_empty_panel() { + reject( + "ensemble empty panel", + json!({"display_name": "e", "ensemble": {"panel": [], "judge": {"model": "j"}}}), + ); +} + +#[test] +fn reject_cost_missing_field() { + reject( + "cost missing output_per_1k", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "cost": {"input_per_1k": 1.0}}), + ); +} + +#[test] +fn reject_cost_negative() { + reject( + "cost negative input_per_1k", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "cost": {"input_per_1k": -1.0, "output_per_1k": 0.0}}), + ); +} + +#[test] +fn reject_background_check_missing_required() { + reject( + "background_model_check missing prompt", + json!({ + "display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", + "background_model_check": {"enabled": true, "interval_seconds": 5, "timeout_seconds": 1, "max_tokens": 1, "stale_after_seconds": 1} + }), + ); +} + +#[test] +fn reject_background_check_interval_too_small() { + reject( + "background_model_check interval_seconds < 5", + json!({ + "display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", + "background_model_check": {"enabled": true, "interval_seconds": 4, "timeout_seconds": 1, "prompt": "ok", "max_tokens": 1, "stale_after_seconds": 1} + }), + ); +} + +#[test] +fn reject_background_check_status_out_of_range() { + reject( + "background_model_check ignore_statuses below 100", + json!({ + "display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", + "background_model_check": {"enabled": true, "interval_seconds": 5, "timeout_seconds": 1, "prompt": "ok", "max_tokens": 1, "ignore_statuses": [99], "stale_after_seconds": 1} + }), + ); + reject( + "background_model_check ignore_statuses above 599", + json!({ + "display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", + "background_model_check": {"enabled": true, "interval_seconds": 5, "timeout_seconds": 1, "prompt": "ok", "max_tokens": 1, "ignore_statuses": [600], "stale_after_seconds": 1} + }), + ); +} + +#[test] +fn reject_cooldown_unknown_field() { + reject( + "cooldown unknown field", + json!({ + "display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", + "cooldown": {"enabled": true, "bogus": 1} + }), + ); +} + +// --------------------------------------------------------------------------- +// INTENTIONALLY FLIPPED by the single-source refactor. +// +// The hand-written validator's `$defs/rate_limit` lists only 5 fields +// (tpm/tpd/rpm/rpd/concurrency) with `additionalProperties: false`, so it +// rejects `rps`/`rph` — even though the `RateLimit` struct declares them and +// the rate limiter honors per-second / per-hour windows (#426). These two +// assertions encode TODAY's (buggy) behavior; the refactor commit flips them +// to `accept(...)` and adds an E2E proving the limiter enforces them. +// --------------------------------------------------------------------------- + +#[test] +fn reject_rate_limit_rps_today() { + reject( + "rate_limit.rps (flips to ACCEPT in the refactor)", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "rate_limit": {"rps": 10}}), + ); +} + +#[test] +fn reject_rate_limit_rph_today() { + reject( + "rate_limit.rph (flips to ACCEPT in the refactor)", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "rate_limit": {"rph": 10}}), + ); +} From e53cc17431b09269d42be6b326d65a6d559133e4 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 22 Jun 2026 21:57:56 +0800 Subject: [PATCH 2/8] refactor(core): derive model runtime validator from the struct (single source) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model resource had two independently hand-maintained schema representations in this crate: the runtime validator (a json! literal in models/schema.rs) and the schemars-derived struct (published to schemas/resources/ and vendored downstream). Nothing gated them against each other, so they drifted — most visibly, the runtime validator's $defs/rate_limit omitted rps/rph while the RateLimit struct and the rate limiter (store/local.rs, store/redis.rs) support them, so a model carrying rate_limit.rps was accepted by the published schema but silently dropped at the DP loader. Make the Model struct the single source: port every per-field constraint (provider pattern/length, minLength, minItems, numeric ranges, status-code item bounds) onto the struct as schemars attributes, and express the one cross-field invariant schemars cannot derive (direct/routing/ensemble mutual exclusion) as model_one_of(), injected by the new producer model_root_schema(). Both the runtime validator and dump-schema call that producer, so published == enforced by construction. option_add_null_type is disabled for the model generation so optional fields stay plain-but-absent (matching the wire shape) rather than nullable. Behavior is preserved exactly (40-case characterization corpus) except the intended fix: rps/rph are now accepted on both model and apikey rate_limit. Regenerated model/ensemble/routing schemas reflect the constraints now carried on the structs. Other resources still use hand-written validators; migrating them to the same producer pattern is follow-up work. --- crates/aisix-admin/src/openapi.rs | 13 + crates/aisix-core/src/bin/dump-schema.rs | 28 +- crates/aisix-core/src/models/ensemble.rs | 6 + crates/aisix-core/src/models/model.rs | 62 ++ crates/aisix-core/src/models/routing.rs | 2 + crates/aisix-core/src/models/schema.rs | 221 +----- .../tests/model_schema_characterization.rs | 61 +- schemas/resources/ensemble.schema.json | 17 +- schemas/resources/model.schema.json | 669 +++++++++--------- schemas/resources/routing.schema.json | 6 +- 10 files changed, 527 insertions(+), 558 deletions(-) diff --git a/crates/aisix-admin/src/openapi.rs b/crates/aisix-admin/src/openapi.rs index bb79fc62..4e699cf9 100644 --- a/crates/aisix-admin/src/openapi.rs +++ b/crates/aisix-admin/src/openapi.rs @@ -3231,6 +3231,13 @@ fn add_variant_titles(doc: &mut Value) { "/components/schemas/KeywordPattern/oneOf", &["Literal", "Regex"], ), + ( + // Model's top-level direct/routing/ensemble mutual-exclusion + // `oneOf` (injected by `aisix_core::models::schema::model_root_schema`). + // Order must match `aisix_core::models::model::model_one_of`. + "/components/schemas/Model/oneOf", + &["Routing model", "Direct model", "Ensemble model"], + ), ( "/components/schemas/ObjectStoreAuthMode/oneOf", &["Credential reference", "Cloud identity"], @@ -4136,6 +4143,12 @@ mod tests { } for (key, child) in map { + // `not` subschemas are negative constraints (e.g. Model's + // direct/routing/ensemble mutual exclusion), never rendered + // as ReDoc tabs, so their inner variants need no titles. + if key == "not" { + continue; + } collect_untitled_schema_variants(child, format!("{path}/{key}"), missing); } } diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 6f737ee1..22834372 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -31,8 +31,8 @@ use std::path::{Path, PathBuf}; use schemars::JsonSchema; use aisix_core::models::{ - ApiKey, CachePolicy, EnsembleConfig, Guardrail, Model, ObservabilityExporter, ProviderKey, - RateLimit, RateLimitPolicy, Routing, + ApiKey, CachePolicy, EnsembleConfig, Guardrail, ObservabilityExporter, ProviderKey, RateLimit, + RateLimitPolicy, Routing, }; fn main() { @@ -43,7 +43,14 @@ fn main() { dump::(&out_dir, "cache_policy"); dump::(&out_dir, "ensemble"); dump::(&out_dir, "guardrail"); - dump::(&out_dir, "model"); + // `model` is assembled by a dedicated producer that derives from the + // `Model` struct and injects the cross-field `oneOf` — the same function + // the runtime validator uses, so published == enforced. + dump_value( + &out_dir, + "model", + aisix_core::models::schema::model_root_schema(), + ); dump::(&out_dir, "observability_exporter"); dump::(&out_dir, "provider_key"); dump::(&out_dir, "rate_limit"); @@ -52,7 +59,20 @@ fn main() { } fn dump(out_dir: &Path, name: &str) { - let schema = schemars::schema_for!(T); + // Serialize the `RootSchema` directly to preserve schemars' native key + // ordering. (Routing through `serde_json::Value` would re-sort keys.) + let mut json = + serde_json::to_string_pretty(&schemars::schema_for!(T)).expect("serialize schema"); + json.push('\n'); + let path = out_dir.join(format!("{name}.schema.json")); + fs::write(&path, json).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + println!("wrote {}", path.display()); +} + +/// Write a pre-assembled schema `Value`. Used for resources whose canonical +/// schema is built by a dedicated producer rather than a bare `schema_for!` +/// (e.g. `model`, which injects the cross-field `oneOf`). +fn dump_value(out_dir: &Path, name: &str, schema: serde_json::Value) { let mut json = serde_json::to_string_pretty(&schema).expect("serialize schema"); json.push('\n'); let path = out_dir.join(format!("{name}.schema.json")); diff --git a/crates/aisix-core/src/models/ensemble.rs b/crates/aisix-core/src/models/ensemble.rs index c2d80ae7..c440039a 100644 --- a/crates/aisix-core/src/models/ensemble.rs +++ b/crates/aisix-core/src/models/ensemble.rs @@ -18,9 +18,11 @@ use serde::{Deserialize, Serialize}; #[serde(deny_unknown_fields)] pub struct PanelMember { /// Model alias for a direct model that receives one panel request. + #[schemars(length(min = 1))] pub model: String, /// Sampling temperature for this panel member. Omit it to keep the request's temperature. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0.0))] pub temperature: Option, /// Sampling seed for this panel member. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -48,9 +50,11 @@ impl PanelMember { #[serde(deny_unknown_fields)] pub struct Judge { /// Model alias for the direct model that synthesizes panel responses. + #[schemars(length(min = 1))] pub model: String, /// Override for the built-in synthesis prompt template. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub synthesis_prompt: Option, } @@ -73,11 +77,13 @@ const DEFAULT_MIN_RESPONSES: usize = 2; #[serde(deny_unknown_fields)] pub struct EnsembleConfig { /// Direct models called concurrently for each ensemble request. + #[schemars(length(min = 1))] pub panel: Vec, /// Direct model that combines successful panel responses. pub judge: Judge, /// Minimum successful panel responses required before judge synthesis. When omitted, the gateway requires the smaller of 2 and the panel size. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] pub min_responses: Option, /// Per-call upstream deadline applied to each panel member and the judge. Set `0` or omit it to disable the ensemble-level deadline. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/aisix-core/src/models/model.rs b/crates/aisix-core/src/models/model.rs index 050bc2d6..1c36b7b8 100644 --- a/crates/aisix-core/src/models/model.rs +++ b/crates/aisix-core/src/models/model.rs @@ -13,6 +13,7 @@ //! etcd path: `{prefix}/models/{uuid}`. Secondary index on `display_name`. use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use super::ensemble::EnsembleConfig; use super::rate_limit::RateLimit; @@ -41,8 +42,10 @@ pub enum Adapter { #[serde(deny_unknown_fields)] pub struct ModelCost { /// Prompt token cost in USD per 1,000 tokens. + #[schemars(range(min = 0.0))] pub input_per_1k: f64, /// Completion token cost in USD per 1,000 tokens. + #[schemars(range(min = 0.0))] pub output_per_1k: f64, } @@ -61,17 +64,23 @@ pub struct BackgroundModelCheck { /// Whether background health checks are enabled for this model. pub enabled: bool, /// Seconds between background health checks. Minimum: 5. + #[schemars(range(min = 5))] pub interval_seconds: u64, /// Request timeout in seconds for each background health check. Minimum: 1. + #[schemars(range(min = 1))] pub timeout_seconds: u64, /// Prompt sent to the model during each background health check. + #[schemars(length(min = 1))] pub prompt: String, /// Maximum completion tokens requested during each background health check. + #[schemars(range(min = 1))] pub max_tokens: u32, #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(inner(range(min = 100, max = 599)))] /// Upstream status codes to ignore when evaluating background check failures. pub ignore_statuses: Vec, /// Seconds after which the last completed background check is considered stale. + #[schemars(range(min = 1))] pub stale_after_seconds: u64, } @@ -87,12 +96,14 @@ pub struct CooldownConfig { pub default_seconds: Option, /// Upper bound on cooldown TTL when `Retry-After` is used. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] pub max_seconds: Option, /// Whether to use the upstream's `Retry-After` header as the cooldown TTL when it contains seconds. #[serde(default, skip_serializing_if = "Option::is_none")] pub honor_retry_after: Option, /// Status codes that trigger cooldown, covering authentication failures, rate limits, and transient server errors. Caller-side validation errors such as `400`, `403`, and `422` are excluded. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(inner(range(min = 100, max = 599)))] pub trigger_statuses: Option>, /// Whether request-path timeouts trigger cooldown. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -151,18 +162,27 @@ pub struct Model { /// Operator-facing unique label. Surfaces on `/v1/models`, /// `req.model` on chat completions, `ApiKey.allowed_models`, and /// the dashboard model list. `Resource::name()` returns this. + #[schemars(length(min = 1))] pub display_name: String, /// Upstream vendor identity used for dispatch, compatibility checks, telemetry, and access logs. Routing and ensemble models leave this field unset. + // + // `provider` is the open vendor identity (models.dev catalog id — + // e.g. `openai`, `xai`, `wafer.ai`). The pattern accepts the dot + // character because at least one real models.dev id (`wafer.ai`) + // contains it; rejecting `.` would re-create the #417 bug class. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(regex(pattern = "^[a-z0-9][a-z0-9._-]*$"), length(min = 1, max = 64))] pub provider: Option, /// Upstream model identifier sent in provider requests. Routing and ensemble models leave this field unset. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub model_name: Option, /// Provider key resource ID used to authenticate upstream requests. Routing and ensemble models leave this field unset. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub provider_key_id: Option, /// End-to-end timeout in milliseconds for non-streaming upstream calls. `0` or absent disables the non-streaming timeout. @@ -179,6 +199,7 @@ pub struct Model { /// Client IP allowlist in CIDR notation. Empty or absent allows all clients. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(inner(length(min = 1)))] pub allowed_cidrs: Option>, /// Virtual routing configuration. When set, the gateway selects a target @@ -284,6 +305,47 @@ impl Model { } } +/// The one cross-field invariant the runtime schema enforces that +/// `schemars` cannot derive from the flat struct: a Model ships EXACTLY +/// one shape — a `routing` block, an `ensemble` block, or the three direct +/// upstream fields (`provider`/`model_name`/`provider_key_id`) together. +/// [`crate::models::schema::model_root_schema`] injects this as a top-level +/// `oneOf` into the generated schema, so the published schema and the +/// runtime validator share this single definition. +pub fn model_one_of() -> Value { + json!([ + { + "required": ["routing"], + "not": { "anyOf": [ + { "required": ["provider"] }, + { "required": ["model_name"] }, + { "required": ["provider_key_id"] }, + { "required": ["background_model_check"] }, + { "required": ["cooldown"] }, + { "required": ["ensemble"] } + ]} + }, + { + "required": ["provider", "model_name", "provider_key_id"], + "not": { "anyOf": [ + { "required": ["routing"] }, + { "required": ["ensemble"] } + ]} + }, + { + "required": ["ensemble"], + "not": { "anyOf": [ + { "required": ["provider"] }, + { "required": ["model_name"] }, + { "required": ["provider_key_id"] }, + { "required": ["routing"] }, + { "required": ["background_model_check"] }, + { "required": ["cooldown"] } + ]} + } + ]) +} + impl Resource for Model { fn id(&self) -> &str { &self.runtime_id diff --git a/crates/aisix-core/src/models/routing.rs b/crates/aisix-core/src/models/routing.rs index b6538639..e1ceb036 100644 --- a/crates/aisix-core/src/models/routing.rs +++ b/crates/aisix-core/src/models/routing.rs @@ -35,6 +35,7 @@ pub enum RoutingStrategy { #[serde(deny_unknown_fields)] pub struct RoutingTarget { /// Model alias for a direct model that can receive routed traffic. + #[schemars(length(min = 1))] pub model: String, /// Target weight for `weighted` routing. Other strategies ignore this field. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -82,6 +83,7 @@ pub struct Routing { #[serde(default)] pub strategy: RoutingStrategy, /// Ordered set of direct models available to this routing model. + #[schemars(length(min = 1))] pub targets: Vec, /// Retry attempts on the current target before failing over. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index c794099e..e3726e0f 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -38,7 +38,7 @@ impl Schemas { fn compile() -> Self { Self { model: jsonschema::options() - .build(&model_schema()) + .build(&model_root_schema()) .expect("model schema is well-formed"), apikey: jsonschema::options() .build(&apikey_schema()) @@ -117,197 +117,32 @@ pub fn validate_guardrail_attachment(value: &Value) -> Result<(), SchemaError> { validate(&SCHEMAS.guardrail_attachment, value) } -fn model_schema() -> Value { - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["display_name"], - "additionalProperties": false, - "properties": { - "display_name": { "type": "string", "minLength": 1 }, - // `provider` is the open vendor identity (models.dev catalog id - // — e.g. `openai`, `xai`, `wafer.ai`). The pattern accepts the - // dot character because at least one real models.dev id - // (`wafer.ai`) contains it; rejecting `.` would re-create the - // #417 bug class for that vendor. - "provider": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9][a-z0-9._-]*$" }, - "model_name": { "type": "string", "minLength": 1 }, - "provider_key_id": { "type": "string", "minLength": 1 }, - "timeout": { "type": "integer", "minimum": 0 }, - "stream_timeout": { "type": "integer", "minimum": 0 }, - // Client-IP allowlist (#557). Permitted on both direct and - // routing models — the gate binds to whichever model the client - // names, so a Model Group can be IP-restricted too. CIDR format - // is validated by cp-api on write; the DP skips malformed entries. - "allowed_cidrs": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "rate_limit": { "$ref": "#/$defs/rate_limit" }, - "routing": { - "type": "object", - "required": ["targets"], - "additionalProperties": false, - "properties": { - "strategy": { - "type": "string", - "enum": ["round_robin", "weighted", "failover"] - }, - "targets": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["model"], - "additionalProperties": false, - "properties": { - "model": { "type": "string", "minLength": 1 }, - "weight": { "type": "integer", "minimum": 0 } - } - } - }, - "retries": { "type": "integer", "minimum": 0 }, - "max_fallbacks": { "type": "integer", "minimum": 0 }, - "retry_on_429": { "type": "boolean" }, - "on_all_filtered": { - "type": "string", - "enum": ["fail", "original_order"] - } - } - }, - "cost": { - "type": "object", - "required": ["input_per_1k", "output_per_1k"], - "additionalProperties": false, - "properties": { - "input_per_1k": { "type": "number", "minimum": 0 }, - "output_per_1k": { "type": "number", "minimum": 0 } - } - }, - "background_model_check": { - "type": "object", - "required": [ - "enabled", - "interval_seconds", - "timeout_seconds", - "prompt", - "max_tokens", - "stale_after_seconds" - ], - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean" }, - // Minimum 5s guards against misconfiguration. Setting - // interval_seconds=1 with multiple direct models would - // burn provider quota and money very quickly. - "interval_seconds": { "type": "integer", "minimum": 5 }, - "timeout_seconds": { "type": "integer", "minimum": 1 }, - "prompt": { "type": "string", "minLength": 1 }, - "max_tokens": { "type": "integer", "minimum": 1 }, - "ignore_statuses": { - "type": "array", - "items": { "type": "integer", "minimum": 100, "maximum": 599 } - }, - "stale_after_seconds": { "type": "integer", "minimum": 1 } - } - }, - "cooldown": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean" }, - "default_seconds": { "type": "integer", "minimum": 0 }, - "max_seconds": { "type": "integer", "minimum": 1 }, - "honor_retry_after": { "type": "boolean" }, - "trigger_statuses": { - "type": "array", - "items": { "type": "integer", "minimum": 100, "maximum": 599 } - }, - "trigger_on_timeout": { "type": "boolean" }, - "trigger_on_transport": { "type": "boolean" } - } - }, - "ensemble": { - "type": "object", - "required": ["panel", "judge"], - "additionalProperties": false, - "properties": { - "panel": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["model"], - "additionalProperties": false, - "properties": { - "model": { "type": "string", "minLength": 1 }, - "temperature": { "type": "number", "minimum": 0 }, - "seed": { "type": "integer", "minimum": 0 }, - "weight": { "type": "integer", "minimum": 0 } - } - } - }, - "judge": { - "type": "object", - "required": ["model"], - "additionalProperties": false, - "properties": { - "model": { "type": "string", "minLength": 1 }, - "synthesis_prompt": { "type": "string", "minLength": 1 } - } - }, - "min_responses": { "type": "integer", "minimum": 1 }, - "timeout_ms": { "type": "integer", "minimum": 0 } - } - } - }, - // Direct vs routing vs ensemble model: a model ships EXACTLY one - // of — a `routing` block (virtual router), an `ensemble` block - // (panel + judge fan-out), or the three direct upstream fields - // (provider/model_name/provider_key_id) together. The three - // shapes are mutually exclusive. - "oneOf": [ - { - "required": ["routing"], - "not": { "anyOf": [ - { "required": ["provider"] }, - { "required": ["model_name"] }, - { "required": ["provider_key_id"] }, - { "required": ["background_model_check"] }, - { "required": ["cooldown"] }, - { "required": ["ensemble"] } - ]} - }, - { - "required": ["provider", "model_name", "provider_key_id"], - "not": { "anyOf": [ - { "required": ["routing"] }, - { "required": ["ensemble"] } - ]} - }, - { - "required": ["ensemble"], - "not": { "anyOf": [ - { "required": ["provider"] }, - { "required": ["model_name"] }, - { "required": ["provider_key_id"] }, - { "required": ["routing"] }, - { "required": ["background_model_check"] }, - { "required": ["cooldown"] } - ]} - } - ], - "$defs": { - "rate_limit": { - "type": "object", - "additionalProperties": false, - "properties": { - "tpm": { "type": "integer", "minimum": 0 }, - "tpd": { "type": "integer", "minimum": 0 }, - "rpm": { "type": "integer", "minimum": 0 }, - "rpd": { "type": "integer", "minimum": 0 }, - "concurrency": { "type": "integer", "minimum": 0 } - } - } - } - }) +/// Canonical JSON Schema for the `model` resource. +/// +/// Derived from the [`Model`](crate::models::Model) struct via `schemars` +/// (the single source of field shapes and per-field constraints) plus the one +/// cross-field invariant `schemars` cannot express +/// ([`super::model::model_one_of`]). Both the runtime validator above and the +/// `dump-schema` binary that emits `schemas/resources/model.schema.json` call +/// this function, so the published schema and the enforced schema are the same +/// object by construction — no hand-maintained second copy to drift. +/// +/// `option_add_null_type = false` keeps optional fields plain-but-absent +/// rather than nullable, matching the resource's on-the-wire shape: cp-api +/// omits unset fields and never sends an explicit `null`. +pub fn model_root_schema() -> Value { + use schemars::gen::{SchemaGenerator, SchemaSettings}; + + let settings = SchemaSettings::draft07().with(|s| { + s.option_add_null_type = false; + }); + let root = SchemaGenerator::new(settings).into_root_schema_for::(); + let mut schema = serde_json::to_value(root).expect("model schema serializes to JSON"); + schema + .as_object_mut() + .expect("model root schema is a JSON object") + .insert("oneOf".to_string(), super::model::model_one_of()); + schema } fn apikey_schema() -> Value { @@ -343,7 +178,9 @@ fn apikey_schema() -> Value { "properties": { "tpm": { "type": "integer", "minimum": 0 }, "tpd": { "type": "integer", "minimum": 0 }, + "rps": { "type": "integer", "minimum": 0 }, "rpm": { "type": "integer", "minimum": 0 }, + "rph": { "type": "integer", "minimum": 0 }, "rpd": { "type": "integer", "minimum": 0 }, "concurrency": { "type": "integer", "minimum": 0 } } diff --git a/crates/aisix-core/tests/model_schema_characterization.rs b/crates/aisix-core/tests/model_schema_characterization.rs index c23b7927..65eefc29 100644 --- a/crates/aisix-core/tests/model_schema_characterization.rs +++ b/crates/aisix-core/tests/model_schema_characterization.rs @@ -1,17 +1,15 @@ //! Characterization (golden-corpus) test for the `model` resource validator. //! -//! This pins the EXACT accept/reject behavior of `validate_model` so that the -//! single-source-of-truth refactor (deriving the runtime validator from the -//! `Model` struct + schemars instead of a hand-written `json!` schema) can -//! prove it did not silently widen or narrow the config contract. +//! This pins the EXACT accept/reject behavior of `validate_model`. It was +//! written against the old hand-written `model_schema()` to lock its behavior, +//! then kept green through the single-source-of-truth refactor (the runtime +//! validator is now derived from the `Model` struct + schemars), proving the +//! refactor did not silently widen or narrow the config contract. //! -//! Every case below is a constraint that the hand-written `model_schema()` -//! enforces today. After the refactor, this corpus must stay green — with one -//! deliberate, documented exception: the `rate_limit.rps` / `rate_limit.rph` -//! cases (see `INTENTIONALLY_FLIPPED` below), which the hand-written validator -//! wrongly rejects even though the `RateLimit` struct and the dispatch path -//! support them. The refactor flips those to ACCEPT and the assertions move -//! accordingly, surfacing the behavior change as a visible diff. +//! The only intended behavior change is the `rate_limit.rps` / `rate_limit.rph` +//! pair (see the "FLIPPED by the single-source refactor" section): the old +//! validator wrongly rejected them even though the `RateLimit` struct and the +//! rate limiter support them, so they now ACCEPT — the deliberate bug fix. use aisix_core::models::schema::validate_model; use serde_json::{json, Value}; @@ -287,7 +285,10 @@ fn reject_empty_allowed_cidr_entry() { #[test] fn reject_no_shape_at_all() { - reject("display_name only — no direct/routing/ensemble", json!({"display_name": "m"})); + reject( + "display_name only — no direct/routing/ensemble", + json!({"display_name": "m"}), + ); } #[test] @@ -481,28 +482,40 @@ fn reject_cooldown_unknown_field() { } // --------------------------------------------------------------------------- -// INTENTIONALLY FLIPPED by the single-source refactor. +// FLIPPED by the single-source refactor (the deliberate bug fix). // -// The hand-written validator's `$defs/rate_limit` lists only 5 fields +// The old hand-written `$defs/rate_limit` listed only 5 fields // (tpm/tpd/rpm/rpd/concurrency) with `additionalProperties: false`, so it -// rejects `rps`/`rph` — even though the `RateLimit` struct declares them and -// the rate limiter honors per-second / per-hour windows (#426). These two -// assertions encode TODAY's (buggy) behavior; the refactor commit flips them -// to `accept(...)` and adds an E2E proving the limiter enforces them. +// rejected `rps`/`rph` — even though the `RateLimit` struct declares them and +// the rate limiter honors per-second / per-hour windows (#426). A model with +// `rate_limit.rps` was therefore silently dropped at the DP loader. Now that +// the validator is derived from the struct, both fields are accepted, matching +// what the published schema always advertised and what dispatch enforces. // --------------------------------------------------------------------------- #[test] -fn reject_rate_limit_rps_today() { - reject( - "rate_limit.rps (flips to ACCEPT in the refactor)", +fn accept_rate_limit_rps() { + accept( + "rate_limit.rps", json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "rate_limit": {"rps": 10}}), ); } #[test] -fn reject_rate_limit_rph_today() { - reject( - "rate_limit.rph (flips to ACCEPT in the refactor)", +fn accept_rate_limit_rph() { + accept( + "rate_limit.rph", json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "rate_limit": {"rph": 10}}), ); } + +/// Unknown rate-limit dimensions are still rejected — the struct's +/// `deny_unknown_fields` becomes `additionalProperties: false` in the derived +/// schema, so the flip widened the contract by exactly `rps`/`rph`, no more. +#[test] +fn reject_rate_limit_unknown_field() { + reject( + "rate_limit unknown dimension", + json!({"display_name": "m", "provider": "openai", "model_name": "g", "provider_key_id": "pk-1", "rate_limit": {"tps": 10}}), + ); +} diff --git a/schemas/resources/ensemble.schema.json b/schemas/resources/ensemble.schema.json index f5da1763..20cc2c4a 100644 --- a/schemas/resources/ensemble.schema.json +++ b/schemas/resources/ensemble.schema.json @@ -22,14 +22,15 @@ "null" ], "format": "uint32", - "minimum": 0.0 + "minimum": 1.0 }, "panel": { "description": "Direct models called concurrently for each ensemble request.", "type": "array", "items": { "$ref": "#/definitions/PanelMember" - } + }, + "minItems": 1 }, "timeout_ms": { "description": "Per-call upstream deadline applied to each panel member and the judge. Set `0` or omit it to disable the ensemble-level deadline.", @@ -52,14 +53,16 @@ "properties": { "model": { "description": "Model alias for the direct model that synthesizes panel responses.", - "type": "string" + "type": "string", + "minLength": 1 }, "synthesis_prompt": { "description": "Override for the built-in synthesis prompt template.", "type": [ "string", "null" - ] + ], + "minLength": 1 } }, "additionalProperties": false @@ -73,7 +76,8 @@ "properties": { "model": { "description": "Model alias for a direct model that receives one panel request.", - "type": "string" + "type": "string", + "minLength": 1 }, "seed": { "description": "Sampling seed for this panel member.", @@ -90,7 +94,8 @@ "number", "null" ], - "format": "float" + "format": "float", + "minimum": 0.0 }, "weight": { "description": "Reserved for the future voting/quorum strategy. Ignored by the v1 synthesis path.", diff --git a/schemas/resources/model.schema.json b/schemas/resources/model.schema.json index 1584540e..249d0f0a 100644 --- a/schemas/resources/model.schema.json +++ b/schemas/resources/model.schema.json @@ -1,143 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Model", - "type": "object", - "required": [ - "display_name" - ], - "properties": { - "allowed_cidrs": { - "description": "Client IP allowlist in CIDR notation. Empty or absent allows all clients.", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "background_model_check": { - "description": "Direct-model-only background health-check configuration.", - "anyOf": [ - { - "$ref": "#/definitions/BackgroundModelCheck" - }, - { - "type": "null" - } - ] - }, - "cooldown": { - "description": "Direct-model-only request-path cooldown configuration. Omit this field to use the built-in cooldown behavior.", - "anyOf": [ - { - "$ref": "#/definitions/CooldownConfig" - }, - { - "type": "null" - } - ] - }, - "cost": { - "description": "Per-token cost for budget tracking. Omit it when cost tracking is not needed.", - "anyOf": [ - { - "$ref": "#/definitions/ModelCost" - }, - { - "type": "null" - } - ] - }, - "display_name": { - "description": "Operator-facing unique label. Surfaces on `/v1/models`, `req.model` on chat completions, `ApiKey.allowed_models`, and the dashboard model list. `Resource::name()` returns this.", - "type": "string" - }, - "ensemble": { - "description": "Ensemble configuration for panel calls and judge synthesis.", - "anyOf": [ - { - "$ref": "#/definitions/EnsembleConfig" - }, - { - "type": "null" - } - ] - }, - "model_name": { - "description": "Upstream model identifier sent in provider requests. Routing and ensemble models leave this field unset.", - "type": [ - "string", - "null" - ] - }, - "provider": { - "description": "Upstream vendor identity used for dispatch, compatibility checks, telemetry, and access logs. Routing and ensemble models leave this field unset.", - "type": [ - "string", - "null" - ] - }, - "provider_key_id": { - "description": "Provider key resource ID used to authenticate upstream requests. Routing and ensemble models leave this field unset.", - "type": [ - "string", - "null" - ] - }, - "rate_limit": { - "description": "Request, token, and concurrency limits for this model.", - "anyOf": [ - { - "$ref": "#/definitions/RateLimit" - }, - { - "type": "null" - } - ] - }, - "routing": { - "description": "Virtual routing configuration. When set, the gateway selects a target from `routing.targets` and uses that target model's `provider`, `model_name`, and `provider_key_id` fields for upstream dispatch.", - "anyOf": [ - { - "$ref": "#/definitions/Routing" - }, - { - "type": "null" - } - ] - }, - "stream_timeout": { - "description": "Maximum gap in milliseconds between upstream streaming chunks. `0` or absent falls back to `timeout`.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "timeout": { - "description": "End-to-end timeout in milliseconds for non-streaming upstream calls. `0` or absent disables the non-streaming timeout.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - } - }, "additionalProperties": false, "definitions": { "BackgroundModelCheck": { - "type": "object", - "required": [ - "enabled", - "interval_seconds", - "max_tokens", - "prompt", - "stale_after_seconds", - "timeout_seconds" - ], + "additionalProperties": false, "properties": { "enabled": { "description": "Whether background health checks are enabled for this model.", @@ -145,430 +11,573 @@ }, "ignore_statuses": { "description": "Upstream status codes to ignore when evaluating background check failures.", - "type": "array", "items": { - "type": "integer", "format": "uint16", - "minimum": 0.0 - } + "maximum": 599.0, + "minimum": 100.0, + "type": "integer" + }, + "type": "array" }, "interval_seconds": { "description": "Seconds between background health checks. Minimum: 5.", - "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 5.0, + "type": "integer" }, "max_tokens": { "description": "Maximum completion tokens requested during each background health check.", - "type": "integer", "format": "uint32", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" }, "prompt": { "description": "Prompt sent to the model during each background health check.", + "minLength": 1, "type": "string" }, "stale_after_seconds": { "description": "Seconds after which the last completed background check is considered stale.", - "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" }, "timeout_seconds": { "description": "Request timeout in seconds for each background health check. Minimum: 1.", - "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" } }, - "additionalProperties": false + "required": [ + "enabled", + "interval_seconds", + "max_tokens", + "prompt", + "stale_after_seconds", + "timeout_seconds" + ], + "type": "object" }, "CooldownConfig": { + "additionalProperties": false, "description": "Request-path cooldown settings for a direct model after retryable upstream failures.", - "type": "object", "properties": { "default_seconds": { "description": "Cooldown TTL in seconds when the upstream did not supply a `Retry-After` header or `honor_retry_after` is `false`.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "enabled": { "description": "Whether cooldown is active for this model. Set to `false` to keep the model in rotation regardless of upstream failures.", - "type": [ - "boolean", - "null" - ] + "type": "boolean" }, "honor_retry_after": { "description": "Whether to use the upstream's `Retry-After` header as the cooldown TTL when it contains seconds.", - "type": [ - "boolean", - "null" - ] + "type": "boolean" }, "max_seconds": { "description": "Upper bound on cooldown TTL when `Retry-After` is used.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" }, "trigger_on_timeout": { "description": "Whether request-path timeouts trigger cooldown.", - "type": [ - "boolean", - "null" - ] + "type": "boolean" }, "trigger_on_transport": { "description": "Whether transport, decode, or stream-abort errors trigger cooldown.", - "type": [ - "boolean", - "null" - ] + "type": "boolean" }, "trigger_statuses": { "description": "Status codes that trigger cooldown, covering authentication failures, rate limits, and transient server errors. Caller-side validation errors such as `400`, `403`, and `422` are excluded.", - "type": [ - "array", - "null" - ], "items": { - "type": "integer", "format": "uint16", - "minimum": 0.0 - } + "maximum": 599.0, + "minimum": 100.0, + "type": "integer" + }, + "type": "array" } }, - "additionalProperties": false + "type": "object" }, "EnsembleConfig": { - "type": "object", - "required": [ - "judge", - "panel" - ], + "additionalProperties": false, "properties": { "judge": { - "description": "Direct model that combines successful panel responses.", "allOf": [ { "$ref": "#/definitions/Judge" } - ] + ], + "description": "Direct model that combines successful panel responses." }, "min_responses": { "description": "Minimum successful panel responses required before judge synthesis. When omitted, the gateway requires the smaller of 2 and the panel size.", - "type": [ - "integer", - "null" - ], "format": "uint32", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" }, "panel": { "description": "Direct models called concurrently for each ensemble request.", - "type": "array", "items": { "$ref": "#/definitions/PanelMember" - } + }, + "minItems": 1, + "type": "array" }, "timeout_ms": { "description": "Per-call upstream deadline applied to each panel member and the judge. Set `0` or omit it to disable the ensemble-level deadline.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" } }, - "additionalProperties": false + "required": [ + "judge", + "panel" + ], + "type": "object" }, "Judge": { + "additionalProperties": false, "description": "The judge model that synthesizes the panel responses into one answer. `model` references a direct model alias.", - "type": "object", - "required": [ - "model" - ], "properties": { "model": { "description": "Model alias for the direct model that synthesizes panel responses.", + "minLength": 1, "type": "string" }, "synthesis_prompt": { "description": "Override for the built-in synthesis prompt template.", - "type": [ - "string", - "null" - ] + "minLength": 1, + "type": "string" } }, - "additionalProperties": false + "required": [ + "model" + ], + "type": "object" }, "ModelCost": { + "additionalProperties": false, "description": "Per-token cost for budget tracking. Both values are in USD per 1,000 tokens.", - "type": "object", - "required": [ - "input_per_1k", - "output_per_1k" - ], "properties": { "input_per_1k": { "description": "Prompt token cost in USD per 1,000 tokens.", - "type": "number", - "format": "double" + "format": "double", + "minimum": 0.0, + "type": "number" }, "output_per_1k": { "description": "Completion token cost in USD per 1,000 tokens.", - "type": "number", - "format": "double" + "format": "double", + "minimum": 0.0, + "type": "number" } }, - "additionalProperties": false + "required": [ + "input_per_1k", + "output_per_1k" + ], + "type": "object" }, "OnAllFilteredPolicy": { "description": "Behavior when every routing target is filtered out by runtime health or cooldown state.", "oneOf": [ { "description": "Return `503` with a fixed `Retry-After` hint.", - "type": "string", "enum": [ "fail" - ] + ], + "type": "string" }, { "description": "Route to the original candidate list in declaration order even when all targets were filtered by health or cooldown status. Use only when maintaining availability is preferred over avoiding recently unhealthy targets.", - "type": "string", "enum": [ "original_order" - ] + ], + "type": "string" } ] }, "PanelMember": { + "additionalProperties": false, "description": "One member of an ensemble panel. `model` references a direct model alias.", - "type": "object", - "required": [ - "model" - ], "properties": { "model": { "description": "Model alias for a direct model that receives one panel request.", + "minLength": 1, "type": "string" }, "seed": { "description": "Sampling seed for this panel member.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "temperature": { "description": "Sampling temperature for this panel member. Omit it to keep the request's temperature.", - "type": [ - "number", - "null" - ], - "format": "float" + "format": "float", + "minimum": 0.0, + "type": "number" }, "weight": { "description": "Reserved for the future voting/quorum strategy. Ignored by the v1 synthesis path.", - "type": [ - "integer", - "null" - ], "format": "uint32", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" } }, - "additionalProperties": false + "required": [ + "model" + ], + "type": "object" }, "RateLimit": { - "type": "object", + "additionalProperties": false, "properties": { "concurrency": { "description": "Max concurrent in-flight requests.", - "type": [ - "integer", - "null" - ], "format": "uint32", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "rpd": { "description": "Requests per 86,400-second window.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "rph": { "description": "Requests per 3,600-second window. There is no per-hour token limit field.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "rpm": { "description": "Requests per 60-second window.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "rps": { "description": "Requests per 1-second window. There is no per-second token limit field.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "tpd": { "description": "Tokens per 86,400-second window.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "tpm": { "description": "Tokens per 60-second window.", - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" } }, - "additionalProperties": false + "type": "object" }, "Routing": { - "type": "object", - "required": [ - "targets" - ], + "additionalProperties": false, "properties": { "max_fallbacks": { "description": "Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted.", - "type": [ - "integer", - "null" - ], "format": "uint32", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "on_all_filtered": { - "description": "Policy to apply when runtime status filtering removes every candidate.", - "anyOf": [ + "allOf": [ { "$ref": "#/definitions/OnAllFilteredPolicy" - }, - { - "type": "null" } - ] + ], + "description": "Policy to apply when runtime status filtering removes every candidate." }, "retries": { "description": "Retry attempts on the current target before failing over.", - "type": [ - "integer", - "null" - ], "format": "uint32", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "retry_on_429": { "description": "Whether upstream 429 participates in retries and failover.", - "type": [ - "boolean", - "null" - ] + "type": "boolean" }, "strategy": { - "description": "Strategy used to select a target for each request.", - "default": "failover", "allOf": [ { "$ref": "#/definitions/RoutingStrategy" } - ] + ], + "default": "failover", + "description": "Strategy used to select a target for each request." }, "targets": { "description": "Ordered set of direct models available to this routing model.", - "type": "array", "items": { "$ref": "#/definitions/RoutingTarget" - } + }, + "minItems": 1, + "type": "array" } }, - "additionalProperties": false + "required": [ + "targets" + ], + "type": "object" }, "RoutingStrategy": { "oneOf": [ { "description": "Cycle through targets in declaration order.", - "type": "string", "enum": [ "round_robin" - ] + ], + "type": "string" }, { "description": "Pick targets by configured weight. Missing target weights fall back to 1.", - "type": "string", "enum": [ "weighted" - ] + ], + "type": "string" }, { "description": "Always start with the first target and move to later targets only after failure.", - "type": "string", "enum": [ "failover" - ] + ], + "type": "string" } ] }, "RoutingTarget": { + "additionalProperties": false, "description": "One destination in a routing configuration. `model` references a direct model alias.", - "type": "object", - "required": [ - "model" - ], "properties": { "model": { "description": "Model alias for a direct model that can receive routed traffic.", + "minLength": 1, "type": "string" }, "weight": { "description": "Target weight for `weighted` routing. Other strategies ignore this field.", - "type": [ - "integer", - "null" - ], "format": "uint32", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" } }, - "additionalProperties": false + "required": [ + "model" + ], + "type": "object" + } + }, + "oneOf": [ + { + "not": { + "anyOf": [ + { + "required": [ + "provider" + ] + }, + { + "required": [ + "model_name" + ] + }, + { + "required": [ + "provider_key_id" + ] + }, + { + "required": [ + "background_model_check" + ] + }, + { + "required": [ + "cooldown" + ] + }, + { + "required": [ + "ensemble" + ] + } + ] + }, + "required": [ + "routing" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "routing" + ] + }, + { + "required": [ + "ensemble" + ] + } + ] + }, + "required": [ + "provider", + "model_name", + "provider_key_id" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "provider" + ] + }, + { + "required": [ + "model_name" + ] + }, + { + "required": [ + "provider_key_id" + ] + }, + { + "required": [ + "routing" + ] + }, + { + "required": [ + "background_model_check" + ] + }, + { + "required": [ + "cooldown" + ] + } + ] + }, + "required": [ + "ensemble" + ] + } + ], + "properties": { + "allowed_cidrs": { + "description": "Client IP allowlist in CIDR notation. Empty or absent allows all clients.", + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "background_model_check": { + "allOf": [ + { + "$ref": "#/definitions/BackgroundModelCheck" + } + ], + "description": "Direct-model-only background health-check configuration." + }, + "cooldown": { + "allOf": [ + { + "$ref": "#/definitions/CooldownConfig" + } + ], + "description": "Direct-model-only request-path cooldown configuration. Omit this field to use the built-in cooldown behavior." + }, + "cost": { + "allOf": [ + { + "$ref": "#/definitions/ModelCost" + } + ], + "description": "Per-token cost for budget tracking. Omit it when cost tracking is not needed." + }, + "display_name": { + "description": "Operator-facing unique label. Surfaces on `/v1/models`, `req.model` on chat completions, `ApiKey.allowed_models`, and the dashboard model list. `Resource::name()` returns this.", + "minLength": 1, + "type": "string" + }, + "ensemble": { + "allOf": [ + { + "$ref": "#/definitions/EnsembleConfig" + } + ], + "description": "Ensemble configuration for panel calls and judge synthesis." + }, + "model_name": { + "description": "Upstream model identifier sent in provider requests. Routing and ensemble models leave this field unset.", + "minLength": 1, + "type": "string" + }, + "provider": { + "description": "Upstream vendor identity used for dispatch, compatibility checks, telemetry, and access logs. Routing and ensemble models leave this field unset.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "type": "string" + }, + "provider_key_id": { + "description": "Provider key resource ID used to authenticate upstream requests. Routing and ensemble models leave this field unset.", + "minLength": 1, + "type": "string" + }, + "rate_limit": { + "allOf": [ + { + "$ref": "#/definitions/RateLimit" + } + ], + "description": "Request, token, and concurrency limits for this model." + }, + "routing": { + "allOf": [ + { + "$ref": "#/definitions/Routing" + } + ], + "description": "Virtual routing configuration. When set, the gateway selects a target from `routing.targets` and uses that target model's `provider`, `model_name`, and `provider_key_id` fields for upstream dispatch." + }, + "stream_timeout": { + "description": "Maximum gap in milliseconds between upstream streaming chunks. `0` or absent falls back to `timeout`.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "timeout": { + "description": "End-to-end timeout in milliseconds for non-streaming upstream calls. `0` or absent disables the non-streaming timeout.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" } - } + }, + "required": [ + "display_name" + ], + "title": "Model", + "type": "object" } diff --git a/schemas/resources/routing.schema.json b/schemas/resources/routing.schema.json index 6b49283d..e59eeab7 100644 --- a/schemas/resources/routing.schema.json +++ b/schemas/resources/routing.schema.json @@ -56,7 +56,8 @@ "type": "array", "items": { "$ref": "#/definitions/RoutingTarget" - } + }, + "minItems": 1 } }, "additionalProperties": false, @@ -114,7 +115,8 @@ "properties": { "model": { "description": "Model alias for a direct model that can receive routed traffic.", - "type": "string" + "type": "string", + "minLength": 1 }, "weight": { "description": "Target weight for `weighted` routing. Other strategies ignore this field.", From c9a924faccce763f86c28dee9dff8ae0339f1b3e Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 22 Jun 2026 22:21:26 +0800 Subject: [PATCH 3/8] refactor(core): derive apikey + cache_policy validators from structs Continues the single-source migration (after model). Both runtime validators now build from their structs via the shared struct_root_schema producer; dump-schema emits the same object, so published == enforced. - apikey: keeps nullable Option representation (team_id/user_id accept explicit null, which cp-api sends to clear team/owner); minLength(1) on key_hash/team_id/user_id ported as schemars attrs. Shared RateLimit now exposes all 7 dims incl rps/rph. - cache_policy: minLength/maxLength on name/applies_to and the ttl_seconds 1..=604800 range ported as attrs. Struct has no deny_unknown_fields, so additionalProperties stays open (forward-compat). Characterization corpora (resource_schema_characterization.rs) lock the accept/reject behavior for both. --- crates/aisix-core/src/bin/dump-schema.rs | 23 +-- crates/aisix-core/src/models/apikey.rs | 3 + crates/aisix-core/src/models/cache_policy.rs | 3 + crates/aisix-core/src/models/schema.rs | 117 ++++------- .../tests/resource_schema_characterization.rs | 190 ++++++++++++++++++ schemas/resources/api_key.schema.json | 139 ++++++------- schemas/resources/cache_policy.schema.json | 51 ++--- 7 files changed, 346 insertions(+), 180 deletions(-) create mode 100644 crates/aisix-core/tests/resource_schema_characterization.rs diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 22834372..3813e709 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -30,27 +30,26 @@ use std::path::{Path, PathBuf}; use schemars::JsonSchema; +use aisix_core::models::schema; use aisix_core::models::{ - ApiKey, CachePolicy, EnsembleConfig, Guardrail, ObservabilityExporter, ProviderKey, RateLimit, - RateLimitPolicy, Routing, + EnsembleConfig, Guardrail, ObservabilityExporter, ProviderKey, RateLimit, RateLimitPolicy, + Routing, }; fn main() { let out_dir = workspace_root().join("schemas").join("resources"); fs::create_dir_all(&out_dir).expect("create schemas/resources dir"); - dump::(&out_dir, "api_key"); - dump::(&out_dir, "cache_policy"); + // Resources whose runtime validator is derived from the struct go through + // the SAME `*_root_schema()` producer the validator uses, so the published + // schema == the enforced schema. Resources still on a hand-written + // validator use the bare `schema_for!` dump below. + dump_value(&out_dir, "api_key", schema::apikey_root_schema()); + dump_value(&out_dir, "cache_policy", schema::cache_policy_root_schema()); + dump_value(&out_dir, "model", schema::model_root_schema()); + dump::(&out_dir, "ensemble"); dump::(&out_dir, "guardrail"); - // `model` is assembled by a dedicated producer that derives from the - // `Model` struct and injects the cross-field `oneOf` — the same function - // the runtime validator uses, so published == enforced. - dump_value( - &out_dir, - "model", - aisix_core::models::schema::model_root_schema(), - ); dump::(&out_dir, "observability_exporter"); dump::(&out_dir, "provider_key"); dump::(&out_dir, "rate_limit"); diff --git a/crates/aisix-core/src/models/apikey.rs b/crates/aisix-core/src/models/apikey.rs index e67d02b3..3b0b1ec0 100644 --- a/crates/aisix-core/src/models/apikey.rs +++ b/crates/aisix-core/src/models/apikey.rs @@ -19,6 +19,7 @@ use crate::resource::Resource; pub struct ApiKey { /// SHA-256 hexadecimal hash of the plaintext bearer. The proxy hashes /// incoming bearer tokens before lookup. + #[schemars(length(min = 1))] pub key_hash: String, /// Model identifiers this key may use. An empty array denies access to every model. @@ -31,11 +32,13 @@ pub struct ApiKey { /// Team this API key belongs to. Used for matching team-scope /// rate limit policies. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub team_id: Option, /// Org member who owns this key. Used for matching member-scope /// rate limit policies. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub user_id: Option, /// etcd-key uuid. Filled by the loader and never included in the JSON payload. diff --git a/crates/aisix-core/src/models/cache_policy.rs b/crates/aisix-core/src/models/cache_policy.rs index f1c4dcab..ea323c74 100644 --- a/crates/aisix-core/src/models/cache_policy.rs +++ b/crates/aisix-core/src/models/cache_policy.rs @@ -30,6 +30,7 @@ pub enum CacheBackend { #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] pub struct CachePolicy { /// Operator-facing name that surfaces in metric labels and cache headers. + #[schemars(length(min = 1, max = 120))] pub name: String, /// When false, the cache gate skips this policy. Allows operators @@ -43,11 +44,13 @@ pub struct CachePolicy { /// Cache entry TTL in seconds. #[serde(default = "default_ttl_seconds")] + #[schemars(range(min = 1, max = 604800))] pub ttl_seconds: u32, /// Free-form scope. Supports `"all"`, `"model:"`, and /// `"api_key:"`. See `parsed_applies_to`. #[serde(default = "default_applies_to")] + #[schemars(length(min = 1, max = 255))] pub applies_to: String, /// Set by the loader from the kine path's UUID segment. The DP diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index e3726e0f..749dddf1 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -41,7 +41,7 @@ impl Schemas { .build(&model_root_schema()) .expect("model schema is well-formed"), apikey: jsonschema::options() - .build(&apikey_schema()) + .build(&apikey_root_schema()) .expect("apikey schema is well-formed"), provider_key: jsonschema::options() .build(&provider_key_schema()) @@ -53,7 +53,7 @@ impl Schemas { .build(&guardrail_attachment_schema()) .expect("guardrail_attachment schema is well-formed"), cache_policy: jsonschema::options() - .build(&cache_policy_schema()) + .build(&cache_policy_root_schema()) .expect("cache_policy schema is well-formed"), observability_exporter: jsonschema::options() .build(&observability_exporter_schema()) @@ -117,27 +117,37 @@ pub fn validate_guardrail_attachment(value: &Value) -> Result<(), SchemaError> { validate(&SCHEMAS.guardrail_attachment, value) } -/// Canonical JSON Schema for the `model` resource. +/// Build a resource's canonical JSON Schema from its struct via `schemars`, +/// the single source of field shapes and per-field constraints. /// -/// Derived from the [`Model`](crate::models::Model) struct via `schemars` -/// (the single source of field shapes and per-field constraints) plus the one -/// cross-field invariant `schemars` cannot express -/// ([`super::model::model_one_of`]). Both the runtime validator above and the -/// `dump-schema` binary that emits `schemas/resources/model.schema.json` call -/// this function, so the published schema and the enforced schema are the same -/// object by construction — no hand-maintained second copy to drift. +/// `nullable_options` controls schemars' `Option` representation: `false` +/// keeps optional fields plain-but-absent (`type: string`), matching the wire +/// shape of resources that never receive an explicit `null` (cp-api omits +/// unset fields); `true` keeps the default nullable form (`type: [string, +/// null]`) for resources whose schema deliberately accepts `null` (e.g. +/// ApiKey `team_id`/`user_id`). /// -/// `option_add_null_type = false` keeps optional fields plain-but-absent -/// rather than nullable, matching the resource's on-the-wire shape: cp-api -/// omits unset fields and never sends an explicit `null`. -pub fn model_root_schema() -> Value { +/// Both the runtime validators in [`Schemas::compile`] and the `dump-schema` +/// binary that emits `schemas/resources/*.json` build from these producers, so +/// the published schema and the enforced schema are the same object by +/// construction — no hand-maintained second copy to drift. +fn struct_root_schema(nullable_options: bool) -> Value { use schemars::gen::{SchemaGenerator, SchemaSettings}; let settings = SchemaSettings::draft07().with(|s| { - s.option_add_null_type = false; + s.option_add_null_type = nullable_options; }); - let root = SchemaGenerator::new(settings).into_root_schema_for::(); - let mut schema = serde_json::to_value(root).expect("model schema serializes to JSON"); + let root = SchemaGenerator::new(settings).into_root_schema_for::(); + serde_json::to_value(root).expect("resource schema serializes to JSON") +} + +/// Canonical JSON Schema for the `model` resource: the [`Model`] struct plus +/// the one cross-field invariant `schemars` cannot express +/// ([`super::model::model_one_of`] — the direct/routing/ensemble XOR). +/// +/// [`Model`]: crate::models::Model +pub fn model_root_schema() -> Value { + let mut schema = struct_root_schema::(false); schema .as_object_mut() .expect("model root schema is a JSON object") @@ -145,48 +155,13 @@ pub fn model_root_schema() -> Value { schema } -fn apikey_schema() -> Value { - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["key_hash", "allowed_models"], - "additionalProperties": false, - "properties": { - "key_hash": { "type": "string", "minLength": 1 }, - "allowed_models": { - "type": "array", - "items": { "type": "string" } - }, - "rate_limit": { "$ref": "#/$defs/rate_limit" }, - "team_id": { - "anyOf": [ - { "type": "string", "minLength": 1 }, - { "type": "null" } - ] - }, - "user_id": { - "anyOf": [ - { "type": "string", "minLength": 1 }, - { "type": "null" } - ] - } - }, - "$defs": { - "rate_limit": { - "type": "object", - "additionalProperties": false, - "properties": { - "tpm": { "type": "integer", "minimum": 0 }, - "tpd": { "type": "integer", "minimum": 0 }, - "rps": { "type": "integer", "minimum": 0 }, - "rpm": { "type": "integer", "minimum": 0 }, - "rph": { "type": "integer", "minimum": 0 }, - "rpd": { "type": "integer", "minimum": 0 }, - "concurrency": { "type": "integer", "minimum": 0 } - } - } - } - }) +/// Canonical JSON Schema for the `api_key` resource, derived from the +/// [`ApiKey`](crate::models::ApiKey) struct. Uses the default nullable +/// `Option` representation so `team_id`/`user_id` keep accepting an explicit +/// `null` (cp-api sends `null` to clear team/owner), matching the resource's +/// wire contract. +pub fn apikey_root_schema() -> Value { + struct_root_schema::(true) } fn provider_key_schema() -> Value { @@ -457,24 +432,12 @@ fn guardrail_schema() -> Value { // at parse time. `additionalProperties: true` keeps the schema // forward-compatible: cp-api can ship new optional fields ahead of a DP // rollout without locking the gateway out. -fn cache_policy_schema() -> Value { - // Backends: memory + redis. Semantic backends were removed - // pending DP-side wiring — see ai-gateway issue #116. The schema - // stays `additionalProperties: true` so a newer cp-api can ship - // forward-compat fields without locking out an older DP. - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["name"], - "additionalProperties": true, - "properties": { - "name": { "type": "string", "minLength": 1, "maxLength": 120 }, - "enabled": { "type": "boolean" }, - "backend": { "enum": ["memory", "redis"] }, - "ttl_seconds": { "type": "integer", "minimum": 1, "maximum": 604800 }, - "applies_to": { "type": "string", "minLength": 1, "maxLength": 255 } - } - }) +/// Canonical JSON Schema for the `cache_policy` resource, derived from the +/// [`CachePolicy`](crate::models::CachePolicy) struct. The struct intentionally +/// has no `deny_unknown_fields`, so the schema omits `additionalProperties` +/// (i.e. `true`) — forward-compat fields from a newer cp-api are tolerated. +pub fn cache_policy_root_schema() -> Value { + struct_root_schema::(false) } fn observability_exporter_schema() -> Value { diff --git a/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs new file mode 100644 index 00000000..852cfe7b --- /dev/null +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -0,0 +1,190 @@ +//! Characterization (golden-corpus) tests for the resource validators that +//! were migrated from hand-written `json!` schemas to struct-derived schemas +//! (single source of truth). Each corpus pins the exact accept/reject behavior +//! so the migration can prove it preserved the config contract (except the +//! documented intended changes — e.g. rate_limit `rps`/`rph` on api_key). +//! +//! One table per resource; the label is printed on failure so the offending +//! case is obvious. New resources append their own table as they migrate. + +use aisix_core::models::schema::{validate_apikey, validate_cache_policy}; +use serde_json::{json, Value}; + +/// Run a corpus of `(label, expect_accept, payload)` against `validate`. +#[track_caller] +fn check( + validate: fn(&Value) -> Result<(), aisix_core::models::schema::SchemaError>, + cases: &[(&str, bool, Value)], +) { + for (label, expect_accept, payload) in cases { + let result = validate(payload); + if *expect_accept { + assert!( + result.is_ok(), + "expected ACCEPT for `{label}`, got: {:?}", + result.err() + ); + } else { + assert!( + result.is_err(), + "expected REJECT for `{label}`, but it was accepted" + ); + } + } +} + +#[test] +fn cache_policy_corpus() { + check( + validate_cache_policy, + &[ + ( + "minimal (only required name)", + true, + json!({"name": "prod-default"}), + ), + ( + "full redis policy", + true, + json!({"name": "shared", "enabled": false, "backend": "redis", "ttl_seconds": 600, "applies_to": "model:gpt-4o"}), + ), + ( + "ttl_seconds at lower bound", + true, + json!({"name": "x", "ttl_seconds": 1}), + ), + ( + "ttl_seconds at upper bound", + true, + json!({"name": "x", "ttl_seconds": 604800}), + ), + ( + "applies_to api_key scope", + true, + json!({"name": "k", "applies_to": "api_key:11111111-1111-1111-1111-111111111111"}), + ), + // CachePolicy has no deny_unknown_fields → forward-compat fields tolerated. + ( + "unknown field tolerated", + true, + json!({"name": "future", "backend": "memory", "future_knob": "ignored"}), + ), + ("missing required name", false, json!({"backend": "memory"})), + ("empty name", false, json!({"name": ""})), + ( + "name over 120 chars", + false, + json!({"name": "a".repeat(121)}), + ), + ( + "ttl_seconds below minimum (0)", + false, + json!({"name": "x", "ttl_seconds": 0}), + ), + ( + "ttl_seconds above maximum", + false, + json!({"name": "x", "ttl_seconds": 604801}), + ), + ( + "unknown backend enum", + false, + json!({"name": "x", "backend": "semantic"}), + ), + ( + "empty applies_to", + false, + json!({"name": "x", "applies_to": ""}), + ), + ( + "applies_to over 255 chars", + false, + json!({"name": "x", "applies_to": "m".repeat(256)}), + ), + ], + ); +} + +#[test] +fn apikey_corpus() { + check( + validate_apikey, + &[ + ( + "happy path", + true, + json!({"key_hash": "h", "allowed_models": ["a", "b"]}), + ), + // Empty allowed_models is a deny-all (runtime semantics), valid shape. + ( + "empty allowed_models", + true, + json!({"key_hash": "h", "allowed_models": []}), + ), + ("missing allowed_models", false, json!({"key_hash": "h"})), + ("missing key_hash", false, json!({"allowed_models": ["a"]})), + ( + "empty key_hash", + false, + json!({"key_hash": "", "allowed_models": ["a"]}), + ), + ( + "unknown top-level field", + false, + json!({"key_hash": "h", "allowed_models": ["a"], "bogus": 1}), + ), + ( + "rate_limit ok", + true, + json!({"key_hash": "h", "allowed_models": ["a"], "rate_limit": {"rpm": 60, "concurrency": 5}}), + ), + ( + "rate_limit unknown dim", + false, + json!({"key_hash": "h", "allowed_models": ["a"], "rate_limit": {"bogus": 1}}), + ), + ( + "string team/user", + true, + json!({"key_hash": "h", "allowed_models": ["a"], "team_id": "t1", "user_id": "m1"}), + ), + // The load-bearing nullable case: cp-api sends null to clear team/owner. + ( + "null team and user", + true, + json!({"key_hash": "h", "allowed_models": ["a"], "team_id": null, "user_id": null}), + ), + ( + "one null one absent", + true, + json!({"key_hash": "h", "allowed_models": ["a"], "team_id": null}), + ), + ( + "null rate_limit", + true, + json!({"key_hash": "h", "allowed_models": ["a"], "rate_limit": null}), + ), + ( + "empty team_id", + false, + json!({"key_hash": "h", "allowed_models": ["a"], "team_id": ""}), + ), + ( + "non-string allowed_models item", + false, + json!({"key_hash": "h", "allowed_models": [1, 2]}), + ), + ( + "negative rate_limit dim", + false, + json!({"key_hash": "h", "allowed_models": ["a"], "rate_limit": {"rpm": -1}}), + ), + // The shared-RateLimit rps/rph fix (also applied to api_key). + ( + "rate_limit rps/rph accepted", + true, + json!({"key_hash": "h", "allowed_models": ["a"], "rate_limit": {"rps": 5, "rph": 100}}), + ), + ], + ); +} diff --git a/schemas/resources/api_key.schema.json b/schemas/resources/api_key.schema.json index d8c3c1f1..1ac63844 100644 --- a/schemas/resources/api_key.schema.json +++ b/schemas/resources/api_key.schema.json @@ -1,119 +1,122 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ApiKey", - "type": "object", - "required": [ - "allowed_models", - "key_hash" - ], - "properties": { - "allowed_models": { - "description": "Model identifiers this key may use. An empty array denies access to every model.", - "type": "array", - "items": { - "type": "string" - } - }, - "key_hash": { - "description": "SHA-256 hexadecimal hash of the plaintext bearer. The proxy hashes incoming bearer tokens before lookup.", - "type": "string" - }, - "rate_limit": { - "description": "Request, token, and concurrency limits for this key.", - "anyOf": [ - { - "$ref": "#/definitions/RateLimit" - }, - { - "type": "null" - } - ] - }, - "team_id": { - "description": "Team this API key belongs to. Used for matching team-scope rate limit policies.", - "type": [ - "string", - "null" - ] - }, - "user_id": { - "description": "Org member who owns this key. Used for matching member-scope rate limit policies.", - "type": [ - "string", - "null" - ] - } - }, "additionalProperties": false, "definitions": { "RateLimit": { - "type": "object", + "additionalProperties": false, "properties": { "concurrency": { "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, "type": [ "integer", "null" - ], - "format": "uint32", - "minimum": 0.0 + ] }, "rpd": { "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, "type": [ "integer", "null" - ], - "format": "uint64", - "minimum": 0.0 + ] }, "rph": { "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, "type": [ "integer", "null" - ], - "format": "uint64", - "minimum": 0.0 + ] }, "rpm": { "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, "type": [ "integer", "null" - ], - "format": "uint64", - "minimum": 0.0 + ] }, "rps": { "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, "type": [ "integer", "null" - ], - "format": "uint64", - "minimum": 0.0 + ] }, "tpd": { "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, "type": [ "integer", "null" - ], - "format": "uint64", - "minimum": 0.0 + ] }, "tpm": { "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, "type": [ "integer", "null" - ], - "format": "uint64", - "minimum": 0.0 + ] } }, - "additionalProperties": false + "type": "object" } - } + }, + "properties": { + "allowed_models": { + "description": "Model identifiers this key may use. An empty array denies access to every model.", + "items": { + "type": "string" + }, + "type": "array" + }, + "key_hash": { + "description": "SHA-256 hexadecimal hash of the plaintext bearer. The proxy hashes incoming bearer tokens before lookup.", + "minLength": 1, + "type": "string" + }, + "rate_limit": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimit" + }, + { + "type": "null" + } + ], + "description": "Request, token, and concurrency limits for this key." + }, + "team_id": { + "description": "Team this API key belongs to. Used for matching team-scope rate limit policies.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Org member who owns this key. Used for matching member-scope rate limit policies.", + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "allowed_models", + "key_hash" + ], + "title": "ApiKey", + "type": "object" } diff --git a/schemas/resources/cache_policy.schema.json b/schemas/resources/cache_policy.schema.json index bcd43492..6dbfdcf8 100644 --- a/schemas/resources/cache_policy.schema.json +++ b/schemas/resources/cache_policy.schema.json @@ -1,51 +1,56 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CachePolicy", + "definitions": { + "CacheBackend": { + "description": "Cache backend choice for requests matched by a cache policy. `redis` requires `cache.redis`. Otherwise matching requests are not cached.", + "enum": [ + "memory", + "redis" + ], + "type": "string" + } + }, "description": "Semantic cache policy for chat requests.", - "type": "object", - "required": [ - "name" - ], "properties": { "applies_to": { - "description": "Free-form scope. Supports `\"all\"`, `\"model:\"`, and `\"api_key:\"`. See `parsed_applies_to`.", "default": "all", + "description": "Free-form scope. Supports `\"all\"`, `\"model:\"`, and `\"api_key:\"`. See `parsed_applies_to`.", + "maxLength": 255, + "minLength": 1, "type": "string" }, "backend": { - "description": "Cache backend used for matching requests.", - "default": "memory", "allOf": [ { "$ref": "#/definitions/CacheBackend" } - ] + ], + "default": "memory", + "description": "Cache backend used for matching requests." }, "enabled": { - "description": "When false, the cache gate skips this policy. Allows operators to stage a rule before enabling it.", "default": true, + "description": "When false, the cache gate skips this policy. Allows operators to stage a rule before enabling it.", "type": "boolean" }, "name": { "description": "Operator-facing name that surfaces in metric labels and cache headers.", + "maxLength": 120, + "minLength": 1, "type": "string" }, "ttl_seconds": { - "description": "Cache entry TTL in seconds.", "default": 3600, - "type": "integer", + "description": "Cache entry TTL in seconds.", "format": "uint32", - "minimum": 0.0 + "maximum": 604800.0, + "minimum": 1.0, + "type": "integer" } }, - "definitions": { - "CacheBackend": { - "description": "Cache backend choice for requests matched by a cache policy. `redis` requires `cache.redis`. Otherwise matching requests are not cached.", - "type": "string", - "enum": [ - "memory", - "redis" - ] - } - } + "required": [ + "name" + ], + "title": "CachePolicy", + "type": "object" } From fb40af8db342afaf29ebbe06be2b6ec60371c2f3 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 23 Jun 2026 08:51:39 +0800 Subject: [PATCH 4/8] refactor(core): derive rate_limit_policy validator from struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PolicyScope/PolicyWindow Rust enums (closed sets that were enforced only by the hand-written schema's enum constraint), so scope/window are now struct-derived and rejected at deserialize. The one cross-field rule schemars can't express — at least one of max_requests/max_tokens — is injected as a top-level anyOf by rate_limit_policy_root_schema(). name/ scope_ref minLength and max_requests/max_tokens range(min=1) ported as schemars attrs. aisix-proxy/quota.rs now matches the enums exhaustively (drops the dead String fall-through arms). Behavior change (intended): an unknown scope/window is now rejected at deserialize instead of silently ignored. Characterization corpus added for rate_limit_policy. --- crates/aisix-core/src/bin/dump-schema.rs | 9 +- crates/aisix-core/src/lib.rs | 6 +- crates/aisix-core/src/models/mod.rs | 2 +- .../src/models/rate_limit_policy.rs | 77 ++++++++++++++++- crates/aisix-core/src/models/schema.rs | 37 ++++----- .../tests/resource_schema_characterization.rs | 83 ++++++++++++++++++- crates/aisix-etcd/src/loader.rs | 2 +- crates/aisix-proxy/src/quota.rs | 42 ++++++---- .../resources/rate_limit_policy.schema.json | 70 +++++++++++----- 9 files changed, 257 insertions(+), 71 deletions(-) diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 3813e709..33ec96e9 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -32,8 +32,7 @@ use schemars::JsonSchema; use aisix_core::models::schema; use aisix_core::models::{ - EnsembleConfig, Guardrail, ObservabilityExporter, ProviderKey, RateLimit, RateLimitPolicy, - Routing, + EnsembleConfig, Guardrail, ObservabilityExporter, ProviderKey, RateLimit, Routing, }; fn main() { @@ -47,13 +46,17 @@ fn main() { dump_value(&out_dir, "api_key", schema::apikey_root_schema()); dump_value(&out_dir, "cache_policy", schema::cache_policy_root_schema()); dump_value(&out_dir, "model", schema::model_root_schema()); + dump_value( + &out_dir, + "rate_limit_policy", + schema::rate_limit_policy_root_schema(), + ); dump::(&out_dir, "ensemble"); dump::(&out_dir, "guardrail"); dump::(&out_dir, "observability_exporter"); dump::(&out_dir, "provider_key"); dump::(&out_dir, "rate_limit"); - dump::(&out_dir, "rate_limit_policy"); dump::(&out_dir, "routing"); } diff --git a/crates/aisix-core/src/lib.rs b/crates/aisix-core/src/lib.rs index 78e2337d..97f157e6 100644 --- a/crates/aisix-core/src/lib.rs +++ b/crates/aisix-core/src/lib.rs @@ -35,9 +35,9 @@ pub use models::{ validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, Adapter, AisixSnapshot, ApiKey, AppliedGuardrail, CachePolicy, CooldownConfig, ExporterKind, Guardrail, GuardrailHookPoint, GuardrailKind, KeywordConfig, KeywordPattern, Model, ObservabilityExporter, - OnAllFilteredPolicy, ParamConstraints, ProviderKey, RateLimit, RateLimitPolicy, - RequestOverrides, ResponseOverrides, Routing, RoutingStrategy, RoutingTarget, SchemaError, - StreamDoneMarker, TelemetryTags, DEFAULT_COOLDOWN_TRIGGER_STATUSES, + OnAllFilteredPolicy, ParamConstraints, PolicyScope, PolicyWindow, ProviderKey, RateLimit, + RateLimitPolicy, RequestOverrides, ResponseOverrides, Routing, RoutingStrategy, RoutingTarget, + SchemaError, StreamDoneMarker, 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 1cf2b99e..d382c00b 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -49,7 +49,7 @@ pub use provider_key::{ TelemetryTags, }; pub use rate_limit::RateLimit; -pub use rate_limit_policy::RateLimitPolicy; +pub use rate_limit_policy::{PolicyScope, PolicyWindow, RateLimitPolicy}; pub use routing::{OnAllFilteredPolicy, Routing, RoutingStrategy, RoutingTarget}; pub use schema::{ validate_apikey, validate_cache_policy, validate_guardrail, validate_guardrail_attachment, diff --git a/crates/aisix-core/src/models/rate_limit_policy.rs b/crates/aisix-core/src/models/rate_limit_policy.rs index d98a404d..0a425044 100644 --- a/crates/aisix-core/src/models/rate_limit_policy.rs +++ b/crates/aisix-core/src/models/rate_limit_policy.rs @@ -16,25 +16,94 @@ //! member's `user_id` appended for the `team_member` scope. use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use crate::resource::Resource; +/// Subject a [`RateLimitPolicy`] targets, paired with `scope_ref`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PolicyScope { + ApiKey, + Model, + Team, + Member, + TeamMember, +} + +impl PolicyScope { + pub fn as_str(&self) -> &'static str { + match self { + Self::ApiKey => "api_key", + Self::Model => "model", + Self::Team => "team", + Self::Member => "member", + Self::TeamMember => "team_member", + } + } +} + +impl std::fmt::Display for PolicyScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Fixed-window length a [`RateLimitPolicy`] applies its limits over. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PolicyWindow { + Second, + Minute, + Hour, +} + +impl PolicyWindow { + pub fn as_str(&self) -> &'static str { + match self { + Self::Second => "second", + Self::Minute => "minute", + Self::Hour => "hour", + } + } +} + +impl std::fmt::Display for PolicyWindow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct RateLimitPolicy { + #[schemars(length(min = 1))] pub name: String, - pub scope: String, + pub scope: PolicyScope, + #[schemars(length(min = 1))] pub scope_ref: String, - pub window: String, + pub window: PolicyWindow, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] pub max_requests: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] pub max_tokens: Option, #[serde(skip)] pub(crate) runtime_id: String, } +/// The one cross-field invariant `schemars` can't derive: a policy must cap at +/// least one of `max_requests` / `max_tokens`. Injected as a top-level `anyOf` +/// by [`crate::models::schema::rate_limit_policy_root_schema`]. +pub fn rate_limit_policy_any_of() -> Value { + json!([ + { "required": ["max_requests"] }, + { "required": ["max_tokens"] } + ]) +} + impl Resource for RateLimitPolicy { fn id(&self) -> &str { &self.runtime_id @@ -68,9 +137,9 @@ mod tests { ) .unwrap(); assert_eq!(p.name, "team-quota"); - assert_eq!(p.scope, "team"); + assert_eq!(p.scope, PolicyScope::Team); assert_eq!(p.scope_ref, "team-uuid-1"); - assert_eq!(p.window, "minute"); + assert_eq!(p.window, PolicyWindow::Minute); assert_eq!(p.max_requests, Some(100)); assert_eq!(p.max_tokens, Some(50000)); } diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index 749dddf1..b6dc350a 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -59,7 +59,7 @@ impl Schemas { .build(&observability_exporter_schema()) .expect("observability_exporter schema is well-formed"), rate_limit_policy: jsonschema::options() - .build(&rate_limit_policy_schema()) + .build(&rate_limit_policy_root_schema()) .expect("rate_limit_policy schema is well-formed"), } } @@ -599,25 +599,22 @@ fn observability_exporter_schema() -> Value { }) } -fn rate_limit_policy_schema() -> Value { - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["name", "scope", "scope_ref", "window"], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1 }, - "scope": { "type": "string", "enum": ["api_key", "model", "team", "member", "team_member"] }, - "scope_ref": { "type": "string", "minLength": 1 }, - "window": { "type": "string", "enum": ["second", "minute", "hour"] }, - "max_requests": { "type": "integer", "minimum": 1 }, - "max_tokens": { "type": "integer", "minimum": 1 } - }, - "anyOf": [ - { "required": ["max_requests"] }, - { "required": ["max_tokens"] } - ] - }) +/// Canonical JSON Schema for the `rate_limit_policy` resource, derived from the +/// [`RateLimitPolicy`](crate::models::RateLimitPolicy) struct (the `scope`/ +/// `window` closed sets come from the `PolicyScope`/`PolicyWindow` enums) plus +/// the one cross-field invariant `schemars` can't express: at least one of +/// `max_requests`/`max_tokens` must be set +/// ([`super::rate_limit_policy::rate_limit_policy_any_of`]). +pub fn rate_limit_policy_root_schema() -> Value { + let mut schema = struct_root_schema::(false); + schema + .as_object_mut() + .expect("rate_limit_policy root schema is a JSON object") + .insert( + "anyOf".to_string(), + super::rate_limit_policy::rate_limit_policy_any_of(), + ); + schema } fn guardrail_attachment_schema() -> Value { diff --git a/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs index 852cfe7b..19724a48 100644 --- a/crates/aisix-core/tests/resource_schema_characterization.rs +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -7,7 +7,9 @@ //! One table per resource; the label is printed on failure so the offending //! case is obvious. New resources append their own table as they migrate. -use aisix_core::models::schema::{validate_apikey, validate_cache_policy}; +use aisix_core::models::schema::{ + validate_apikey, validate_cache_policy, validate_rate_limit_policy, +}; use serde_json::{json, Value}; /// Run a corpus of `(label, expect_accept, payload)` against `validate`. @@ -188,3 +190,82 @@ fn apikey_corpus() { ], ); } + +#[test] +fn rate_limit_policy_corpus() { + check( + validate_rate_limit_policy, + &[ + ( + "full", + true, + json!({"name": "q", "scope": "team", "scope_ref": "t1", "window": "minute", "max_requests": 100, "max_tokens": 50000}), + ), + ( + "only max_requests (anyOf)", + true, + json!({"name": "q", "scope": "api_key", "scope_ref": "k1", "window": "minute", "max_requests": 60}), + ), + ( + "only max_tokens (anyOf)", + true, + json!({"name": "q", "scope": "member", "scope_ref": "m1", "window": "hour", "max_tokens": 1000000}), + ), + ( + "team_member + second window", + true, + json!({"name": "q", "scope": "team_member", "scope_ref": "t1", "window": "second", "max_requests": 10}), + ), + ( + "neither cap present (anyOf)", + false, + json!({"name": "q", "scope": "team", "scope_ref": "t1", "window": "minute"}), + ), + ( + "missing name", + false, + json!({"scope": "team", "scope_ref": "t1", "window": "minute", "max_requests": 1}), + ), + ( + "unknown scope enum", + false, + json!({"name": "q", "scope": "region", "scope_ref": "t1", "window": "minute", "max_requests": 1}), + ), + ( + "unknown window enum", + false, + json!({"name": "q", "scope": "team", "scope_ref": "t1", "window": "day", "max_requests": 1}), + ), + ( + "max_requests below minimum (0)", + false, + json!({"name": "q", "scope": "team", "scope_ref": "t1", "window": "minute", "max_requests": 0}), + ), + ( + "max_tokens below minimum (0)", + false, + json!({"name": "q", "scope": "team", "scope_ref": "t1", "window": "minute", "max_tokens": 0}), + ), + ( + "empty name", + false, + json!({"name": "", "scope": "team", "scope_ref": "t1", "window": "minute", "max_requests": 1}), + ), + ( + "empty scope_ref", + false, + json!({"name": "q", "scope": "team", "scope_ref": "", "window": "minute", "max_requests": 1}), + ), + ( + "unknown field", + false, + json!({"name": "q", "scope": "team", "scope_ref": "t1", "window": "minute", "max_requests": 1, "extra": true}), + ), + ( + "negative max_requests", + false, + json!({"name": "q", "scope": "team", "scope_ref": "t1", "window": "minute", "max_requests": -1}), + ), + ], + ); +} diff --git a/crates/aisix-etcd/src/loader.rs b/crates/aisix-etcd/src/loader.rs index 45478b32..b1c0d938 100644 --- a/crates/aisix-etcd/src/loader.rs +++ b/crates/aisix-etcd/src/loader.rs @@ -561,7 +561,7 @@ mod tests { assert_eq!(snap.rate_limit_policies.len(), 1); let entry = snap.rate_limit_policies.get_by_id("rlp-1").unwrap(); assert_eq!(entry.value.name, "team-quota"); - assert_eq!(entry.value.scope, "team"); + assert_eq!(entry.value.scope, aisix_core::models::PolicyScope::Team); assert_eq!(entry.value.scope_ref, "team-uuid-1"); assert_eq!(entry.value.max_requests, Some(100)); } diff --git a/crates/aisix-proxy/src/quota.rs b/crates/aisix-proxy/src/quota.rs index cbf98baf..5570fdb9 100644 --- a/crates/aisix-proxy/src/quota.rs +++ b/crates/aisix-proxy/src/quota.rs @@ -15,7 +15,7 @@ //! 429. The returned [`MultiReservation`] commits token usage to all //! layers and releases all concurrency permits on drop. -use aisix_core::models::RateLimitPolicy; +use aisix_core::models::{PolicyScope, PolicyWindow, RateLimitPolicy}; use aisix_core::RateLimit; use aisix_ratelimit::MultiReservation; @@ -51,8 +51,8 @@ impl ModelRateLimit { fn policy_to_rate_limit(policy: &RateLimitPolicy) -> RateLimit { let mut rl = RateLimit::default(); - match policy.window.as_str() { - "second" => { + match policy.window { + PolicyWindow::Second => { // Pre-fix (api7/AISIX-Cloud#426): `rl.rpm = max * 60` — a // 5/second policy was upscaled to 300/minute, allowing // 60× bursts past the operator-declared cap inside any @@ -77,11 +77,11 @@ fn policy_to_rate_limit(policy: &RateLimitPolicy) -> RateLimit { ); } } - "minute" => { + PolicyWindow::Minute => { rl.rpm = policy.max_requests; rl.tpm = policy.max_tokens; } - "hour" => { + PolicyWindow::Hour => { // Pre-fix (api7/AISIX-Cloud#426): `rl.rpd = max * 24` — // a 1000/hour policy was upscaled to 24000/day, allowing // the entire hourly cap to be burned in any single hour @@ -100,7 +100,6 @@ fn policy_to_rate_limit(policy: &RateLimitPolicy) -> RateLimit { ); } } - _ => {} } rl } @@ -112,7 +111,7 @@ fn policy_to_rate_limit(policy: &RateLimitPolicy) -> RateLimit { /// (LiteLLM's `{team_id}:{user_id}` shape). fn policy_bucket_key(policy: &RateLimitPolicy, entry_id: &str, auth: &AuthenticatedKey) -> String { let base = format!("policy:{}:{}:{}", policy.scope, policy.scope_ref, entry_id); - if policy.scope == "team_member" { + if policy.scope == PolicyScope::TeamMember { if let Some(user_id) = auth.key().user_id.as_deref() { return format!("{base}:{user_id}"); } @@ -156,19 +155,18 @@ async fn reserve_layers( let snap = state.snapshot.load(); for entry in snap.rate_limit_policies.entries() { let policy = &entry.value; - let applies = match policy.scope.as_str() { - "api_key" => policy.scope_ref == auth.entry.id, - "model" => model_rl.is_some_and(|m| policy.scope_ref == m.entry_id), - "team" => auth.key().team_id.as_deref() == Some(policy.scope_ref.as_str()), - "member" => auth.key().user_id.as_deref() == Some(policy.scope_ref.as_str()), + let applies = match policy.scope { + PolicyScope::ApiKey => policy.scope_ref == auth.entry.id, + PolicyScope::Model => model_rl.is_some_and(|m| policy.scope_ref == m.entry_id), + PolicyScope::Team => auth.key().team_id.as_deref() == Some(policy.scope_ref.as_str()), + PolicyScope::Member => auth.key().user_id.as_deref() == Some(policy.scope_ref.as_str()), // Per-member default for a team: matches every key whose // team_id == scope_ref, but only when the key carries a // user_id (the bucket is keyed per member below). - "team_member" => { + PolicyScope::TeamMember => { auth.key().team_id.as_deref() == Some(policy.scope_ref.as_str()) && auth.key().user_id.is_some() } - _ => false, }; if !applies { continue; @@ -276,7 +274,7 @@ pub(crate) async fn reserve_model_only( let snap = state.snapshot.load(); for entry in snap.rate_limit_policies.entries() { let policy = &entry.value; - if policy.scope != "model" || policy.scope_ref != model_entry_id { + if policy.scope != PolicyScope::Model || policy.scope_ref != model_entry_id { continue; } let rl = policy_to_rate_limit(policy); @@ -433,9 +431,17 @@ mod tests { } #[test] - fn unknown_window_produces_unrestricted() { - let rl = policy_to_rate_limit(&make_policy("week", Some(100), Some(100))); - assert!(rl.is_unrestricted()); + fn unknown_window_is_rejected_at_deserialize() { + // `PolicyWindow` is a closed enum, so an unknown window is rejected at + // deserialize rather than silently producing an unrestricted limit. + let r: Result = serde_json::from_value(serde_json::json!({ + "name": "test", + "scope": "team", + "scope_ref": "ref", + "window": "week", + "max_requests": 100, + })); + assert!(r.is_err()); } #[test] diff --git a/schemas/resources/rate_limit_policy.schema.json b/schemas/resources/rate_limit_policy.schema.json index dc7154e3..57c9e425 100644 --- a/schemas/resources/rate_limit_policy.schema.json +++ b/schemas/resources/rate_limit_policy.schema.json @@ -1,42 +1,72 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "RateLimitPolicy", - "type": "object", - "required": [ - "name", - "scope", - "scope_ref", - "window" + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "max_requests" + ] + }, + { + "required": [ + "max_tokens" + ] + } ], + "definitions": { + "PolicyScope": { + "description": "Subject a [`RateLimitPolicy`] targets, paired with `scope_ref`.", + "enum": [ + "api_key", + "model", + "team", + "member", + "team_member" + ], + "type": "string" + }, + "PolicyWindow": { + "description": "Fixed-window length a [`RateLimitPolicy`] applies its limits over.", + "enum": [ + "second", + "minute", + "hour" + ], + "type": "string" + } + }, "properties": { "max_requests": { - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" }, "max_tokens": { - "type": [ - "integer", - "null" - ], "format": "uint64", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" }, "name": { + "minLength": 1, "type": "string" }, "scope": { - "type": "string" + "$ref": "#/definitions/PolicyScope" }, "scope_ref": { + "minLength": 1, "type": "string" }, "window": { - "type": "string" + "$ref": "#/definitions/PolicyWindow" } }, - "additionalProperties": false + "required": [ + "name", + "scope", + "scope_ref", + "window" + ], + "title": "RateLimitPolicy", + "type": "object" } From b65e686df324e23da01aa756c6d034a26b14ff6c Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 23 Jun 2026 08:59:17 +0800 Subject: [PATCH 5/8] refactor(core): derive provider_key validator from struct provider_key runtime validator now builds from the ProviderKey struct. telemetry_tags.kind becomes a closed TelemetryKind enum (catalog|byo), so the closed set is struct-derived and rejected at deserialize; the usage-event provider_kind emission (chat/messages) maps it via as_str(). display_name/secret gain minLength(1). Nullable Option representation kept (true) so cp-api's explicit null on telemetry labels still passes. --- crates/aisix-core/src/bin/dump-schema.rs | 7 +- crates/aisix-core/src/lib.rs | 2 +- crates/aisix-core/src/models/mod.rs | 2 +- crates/aisix-core/src/models/provider_key.rs | 26 +- crates/aisix-core/src/models/schema.rs | 105 +------ .../tests/resource_schema_characterization.rs | 130 ++++++++- crates/aisix-proxy/src/chat.rs | 2 +- crates/aisix-proxy/src/messages.rs | 2 +- schemas/resources/provider_key.schema.json | 264 +++++++++--------- 9 files changed, 306 insertions(+), 234 deletions(-) diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 33ec96e9..651d4166 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -31,9 +31,7 @@ use std::path::{Path, PathBuf}; use schemars::JsonSchema; use aisix_core::models::schema; -use aisix_core::models::{ - EnsembleConfig, Guardrail, ObservabilityExporter, ProviderKey, RateLimit, Routing, -}; +use aisix_core::models::{EnsembleConfig, Guardrail, ObservabilityExporter, RateLimit, Routing}; fn main() { let out_dir = workspace_root().join("schemas").join("resources"); @@ -52,10 +50,11 @@ fn main() { schema::rate_limit_policy_root_schema(), ); + dump_value(&out_dir, "provider_key", schema::provider_key_root_schema()); + dump::(&out_dir, "ensemble"); dump::(&out_dir, "guardrail"); dump::(&out_dir, "observability_exporter"); - dump::(&out_dir, "provider_key"); dump::(&out_dir, "rate_limit"); dump::(&out_dir, "routing"); } diff --git a/crates/aisix-core/src/lib.rs b/crates/aisix-core/src/lib.rs index 97f157e6..eeb0d13d 100644 --- a/crates/aisix-core/src/lib.rs +++ b/crates/aisix-core/src/lib.rs @@ -37,7 +37,7 @@ pub use models::{ GuardrailHookPoint, GuardrailKind, KeywordConfig, KeywordPattern, Model, ObservabilityExporter, OnAllFilteredPolicy, ParamConstraints, PolicyScope, PolicyWindow, ProviderKey, RateLimit, RateLimitPolicy, RequestOverrides, ResponseOverrides, Routing, RoutingStrategy, RoutingTarget, - SchemaError, StreamDoneMarker, TelemetryTags, DEFAULT_COOLDOWN_TRIGGER_STATUSES, + SchemaError, StreamDoneMarker, TelemetryKind, 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 d382c00b..3ca0baae 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -46,7 +46,7 @@ pub use observability_exporter::{ }; pub use provider_key::{ ParamConstraints, ProviderKey, RequestOverrides, ResponseOverrides, StreamDoneMarker, - TelemetryTags, + TelemetryKind, TelemetryTags, }; pub use rate_limit::RateLimit; pub use rate_limit_policy::{PolicyScope, PolicyWindow, RateLimitPolicy}; diff --git a/crates/aisix-core/src/models/provider_key.rs b/crates/aisix-core/src/models/provider_key.rs index 7bf73b36..f71044fd 100644 --- a/crates/aisix-core/src/models/provider_key.rs +++ b/crates/aisix-core/src/models/provider_key.rs @@ -32,11 +32,13 @@ pub struct ProviderKey { /// Operator-facing label, unique within the gateway. Surfaces in /// the Admin API list view and in dashboard UIs that wrap this /// resource. + #[schemars(length(min = 1))] pub display_name: String, /// Upstream provider's API key. The data plane receives plaintext so it /// can authenticate to the upstream provider. Protect the configuration /// store and transport accordingly. + #[schemars(length(min = 1))] pub secret: String, /// Override base URL for the upstream provider. Required for custom or OpenAI-compatible providers that should not use a built-in vendor endpoint. @@ -126,6 +128,24 @@ where Ok(out) } +/// Provider-key category: `catalog` for curated providers, `byo` for +/// bring-your-own. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum TelemetryKind { + Catalog, + Byo, +} + +impl TelemetryKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Catalog => "catalog", + Self::Byo => "byo", + } + } +} + /// Telemetry attribution tags emitted with requests routed through this provider key. #[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -133,7 +153,7 @@ pub struct TelemetryTags { /// Provider-key category, such as `"catalog"` for curated providers or /// `"byo"` for bring-your-own providers. #[serde(default, skip_serializing_if = "Option::is_none")] - pub kind: Option, + pub kind: Option, /// Whether this provider is surfaced in the featured list. #[serde(default)] @@ -330,7 +350,7 @@ mod tests { .unwrap(); assert_eq!(p.provider, "deepseek"); assert_eq!(p.adapter, Some(Adapter::Openai)); - assert_eq!(p.telemetry_tags.kind.as_deref(), Some("catalog")); + assert_eq!(p.telemetry_tags.kind, Some(TelemetryKind::Catalog)); assert!(p.telemetry_tags.featured); assert_eq!( p.telemetry_tags.branded_provider.as_deref(), @@ -356,7 +376,7 @@ mod tests { }"#, ) .unwrap(); - assert_eq!(p.telemetry_tags.kind.as_deref(), Some("byo")); + assert_eq!(p.telemetry_tags.kind, Some(TelemetryKind::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")); diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index b6dc350a..14073e14 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -44,7 +44,7 @@ impl Schemas { .build(&apikey_root_schema()) .expect("apikey schema is well-formed"), provider_key: jsonschema::options() - .build(&provider_key_schema()) + .build(&provider_key_root_schema()) .expect("provider_key schema is well-formed"), guardrail: jsonschema::options() .build(&guardrail_schema()) @@ -164,102 +164,13 @@ pub fn apikey_root_schema() -> Value { struct_root_schema::(true) } -fn provider_key_schema() -> Value { - // `provider`, `adapter`, and `telemetry_tags` were added as a - // skeleton for issue #302 Phase A (PR #298). `request` and - // `response` were added in Phase A2.5 to land the on-disk shape - // for the `RuntimeConfig.request` / `RuntimeConfig.response` - // blocks from issue #302 §5. All Phase A fields 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", - "required": ["display_name", "secret"], - "additionalProperties": false, - "properties": { - "display_name": { "type": "string", "minLength": 1 }, - "secret": { "type": "string", "minLength": 1 }, - "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"] } - } - }, - // Phase A2.5 — RuntimeConfig.request, see issue #302 §5. - // Each sub-field is the input to a primitive apply - // function in aisix-provider-openai's overrides module. - "request": { - "type": "object", - "additionalProperties": false, - "properties": { - "param_renames": { - "type": "object", - "additionalProperties": { "type": "string" } - }, - "param_constraints": { - "type": "object", - "additionalProperties": false, - "properties": { - "temperature_max": { "type": "number" }, - "temperature_min": { "type": "number" } - } - }, - "default_headers": { - "type": "object", - "additionalProperties": { "type": "string" } - }, - // Free-form on purpose — the cp-api spec lets - // operators set any default top-level body field - // (`safe_prompt`, `transforms`, etc.); the apply - // path only adds keys when the caller did not - // set them. - "default_body_fields": { - "type": "object" - } - } - }, - // Phase A2.5 — RuntimeConfig.response, see issue #302 §5. - "response": { - "type": "object", - "additionalProperties": false, - "properties": { - "stream_done_marker": { "type": "string", "enum": ["required", "optional", "none"] }, - "content_list_to_string": { "type": "boolean" }, - // Open string in Phase A2.5 — matches the Rust - // `Option`. Phase D pins the closed - // ("openai" | "passthrough") set. - "error_envelope": { "type": "string" }, - "reasoning_field": { "type": "string" } - } - }, - // Issue #411 — per-PK passthrough header strip list. - // Optional (defaults applied DP-side via - // `#[serde(default = "default_strip_headers")]`); when - // present, must be an array of strings. Entries are - // normalised (trim/lowercase/dedup/drop-empties) on - // deserialize so this validator doesn't enforce - // formatting beyond the type shape. - "strip_headers": { - "type": "array", - "items": { "type": "string" } - } - } - }) +/// Canonical JSON Schema for the `provider_key` resource, derived from the +/// [`ProviderKey`](crate::models::ProviderKey) struct. Uses the nullable +/// `Option` representation (`true`): `TelemetryTags` carries fields cp-api +/// sends as explicit `null` (`branded_provider`/`pk_label`/`byo_label`), and +/// keeping all optionals nullable matches the resource's wire contract. +pub fn provider_key_root_schema() -> Value { + struct_root_schema::(true) } fn guardrail_schema() -> Value { diff --git a/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs index 19724a48..5de352f5 100644 --- a/crates/aisix-core/tests/resource_schema_characterization.rs +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -8,7 +8,7 @@ //! case is obvious. New resources append their own table as they migrate. use aisix_core::models::schema::{ - validate_apikey, validate_cache_policy, validate_rate_limit_policy, + validate_apikey, validate_cache_policy, validate_provider_key, validate_rate_limit_policy, }; use serde_json::{json, Value}; @@ -269,3 +269,131 @@ fn rate_limit_policy_corpus() { ], ); } + +#[test] +fn provider_key_corpus() { + check( + validate_provider_key, + &[ + ( + "minimal", + true, + json!({"display_name": "openai-prod", "secret": "sk-x"}), + ), + ( + "with api_base + provider", + true, + json!({"display_name": "p", "secret": "sk-x", "api_base": "https://api.openai.com/v1", "provider": "deepseek"}), + ), + ("missing display_name", false, json!({"secret": "sk-x"})), + ("missing secret", false, json!({"display_name": "x"})), + ( + "unknown top-level field", + false, + json!({"display_name": "x", "secret": "k", "rogue": 1}), + ), + ( + "empty display_name", + false, + json!({"display_name": "", "secret": "k"}), + ), + ( + "empty secret", + false, + json!({"display_name": "x", "secret": ""}), + ), + ( + "adapter azure-openai", + true, + json!({"display_name": "x", "secret": "k", "adapter": "azure-openai"}), + ), + ( + "adapter invalid", + false, + json!({"display_name": "x", "secret": "k", "adapter": "not-a-real-adapter"}), + ), + // option_add_null_type=true: optional fields accept explicit null. + ( + "adapter null", + true, + json!({"display_name": "x", "secret": "k", "adapter": null}), + ), + ( + "telemetry catalog", + true, + json!({"display_name": "x", "secret": "k", "telemetry_tags": {"kind": "catalog", "featured": true, "branded_provider": "deepseek", "pk_label": "prod"}}), + ), + ( + "telemetry byo, branded omitted", + true, + json!({"display_name": "x", "secret": "k", "telemetry_tags": {"kind": "byo", "byo_label": "platform-team"}}), + ), + // The load-bearing nullable case: cp-api sends branded_provider:null. + ( + "telemetry branded_provider null", + true, + json!({"display_name": "x", "secret": "k", "telemetry_tags": {"branded_provider": null}}), + ), + ( + "telemetry unknown tag", + false, + json!({"display_name": "x", "secret": "k", "telemetry_tags": {"unknown_tag": "v"}}), + ), + ( + "telemetry kind invalid (closed enum)", + false, + json!({"display_name": "x", "secret": "k", "telemetry_tags": {"kind": "third-party"}}), + ), + ( + "request empty", + true, + json!({"display_name": "x", "secret": "k", "request": {}}), + ), + ( + "request full", + true, + json!({"display_name": "x", "secret": "k", "request": {"param_renames": {"max_completion_tokens": "max_tokens"}, "param_constraints": {"temperature_max": 1.0}, "default_headers": {"X-Foo": "bar"}, "default_body_fields": {"safe_prompt": true}}}), + ), + ( + "request typo field", + false, + json!({"display_name": "x", "secret": "k", "request": {"param_rename": {}}}), + ), + ( + "param_constraints unknown field", + false, + json!({"display_name": "x", "secret": "k", "request": {"param_constraints": {"top_p_max": 0.9}}}), + ), + ( + "response full", + true, + json!({"display_name": "x", "secret": "k", "response": {"stream_done_marker": "none", "content_list_to_string": false, "error_envelope": "openai", "reasoning_field": "delta.reasoning_content"}}), + ), + ( + "response bad stream_done_marker", + false, + json!({"display_name": "x", "secret": "k", "response": {"stream_done_marker": "maybe"}}), + ), + ( + "response stream_done_marker case-sensitive", + false, + json!({"display_name": "x", "secret": "k", "response": {"stream_done_marker": "Required"}}), + ), + ( + "response typo field", + false, + json!({"display_name": "x", "secret": "k", "response": {"reasoning_fields": "x"}}), + ), + ( + "strip_headers empty", + true, + json!({"display_name": "x", "secret": "k", "strip_headers": []}), + ), + ( + "strip_headers non-string item", + false, + json!({"display_name": "x", "secret": "k", "strip_headers": [1, 2]}), + ), + ], + ); +} diff --git a/crates/aisix-proxy/src/chat.rs b/crates/aisix-proxy/src/chat.rs index 94e973bc..4c1ba37f 100644 --- a/crates/aisix-proxy/src/chat.rs +++ b/crates/aisix-proxy/src/chat.rs @@ -2717,7 +2717,7 @@ fn emit_usage_event( // as defence-in-depth against log/JSON injection downstream // (PR #382 audit MEDIUM-3; admission-side cap tracked // separately). - provider_kind: sanitize_tag(tags.kind.unwrap_or_default()), + provider_kind: sanitize_tag(tags.kind.map(|k| k.as_str().to_owned()).unwrap_or_default()), provider_featured: tags.featured, branded_provider: sanitize_tag(tags.branded_provider.unwrap_or_default()), pk_label: sanitize_tag(tags.pk_label.unwrap_or_default()), diff --git a/crates/aisix-proxy/src/messages.rs b/crates/aisix-proxy/src/messages.rs index b8b717d0..53988258 100644 --- a/crates/aisix-proxy/src/messages.rs +++ b/crates/aisix-proxy/src/messages.rs @@ -1819,7 +1819,7 @@ fn emit_anthropic_usage_event( attempt_model: attempt.model, error_class: attempt.error_class, error_message: attempt.error_message, - provider_kind: sanitize_tag(tags.kind.unwrap_or_default()), + provider_kind: sanitize_tag(tags.kind.map(|k| k.as_str().to_owned()).unwrap_or_default()), provider_featured: tags.featured, branded_provider: sanitize_tag(tags.branded_provider.unwrap_or_default()), pk_label: sanitize_tag(tags.pk_label.unwrap_or_default()), diff --git a/schemas/resources/provider_key.schema.json b/schemas/resources/provider_key.schema.json index c38252ae..916075a5 100644 --- a/schemas/resources/provider_key.schema.json +++ b/schemas/resources/provider_key.schema.json @@ -1,144 +1,58 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ProviderKey", - "type": "object", - "required": [ - "display_name", - "secret" - ], - "properties": { - "adapter": { - "description": "Upstream API protocol family used when provider-specific dispatch is unavailable.", - "anyOf": [ - { - "$ref": "#/definitions/Adapter" - }, - { - "type": "null" - } - ] - }, - "api_base": { - "description": "Override base URL for the upstream provider. Required for custom or OpenAI-compatible providers that should not use a built-in vendor endpoint.", - "type": [ - "string", - "null" - ] - }, - "display_name": { - "description": "Operator-facing label, unique within the gateway. Surfaces in the Admin API list view and in dashboard UIs that wrap this resource.", - "type": "string" - }, - "provider": { - "description": "Upstream provider identifier, such as `\"deepseek\"`, `\"openai\"`, or a model catalog ID. The gateway uses this value for provider-specific dispatch and base URL validation.", - "default": "", - "type": "string" - }, - "request": { - "description": "Per-key request-shape overrides applied by supported provider paths before dispatch to the upstream provider.", - "anyOf": [ - { - "$ref": "#/definitions/RequestOverrides" - }, - { - "type": "null" - } - ] - }, - "response": { - "description": "Per-key response-shape overrides applied by provider bridges that support response transformation.", - "anyOf": [ - { - "$ref": "#/definitions/ResponseOverrides" - }, - { - "type": "null" - } - ] - }, - "secret": { - "description": "Upstream provider's API key. The data plane receives plaintext so it can authenticate to the upstream provider. Protect the configuration store and transport accordingly.", - "type": "string" - }, - "strip_headers": { - "description": "Inbound headers removed before passthrough forwarding.", - "default": [ - "authorization", - "cookie", - "set-cookie", - "x-api-key" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "telemetry_tags": { - "description": "Telemetry tags carried alongside the key for metric and log emission.", - "default": { - "featured": false - }, - "allOf": [ - { - "$ref": "#/definitions/TelemetryTags" - } - ] - } - }, "additionalProperties": false, "definitions": { "Adapter": { "description": "Upstream API protocol family used for provider dispatch.", - "type": "string", "enum": [ "openai", "anthropic", "bedrock", "vertex", "azure-openai" - ] + ], + "type": "string" }, "ParamConstraints": { + "additionalProperties": false, "description": "Numeric range clamps applied to chat-completion request bodies.", - "type": "object", "properties": { "temperature_max": { "description": "Upper bound for `temperature`. Values above this are clamped to this value. If omitted, no upper bound is applied.", + "format": "double", "type": [ "number", "null" - ], - "format": "double" + ] }, "temperature_min": { "description": "Lower bound for `temperature`. Values below this are clamped to this value. If omitted, no lower bound is applied.", + "format": "double", "type": [ "number", "null" - ], - "format": "double" + ] } }, - "additionalProperties": false + "type": "object" }, "RequestOverrides": { + "additionalProperties": false, "description": "Per-`ProviderKey` request-shape overrides. Use these fields to rename request body parameters, clamp supported numeric parameters, add fallback outbound headers, or add fallback outbound body fields.", - "type": "object", "properties": { "default_body_fields": { + "additionalProperties": true, "description": "`apply_default_body_fields` input. Top-level body fields added when the caller did not set them. `serde_json::Map` preserves insertion order on serialize, matching the etcd round-trip.", - "type": "object", - "additionalProperties": true + "type": "object" }, "default_headers": { - "description": "`apply_default_headers` input. Top-level headers added to the outbound request when the caller did not set them. Reserved auth headers are dropped by `apply_default_headers` as defense-in-depth.", - "type": "object", "additionalProperties": { "type": "string" - } + }, + "description": "`apply_default_headers` input. Top-level headers added to the outbound request when the caller did not set them. Reserved auth headers are dropped by `apply_default_headers` as defense-in-depth.", + "type": "object" }, "param_constraints": { - "description": "Parameter constraints applied to the outbound request. If omitted, no clamping is applied.", "anyOf": [ { "$ref": "#/definitions/ParamConstraints" @@ -146,25 +60,26 @@ { "type": "null" } - ] + ], + "description": "Parameter constraints applied to the outbound request. If omitted, no clamping is applied." }, "param_renames": { - "description": "`apply_param_renames` input. Top-level body keys named on the left are renamed to the right. Leave empty to preserve request parameter names.", - "type": "object", "additionalProperties": { "type": "string" - } + }, + "description": "`apply_param_renames` input. Top-level body keys named on the left are renamed to the right. Leave empty to preserve request parameter names.", + "type": "object" } }, - "additionalProperties": false + "type": "object" }, "ResponseOverrides": { + "additionalProperties": false, "description": "Per-`ProviderKey` response-shape overrides. Use these fields to describe stream termination behavior, flatten list-style content when needed, select an error envelope strategy, or lift provider-specific reasoning content.", - "type": "object", "properties": { "content_list_to_string": { - "description": "When `true`, the request-body `messages[*].content` array of text blocks gets flattened to a single string before dispatch.", "default": false, + "description": "When `true`, the request-body `messages[*].content` array of text blocks gets flattened to a single string before dispatch.", "type": "boolean" }, "error_envelope": { @@ -182,7 +97,6 @@ ] }, "stream_done_marker": { - "description": "Stream `[DONE]` terminator expectation. If omitted, either presence or absence of the terminator is accepted.", "anyOf": [ { "$ref": "#/definitions/StreamDoneMarker" @@ -190,40 +104,49 @@ { "type": "null" } - ] + ], + "description": "Stream `[DONE]` terminator expectation. If omitted, either presence or absence of the terminator is accepted." } }, - "additionalProperties": false + "type": "object" }, "StreamDoneMarker": { "description": "Stream `[DONE]` terminator policy for an SSE response. Values are `\"required\"`, `\"optional\"`, or `\"none\"`.", "oneOf": [ { "description": "Upstream is expected to emit `data: [DONE]`. Absence is logged as a diagnostic warning.", - "type": "string", "enum": [ "required" - ] + ], + "type": "string" }, { "description": "Either presence or absence is acceptable. Used when the upstream is OpenAI-compatible but does not require the terminator.", - "type": "string", "enum": [ "optional" - ] + ], + "type": "string" }, { "description": "Upstream is expected to omit the marker and terminate on connection close.", - "type": "string", "enum": [ "none" - ] + ], + "type": "string" } ] }, + "TelemetryKind": { + "description": "Provider-key category: `catalog` for curated providers, `byo` for bring-your-own.", + "enum": [ + "catalog", + "byo" + ], + "type": "string" + }, "TelemetryTags": { + "additionalProperties": false, "description": "Telemetry attribution tags emitted with requests routed through this provider key.", - "type": "object", "properties": { "branded_provider": { "description": "Branded provider slug for catalog entries, such as `\"openai\"` or `\"anthropic\"`. Bring-your-own providers leave this field unset.", @@ -240,16 +163,20 @@ ] }, "featured": { - "description": "Whether this provider is surfaced in the featured list.", "default": false, + "description": "Whether this provider is surfaced in the featured list.", "type": "boolean" }, "kind": { - "description": "Provider-key category, such as `\"catalog\"` for curated providers or `\"byo\"` for bring-your-own providers.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/TelemetryKind" + }, + { + "type": "null" + } + ], + "description": "Provider-key category, such as `\"catalog\"` for curated providers or `\"byo\"` for bring-your-own providers." }, "pk_label": { "description": "Operator-defined label for this provider key, such as `\"production\"` or `\"shared-test\"`.", @@ -259,7 +186,94 @@ ] } }, - "additionalProperties": false + "type": "object" } - } + }, + "properties": { + "adapter": { + "anyOf": [ + { + "$ref": "#/definitions/Adapter" + }, + { + "type": "null" + } + ], + "description": "Upstream API protocol family used when provider-specific dispatch is unavailable." + }, + "api_base": { + "description": "Override base URL for the upstream provider. Required for custom or OpenAI-compatible providers that should not use a built-in vendor endpoint.", + "type": [ + "string", + "null" + ] + }, + "display_name": { + "description": "Operator-facing label, unique within the gateway. Surfaces in the Admin API list view and in dashboard UIs that wrap this resource.", + "minLength": 1, + "type": "string" + }, + "provider": { + "default": "", + "description": "Upstream provider identifier, such as `\"deepseek\"`, `\"openai\"`, or a model catalog ID. The gateway uses this value for provider-specific dispatch and base URL validation.", + "type": "string" + }, + "request": { + "anyOf": [ + { + "$ref": "#/definitions/RequestOverrides" + }, + { + "type": "null" + } + ], + "description": "Per-key request-shape overrides applied by supported provider paths before dispatch to the upstream provider." + }, + "response": { + "anyOf": [ + { + "$ref": "#/definitions/ResponseOverrides" + }, + { + "type": "null" + } + ], + "description": "Per-key response-shape overrides applied by provider bridges that support response transformation." + }, + "secret": { + "description": "Upstream provider's API key. The data plane receives plaintext so it can authenticate to the upstream provider. Protect the configuration store and transport accordingly.", + "minLength": 1, + "type": "string" + }, + "strip_headers": { + "default": [ + "authorization", + "cookie", + "set-cookie", + "x-api-key" + ], + "description": "Inbound headers removed before passthrough forwarding.", + "items": { + "type": "string" + }, + "type": "array" + }, + "telemetry_tags": { + "allOf": [ + { + "$ref": "#/definitions/TelemetryTags" + } + ], + "default": { + "featured": false + }, + "description": "Telemetry tags carried alongside the key for metric and log emission." + } + }, + "required": [ + "display_name", + "secret" + ], + "title": "ProviderKey", + "type": "object" } From 9d1b6850d0dd5da36fe037fbe9407e4ba6e9206d Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 23 Jun 2026 09:09:42 +0800 Subject: [PATCH 6/8] refactor(core): derive observability_exporter validator from struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit observability_exporter runtime validator now builds from the ObservabilityExporter struct. schemars renders the internally-tagged ExporterKind as a native top-level oneOf; the producer post-processes it to (1) re-close each branch with additionalProperties:false — schemars drops deny_unknown_fields in tagged-enum branches and serde doesn't enforce it there, so this restores plaintext-secret rejection — copying the shared name/enabled into each closed branch, and (2) inject the object_store cloud-identity cross-field rule (if/then/else) schemars can't derive. Per-field endpoint/site regex, project/logstore/bucket/etc minLength, content_max_bytes caps ported as schemars attrs. Intended tightening: cross-kind field leakage (e.g. a datadog exporter carrying an otlp 'project') is now rejected; no valid config mixes kinds. Characterization corpus added for observability_exporter. --- crates/aisix-admin/src/openapi.rs | 7 + crates/aisix-core/src/bin/dump-schema.rs | 8 +- .../src/models/observability_exporter.rs | 26 +- crates/aisix-core/src/models/schema.rs | 231 ++++------ .../tests/resource_schema_characterization.rs | 143 ++++++- .../observability_exporter.schema.json | 403 +++++++++++------- 6 files changed, 499 insertions(+), 319 deletions(-) diff --git a/crates/aisix-admin/src/openapi.rs b/crates/aisix-admin/src/openapi.rs index 4e699cf9..6b4f6d89 100644 --- a/crates/aisix-admin/src/openapi.rs +++ b/crates/aisix-admin/src/openapi.rs @@ -4030,6 +4030,13 @@ mod tests { } for (key, child) in map { + // `if`/`then`/`else` are cross-field constraint subschemas + // (e.g. object_store's cloud-identity rule), not ReDoc- + // rendered property definitions, so their inner properties + // need no descriptions. + if matches!(key.as_str(), "if" | "then" | "else") { + continue; + } collect_missing_property_descriptions(child, format!("{path}/{key}"), missing); } } diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 651d4166..d6d67988 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -31,7 +31,7 @@ use std::path::{Path, PathBuf}; use schemars::JsonSchema; use aisix_core::models::schema; -use aisix_core::models::{EnsembleConfig, Guardrail, ObservabilityExporter, RateLimit, Routing}; +use aisix_core::models::{EnsembleConfig, Guardrail, RateLimit, Routing}; fn main() { let out_dir = workspace_root().join("schemas").join("resources"); @@ -51,10 +51,14 @@ fn main() { ); dump_value(&out_dir, "provider_key", schema::provider_key_root_schema()); + dump_value( + &out_dir, + "observability_exporter", + schema::observability_exporter_root_schema(), + ); dump::(&out_dir, "ensemble"); dump::(&out_dir, "guardrail"); - dump::(&out_dir, "observability_exporter"); dump::(&out_dir, "rate_limit"); dump::(&out_dir, "routing"); } diff --git a/crates/aisix-core/src/models/observability_exporter.rs b/crates/aisix-core/src/models/observability_exporter.rs index 9fc165ed..05f6559d 100644 --- a/crates/aisix-core/src/models/observability_exporter.rs +++ b/crates/aisix-core/src/models/observability_exporter.rs @@ -50,6 +50,9 @@ pub enum ExporterKind { pub struct OtlpHttpConfig { /// Full URL of the OTLP/HTTP traces endpoint. Include the receiver's /// expected path, such as `/v1/traces`. + #[schemars(regex( + pattern = r"^https://.+|^http://(mock-otlp|otel-collector|127\.0\.0\.1|localhost)(:[0-9]+)?(/.*)?$" + ))] pub endpoint: String, /// Static headers attached to every export request, such as authorization or vendor-specific API-key headers. @@ -67,7 +70,7 @@ pub struct OtlpHttpConfig { /// Maximum bytes per captured prompt or response field when `content_mode` is `full`. #[serde(default = "default_content_max_bytes")] - #[schemars(range(min = 1))] + #[schemars(range(min = 1, max = 1_048_576))] pub content_max_bytes: u32, } @@ -77,15 +80,21 @@ pub struct OtlpHttpConfig { pub struct AliyunSlsConfig { /// SLS regional endpoint host without a scheme, such as `ap-southeast-3.log.aliyuncs.com`. /// Signed requests are sent to this endpoint with the SLS project as the host prefix. + #[schemars(regex( + pattern = r"^[a-z0-9][a-z0-9.-]*\.aliyuncs\.com$|^http://(mock-sls|127\.0\.0\.1|localhost)(:[0-9]+)?$" + ))] pub endpoint: String, /// SLS project that prefixes the regional endpoint in signed requests. + #[schemars(length(min = 1))] pub project: String, /// SLS logstore that receives the request-event logs. + #[schemars(length(min = 1))] pub logstore: String, /// Credential reference resolved by the data plane at delivery time. The plaintext AccessKey is not stored in this resource. + #[schemars(length(min = 1))] pub credential_ref: String, /// Controls whether logs include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`. @@ -94,7 +103,7 @@ pub struct AliyunSlsConfig { /// Maximum bytes per captured prompt or response field when `content_mode` is `full`. #[serde(default = "default_content_max_bytes")] - #[schemars(range(min = 1))] + #[schemars(range(min = 1, max = 1_048_576))] pub content_max_bytes: u32, } @@ -121,17 +130,23 @@ const fn default_content_max_bytes() -> u32 { #[serde(deny_unknown_fields)] pub struct DatadogConfig { /// Datadog site, such as `datadoghq.com`, `us3.datadoghq.com`, or `datadoghq.eu`. + #[schemars(regex( + pattern = r"^(datadoghq\.com|us3\.datadoghq\.com|us5\.datadoghq\.com|datadoghq\.eu|ap1\.datadoghq\.com|ap2\.datadoghq\.com|ddog-gov\.com)$|^(mock-datadog|127\.0\.0\.1|localhost)(:[0-9]+)?$" + ))] pub site: String, /// Credential reference resolved by the data plane at delivery time. The plaintext Datadog API key is not stored in this resource. + #[schemars(length(min = 1))] pub credential_ref: String, /// Datadog `service` reserved attribute. Every log from this exporter is /// tagged with this service name in Datadog Log Explorer. + #[schemars(length(min = 1))] pub service: String, /// Datadog `ddsource` reserved attribute. Identifies the integration or source. #[serde(default = "default_ddsource")] + #[schemars(length(min = 1))] pub ddsource: String, /// Operator-defined tags rendered into Datadog's comma-joined `ddtags` @@ -168,18 +183,24 @@ pub struct ObjectStoreConfig { pub provider: ObjectStoreProvider, /// Bucket for S3 or GCS, or container for Azure Blob, that receives exported files. + #[schemars(length(min = 1))] pub bucket: String, /// Key prefix the partition path is appended to, e.g. `ai-gateway`. /// The full key is `/org=…/env=…/table=…/dt=…/hh=…/`. + #[schemars(length(min = 1))] pub prefix: String, /// AWS region for S3 SigV4 signature scope. Set this for S3 buckets outside `us-east-1`. Ignored for GCS and Azure Blob. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub region: Option, /// Backend host override for S3-compatible stores such as MinIO, Aliyun OSS, or Cloudflare R2. When omitted, the provider's native endpoint is used. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(regex( + pattern = r"^https://.+|^http://(minio|azurite|fake-gcs-server|fake-gcs|127\.0\.0\.1|localhost)(:[0-9]+)?(/.*)?$" + ))] pub endpoint: Option, /// Compression applied to each NDJSON file before upload. @@ -237,6 +258,7 @@ pub enum ObjectStoreAuthMode { pub struct ObservabilityExporter { /// Operator-facing label, surfaced in logs and dashboard lists. The etcd /// key UUID is the resource identity. + #[schemars(length(min = 1, max = 120))] pub name: String, /// Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry. diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index 14073e14..09d7158d 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -56,7 +56,7 @@ impl Schemas { .build(&cache_policy_root_schema()) .expect("cache_policy schema is well-formed"), observability_exporter: jsonschema::options() - .build(&observability_exporter_schema()) + .build(&observability_exporter_root_schema()) .expect("observability_exporter schema is well-formed"), rate_limit_policy: jsonschema::options() .build(&rate_limit_policy_root_schema()) @@ -351,163 +351,84 @@ pub fn cache_policy_root_schema() -> Value { struct_root_schema::(false) } -fn observability_exporter_schema() -> Value { - // Discriminated by `kind`; each branch's fields land flat at the top - // level (matches the Guardrail wire shape — see - // `models/observability_exporter.rs`). `additionalProperties` only - // considers THIS object's `properties` (not those inside `allOf`/`then`), - // so every kind's fields are listed at the top level as the union; - // per-kind required-fields and the endpoint pattern live in the - // `if`/`then` branches. Further kinds (`s3_ndjson`, …) land the same way. - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["name", "kind"], - "additionalProperties": false, - "properties": { - "name": { "type": "string", "minLength": 1, "maxLength": 120 }, - "enabled": { "type": "boolean" }, - "kind": { "type": "string", "enum": ["otlp_http", "aliyun_sls", "object_store", "datadog"] }, - // Shared field; the per-kind pattern is enforced in the branches. - "endpoint": { "type": "string" }, - // otlp_http field. - "headers": { - "type": "object", - "additionalProperties": { "type": "string" } - }, - // aliyun_sls fields. The AccessKey is NEVER here — only a - // `credential_ref` the DP resolves locally (no plaintext key on - // the kine path). - "project": { "type": "string", "minLength": 1 }, - "logstore": { "type": "string", "minLength": 1 }, - "credential_ref": { "type": "string", "minLength": 1 }, - // Content capture (opt-in), shared by aliyun_sls + datadog + - // otlp_http. `full` writes captured prompt / response to the sink; - // `content_max_bytes` truncates each FIELD. It is not a per-log - // bound — a datadog log carries both prompt and response, so - // byte-aware splitting to Datadog's 1 MB-per-log / 5 MB-per-request - // intake limits is tracked separately (api7/ai-gateway#556), not - // enforced by this cap. - "content_mode": { "type": "string", "enum": ["metadata_only", "full"] }, - "content_max_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }, - // otlp_http per-request sampling (#519 B.2). Absent = 1.0 (export - // everything). serde's `deny_unknown_fields` keeps it off the - // other kinds; this is the bounds check the loader runs before - // deserialize, so an out-of-range rate never reaches the sink. - "sample_rate": { "type": "number", "minimum": 0.0, "maximum": 1.0 }, - // object_store fields (S3 / GCS / Azure Blob, one variant). Cloud - // credentials are NEVER here — only the shared `credential_ref`. - "provider": { "type": "string", "enum": ["s3", "gcs", "azure_blob"] }, - "bucket": { "type": "string", "minLength": 1 }, - "prefix": { "type": "string", "minLength": 1 }, - "region": { "type": "string", "minLength": 1 }, - "compression": { "type": "string", "enum": ["gzip", "none"] }, - // object_store auth mode: how the DP reaches the bucket. - "auth_mode": { "type": "string", "enum": ["credential_ref", "cloud_identity"] }, - // datadog fields. The Datadog API key is NEVER here — only the - // shared `credential_ref` the DP resolves locally. `site` is - // constrained to the allow-list in the per-kind branch below. - "site": { "type": "string", "minLength": 1 }, - "service": { "type": "string", "minLength": 1 }, - "ddsource": { "type": "string", "minLength": 1 }, - "tags": { - "type": "array", - "items": { "type": "string" } - } - }, - "allOf": [ - { - "if": { "properties": { "kind": { "const": "otlp_http" } } }, - "then": { - "required": ["endpoint"], - "properties": { - // Reject http:// and any non-URL by anchoring on - // https://. Loopback bypass for e2e: allow - // http://mock-otlp:* / otel-collector / 127.0.0.1 / - // localhost so the compose test can wire a fake - // receiver without TLS. - "endpoint": { - "pattern": "^https://.+|^http://(mock-otlp|otel-collector|127\\.0\\.0\\.1|localhost)(:[0-9]+)?(/.*)?$" - } - } - } - }, - { - "if": { "properties": { "kind": { "const": "aliyun_sls" } } }, - "then": { - "required": ["endpoint", "project", "logstore", "credential_ref"], - "properties": { - // A bare SLS region host (the sink prepends - // https://.). Loopback bypass for e2e: a - // scheme-qualified mock-sls / 127.0.0.1 / localhost - // the sink posts to directly. - "endpoint": { - "pattern": "^[a-z0-9][a-z0-9.-]*\\.aliyuncs\\.com$|^http://(mock-sls|127\\.0\\.0\\.1|localhost)(:[0-9]+)?$" - } - } - } - }, - { - "if": { "properties": { "kind": { "const": "object_store" } } }, - "then": { - "required": ["provider", "bucket", "prefix"], - "properties": { - // `endpoint` is optional — set only for S3-compatible - // stores (MinIO / OSS / R2). When present: https, or a - // loopback emulator host (MinIO / Azurite / - // fake-gcs-server) for the compose e2e — never a way to - // redirect real traffic to an arbitrary plaintext host. - "endpoint": { - "pattern": "^https://.+|^http://(minio|azurite|fake-gcs-server|fake-gcs|127\\.0\\.0\\.1|localhost)(:[0-9]+)?(/.*)?$" - } - }, - "allOf": [ - { - // cloud_identity: the DP authenticates with its own - // attached cloud identity — S3 / GCS only (Azure - // managed identity needs a non-secret account name - // the keyless config does not carry), and no - // credential_ref. Otherwise (the default - // credential_ref mode) credential_ref is required. - "if": { - "required": ["auth_mode"], - "properties": { "auth_mode": { "const": "cloud_identity" } } - }, - "then": { - "properties": { "provider": { "enum": ["s3", "gcs"] } } - }, - "else": { - "required": ["credential_ref"] - } - } - ] - } - }, - { - "if": { "properties": { "kind": { "const": "datadog" } } }, - "then": { - "required": ["site", "credential_ref", "service"], - "properties": { - // The Datadog site, constrained to the supported intake - // sites; the sink posts to `https://http-intake.logs.`. - // Loopback bypass for e2e: a bare mock-datadog / 127.0.0.1 - // / localhost host, OPTIONALLY with a `:port`, which the - // sink posts to over http:// directly (a local mock intake - // needs no TLS) — never a way to redirect real traffic to - // an arbitrary host. The `:port` is allowed ONLY on the - // loopback hosts (the e2e harness binds a free port); the - // real sites match exactly, no port. Mirrors the - // aliyun_sls / object_store loopback patterns — the prior - // exact-enum rejected the harness's free-port host while - // the sink's `is_loopback_site` accepted it (#548). - "site": { - "pattern": "^(datadoghq\\.com|us3\\.datadoghq\\.com|us5\\.datadoghq\\.com|datadoghq\\.eu|ap1\\.datadoghq\\.com|ap2\\.datadoghq\\.com|ddog-gov\\.com)$|^(mock-datadog|127\\.0\\.0\\.1|localhost)(:[0-9]+)?$" - } +/// Canonical JSON Schema for the `observability_exporter` resource, derived +/// from the [`ObservabilityExporter`](crate::models::ObservabilityExporter) +/// struct. `schemars` renders the internally-tagged `ExporterKind` as a native +/// top-level `oneOf`, but two things need fixing up by hand: +/// +/// 1. `schemars` drops `deny_unknown_fields` inside tagged-enum branches, and +/// serde does not enforce it there either, so each branch is re-closed with +/// `additionalProperties: false` (rejecting a smuggled plaintext secret). +/// Because a closed branch only lists its own kind's fields, the shared +/// top-level `name`/`enabled` are copied into every branch. +/// 2. The `object_store` cloud-identity cross-field rule (cloud_identity ⇒ +/// provider ∈ {s3,gcs} and no credential_ref; otherwise credential_ref +/// required) is injected as an `allOf`/`if`/`then`/`else` — `schemars` can't +/// derive cross-field constraints. +/// +/// Re-closing each branch also rejects cross-kind field leakage (e.g. a +/// `datadog` exporter carrying an otlp `project`) that the previous +/// single-union-object validator silently accepted; no valid config mixes kinds. +pub fn observability_exporter_root_schema() -> Value { + let mut schema = struct_root_schema::(false); + let obj = schema + .as_object_mut() + .expect("observability_exporter root schema is a JSON object"); + + let top_props = obj + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + if let Some(Value::Array(branches)) = obj.get_mut("oneOf") { + for branch in branches.iter_mut() { + let Some(branch_obj) = branch.as_object_mut() else { + continue; + }; + let is_object_store = branch_kind(branch_obj) == Some("object_store"); + + let props = branch_obj + .entry("properties".to_string()) + .or_insert_with(|| json!({})); + if let Some(props_obj) = props.as_object_mut() { + for key in ["name", "enabled"] { + if let Some(v) = top_props.get(key) { + props_obj + .entry(key.to_string()) + .or_insert_with(|| v.clone()); } } } - ] - }) + + if is_object_store { + branch_obj.insert( + "allOf".to_string(), + json!([{ + "if": { + "required": ["auth_mode"], + "properties": { "auth_mode": { "const": "cloud_identity" } } + }, + "then": { "properties": { "provider": { "enum": ["s3", "gcs"] } } }, + "else": { "required": ["credential_ref"] } + }]), + ); + } + + branch_obj.insert("additionalProperties".to_string(), json!(false)); + } + } + schema +} + +/// The `kind` discriminator value of a schemars-generated tagged-enum `oneOf` +/// branch, whether rendered as a `const` or a single-element `enum`. +fn branch_kind(branch: &serde_json::Map) -> Option<&str> { + let kind = branch.get("properties")?.get("kind")?; + if let Some(c) = kind.get("const").and_then(Value::as_str) { + return Some(c); + } + kind.get("enum")?.as_array()?.first()?.as_str() } /// Canonical JSON Schema for the `rate_limit_policy` resource, derived from the diff --git a/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs index 5de352f5..294a7276 100644 --- a/crates/aisix-core/tests/resource_schema_characterization.rs +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -8,7 +8,8 @@ //! case is obvious. New resources append their own table as they migrate. use aisix_core::models::schema::{ - validate_apikey, validate_cache_policy, validate_provider_key, validate_rate_limit_policy, + validate_apikey, validate_cache_policy, validate_observability_exporter, validate_provider_key, + validate_rate_limit_policy, }; use serde_json::{json, Value}; @@ -397,3 +398,143 @@ fn provider_key_corpus() { ], ); } + +#[test] +fn observability_exporter_corpus() { + check( + validate_observability_exporter, + &[ + // otlp_http + ( + "otlp minimal", + true, + json!({"name": "hc", "kind": "otlp_http", "endpoint": "https://api.honeycomb.io/v1/traces"}), + ), + ( + "otlp loopback http", + true, + json!({"name": "e2e", "kind": "otlp_http", "endpoint": "http://mock-otlp:4318/v1/traces"}), + ), + ( + "otlp plain http non-loopback (pattern)", + false, + json!({"name": "x", "kind": "otlp_http", "endpoint": "http://api.honeycomb.io/v1/traces"}), + ), + ( + "otlp sample_rate > 1", + false, + json!({"name": "x", "kind": "otlp_http", "endpoint": "https://x", "sample_rate": 1.1}), + ), + ( + "otlp missing endpoint", + false, + json!({"name": "x", "kind": "otlp_http"}), + ), + ( + "otlp content_mode unknown", + false, + json!({"name": "x", "kind": "otlp_http", "endpoint": "https://x", "content_mode": "verbose"}), + ), + ( + "otlp content_max_bytes 0", + false, + json!({"name": "x", "kind": "otlp_http", "endpoint": "https://x", "content_max_bytes": 0}), + ), + ( + "otlp content_max_bytes > 1MiB (cap preserved)", + false, + json!({"name": "x", "kind": "otlp_http", "endpoint": "https://x", "content_max_bytes": 2000000}), + ), + // aliyun_sls + ( + "sls full", + true, + json!({"name": "sls", "kind": "aliyun_sls", "endpoint": "ap-southeast-3.log.aliyuncs.com", "project": "p", "logstore": "l", "credential_ref": "r"}), + ), + ( + "sls missing logstore", + false, + json!({"name": "x", "kind": "aliyun_sls", "endpoint": "ap-southeast-3.log.aliyuncs.com", "project": "p", "credential_ref": "r"}), + ), + ( + "sls bad endpoint host (pattern)", + false, + json!({"name": "x", "kind": "aliyun_sls", "endpoint": "https://evil.example.com", "project": "p", "logstore": "l", "credential_ref": "r"}), + ), + ( + "sls plaintext secret (additionalProperties:false)", + false, + json!({"name": "x", "kind": "aliyun_sls", "endpoint": "ap-southeast-3.log.aliyuncs.com", "project": "p", "logstore": "l", "credential_ref": "r", "access_key_secret": "AKIA"}), + ), + // object_store + ( + "s3 credential_ref mode", + true, + json!({"name": "s3", "kind": "object_store", "provider": "s3", "bucket": "b", "prefix": "p", "credential_ref": "r"}), + ), + ( + "s3 cloud_identity (no credential_ref)", + true, + json!({"name": "x", "kind": "object_store", "provider": "s3", "bucket": "b", "prefix": "p", "auth_mode": "cloud_identity"}), + ), + ( + "azure_blob + cloud_identity (cross-field)", + false, + json!({"name": "x", "kind": "object_store", "provider": "azure_blob", "bucket": "c", "prefix": "p", "auth_mode": "cloud_identity"}), + ), + ( + "credential_ref mode missing credential_ref (else)", + false, + json!({"name": "x", "kind": "object_store", "provider": "s3", "bucket": "b", "prefix": "p"}), + ), + ( + "bad provider enum", + false, + json!({"name": "x", "kind": "object_store", "provider": "wasabi", "bucket": "b", "prefix": "p", "credential_ref": "r"}), + ), + ( + "loopback minio endpoint", + true, + json!({"name": "x", "kind": "object_store", "provider": "s3", "bucket": "b", "prefix": "p", "endpoint": "http://minio:9000", "credential_ref": "r"}), + ), + // datadog + ( + "datadog allow-list site", + true, + json!({"name": "dd", "kind": "datadog", "site": "datadoghq.eu", "credential_ref": "r", "service": "s"}), + ), + ( + "datadog non-allow-list site (pattern)", + false, + json!({"name": "x", "kind": "datadog", "site": "datadoghq.org", "credential_ref": "r", "service": "s"}), + ), + ( + "datadog content_max_bytes > 1MiB", + false, + json!({"name": "x", "kind": "datadog", "site": "datadoghq.com", "credential_ref": "r", "service": "s", "content_max_bytes": 1048577}), + ), + // Cross-kind field leakage now rejected (per-branch additionalProperties:false). + ( + "datadog carrying otlp/sls field", + false, + json!({"name": "x", "kind": "datadog", "site": "datadoghq.com", "credential_ref": "r", "service": "s", "project": "leaked"}), + ), + // shared / discriminator + ( + "unknown kind", + false, + json!({"name": "x", "kind": "splunk_hec", "endpoint": "https://x"}), + ), + ( + "missing name", + false, + json!({"kind": "otlp_http", "endpoint": "https://x"}), + ), + ( + "name too long (>120)", + false, + json!({"name": "a".repeat(121), "kind": "otlp_http", "endpoint": "https://x"}), + ), + ], + ); +} diff --git a/schemas/resources/observability_exporter.schema.json b/schemas/resources/observability_exporter.schema.json index 8aa700dd..dc4b38b7 100644 --- a/schemas/resources/observability_exporter.schema.json +++ b/schemas/resources/observability_exporter.schema.json @@ -1,324 +1,409 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ObservabilityExporter", + "definitions": { + "ObjectStoreAuthMode": { + "description": "How the data plane obtains credentials for the object-storage bucket.", + "oneOf": [ + { + "description": "Resolve `credential_ref` to static keys from data plane environment variables named `OBJSTORE_CRED__`.", + "enum": [ + "credential_ref" + ], + "type": "string" + }, + { + "description": "Use the data plane host's attached cloud identity. Supported for S3 and GCS only.", + "enum": [ + "cloud_identity" + ], + "type": "string" + } + ] + }, + "ObjectStoreCompression": { + "description": "File compression for object-storage uploads.", + "oneOf": [ + { + "description": "gzip compression as defined by RFC 1952. Accepted by Snowpipe and Auto Loader.", + "enum": [ + "gzip" + ], + "type": "string" + }, + { + "description": "No compression. Emits raw NDJSON.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "ObjectStoreProvider": { + "description": "Object-storage backend selector. The sink builds one backend client per variant. Batching, key layout, and retry behavior are shared.", + "enum": [ + "s3", + "gcs", + "azure_blob" + ], + "type": "string" + }, + "SlsContentMode": { + "description": "Content-capture mode for an observability exporter.", + "oneOf": [ + { + "description": "Operational metadata only. Prompt and response content are omitted.", + "enum": [ + "metadata_only" + ], + "type": "string" + }, + { + "description": "Metadata plus the captured request prompt and assembled response.", + "enum": [ + "full" + ], + "type": "string" + } + ] + } + }, "description": "Telemetry exporter configuration.", - "type": "object", "oneOf": [ { + "additionalProperties": false, "description": "OTLP/HTTP trace exporter configuration.", - "type": "object", - "required": [ - "endpoint", - "kind" - ], "properties": { "content_max_bytes": { - "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`.", "default": 131072, - "type": "integer", + "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`.", "format": "uint32", - "minimum": 1.0 + "maximum": 1048576.0, + "minimum": 1.0, + "type": "integer" }, "content_mode": { - "description": "Controls whether spans include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`.", - "default": "metadata_only", "allOf": [ { "$ref": "#/definitions/SlsContentMode" } - ] + ], + "default": "metadata_only", + "description": "Controls whether spans include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`." + }, + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" }, "endpoint": { "description": "Full URL of the OTLP/HTTP traces endpoint. Include the receiver's expected path, such as `/v1/traces`.", + "pattern": "^https://.+|^http://(mock-otlp|otel-collector|127\\.0\\.0\\.1|localhost)(:[0-9]+)?(/.*)?$", "type": "string" }, "headers": { - "description": "Static headers attached to every export request, such as authorization or vendor-specific API-key headers.", - "type": "object", "additionalProperties": { "type": "string" - } + }, + "description": "Static headers attached to every export request, such as authorization or vendor-specific API-key headers.", + "type": "object" }, "kind": { - "type": "string", "enum": [ "otlp_http" - ] + ], + "type": "string" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, + "type": "string" }, "sample_rate": { "description": "Fraction of requests exported as traces, from `0.0` to `1.0`.", - "type": [ - "number", - "null" - ], "format": "double", "maximum": 1.0, - "minimum": 0.0 + "minimum": 0.0, + "type": "number" } - } - }, - { - "description": "Aliyun SLS PutLogs exporter configuration.", - "type": "object", + }, "required": [ - "credential_ref", "endpoint", - "kind", - "logstore", - "project" + "kind" ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Aliyun SLS PutLogs exporter configuration.", "properties": { "content_max_bytes": { - "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`.", "default": 131072, - "type": "integer", + "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`.", "format": "uint32", - "minimum": 1.0 + "maximum": 1048576.0, + "minimum": 1.0, + "type": "integer" }, "content_mode": { - "description": "Controls whether logs include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`.", - "default": "metadata_only", "allOf": [ { "$ref": "#/definitions/SlsContentMode" } - ] + ], + "default": "metadata_only", + "description": "Controls whether logs include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`." }, "credential_ref": { "description": "Credential reference resolved by the data plane at delivery time. The plaintext AccessKey is not stored in this resource.", + "minLength": 1, "type": "string" }, + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" + }, "endpoint": { "description": "SLS regional endpoint host without a scheme, such as `ap-southeast-3.log.aliyuncs.com`. Signed requests are sent to this endpoint with the SLS project as the host prefix.", + "pattern": "^[a-z0-9][a-z0-9.-]*\\.aliyuncs\\.com$|^http://(mock-sls|127\\.0\\.0\\.1|localhost)(:[0-9]+)?$", "type": "string" }, "kind": { - "type": "string", "enum": [ "aliyun_sls" - ] + ], + "type": "string" }, "logstore": { "description": "SLS logstore that receives the request-event logs.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, "type": "string" }, "project": { "description": "SLS project that prefixes the regional endpoint in signed requests.", + "minLength": 1, "type": "string" } - } - }, - { - "description": "Object-storage exporter configuration for S3, GCS, Azure Blob, and compatible S3 backends.", - "type": "object", + }, "required": [ - "bucket", + "credential_ref", + "endpoint", "kind", - "prefix", - "provider" + "logstore", + "project" + ], + "type": "object" + }, + { + "additionalProperties": false, + "allOf": [ + { + "else": { + "required": [ + "credential_ref" + ] + }, + "if": { + "properties": { + "auth_mode": { + "const": "cloud_identity" + } + }, + "required": [ + "auth_mode" + ] + }, + "then": { + "properties": { + "provider": { + "enum": [ + "s3", + "gcs" + ] + } + } + } + } ], + "description": "Object-storage exporter configuration for S3, GCS, Azure Blob, and compatible S3 backends.", "properties": { "auth_mode": { - "description": "How the data plane authenticates to the bucket.", - "default": "credential_ref", "allOf": [ { "$ref": "#/definitions/ObjectStoreAuthMode" } - ] + ], + "default": "credential_ref", + "description": "How the data plane authenticates to the bucket." }, "bucket": { "description": "Bucket for S3 or GCS, or container for Azure Blob, that receives exported files.", + "minLength": 1, "type": "string" }, "compression": { - "description": "Compression applied to each NDJSON file before upload.", - "default": "gzip", "allOf": [ { "$ref": "#/definitions/ObjectStoreCompression" } - ] + ], + "default": "gzip", + "description": "Compression applied to each NDJSON file before upload." }, "credential_ref": { "description": "Credential reference resolved by the data plane at delivery time. Required when `auth_mode` is `credential_ref`.", "type": "string" }, + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" + }, "endpoint": { "description": "Backend host override for S3-compatible stores such as MinIO, Aliyun OSS, or Cloudflare R2. When omitted, the provider's native endpoint is used.", - "type": [ - "string", - "null" - ] + "pattern": "^https://.+|^http://(minio|azurite|fake-gcs-server|fake-gcs|127\\.0\\.0\\.1|localhost)(:[0-9]+)?(/.*)?$", + "type": "string" }, "kind": { - "type": "string", "enum": [ "object_store" - ] + ], + "type": "string" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, + "type": "string" }, "prefix": { "description": "Key prefix the partition path is appended to, e.g. `ai-gateway`. The full key is `/org=…/env=…/table=…/dt=…/hh=…/`.", + "minLength": 1, "type": "string" }, "provider": { - "description": "Which object-storage backend the bucket lives in.", "allOf": [ { "$ref": "#/definitions/ObjectStoreProvider" } - ] + ], + "description": "Which object-storage backend the bucket lives in." }, "region": { "description": "AWS region for S3 SigV4 signature scope. Set this for S3 buckets outside `us-east-1`. Ignored for GCS and Azure Blob.", - "type": [ - "string", - "null" - ] + "minLength": 1, + "type": "string" } - } - }, - { - "description": "Datadog native Logs HTTP intake exporter configuration.", - "type": "object", + }, "required": [ - "credential_ref", + "bucket", "kind", - "service", - "site" + "prefix", + "provider" ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Datadog native Logs HTTP intake exporter configuration.", "properties": { "content_max_bytes": { - "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`. Keep this under Datadog intake limits to avoid delivery errors.", "default": 131072, - "type": "integer", + "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`. Keep this under Datadog intake limits to avoid delivery errors.", "format": "uint32", "maximum": 1048576.0, - "minimum": 1.0 + "minimum": 1.0, + "type": "integer" }, "content_mode": { - "description": "Controls whether logs include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`.", - "default": "metadata_only", "allOf": [ { "$ref": "#/definitions/SlsContentMode" } - ] + ], + "default": "metadata_only", + "description": "Controls whether logs include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`." }, "credential_ref": { "description": "Credential reference resolved by the data plane at delivery time. The plaintext Datadog API key is not stored in this resource.", + "minLength": 1, "type": "string" }, "ddsource": { - "description": "Datadog `ddsource` reserved attribute. Identifies the integration or source.", "default": "aisix-ai-gateway", + "description": "Datadog `ddsource` reserved attribute. Identifies the integration or source.", + "minLength": 1, "type": "string" }, + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" + }, "kind": { - "type": "string", "enum": [ "datadog" - ] + ], + "type": "string" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, + "type": "string" }, "service": { "description": "Datadog `service` reserved attribute. Every log from this exporter is tagged with this service name in Datadog Log Explorer.", + "minLength": 1, "type": "string" }, "site": { "description": "Datadog site, such as `datadoghq.com`, `us3.datadoghq.com`, or `datadoghq.eu`.", + "pattern": "^(datadoghq\\.com|us3\\.datadoghq\\.com|us5\\.datadoghq\\.com|datadoghq\\.eu|ap1\\.datadoghq\\.com|ap2\\.datadoghq\\.com|ddog-gov\\.com)$|^(mock-datadog|127\\.0\\.0\\.1|localhost)(:[0-9]+)?$", "type": "string" }, "tags": { - "description": "Operator-defined tags rendered into Datadog's comma-joined `ddtags` reserved attribute. For example, `[\"team:platform\", \"tier:prod\"]` becomes `team:platform,tier:prod`. Leave empty when no tags should be sent.", "default": [], - "type": "array", + "description": "Operator-defined tags rendered into Datadog's comma-joined `ddtags` reserved attribute. For example, `[\"team:platform\", \"tier:prod\"]` becomes `team:platform,tier:prod`. Leave empty when no tags should be sent.", "items": { "type": "string" - } + }, + "type": "array" } - } + }, + "required": [ + "credential_ref", + "kind", + "service", + "site" + ], + "type": "object" } ], - "required": [ - "name" - ], "properties": { "enabled": { - "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", "type": "boolean" }, "name": { "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, "type": "string" } }, - "definitions": { - "ObjectStoreAuthMode": { - "description": "How the data plane obtains credentials for the object-storage bucket.", - "oneOf": [ - { - "description": "Resolve `credential_ref` to static keys from data plane environment variables named `OBJSTORE_CRED__`.", - "type": "string", - "enum": [ - "credential_ref" - ] - }, - { - "description": "Use the data plane host's attached cloud identity. Supported for S3 and GCS only.", - "type": "string", - "enum": [ - "cloud_identity" - ] - } - ] - }, - "ObjectStoreCompression": { - "description": "File compression for object-storage uploads.", - "oneOf": [ - { - "description": "gzip compression as defined by RFC 1952. Accepted by Snowpipe and Auto Loader.", - "type": "string", - "enum": [ - "gzip" - ] - }, - { - "description": "No compression. Emits raw NDJSON.", - "type": "string", - "enum": [ - "none" - ] - } - ] - }, - "ObjectStoreProvider": { - "description": "Object-storage backend selector. The sink builds one backend client per variant. Batching, key layout, and retry behavior are shared.", - "type": "string", - "enum": [ - "s3", - "gcs", - "azure_blob" - ] - }, - "SlsContentMode": { - "description": "Content-capture mode for an observability exporter.", - "oneOf": [ - { - "description": "Operational metadata only. Prompt and response content are omitted.", - "type": "string", - "enum": [ - "metadata_only" - ] - }, - { - "description": "Metadata plus the captured request prompt and assembled response.", - "type": "string", - "enum": [ - "full" - ] - } - ] - } - } + "required": [ + "name" + ], + "title": "ObservabilityExporter", + "type": "object" } From 497c1e794d786f808784acc053a8a48b23cc2a05 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 23 Jun 2026 09:22:31 +0800 Subject: [PATCH 7/8] refactor(core): derive guardrail + guardrail_attachment validators from structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final resources in the single-source migration. Both runtime validators now build from their structs via *_root_schema() producers used by dump-schema too (published == enforced). guardrail: schemars renders the internally-tagged GuardrailKind as a native oneOf; the top level + branches stay open (unknown inner fields are caught by serde at deserialize, matching the old schema). The producer (1) re-closes the tagged sub-enums KeywordPattern/ BedrockAWSCredentials/BedrockLatencyMode with additionalProperties:false (schemars drops their deny_unknown_fields), (2) injects the closed enums for the stringly-typed moderation fields (output_type/text_source/ stream_processing_mode/on_buffer_exceeded/risk_level_threshold/categories) — kept as String since their values flow through aisix-guardrails as strings; converting to Rust enums would churn that crate — and (3) republishes created_at's date-time format. Per-field length/range attrs (name, bedrock id/version/region, endpoints, api_key, severity_threshold 0..7, window_size, max_buffer_bytes, timeout_ms u32 cap) ported to the structs. guardrail_attachment: struct-derived (nullable scope_id), now also published (it had no schemas/resources file before). Characterization corpora added for both. --- crates/aisix-core/src/bin/dump-schema.rs | 19 +- crates/aisix-core/src/models/guardrail.rs | 28 +- crates/aisix-core/src/models/schema.rs | 305 ++++----- .../tests/resource_schema_characterization.rs | 234 ++++++- schemas/resources/guardrail.schema.json | 592 ++++++++++-------- .../guardrail_attachment.schema.json | 55 ++ 6 files changed, 773 insertions(+), 460 deletions(-) create mode 100644 schemas/resources/guardrail_attachment.schema.json diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index d6d67988..534f42d6 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -31,16 +31,17 @@ use std::path::{Path, PathBuf}; use schemars::JsonSchema; use aisix_core::models::schema; -use aisix_core::models::{EnsembleConfig, Guardrail, RateLimit, Routing}; +use aisix_core::models::{EnsembleConfig, RateLimit, Routing}; fn main() { let out_dir = workspace_root().join("schemas").join("resources"); fs::create_dir_all(&out_dir).expect("create schemas/resources dir"); - // Resources whose runtime validator is derived from the struct go through - // the SAME `*_root_schema()` producer the validator uses, so the published - // schema == the enforced schema. Resources still on a hand-written - // validator use the bare `schema_for!` dump below. + // Every resource with a runtime validator goes through the SAME + // `*_root_schema()` producer the validator uses, so the published schema == + // the enforced schema by construction. `ensemble`/`rate_limit`/`routing` + // have no standalone validator (they are nested struct types) so they dump + // straight from the struct via `schema_for!`. dump_value(&out_dir, "api_key", schema::apikey_root_schema()); dump_value(&out_dir, "cache_policy", schema::cache_policy_root_schema()); dump_value(&out_dir, "model", schema::model_root_schema()); @@ -49,16 +50,20 @@ fn main() { "rate_limit_policy", schema::rate_limit_policy_root_schema(), ); - dump_value(&out_dir, "provider_key", schema::provider_key_root_schema()); dump_value( &out_dir, "observability_exporter", schema::observability_exporter_root_schema(), ); + dump_value(&out_dir, "guardrail", schema::guardrail_root_schema()); + dump_value( + &out_dir, + "guardrail_attachment", + schema::guardrail_attachment_root_schema(), + ); dump::(&out_dir, "ensemble"); - dump::(&out_dir, "guardrail"); dump::(&out_dir, "rate_limit"); dump::(&out_dir, "routing"); } diff --git a/crates/aisix-core/src/models/guardrail.rs b/crates/aisix-core/src/models/guardrail.rs index 0ec4d39b..dadac78f 100644 --- a/crates/aisix-core/src/models/guardrail.rs +++ b/crates/aisix-core/src/models/guardrail.rs @@ -63,9 +63,9 @@ pub enum GuardrailHookPoint { #[serde(tag = "kind", content = "value", rename_all = "lowercase")] pub enum KeywordPattern { /// Literal string to match. - Literal(String), + Literal(#[schemars(length(min = 1))] String), /// Regular expression pattern to match. - Regex(String), + Regex(#[schemars(length(min = 1))] String), } /// Config block for `kind: "keyword"`. @@ -83,10 +83,12 @@ pub struct KeywordConfig { pub enum BedrockAWSCredentials { Static { /// AWS access key ID for static Bedrock guardrail credentials. + #[schemars(length(min = 1))] access_key_id: String, /// Decrypted before projection. Plaintext is held in memory only /// and is not logged. The data plane passes it to the /// AWS SDK's static credentials provider. + #[schemars(length(min = 1))] secret_access_key: String, }, } @@ -98,6 +100,7 @@ pub enum BedrockLatencyMode { Serial, Timed { /// Maximum time in milliseconds to wait for the Bedrock guardrail response. + #[schemars(range(min = 100, max = 5000))] timeout_ms: u32, }, } @@ -115,12 +118,15 @@ pub struct AzureContentSafetyConfig { /// Azure Cognitive Services resource endpoint, e.g. /// `https://my-resource.cognitiveservices.azure.com`. /// The data plane appends `/contentsafety/text:shieldPrompt?api-version=2024-09-01`. + #[schemars(length(min = 1))] pub endpoint: String, /// Azure subscription key sent with the `Ocp-Apim-Subscription-Key` header. Decrypted before /// projection. Plaintext is held in memory only and is not logged. + #[schemars(length(min = 1))] pub api_key: String, /// HTTP call timeout in milliseconds. A value of `0` triggers the timeout immediately. #[serde(default = "default_acs_timeout_ms")] + #[schemars(range(max = 4_294_967_295u32))] pub timeout_ms: u32, } @@ -142,14 +148,17 @@ fn default_acs_timeout_ms() -> u32 { pub struct AzureContentSafetyTextModerationConfig { /// Azure Cognitive Services resource endpoint. The data plane appends /// `/contentsafety/text:analyze?api-version=2024-09-01`. + #[schemars(length(min = 1))] pub endpoint: String, /// Azure subscription key sent with the `Ocp-Apim-Subscription-Key` header. Plaintext is held in /// memory only and is not logged. + #[schemars(length(min = 1))] pub api_key: String, /// HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` /// govern the verdict when it elapses. A value of `0` triggers the timeout /// immediately. #[serde(default = "default_acs_timeout_ms")] + #[schemars(range(max = 4_294_967_295u32))] pub timeout_ms: u32, // --- moderation parameters --- @@ -161,6 +170,7 @@ pub struct AzureContentSafetyTextModerationConfig { pub categories: Vec, /// General severity threshold. A category at or above it blocks. #[serde(default = "default_acs_severity_threshold")] + #[schemars(range(max = 7))] pub severity_threshold: u8, /// Per-category threshold overrides. These take precedence over the general threshold. #[serde(default)] @@ -182,12 +192,14 @@ pub struct AzureContentSafetyTextModerationConfig { pub stream_processing_mode: String, /// Sliding-window size in characters for window mode. #[serde(default = "default_acs_window_size")] + #[schemars(range(min = 1, max = 10_000))] pub window_size: u32, /// Chars carried between windows so a span split across a boundary is still caught. #[serde(default = "default_acs_window_overlap_size")] pub window_overlap_size: u32, /// Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies. #[serde(default = "default_acs_max_buffer_bytes")] + #[schemars(range(min = 1))] pub max_buffer_bytes: u64, /// Buffer-overflow policy. Use `fail_open` to allow output when the buffer cap is hit. #[serde(default = "default_acs_on_buffer_exceeded")] @@ -257,14 +269,18 @@ fn default_acs_on_buffer_exceeded() -> String { pub struct AliyunTextModerationConfig { /// Aliyun region the guardrail lives in, e.g. `cn-shanghai`. The data plane /// builds the endpoint `https://green-cip..aliyuncs.com`. + #[schemars(length(min = 1))] pub region: String, /// Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`. #[serde(default)] + #[schemars(length(min = 1))] pub endpoint: Option, /// Aliyun AccessKey ID. + #[schemars(length(min = 1))] pub access_key_id: String, /// Aliyun AccessKey secret. Decrypted before projection. Plaintext is held /// in memory only and is not logged. Used to sign the request. + #[schemars(length(min = 1))] pub access_key_secret: String, /// Minimum risk level that triggers a block: `low`, `medium`, or `high`. A returned level at or above this blocks. #[serde(default = "default_aliyun_risk_level_threshold")] @@ -273,6 +289,7 @@ pub struct AliyunTextModerationConfig { /// govern the verdict when it elapses. A value of `0` triggers the timeout /// immediately. #[serde(default = "default_acs_timeout_ms")] + #[schemars(range(max = 4_294_967_295u32))] pub timeout_ms: u32, /// Fail-open policy for the output hook. When disabled, an Aliyun outage does not release unscanned model output. #[serde(default)] @@ -285,12 +302,14 @@ pub struct AliyunTextModerationConfig { pub stream_processing_mode: String, /// Sliding-window size in characters when window mode is used. Aliyun limits each `llm_response_moderation` call to 2,000 characters. #[serde(default = "default_aliyun_window_size")] + #[schemars(range(min = 1, max = 2_000))] pub window_size: u32, /// Chars carried between windows so a span split across a boundary is still caught. #[serde(default = "default_aliyun_window_overlap_size")] pub window_overlap_size: u32, /// Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies. #[serde(default = "default_acs_max_buffer_bytes")] + #[schemars(range(min = 1))] pub max_buffer_bytes: u64, /// Buffer-overflow policy. Use `fail_open` to allow output when the buffer cap is hit. #[serde(default = "default_acs_on_buffer_exceeded")] @@ -316,10 +335,13 @@ fn default_aliyun_window_overlap_size() -> u32 { #[serde(deny_unknown_fields)] pub struct BedrockConfig { /// Guardrail identifier issued by the AWS console. + #[schemars(length(min = 1, max = 64))] pub guardrail_id: String, /// Version label: `DRAFT`, `1`, `2`, ... + #[schemars(length(min = 1, max = 16))] pub guardrail_version: String, /// AWS region for the Bedrock endpoint, such as `us-east-1`. + #[schemars(length(min = 1))] pub region: String, /// IAM credentials for Bedrock requests. pub aws_credentials: BedrockAWSCredentials, @@ -392,6 +414,7 @@ pub struct AppliedGuardrail { #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] pub struct Guardrail { /// Operator-facing name that surfaces in metric labels and error reasons. + #[schemars(length(min = 1))] pub name: String, /// When false, the chain skips this rule entirely. Allows operators @@ -499,6 +522,7 @@ pub enum GuardrailScopeType { #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] pub struct GuardrailAttachment { /// UUID of the guardrail definition this attachment points to. + #[schemars(length(min = 1))] pub guardrail_id: String, /// What dimension of the request this attachment is scoped to. diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index 09d7158d..b23ad4a1 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -47,10 +47,10 @@ impl Schemas { .build(&provider_key_root_schema()) .expect("provider_key schema is well-formed"), guardrail: jsonschema::options() - .build(&guardrail_schema()) + .build(&guardrail_root_schema()) .expect("guardrail schema is well-formed"), guardrail_attachment: jsonschema::options() - .build(&guardrail_attachment_schema()) + .build(&guardrail_attachment_root_schema()) .expect("guardrail_attachment schema is well-formed"), cache_policy: jsonschema::options() .build(&cache_policy_root_schema()) @@ -173,176 +173,128 @@ pub fn provider_key_root_schema() -> Value { struct_root_schema::(true) } -fn guardrail_schema() -> Value { - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["name", "kind"], - // Each kind variant adds its own keys; the per-kind oneOf - // below pins them. Top-level stays open so future kinds - // (lakera, protect_ai) only edit the oneOf branch. - "additionalProperties": true, - "properties": { - "name": { "type": "string", "minLength": 1 }, - "enabled": { "type": "boolean" }, - "hook_point": { "enum": ["input", "output", "both"] }, - "fail_open": { "type": "boolean" }, - "created_at": { "type": "string", "format": "date-time" }, - "kind": { "enum": ["keyword", "bedrock", "azure_content_safety", "azure_content_safety_text_moderation", "aliyun_text_moderation"] } - }, - "oneOf": [ +/// Canonical JSON Schema for the `guardrail` resource, derived from the +/// [`Guardrail`](crate::models::Guardrail) struct. `schemars` renders the +/// internally-tagged `GuardrailKind` as a native top-level `oneOf`; the +/// top-level object and its branches are intentionally open (matching the +/// hand-written schema — unknown inner fields are caught by serde at +/// deserialize). Three things need fixing up: +/// +/// 1. The tagged sub-enums (`KeywordPattern`/`BedrockAWSCredentials`/ +/// `BedrockLatencyMode`) lose `deny_unknown_fields` in their `oneOf` +/// branches, so each is re-closed with `additionalProperties: false`. +/// 2. The stringly-typed moderation fields carry closed sets the hand-written +/// schema enforced via `enum`. They stay `String` on the struct (their +/// values flow through `aisix-guardrails` as strings; converting them to +/// Rust enums would churn that crate's processing), so the closed set is +/// injected here into the relevant kind branch. +/// 3. `created_at` republishes its `date-time` format (annotation-only). +pub fn guardrail_root_schema() -> Value { + let mut schema = struct_root_schema::(false); + let obj = schema + .as_object_mut() + .expect("guardrail root schema is a JSON object"); + + if let Some(Value::Object(defs)) = obj.get_mut("definitions") { + for name in [ + "KeywordPattern", + "BedrockAWSCredentials", + "BedrockLatencyMode", + ] { + if let Some(Value::Array(branches)) = + defs.get_mut(name).and_then(|d| d.get_mut("oneOf")) { - "type": "object", - "required": ["kind", "patterns"], - "properties": { - "kind": { "const": "keyword" }, - "patterns": { - "type": "array", - "items": { "$ref": "#/$defs/keyword_pattern" } + for branch in branches.iter_mut() { + if let Some(b) = branch.as_object_mut() { + b.insert("additionalProperties".to_string(), json!(false)); } } - }, - { - "type": "object", - "required": [ - "kind", "guardrail_id", "guardrail_version", - "region", "aws_credentials", "latency_mode" - ], - "properties": { - "kind": { "const": "bedrock" }, - "guardrail_id": { "type": "string", "minLength": 1, "maxLength": 64 }, - "guardrail_version": { "type": "string", "minLength": 1, "maxLength": 16 }, - "region": { "type": "string", "minLength": 1 }, - "aws_credentials": { "$ref": "#/$defs/bedrock_aws_credentials" }, - "latency_mode": { "$ref": "#/$defs/bedrock_latency_mode" } - } - }, - { - // kind=azure_content_safety — Azure AI Content Safety - // Prompt Shield. Mirrors AzureContentSafetyConfig in - // guardrail.rs: endpoint + api_key required, timeout_ms - // optional (u32, defaults to 5000 on the struct). - "type": "object", - "required": ["kind", "endpoint", "api_key"], - "properties": { - "kind": { "const": "azure_content_safety" }, - "endpoint": { "type": "string", "minLength": 1 }, - "api_key": { "type": "string", "minLength": 1 }, - "timeout_ms": { "type": "integer", "minimum": 0, "maximum": 4_294_967_295u64 } - } - }, - { - // kind=azure_content_safety_text_moderation — text:analyze - // category-severity + blocklist moderation. P2 (#379). - // Connection block matches azure_content_safety; the - // moderation + streaming params are optional (cp-api applies - // defaults + strict validation on write). - "type": "object", - "required": ["kind", "endpoint", "api_key"], - "properties": { - "kind": { "const": "azure_content_safety_text_moderation" }, - "endpoint": { "type": "string", "minLength": 1 }, - "api_key": { "type": "string", "minLength": 1 }, - "timeout_ms": { "type": "integer", "minimum": 0, "maximum": 4_294_967_295u64 }, - "output_type": { "enum": ["FourSeverityLevels", "EightSeverityLevels"] }, - "categories": { - "type": "array", - "items": { "enum": ["Hate", "Sexual", "SelfHarm", "Violence"] } - }, - "severity_threshold": { "type": "integer", "minimum": 0, "maximum": 7 }, - "severity_threshold_by_category": { "type": "object" }, - "blocklist_names": { "type": "array", "items": { "type": "string" } }, - "halt_on_blocklist_hit": { "type": "boolean" }, - "text_source": { "enum": ["concatenate_user_content", "concatenate_all_content"] }, - "stream_processing_mode": { "enum": ["window", "buffer_full"] }, - "window_size": { "type": "integer", "minimum": 1, "maximum": 10_000 }, - "window_overlap_size": { "type": "integer", "minimum": 0 }, - "max_buffer_bytes": { "type": "integer", "minimum": 1 }, - "on_buffer_exceeded": { "enum": ["fail_closed", "fail_open"] }, - "output_fail_open": { "type": "boolean" } - } - }, - { - // kind=aliyun_text_moderation — Aliyun content-safety - // guardrail (TextModerationPlus). Mirrors - // AliyunTextModerationConfig in guardrail.rs: region + - // access keys required, endpoint override + threshold + - // streaming params optional (cp-api applies defaults + - // strict validation on write). #603. - "type": "object", - "required": ["kind", "region", "access_key_id", "access_key_secret"], - "properties": { - "kind": { "const": "aliyun_text_moderation" }, - "region": { "type": "string", "minLength": 1 }, - "endpoint": { "type": "string", "minLength": 1 }, - "access_key_id": { "type": "string", "minLength": 1 }, - "access_key_secret": { "type": "string", "minLength": 1 }, - "risk_level_threshold": { "enum": ["low", "medium", "high"] }, - "timeout_ms": { "type": "integer", "minimum": 0, "maximum": 4_294_967_295u64 }, - "output_fail_open": { "type": "boolean" }, - "stream_processing_mode": { "enum": ["window", "buffer_full"] }, - "window_size": { "type": "integer", "minimum": 1, "maximum": 2_000 }, - "window_overlap_size": { "type": "integer", "minimum": 0 }, - "max_buffer_bytes": { "type": "integer", "minimum": 1 }, - "on_buffer_exceeded": { "enum": ["fail_closed", "fail_open"] } - } } - ], - "$defs": { - "keyword_pattern": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "value"], - "properties": { - "kind": { "enum": ["literal", "regex"] }, - "value": { "type": "string", "minLength": 1 } + } + } + + if let Some(Value::Array(branches)) = obj.get_mut("oneOf") { + for branch in branches.iter_mut() { + let Some(b) = branch.as_object_mut() else { + continue; + }; + match branch_kind(b) { + Some("azure_content_safety_text_moderation") => { + set_property_enum( + b, + "output_type", + json!(["FourSeverityLevels", "EightSeverityLevels"]), + ); + set_property_enum( + b, + "text_source", + json!(["concatenate_user_content", "concatenate_all_content"]), + ); + set_property_enum( + b, + "stream_processing_mode", + json!(["window", "buffer_full"]), + ); + set_property_enum(b, "on_buffer_exceeded", json!(["fail_closed", "fail_open"])); + set_property_items_enum( + b, + "categories", + json!(["Hate", "Sexual", "SelfHarm", "Violence"]), + ); } - }, - "bedrock_aws_credentials": { - "type": "object", - // v1 ships kind=static (plaintext access keys on the - // kine wire — cp-api decrypts the envelope-encrypted - // secret at projection time, see PRD-09c §6.3). - // Phase 4 adds kind=role_arn (sts:AssumeRole) under - // the same `kind` discriminator. - "required": ["kind", "access_key_id", "secret_access_key"], - "properties": { - "kind": { "const": "static" }, - "access_key_id": { "type": "string", "minLength": 1 }, - "secret_access_key": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false - }, - "bedrock_latency_mode": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["kind"], - "properties": { "kind": { "const": "serial" } } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["kind", "timeout_ms"], - "properties": { - "kind": { "const": "timed" }, - "timeout_ms": { "type": "integer", "minimum": 100, "maximum": 5000 } - } - } - ] + Some("aliyun_text_moderation") => { + set_property_enum(b, "risk_level_threshold", json!(["low", "medium", "high"])); + set_property_enum( + b, + "stream_processing_mode", + json!(["window", "buffer_full"]), + ); + set_property_enum(b, "on_buffer_exceeded", json!(["fail_closed", "fail_open"])); + } + _ => {} } } - }) + } + + if let Some(created_at) = obj + .get_mut("properties") + .and_then(|p| p.get_mut("created_at")) + .and_then(Value::as_object_mut) + { + created_at.insert("format".to_string(), json!("date-time")); + } + + schema +} + +/// Set a closed `enum` on a oneOf branch's property (for stringly-typed fields +/// whose closed set lives only in the schema, not the Rust type). +fn set_property_enum(branch: &mut serde_json::Map, field: &str, values: Value) { + if let Some(prop) = branch + .get_mut("properties") + .and_then(|p| p.get_mut(field)) + .and_then(Value::as_object_mut) + { + prop.insert("enum".to_string(), values); + } +} + +/// Like [`set_property_enum`] but for the `items` of an array property. +fn set_property_items_enum( + branch: &mut serde_json::Map, + field: &str, + values: Value, +) { + if let Some(items) = branch + .get_mut("properties") + .and_then(|p| p.get_mut(field)) + .and_then(|f| f.get_mut("items")) + .and_then(Value::as_object_mut) + { + items.insert("enum".to_string(), values); + } } -// Mirrors cp-api's cache_policies validation rules (validateCachePolicyShape -// in internal/cpapi/resources/cache_policies.go). The DP is the second -// line of defence — cp-api rejects malformed payloads on write, but kine -// can still surface stale or hand-edited rows on watch, so we re-validate -// at parse time. `additionalProperties: true` keeps the schema -// forward-compatible: cp-api can ship new optional fields ahead of a DP -// rollout without locking the gateway out. /// Canonical JSON Schema for the `cache_policy` resource, derived from the /// [`CachePolicy`](crate::models::CachePolicy) struct. The struct intentionally /// has no `deny_unknown_fields`, so the schema omits `additionalProperties` @@ -449,26 +401,13 @@ pub fn rate_limit_policy_root_schema() -> Value { schema } -fn guardrail_attachment_schema() -> Value { - // `additionalProperties` is NOT set to false: cp-api includes `env_id` - // in the kine payload (for its own idempotency logic) which the DP - // doesn't need. Allowing extra keys here keeps the schema forward- - // compatible if cp-api adds more metadata fields later. - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["guardrail_id", "scope_type", "priority"], - "properties": { - "guardrail_id": { "type": "string", "minLength": 1 }, - "scope_type": { - "type": "string", - "enum": ["env", "model", "api_key", "team"] - }, - "scope_id": { "type": ["string", "null"] }, - "priority": { "type": "integer" }, - "enabled": { "type": "boolean" } - } - }) +/// Canonical JSON Schema for the `guardrail_attachment` resource, derived from +/// the [`GuardrailAttachment`](crate::models::GuardrailAttachment) struct. Uses +/// the nullable `Option` representation (`scope_id` is `null` for `env`-scoped +/// attachments) and stays open (no `deny_unknown_fields`): cp-api includes an +/// `env_id` the DP ignores. +pub fn guardrail_attachment_root_schema() -> Value { + struct_root_schema::(true) } #[cfg(test)] diff --git a/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs index 294a7276..8b866424 100644 --- a/crates/aisix-core/tests/resource_schema_characterization.rs +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -8,8 +8,8 @@ //! case is obvious. New resources append their own table as they migrate. use aisix_core::models::schema::{ - validate_apikey, validate_cache_policy, validate_observability_exporter, validate_provider_key, - validate_rate_limit_policy, + validate_apikey, validate_cache_policy, validate_guardrail, validate_guardrail_attachment, + validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, }; use serde_json::{json, Value}; @@ -538,3 +538,233 @@ fn observability_exporter_corpus() { ], ); } + +#[test] +fn guardrail_corpus() { + check( + validate_guardrail, + &[ + // keyword + ( + "keyword empty patterns", + true, + json!({"name": "k", "kind": "keyword", "patterns": []}), + ), + ( + "keyword literal + regex", + true, + json!({"name": "k", "kind": "keyword", "patterns": [{"kind": "literal", "value": "AKIA"}, {"kind": "regex", "value": "\\d{3}"}]}), + ), + ( + "keyword missing patterns", + false, + json!({"name": "k", "kind": "keyword"}), + ), + ( + "empty name", + false, + json!({"name": "", "kind": "keyword", "patterns": []}), + ), + ( + "keyword pattern empty value", + false, + json!({"name": "k", "kind": "keyword", "patterns": [{"kind": "literal", "value": ""}]}), + ), + ( + "keyword pattern bad kind", + false, + json!({"name": "k", "kind": "keyword", "patterns": [{"kind": "glob", "value": "x"}]}), + ), + ( + "keyword pattern extra field", + false, + json!({"name": "k", "kind": "keyword", "patterns": [{"kind": "literal", "value": "x", "extra": 1}]}), + ), + // top-level / kind discriminator + ( + "missing name", + false, + json!({"kind": "keyword", "patterns": []}), + ), + ( + "unknown kind", + false, + json!({"name": "k", "kind": "lakera", "patterns": []}), + ), + ("missing kind", false, json!({"name": "k"})), + ( + "hook_point + p0c fields", + true, + json!({"name": "k", "kind": "keyword", "patterns": [], "hook_point": "input", "enforcement_mode": "monitor", "created_at": "2026-01-01T00:00:00Z"}), + ), + ( + "bad hook_point", + false, + json!({"name": "k", "kind": "keyword", "patterns": [], "hook_point": "sideways"}), + ), + // bedrock + ( + "bedrock serial", + true, + json!({"name": "b", "kind": "bedrock", "guardrail_id": "gid", "guardrail_version": "DRAFT", "region": "us-east-1", "aws_credentials": {"kind": "static", "access_key_id": "AKIA", "secret_access_key": "s"}, "latency_mode": {"kind": "serial"}}), + ), + ( + "bedrock timed", + true, + json!({"name": "b", "kind": "bedrock", "guardrail_id": "gid", "guardrail_version": "1", "region": "us-east-1", "aws_credentials": {"kind": "static", "access_key_id": "AKIA", "secret_access_key": "s"}, "latency_mode": {"kind": "timed", "timeout_ms": 500}}), + ), + ( + "bedrock missing guardrail_id", + false, + json!({"name": "b", "kind": "bedrock", "guardrail_version": "1", "region": "us-east-1", "aws_credentials": {"kind": "static", "access_key_id": "a", "secret_access_key": "s"}, "latency_mode": {"kind": "serial"}}), + ), + ( + "bedrock timed timeout < 100", + false, + json!({"name": "b", "kind": "bedrock", "guardrail_id": "g", "guardrail_version": "1", "region": "us-east-1", "aws_credentials": {"kind": "static", "access_key_id": "a", "secret_access_key": "s"}, "latency_mode": {"kind": "timed", "timeout_ms": 50}}), + ), + ( + "bedrock latency_mode extra field", + false, + json!({"name": "b", "kind": "bedrock", "guardrail_id": "g", "guardrail_version": "1", "region": "us-east-1", "aws_credentials": {"kind": "static", "access_key_id": "a", "secret_access_key": "s"}, "latency_mode": {"kind": "timed", "timeout_ms": 500, "extra": 1}}), + ), + ( + "bedrock aws_credentials extra field", + false, + json!({"name": "b", "kind": "bedrock", "guardrail_id": "g", "guardrail_version": "1", "region": "us-east-1", "aws_credentials": {"kind": "static", "access_key_id": "a", "secret_access_key": "s", "junk": 1}, "latency_mode": {"kind": "serial"}}), + ), + // azure_content_safety + ( + "azure cs minimal", + true, + json!({"name": "a", "kind": "azure_content_safety", "endpoint": "https://x.cognitiveservices.azure.com", "api_key": "k"}), + ), + ( + "azure cs missing endpoint", + false, + json!({"name": "a", "kind": "azure_content_safety", "api_key": "k"}), + ), + ( + "azure cs timeout overflow (u32)", + false, + json!({"name": "a", "kind": "azure_content_safety", "endpoint": "https://x", "api_key": "k", "timeout_ms": 4_294_967_296u64}), + ), + // azure_content_safety_text_moderation + ( + "azure tm minimal", + true, + json!({"name": "m", "kind": "azure_content_safety_text_moderation", "endpoint": "https://x", "api_key": "k"}), + ), + ( + "azure tm full", + true, + json!({"name": "m", "kind": "azure_content_safety_text_moderation", "endpoint": "https://x", "api_key": "k", "output_type": "EightSeverityLevels", "categories": ["Hate", "Violence"], "severity_threshold": 0, "stream_processing_mode": "buffer_full", "window_size": 5000, "on_buffer_exceeded": "fail_open"}), + ), + ( + "azure tm severity > 7", + false, + json!({"name": "m", "kind": "azure_content_safety_text_moderation", "endpoint": "https://x", "api_key": "k", "severity_threshold": 8}), + ), + ( + "azure tm window_size > 10000", + false, + json!({"name": "m", "kind": "azure_content_safety_text_moderation", "endpoint": "https://x", "api_key": "k", "window_size": 20000}), + ), + ( + "azure tm output_type enum (injected)", + false, + json!({"name": "m", "kind": "azure_content_safety_text_moderation", "endpoint": "https://x", "api_key": "k", "output_type": "Twelve"}), + ), + ( + "azure tm categories item enum (injected)", + false, + json!({"name": "m", "kind": "azure_content_safety_text_moderation", "endpoint": "https://x", "api_key": "k", "categories": ["Nope"]}), + ), + // aliyun_text_moderation + ( + "aliyun minimal", + true, + json!({"name": "al", "kind": "aliyun_text_moderation", "region": "cn-shanghai", "access_key_id": "LTAI", "access_key_secret": "s"}), + ), + ( + "aliyun missing region", + false, + json!({"name": "al", "kind": "aliyun_text_moderation", "access_key_id": "id", "access_key_secret": "s"}), + ), + ( + "aliyun risk_level enum (injected)", + false, + json!({"name": "al", "kind": "aliyun_text_moderation", "region": "cn", "access_key_id": "id", "access_key_secret": "s", "risk_level_threshold": "critical"}), + ), + ( + "aliyun window_size > 2000", + false, + json!({"name": "al", "kind": "aliyun_text_moderation", "region": "cn", "access_key_id": "id", "access_key_secret": "s", "window_size": 3000}), + ), + ], + ); +} + +#[test] +fn guardrail_attachment_corpus() { + check( + validate_guardrail_attachment, + &[ + ( + "env scope null scope_id", + true, + json!({"guardrail_id": "gid", "scope_type": "env", "scope_id": null, "priority": 0}), + ), + ( + "model scope", + true, + json!({"guardrail_id": "gid", "scope_type": "model", "scope_id": "mid", "priority": 10, "enabled": false}), + ), + ( + "team scope negative priority", + true, + json!({"guardrail_id": "gid", "scope_type": "team", "scope_id": "tid", "priority": -5}), + ), + ( + "api_key scope_id omitted", + true, + json!({"guardrail_id": "gid", "scope_type": "api_key", "priority": 1}), + ), + ( + "extra field tolerated (open)", + true, + json!({"guardrail_id": "gid", "scope_type": "env", "priority": 1, "env_id": "e1"}), + ), + ( + "missing guardrail_id", + false, + json!({"scope_type": "env", "priority": 1}), + ), + ( + "empty guardrail_id", + false, + json!({"guardrail_id": "", "scope_type": "env", "priority": 1}), + ), + ( + "bad scope_type enum", + false, + json!({"guardrail_id": "gid", "scope_type": "org", "priority": 1}), + ), + ( + "missing scope_type", + false, + json!({"guardrail_id": "gid", "priority": 1}), + ), + ( + "missing priority", + false, + json!({"guardrail_id": "gid", "scope_type": "env"}), + ), + ( + "priority not integer", + false, + json!({"guardrail_id": "gid", "scope_type": "env", "priority": "high"}), + ), + ], + ); +} diff --git a/schemas/resources/guardrail.schema.json b/schemas/resources/guardrail.schema.json index 6b156c42..129ee8ee 100644 --- a/schemas/resources/guardrail.schema.json +++ b/schemas/resources/guardrail.schema.json @@ -1,508 +1,568 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Guardrail", + "definitions": { + "BedrockAWSCredentials": { + "description": "AWS credentials for a Bedrock guardrail.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "access_key_id": { + "description": "AWS access key ID for static Bedrock guardrail credentials.", + "minLength": 1, + "type": "string" + }, + "kind": { + "enum": [ + "static" + ], + "type": "string" + }, + "secret_access_key": { + "description": "Decrypted before projection. Plaintext is held in memory only and is not logged. The data plane passes it to the AWS SDK's static credentials provider.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "access_key_id", + "kind", + "secret_access_key" + ], + "type": "object" + } + ] + }, + "BedrockLatencyMode": { + "description": "Per-guardrail latency policy for `kind: \"bedrock\"`. `serial` waits for the guardrail response. `timed` aborts at `timeout_ms` and applies `fail_open`.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "serial" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "timed" + ], + "type": "string" + }, + "timeout_ms": { + "description": "Maximum time in milliseconds to wait for the Bedrock guardrail response.", + "format": "uint32", + "maximum": 5000.0, + "minimum": 100.0, + "type": "integer" + } + }, + "required": [ + "kind", + "timeout_ms" + ], + "type": "object" + } + ] + }, + "GuardrailHookPoint": { + "description": "What part of the request lifecycle a guardrail inspects.", + "oneOf": [ + { + "description": "Run on the request payload before the upstream call.", + "enum": [ + "input" + ], + "type": "string" + }, + { + "description": "Run on the upstream response before the cache write + render.", + "enum": [ + "output" + ], + "type": "string" + }, + { + "description": "Run on both input and output.", + "enum": [ + "both" + ], + "type": "string" + } + ] + }, + "KeywordPattern": { + "description": "Literal or regular-expression pattern used by a keyword guardrail.", + "oneOf": [ + { + "additionalProperties": false, + "description": "Literal string to match.", + "properties": { + "kind": { + "enum": [ + "literal" + ], + "type": "string" + }, + "value": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Regular expression pattern to match.", + "properties": { + "kind": { + "enum": [ + "regex" + ], + "type": "string" + }, + "value": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + } + }, "description": "Content policy evaluated before or after upstream calls.", - "type": "object", "oneOf": [ { "description": "In-process literal/regex blocklist. Always available.", - "type": "object", - "required": [ - "kind", - "patterns" - ], "properties": { "kind": { - "type": "string", "enum": [ "keyword" - ] + ], + "type": "string" }, "patterns": { "description": "Blocklist patterns. An empty list is valid and allows every request, equivalent to `enabled: false`.", - "type": "array", "items": { "$ref": "#/definitions/KeywordPattern" - } + }, + "type": "array" } - } - }, - { - "description": "AWS Bedrock managed guardrail using `ApplyGuardrail` on input, output, or both.", - "type": "object", + }, "required": [ - "aws_credentials", - "guardrail_id", - "guardrail_version", "kind", - "latency_mode", - "region" + "patterns" ], + "type": "object" + }, + { + "description": "AWS Bedrock managed guardrail using `ApplyGuardrail` on input, output, or both.", "properties": { "aws_credentials": { - "description": "IAM credentials for Bedrock requests.", "allOf": [ { "$ref": "#/definitions/BedrockAWSCredentials" } - ] + ], + "description": "IAM credentials for Bedrock requests." }, "guardrail_id": { "description": "Guardrail identifier issued by the AWS console.", + "maxLength": 64, + "minLength": 1, "type": "string" }, "guardrail_version": { "description": "Version label: `DRAFT`, `1`, `2`, ...", + "maxLength": 16, + "minLength": 1, "type": "string" }, "kind": { - "type": "string", "enum": [ "bedrock" - ] + ], + "type": "string" }, "latency_mode": { - "description": "Bedrock guardrail latency policy. Use `timed` with `timeout_ms` to cap wait time.", "allOf": [ { "$ref": "#/definitions/BedrockLatencyMode" } - ] + ], + "description": "Bedrock guardrail latency policy. Use `timed` with `timeout_ms` to cap wait time." }, "region": { "description": "AWS region for the Bedrock endpoint, such as `us-east-1`.", + "minLength": 1, "type": "string" } - } + }, + "required": [ + "aws_credentials", + "guardrail_id", + "guardrail_version", + "kind", + "latency_mode", + "region" + ], + "type": "object" }, { "description": "Azure AI Content Safety Prompt Shield. Detects jailbreak and indirect injection attacks via the `/contentsafety/text:shieldPrompt` API.", - "type": "object", - "required": [ - "api_key", - "endpoint", - "kind" - ], "properties": { "api_key": { "description": "Azure subscription key sent with the `Ocp-Apim-Subscription-Key` header. Decrypted before projection. Plaintext is held in memory only and is not logged.", + "minLength": 1, "type": "string" }, "endpoint": { "description": "Azure Cognitive Services resource endpoint, e.g. `https://my-resource.cognitiveservices.azure.com`. The data plane appends `/contentsafety/text:shieldPrompt?api-version=2024-09-01`.", + "minLength": 1, "type": "string" }, "kind": { - "type": "string", "enum": [ "azure_content_safety" - ] + ], + "type": "string" }, "timeout_ms": { - "description": "HTTP call timeout in milliseconds. A value of `0` triggers the timeout immediately.", "default": 5000, - "type": "integer", + "description": "HTTP call timeout in milliseconds. A value of `0` triggers the timeout immediately.", "format": "uint32", - "minimum": 0.0 + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" } - } - }, - { - "description": "Azure AI Content Safety Text Moderation. Category-severity and blocklist moderation via the `/contentsafety/text:analyze` API, on input and/or output, including streaming output.", - "type": "object", + }, "required": [ "api_key", "endpoint", "kind" ], + "type": "object" + }, + { + "description": "Azure AI Content Safety Text Moderation. Category-severity and blocklist moderation via the `/contentsafety/text:analyze` API, on input and/or output, including streaming output.", "properties": { "api_key": { "description": "Azure subscription key sent with the `Ocp-Apim-Subscription-Key` header. Plaintext is held in memory only and is not logged.", + "minLength": 1, "type": "string" }, "blocklist_names": { - "description": "Azure CS blocklist names to match against.", "default": [], - "type": "array", + "description": "Azure CS blocklist names to match against.", "items": { "type": "string" - } + }, + "type": "array" }, "categories": { - "description": "Categories to analyze.", "default": [ "Hate", "Sexual", "SelfHarm", "Violence" ], - "type": "array", + "description": "Categories to analyze.", "items": { + "enum": [ + "Hate", + "Sexual", + "SelfHarm", + "Violence" + ], "type": "string" - } + }, + "type": "array" }, "endpoint": { "description": "Azure Cognitive Services resource endpoint. The data plane appends `/contentsafety/text:analyze?api-version=2024-09-01`.", + "minLength": 1, "type": "string" }, "halt_on_blocklist_hit": { - "description": "Forwarded to Azure's `haltOnBlocklistHit`.", "default": false, + "description": "Forwarded to Azure's `haltOnBlocklistHit`.", "type": "boolean" }, "kind": { - "type": "string", "enum": [ "azure_content_safety_text_moderation" - ] + ], + "type": "string" }, "max_buffer_bytes": { - "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", "default": 262144, - "type": "integer", + "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", "format": "uint64", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" }, "on_buffer_exceeded": { - "description": "Buffer-overflow policy. Use `fail_open` to allow output when the buffer cap is hit.", "default": "fail_closed", + "description": "Buffer-overflow policy. Use `fail_open` to allow output when the buffer cap is hit.", + "enum": [ + "fail_closed", + "fail_open" + ], "type": "string" }, "output_fail_open": { - "description": "Fail-open policy for the output hook. When disabled, an Azure outage does not release unscanned model output.", "default": false, + "description": "Fail-open policy for the output hook. When disabled, an Azure outage does not release unscanned model output.", "type": "boolean" }, "output_type": { - "description": "Severity scale. Use `FourSeverityLevels` for 0, 2, 4, and 6, or `EightSeverityLevels` for 0 through 7.", "default": "FourSeverityLevels", + "description": "Severity scale. Use `FourSeverityLevels` for 0, 2, 4, and 6, or `EightSeverityLevels` for 0 through 7.", + "enum": [ + "FourSeverityLevels", + "EightSeverityLevels" + ], "type": "string" }, "severity_threshold": { - "description": "General severity threshold. A category at or above it blocks.", "default": 2, - "type": "integer", + "description": "General severity threshold. A category at or above it blocks.", "format": "uint8", - "minimum": 0.0 + "maximum": 7.0, + "minimum": 0.0, + "type": "integer" }, "severity_threshold_by_category": { - "description": "Per-category threshold overrides. These take precedence over the general threshold.", - "default": {}, - "type": "object", "additionalProperties": { - "type": "integer", "format": "uint8", - "minimum": 0.0 - } + "minimum": 0.0, + "type": "integer" + }, + "default": {}, + "description": "Per-category threshold overrides. These take precedence over the general threshold.", + "type": "object" }, "stream_processing_mode": { - "description": "`window` for sliding-window incremental release or `buffer_full` for whole-response hold-back.", "default": "window", + "description": "`window` for sliding-window incremental release or `buffer_full` for whole-response hold-back.", + "enum": [ + "window", + "buffer_full" + ], "type": "string" }, "text_source": { - "description": "Input-hook text selection. Use `concatenate_all_content` to include all message content. Ignored on the output hook.", "default": "concatenate_user_content", + "description": "Input-hook text selection. Use `concatenate_all_content` to include all message content. Ignored on the output hook.", + "enum": [ + "concatenate_user_content", + "concatenate_all_content" + ], "type": "string" }, "timeout_ms": { - "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", "default": 5000, - "type": "integer", + "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", "format": "uint32", - "minimum": 0.0 + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" }, "window_overlap_size": { - "description": "Chars carried between windows so a span split across a boundary is still caught.", "default": 256, - "type": "integer", + "description": "Chars carried between windows so a span split across a boundary is still caught.", "format": "uint32", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "window_size": { - "description": "Sliding-window size in characters for window mode.", "default": 10000, - "type": "integer", + "description": "Sliding-window size in characters for window mode.", "format": "uint32", - "minimum": 0.0 + "maximum": 10000.0, + "minimum": 1.0, + "type": "integer" } - } + }, + "required": [ + "api_key", + "endpoint", + "kind" + ], + "type": "object" }, { "description": "Aliyun content-safety guardrail. Risk-level moderation via the `TextModerationPlus` action on `green-cip..aliyuncs.com`, on input and/or output, including streaming output.", - "type": "object", - "required": [ - "access_key_id", - "access_key_secret", - "kind", - "region" - ], "properties": { "access_key_id": { "description": "Aliyun AccessKey ID.", + "minLength": 1, "type": "string" }, "access_key_secret": { "description": "Aliyun AccessKey secret. Decrypted before projection. Plaintext is held in memory only and is not logged. Used to sign the request.", + "minLength": 1, "type": "string" }, "endpoint": { - "description": "Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`.", "default": null, - "type": [ - "string", - "null" - ] + "description": "Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`.", + "minLength": 1, + "type": "string" }, "kind": { - "type": "string", "enum": [ "aliyun_text_moderation" - ] + ], + "type": "string" }, "max_buffer_bytes": { - "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", "default": 262144, - "type": "integer", + "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", "format": "uint64", - "minimum": 0.0 + "minimum": 1.0, + "type": "integer" }, "on_buffer_exceeded": { - "description": "Buffer-overflow policy. Use `fail_open` to allow output when the buffer cap is hit.", "default": "fail_closed", + "description": "Buffer-overflow policy. Use `fail_open` to allow output when the buffer cap is hit.", + "enum": [ + "fail_closed", + "fail_open" + ], "type": "string" }, "output_fail_open": { - "description": "Fail-open policy for the output hook. When disabled, an Aliyun outage does not release unscanned model output.", "default": false, + "description": "Fail-open policy for the output hook. When disabled, an Aliyun outage does not release unscanned model output.", "type": "boolean" }, "region": { "description": "Aliyun region the guardrail lives in, e.g. `cn-shanghai`. The data plane builds the endpoint `https://green-cip..aliyuncs.com`.", + "minLength": 1, "type": "string" }, "risk_level_threshold": { - "description": "Minimum risk level that triggers a block: `low`, `medium`, or `high`. A returned level at or above this blocks.", "default": "high", + "description": "Minimum risk level that triggers a block: `low`, `medium`, or `high`. A returned level at or above this blocks.", + "enum": [ + "low", + "medium", + "high" + ], "type": "string" }, "stream_processing_mode": { - "description": "`window` for sliding-window incremental release or `buffer_full` for whole-response hold-back.", "default": "window", + "description": "`window` for sliding-window incremental release or `buffer_full` for whole-response hold-back.", + "enum": [ + "window", + "buffer_full" + ], "type": "string" }, "timeout_ms": { - "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", "default": 5000, - "type": "integer", + "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", "format": "uint32", - "minimum": 0.0 + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" }, "window_overlap_size": { - "description": "Chars carried between windows so a span split across a boundary is still caught.", "default": 128, - "type": "integer", + "description": "Chars carried between windows so a span split across a boundary is still caught.", "format": "uint32", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "window_size": { - "description": "Sliding-window size in characters when window mode is used. Aliyun limits each `llm_response_moderation` call to 2,000 characters.", "default": 2000, - "type": "integer", + "description": "Sliding-window size in characters when window mode is used. Aliyun limits each `llm_response_moderation` call to 2,000 characters.", "format": "uint32", - "minimum": 0.0 + "maximum": 2000.0, + "minimum": 1.0, + "type": "integer" } - } + }, + "required": [ + "access_key_id", + "access_key_secret", + "kind", + "region" + ], + "type": "object" } ], - "required": [ - "name" - ], "properties": { "created_at": { "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", - "type": [ - "string", - "null" - ] + "format": "date-time", + "type": "string" }, "direction": { - "description": "Attachment direction hint: `input`, `output`, or `both`. Stored for compatibility. Current hook selection still follows `hook_point`.", "default": "both", + "description": "Attachment direction hint: `input`, `output`, or `both`. Stored for compatibility. Current hook selection still follows `hook_point`.", "type": "string" }, "enabled": { - "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", "type": "boolean" }, "enforcement_mode": { - "description": "How the data plane behaves when this guardrail fires. `monitor` is stored for compatibility but not yet enforced.", "default": "block", + "description": "How the data plane behaves when this guardrail fires. `monitor` is stored for compatibility but not yet enforced.", "type": "string" }, "fail_open": { - "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", "type": "boolean" }, "hook_point": { - "description": "Where in the lifecycle this rule runs.", - "default": "both", "allOf": [ { "$ref": "#/definitions/GuardrailHookPoint" } - ] + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." }, "mandatory": { - "description": "Whether guardrail evaluation errors should be fatal. Stored for compatibility. Current enforcement still follows `fail_open`.", "default": false, + "description": "Whether guardrail evaluation errors should be fatal. Stored for compatibility. Current enforcement still follows `fail_open`.", "type": "boolean" }, "name": { "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, "type": "string" } }, - "definitions": { - "BedrockAWSCredentials": { - "description": "AWS credentials for a Bedrock guardrail.", - "oneOf": [ - { - "type": "object", - "required": [ - "access_key_id", - "kind", - "secret_access_key" - ], - "properties": { - "access_key_id": { - "description": "AWS access key ID for static Bedrock guardrail credentials.", - "type": "string" - }, - "kind": { - "type": "string", - "enum": [ - "static" - ] - }, - "secret_access_key": { - "description": "Decrypted before projection. Plaintext is held in memory only and is not logged. The data plane passes it to the AWS SDK's static credentials provider.", - "type": "string" - } - } - } - ] - }, - "BedrockLatencyMode": { - "description": "Per-guardrail latency policy for `kind: \"bedrock\"`. `serial` waits for the guardrail response. `timed` aborts at `timeout_ms` and applies `fail_open`.", - "oneOf": [ - { - "type": "object", - "required": [ - "kind" - ], - "properties": { - "kind": { - "type": "string", - "enum": [ - "serial" - ] - } - } - }, - { - "type": "object", - "required": [ - "kind", - "timeout_ms" - ], - "properties": { - "kind": { - "type": "string", - "enum": [ - "timed" - ] - }, - "timeout_ms": { - "description": "Maximum time in milliseconds to wait for the Bedrock guardrail response.", - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - } - } - ] - }, - "GuardrailHookPoint": { - "description": "What part of the request lifecycle a guardrail inspects.", - "oneOf": [ - { - "description": "Run on the request payload before the upstream call.", - "type": "string", - "enum": [ - "input" - ] - }, - { - "description": "Run on the upstream response before the cache write + render.", - "type": "string", - "enum": [ - "output" - ] - }, - { - "description": "Run on both input and output.", - "type": "string", - "enum": [ - "both" - ] - } - ] - }, - "KeywordPattern": { - "description": "Literal or regular-expression pattern used by a keyword guardrail.", - "oneOf": [ - { - "description": "Literal string to match.", - "type": "object", - "required": [ - "kind", - "value" - ], - "properties": { - "kind": { - "type": "string", - "enum": [ - "literal" - ] - }, - "value": { - "type": "string" - } - } - }, - { - "description": "Regular expression pattern to match.", - "type": "object", - "required": [ - "kind", - "value" - ], - "properties": { - "kind": { - "type": "string", - "enum": [ - "regex" - ] - }, - "value": { - "type": "string" - } - } - } - ] - } - } + "required": [ + "name" + ], + "title": "Guardrail", + "type": "object" } diff --git a/schemas/resources/guardrail_attachment.schema.json b/schemas/resources/guardrail_attachment.schema.json new file mode 100644 index 00000000..acae91fa --- /dev/null +++ b/schemas/resources/guardrail_attachment.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "GuardrailScopeType": { + "description": "Which dimension of the request a guardrail attachment is scoped to.\n\n`Env` applies to every request in the environment (the pre-P0c behaviour). The narrower scopes let operators attach a guardrail to just the models, API keys, or teams that need it.", + "enum": [ + "env", + "model", + "api_key", + "team" + ], + "type": "string" + } + }, + "description": "One attachment row — written by cp-api to `/aisix//guardrail_attachments/`.\n\nThe DP loads these alongside the guardrail definitions and builds a `GuardrailIndex` that resolves the applicable chain per request via `scope_type` + `scope_id` matching.\n\n`deny_unknown_fields` is intentionally NOT set: cp-api includes `env_id` in the payload (for its own idempotency checks) which the DP doesn't need.", + "properties": { + "enabled": { + "default": true, + "description": "When `false`, `GuardrailIndex::resolve` skips this attachment entirely (same as the row not existing).", + "type": "boolean" + }, + "guardrail_id": { + "description": "UUID of the guardrail definition this attachment points to.", + "minLength": 1, + "type": "string" + }, + "priority": { + "description": "Higher number = higher precedence. When the same guardrail appears via multiple matching scopes, the highest-priority attachment wins and duplicates are dropped.", + "format": "int32", + "type": "integer" + }, + "scope_id": { + "description": "The UUID of the specific resource (model / api_key / team). `None` when `scope_type` is `Env` (applies to all requests).", + "type": [ + "string", + "null" + ] + }, + "scope_type": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailScopeType" + } + ], + "description": "What dimension of the request this attachment is scoped to." + } + }, + "required": [ + "guardrail_id", + "priority", + "scope_type" + ], + "title": "GuardrailAttachment", + "type": "object" +} From 23b022c81bfe7d29591864350ba9a4ccf7b7d0ff Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 23 Jun 2026 15:17:35 +0800 Subject: [PATCH 8/8] fix(core): restore object_store credential_ref minLength; pin contract edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #638: - ObjectStoreConfig.credential_ref regained minLength(1) (the hand-written obs union enforced it on the shared credential_ref; SLS/Datadog already had it, object_store was missed) — an empty credential_ref is rejected again instead of failing later at credential resolution. - Characterization corpus pins three contract edges raised in review: object_store empty credential_ref (reject), guardrail created_at:null (reject — the runtime validator always enforced non-null; cp-api omits it, never sends null), and non-env guardrail_attachment with null scope_id (accept — the validator never conditionally required scope_id). --- .../src/models/observability_exporter.rs | 1 + .../tests/resource_schema_characterization.rs | 20 +++++++++++++++++++ .../observability_exporter.schema.json | 1 + 3 files changed, 22 insertions(+) diff --git a/crates/aisix-core/src/models/observability_exporter.rs b/crates/aisix-core/src/models/observability_exporter.rs index 05f6559d..f0d20ae1 100644 --- a/crates/aisix-core/src/models/observability_exporter.rs +++ b/crates/aisix-core/src/models/observability_exporter.rs @@ -213,6 +213,7 @@ pub struct ObjectStoreConfig { /// Credential reference resolved by the data plane at delivery time. Required when `auth_mode` is `credential_ref`. #[serde(default, skip_serializing_if = "String::is_empty")] + #[schemars(length(min = 1))] pub credential_ref: String, } diff --git a/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs index 8b866424..295eab26 100644 --- a/crates/aisix-core/tests/resource_schema_characterization.rs +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -497,6 +497,11 @@ fn observability_exporter_corpus() { true, json!({"name": "x", "kind": "object_store", "provider": "s3", "bucket": "b", "prefix": "p", "endpoint": "http://minio:9000", "credential_ref": "r"}), ), + ( + "object_store empty credential_ref", + false, + json!({"name": "x", "kind": "object_store", "provider": "s3", "bucket": "b", "prefix": "p", "credential_ref": ""}), + ), // datadog ( "datadog allow-list site", @@ -597,6 +602,13 @@ fn guardrail_corpus() { true, json!({"name": "k", "kind": "keyword", "patterns": [], "hook_point": "input", "enforcement_mode": "monitor", "created_at": "2026-01-01T00:00:00Z"}), ), + // created_at is a non-null string (the runtime validator always + // enforced this; cp-api omits it when absent, never sends null). + ( + "created_at null", + false, + json!({"name": "k", "kind": "keyword", "patterns": [], "created_at": null}), + ), ( "bad hook_point", false, @@ -720,6 +732,14 @@ fn guardrail_attachment_corpus() { true, json!({"guardrail_id": "gid", "scope_type": "model", "scope_id": "mid", "priority": 10, "enabled": false}), ), + // Non-`env` scope with null/absent scope_id is accepted — the + // original validator never conditionally required scope_id, and the + // runtime resolver tolerates None. Pinned to keep that contract. + ( + "model scope null scope_id", + true, + json!({"guardrail_id": "gid", "scope_type": "model", "scope_id": null, "priority": 1}), + ), ( "team scope negative priority", true, diff --git a/schemas/resources/observability_exporter.schema.json b/schemas/resources/observability_exporter.schema.json index dc4b38b7..2236bd8b 100644 --- a/schemas/resources/observability_exporter.schema.json +++ b/schemas/resources/observability_exporter.schema.json @@ -260,6 +260,7 @@ }, "credential_ref": { "description": "Credential reference resolved by the data plane at delivery time. Required when `auth_mode` is `credential_ref`.", + "minLength": 1, "type": "string" }, "enabled": {