diff --git a/crates/aisix-admin/src/openapi.rs b/crates/aisix-admin/src/openapi.rs index bb79fc62..6b4f6d89 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"], @@ -4023,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); } } @@ -4136,6 +4150,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..534f42d6 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -30,29 +30,59 @@ use std::path::{Path, PathBuf}; use schemars::JsonSchema; -use aisix_core::models::{ - ApiKey, CachePolicy, EnsembleConfig, Guardrail, Model, ObservabilityExporter, ProviderKey, - RateLimit, RateLimitPolicy, Routing, -}; +use aisix_core::models::schema; +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"); - dump::(&out_dir, "api_key"); - dump::(&out_dir, "cache_policy"); + // 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()); + dump_value( + &out_dir, + "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, "model"); - 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"); } 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/lib.rs b/crates/aisix-core/src/lib.rs index 78e2337d..eeb0d13d 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, TelemetryKind, TelemetryTags, DEFAULT_COOLDOWN_TRIGGER_STATUSES, }; pub use resource::{Resource, ResourceEntry}; pub use snapshot::{ResourceTable, SnapshotHandle}; 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/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/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/mod.rs b/crates/aisix-core/src/models/mod.rs index 1cf2b99e..3ca0baae 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -46,10 +46,10 @@ 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::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/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/observability_exporter.rs b/crates/aisix-core/src/models/observability_exporter.rs index 9fc165ed..f0d20ae1 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. @@ -192,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, } @@ -237,6 +259,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/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/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/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..b23ad4a1 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -38,28 +38,28 @@ 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()) + .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()) + .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_schema()) + .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_schema()) + .build(&rate_limit_policy_root_schema()) .expect("rate_limit_policy schema is well-formed"), } } @@ -117,729 +117,297 @@ 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 } - } - } - } - }) +/// Build a resource's canonical JSON Schema from its struct via `schemars`, +/// the single source of field shapes and per-field constraints. +/// +/// `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`). +/// +/// 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 = nullable_options; + }); + let root = SchemaGenerator::new(settings).into_root_schema_for::(); + serde_json::to_value(root).expect("resource schema serializes to JSON") } -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 }, - "rpm": { "type": "integer", "minimum": 0 }, - "rpd": { "type": "integer", "minimum": 0 }, - "concurrency": { "type": "integer", "minimum": 0 } - } - } - } - }) +/// 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") + .insert("oneOf".to_string(), super::model::model_one_of()); + schema } -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 `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) +} + +/// 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 { - 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 } -// 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. -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 } - } - }) +/// 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); + } } -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]+)?$" - } +/// 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); + } +} + +/// 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) +} + +/// 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 } -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"] } - ] - }) +/// 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() } -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 `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 +} + +/// 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/model_schema_characterization.rs b/crates/aisix-core/tests/model_schema_characterization.rs new file mode 100644 index 00000000..65eefc29 --- /dev/null +++ b/crates/aisix-core/tests/model_schema_characterization.rs @@ -0,0 +1,521 @@ +//! Characterization (golden-corpus) test for the `model` resource validator. +//! +//! 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. +//! +//! 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}; + +/// 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} + }), + ); +} + +// --------------------------------------------------------------------------- +// FLIPPED by the single-source refactor (the deliberate bug fix). +// +// The old hand-written `$defs/rate_limit` listed only 5 fields +// (tpm/tpd/rpm/rpd/concurrency) with `additionalProperties: false`, so it +// 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 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 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/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs new file mode 100644 index 00000000..295eab26 --- /dev/null +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -0,0 +1,790 @@ +//! 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, validate_guardrail, validate_guardrail_attachment, + validate_observability_exporter, validate_provider_key, validate_rate_limit_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}}), + ), + ], + ); +} + +#[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}), + ), + ], + ); +} + +#[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]}), + ), + ], + ); +} + +#[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"}), + ), + ( + "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", + 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"}), + ), + ], + ); +} + +#[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"}), + ), + // 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, + 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}), + ), + // 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, + 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/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/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/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/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" } 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/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" +} 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/observability_exporter.schema.json b/schemas/resources/observability_exporter.schema.json index 8aa700dd..2236bd8b 100644 --- a/schemas/resources/observability_exporter.schema.json +++ b/schemas/resources/observability_exporter.schema.json @@ -1,324 +1,410 @@ { "$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`.", + "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": "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" } 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" } 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" } 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.",