diff --git a/Cargo.lock b/Cargo.lock index ca9a38d3..39b298a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,7 @@ dependencies = [ "once_cell", "regex", "rstest", + "schemars 0.8.22", "serde", "serde_json", "serde_yaml", @@ -3773,6 +3774,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3797,6 +3810,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3872,6 +3897,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/crates/aisix-core/Cargo.toml b/crates/aisix-core/Cargo.toml index 2e72e29f..61b73a96 100644 --- a/crates/aisix-core/Cargo.toml +++ b/crates/aisix-core/Cargo.toml @@ -21,6 +21,7 @@ tracing.workspace = true config.workspace = true humantime-serde.workspace = true jsonschema.workspace = true +schemars.workspace = true once_cell.workspace = true regex.workspace = true # ApiKey::hash_bearer (prd-09a §9A.7B.4) is the canonical SHA-256 the diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs new file mode 100644 index 00000000..f105ffea --- /dev/null +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -0,0 +1,74 @@ +//! Emit canonical JSON Schema files for `aisix-core` resource types. +//! +//! Invocation: +//! +//! ```bash +//! cargo run -p aisix-core --bin dump-schema +//! ``` +//! +//! Writes one file per top-level resource into +//! `/schemas/resources/.schema.json`. Each file +//! is a self-contained JSON Schema draft-07 document (the default of +//! `schemars` 0.8) — nested types live in the `definitions/` section +//! of the same document, no cross-file `$ref` required. +//! +//! Re-run after modifying any resource struct in +//! `crates/aisix-core/src/models/`. CI runs this binary and rejects PRs +//! that leave `schemas/` out of date (drift check, follow-up PR). +//! +//! Downstream consumers: +//! +//! - `crates/aisix-admin/src/openapi.rs` — refactor target: replace +//! inline schema objects in the hand-written OpenAPI doc with +//! `$ref` into these files (follow-up PR). +//! - `api7/AISIX-Cloud` — pulls these files (via submodule or pinned +//! tag) to drive cp-api request validation and dashboard form +//! generation. Refs api7/ai-gateway#304 (#1). + +use std::fs; +use std::path::{Path, PathBuf}; + +use schemars::JsonSchema; + +use aisix_core::models::{ + ApiKey, CachePolicy, Guardrail, Model, ObservabilityExporter, ProviderKey, RateLimit, + RateLimitPolicy, Routing, +}; + +fn main() { + let out_dir = workspace_root().join("schemas").join("resources"); + fs::create_dir_all(&out_dir).expect("create schemas/resources dir"); + + dump::(&out_dir, "api_key"); + dump::(&out_dir, "cache_policy"); + 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); + let mut json = serde_json::to_string_pretty(&schema).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()); +} + +/// Workspace root, derived from the `aisix-core` manifest directory. +/// +/// `CARGO_MANIFEST_DIR` is `/crates/aisix-core` — `parent()` twice +/// resolves to ``. The path is baked in at compile time, so the +/// binary always targets the workspace it was built in (correct for an +/// in-tree code-generation tool; not meant to ship outside the repo). +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("CARGO_MANIFEST_DIR has two ancestors") + .to_path_buf() +} diff --git a/crates/aisix-core/src/models/apikey.rs b/crates/aisix-core/src/models/apikey.rs index 7a5a3961..04e963c1 100644 --- a/crates/aisix-core/src/models/apikey.rs +++ b/crates/aisix-core/src/models/apikey.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use super::rate_limit::RateLimit; use crate::resource::Resource; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct ApiKey { /// SHA-256 hex of the plaintext bearer. Secondary-indexed for diff --git a/crates/aisix-core/src/models/cache_policy.rs b/crates/aisix-core/src/models/cache_policy.rs index cf70fb49..7e3316df 100644 --- a/crates/aisix-core/src/models/cache_policy.rs +++ b/crates/aisix-core/src/models/cache_policy.rs @@ -18,7 +18,9 @@ use crate::resource::Resource; /// Cache backend choice. `Memory` is enforced by the DP today; /// `Redis` is the kine-level wire-shape stub for the upcoming /// shared-cluster backend (DP enforcement pending). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, +)] #[serde(rename_all = "snake_case")] pub enum CacheBackend { #[default] @@ -35,7 +37,7 @@ pub enum CacheBackend { /// new fields ahead of a DP rollout without a hard reject. New /// optional fields land at `#[serde(default)]` here on the next DP /// release. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] pub struct CachePolicy { /// Operator-facing name; surfaces in metric labels + cache headers. pub name: String, diff --git a/crates/aisix-core/src/models/guardrail.rs b/crates/aisix-core/src/models/guardrail.rs index 1c31e117..89ccbf4d 100644 --- a/crates/aisix-core/src/models/guardrail.rs +++ b/crates/aisix-core/src/models/guardrail.rs @@ -32,7 +32,9 @@ use serde::{Deserialize, Serialize}; use crate::resource::Resource; /// What part of the request lifecycle a guardrail inspects. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum GuardrailHookPoint { /// Run on the request payload before bridge dispatch. @@ -49,7 +51,7 @@ pub enum GuardrailHookPoint { /// `Regex` to a compiled `regex::Regex`. Invalid regex at parse /// time is loader-rejected (the DP refuses to apply a guardrail it /// can't compile, so a typo doesn't silently disarm the policy). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(tag = "kind", content = "value", rename_all = "lowercase")] pub enum KeywordPattern { Literal(String), @@ -57,7 +59,7 @@ pub enum KeywordPattern { } /// Config block for `kind: "keyword"`. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct KeywordConfig { /// Blocklist patterns. Empty list is legal but pointless — the @@ -73,7 +75,7 @@ pub struct KeywordConfig { /// envelope-encrypted secret at projection time (same trust /// boundary as `provider_keys` — see PRD-09c §6.3). The DP only /// ever holds plaintext in memory; it does not need a master key. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum BedrockAWSCredentials { Static { @@ -89,7 +91,7 @@ pub enum BedrockAWSCredentials { /// waits unconditionally; `timed` aborts at `timeout_ms` and /// applies the row-level `fail_open` flag. Range matches cp-api's /// validator (100..5000ms) — see PRD-09c §6.6. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum BedrockLatencyMode { Serial, @@ -99,7 +101,7 @@ pub enum BedrockLatencyMode { /// Config block for `kind: "bedrock"`. Phase 1 stores the shape + /// passes it through `aisix-guardrails::build` which logs /// `bedrock not yet implemented` and skips the row. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct BedrockConfig { /// AWS-console-issued guardrail identifier (12 chars today). @@ -116,7 +118,7 @@ pub struct BedrockConfig { /// Provider discriminator. The kind drives which `*_config` block is /// expected; serde's `tag = "kind"` keeps us honest at parse time. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum GuardrailKind { /// In-process literal/regex blocklist. Always available. @@ -137,7 +139,7 @@ pub enum GuardrailKind { /// inner enum needs. Strict typo-rejection happens earlier in the /// JSON Schema (`schema::validate_guardrail`) which the loader /// runs before deserialise on every watch event. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] pub struct Guardrail { /// Operator-facing name; surfaces in metric labels + error reasons. pub name: String, diff --git a/crates/aisix-core/src/models/model.rs b/crates/aisix-core/src/models/model.rs index 9657e0d2..ab01e541 100644 --- a/crates/aisix-core/src/models/model.rs +++ b/crates/aisix-core/src/models/model.rs @@ -18,7 +18,7 @@ use super::routing::Routing; use crate::resource::Resource; /// Supported upstream providers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "lowercase")] pub enum Provider { Openai, @@ -77,7 +77,7 @@ impl Provider { /// variant serializes as `"azure-openai"`. This intentionally differs /// from `Provider`'s `lowercase` casing, which produced no hyphens /// because all current `Provider` names are single tokens. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum Adapter { Openai, @@ -130,7 +130,7 @@ impl From for Adapter { } /// Per-token cost for budget tracking. Both values are in USD per 1,000 tokens. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ModelCost { /// Input (prompt) token cost in USD per 1,000 tokens. @@ -148,7 +148,7 @@ impl ModelCost { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct BackgroundModelCheck { pub enabled: bool, @@ -175,7 +175,7 @@ pub struct BackgroundModelCheck { /// /// All fields are optional; defaults preserve a safe behavior for any /// direct model that doesn't ship a `cooldown` block. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq, Default)] #[serde(deny_unknown_fields)] pub struct CooldownConfig { /// Whether cooldown is active for this model. Default: true. @@ -253,7 +253,7 @@ impl CooldownConfig { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct Model { /// Operator-facing unique label. Surfaces on `/v1/models`, diff --git a/crates/aisix-core/src/models/observability_exporter.rs b/crates/aisix-core/src/models/observability_exporter.rs index 502eb681..f133bbcd 100644 --- a/crates/aisix-core/src/models/observability_exporter.rs +++ b/crates/aisix-core/src/models/observability_exporter.rs @@ -41,13 +41,13 @@ use crate::resource::Resource; /// `tag = "kind"` puts the variant tag inline with the inner struct's /// fields — same shape as `GuardrailKind` so the kine wire stays /// consistent across resource types. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ExporterKind { OtlpHttp(OtlpHttpConfig), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct OtlpHttpConfig { /// Full URL of the OTLP/HTTP traces endpoint. Must already include @@ -70,7 +70,7 @@ pub struct OtlpHttpConfig { /// field. Strict typo rejection happens at the JSON Schema layer /// (`schema::validate_observability_exporter`) which the etcd loader /// runs before the serde deserialize. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] pub struct ObservabilityExporter { /// Operator-facing label, surfaced in /logs and the dashboard list. /// Not used for routing — the etcd-key uuid is the identity. diff --git a/crates/aisix-core/src/models/provider_key.rs b/crates/aisix-core/src/models/provider_key.rs index 437377dc..1bcc6c97 100644 --- a/crates/aisix-core/src/models/provider_key.rs +++ b/crates/aisix-core/src/models/provider_key.rs @@ -26,7 +26,7 @@ use crate::resource::Resource; // `default_body_fields`), neither of which can implement `Eq` due to // NaN / Number-equality semantics. Tests compare via `assert_eq!` // which only needs `PartialEq`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ProviderKey { /// Operator-facing label, unique within the gateway. Surfaces in @@ -101,7 +101,7 @@ pub struct ProviderKey { /// means an omitted block or omitted individual key both yield the /// zero-value `TelemetryTags`, preserving backward compatibility /// with existing `ProviderKey` payloads. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct TelemetryTags { /// `"catalog"` for first-party curated providers, `"byo"` for @@ -145,7 +145,7 @@ pub struct TelemetryTags { /// /// `f64` in [`ParamConstraints`] is the reason the parent /// [`ProviderKey`] derives `PartialEq` rather than `Eq`. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct RequestOverrides { /// `apply_param_renames` input. Top-level body keys named on the @@ -179,7 +179,7 @@ pub struct RequestOverrides { /// /// `f64` not `Eq`: NaN comparisons make a derived `Eq` unsound. /// [`PartialEq`] is enough for the round-trip test. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ParamConstraints { /// Upper bound for `temperature`. Values above this are clamped @@ -208,7 +208,7 @@ pub struct ParamConstraints { /// `error_envelope` is on-disk only — issue #302 §5 keeps it as a /// `"openai" | "passthrough"` string so cp-api can iterate without /// a Rust-side enum migration. Phase D pins the closed set. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ResponseOverrides { /// Stream `[DONE]` terminator expectation. `None` means "no @@ -246,7 +246,7 @@ pub struct ResponseOverrides { /// The runtime apply function lives in `aisix-provider-openai` /// (`apply_stream_done_marker_policy`) and consumes this enum /// directly via re-export from `aisix-core`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "lowercase")] pub enum StreamDoneMarker { /// Upstream must emit `data: [DONE]`. Absence is a wire-shape diff --git a/crates/aisix-core/src/models/rate_limit.rs b/crates/aisix-core/src/models/rate_limit.rs index a2a62fd1..590b994a 100644 --- a/crates/aisix-core/src/models/rate_limit.rs +++ b/crates/aisix-core/src/models/rate_limit.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct RateLimit { /// Tokens per minute (60s window). diff --git a/crates/aisix-core/src/models/rate_limit_policy.rs b/crates/aisix-core/src/models/rate_limit_policy.rs index c5068108..b2676520 100644 --- a/crates/aisix-core/src/models/rate_limit_policy.rs +++ b/crates/aisix-core/src/models/rate_limit_policy.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::resource::Resource; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct RateLimitPolicy { pub name: String, diff --git a/crates/aisix-core/src/models/routing.rs b/crates/aisix-core/src/models/routing.rs index 53c37370..5fc2caa1 100644 --- a/crates/aisix-core/src/models/routing.rs +++ b/crates/aisix-core/src/models/routing.rs @@ -15,7 +15,9 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema, +)] #[serde(rename_all = "snake_case")] pub enum RoutingStrategy { RoundRobin, @@ -28,7 +30,7 @@ pub enum RoutingStrategy { /// One destination in a routing config. `model` references another /// `Model.name` in the snapshot. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct RoutingTarget { pub model: String, @@ -64,7 +66,9 @@ impl RoutingTarget { /// amplifies cascading outages. Operators that prefer the legacy /// behavior (try every candidate regardless of known state) can opt /// into `OriginalOrder` per routing model. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema, +)] #[serde(rename_all = "snake_case")] pub enum OnAllFilteredPolicy { /// Return 503 with a fixed Retry-After hint (currently 30 seconds — @@ -86,7 +90,7 @@ pub enum OnAllFilteredPolicy { OriginalOrder, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct Routing { #[serde(default)] diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 00000000..e669d733 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,77 @@ +# aisix canonical JSON Schemas + +This directory holds canonical JSON Schema files for `aisix-core` resource +types. The files are **auto-generated** from the Rust type definitions in +`crates/aisix-core/src/models/` — do not edit them by hand. + +## Layout + +```text +schemas/ +└── resources/ + ├── api_key.schema.json + ├── cache_policy.schema.json + ├── guardrail.schema.json + ├── model.schema.json + ├── observability_exporter.schema.json + ├── provider_key.schema.json + ├── rate_limit.schema.json + ├── rate_limit_policy.schema.json + └── routing.schema.json +``` + +Each file is a self-contained JSON Schema draft-07 document. Nested +types (e.g. `Adapter`, `RoutingTarget`, `TelemetryTags`) live in the +`definitions/` section of the parent resource — no cross-file `$ref` is +emitted. + +File names use the snake_case singular form of the Rust type +(`api_key.schema.json`, `provider_key.schema.json`). The corresponding +etcd key prefix uses the plural `Resource::kind()` value +(`api_keys`, `provider_keys`); the two naming conventions are +deliberately distinct because the schema file is a per-type artifact +while the etcd prefix groups a collection of instances. + +## Forward-compatibility + +Three top-level resources intentionally **omit** +`additionalProperties: false`: + +- `guardrail.schema.json` — the discriminated-union `kind` field uses + serde's `flatten + tag` pattern, which is incompatible with a strict + outer deny; strict typo-rejection happens earlier via + `aisix-core::models::schema::validate_guardrail`. +- `cache_policy.schema.json` — cp-api may ship forward-compat fields + ahead of a DP rollout, e.g. a new backend variant. +- `observability_exporter.schema.json` — same forward-compat reason as + `cache_policy`. + +Downstream consumers that default to strict validation should permit +unknown keys for these three resources; the other six are strict. + +## Regenerating + +After modifying any resource struct in `crates/aisix-core/src/models/`, +re-run: + +```bash +cargo run -p aisix-core --bin dump-schema +``` + +CI runs the same command and fails the build if `schemas/` drifts from +the Rust types (drift-check workflow, separate PR). + +## Downstream consumers + +- `crates/aisix-admin/src/openapi.rs` — DP admin OpenAPI 3.1 document. + Refactor target: replace inline schema objects with `$ref` into these + files. (Follow-up PR.) +- `api7/AISIX-Cloud` cp-api — pulls these files (via submodule or + pinned tag) for REST input validation against the same shape DP + consumes from etcd. +- `api7/AISIX-Cloud` dashboard — renders forms straight from these + schemas with [RJSF](https://github.com/rjsf-team/react-jsonschema-form) + or equivalent, instead of hand-coded validators. + +Refs api7/ai-gateway#304 item #1 (canonical JSON Schema as config +source of truth). diff --git a/schemas/resources/api_key.schema.json b/schemas/resources/api_key.schema.json new file mode 100644 index 00000000..0da7e06a --- /dev/null +++ b/schemas/resources/api_key.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ApiKey", + "type": "object", + "required": [ + "allowed_models", + "key_hash" + ], + "properties": { + "allowed_models": { + "description": "Whitelisted Model identifiers. cp-api stores them as model UUIDs; self-hosted dev fixtures may still use names — the DP does string equality and doesn't care which. An **empty array** denies every model (spec §3 authz rule).", + "type": "array", + "items": { + "type": "string" + } + }, + "key_hash": { + "description": "SHA-256 hex of the plaintext bearer. Secondary-indexed for O(1) auth — the proxy hashes incoming bearers before lookup.", + "type": "string" + }, + "owner_id": { + "description": "Org member who owns this key. Used for matching member-scope rate limit policies.", + "type": [ + "string", + "null" + ] + }, + "rate_limit": { + "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" + ] + } + }, + "additionalProperties": false, + "definitions": { + "RateLimit": { + "type": "object", + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "rpd": { + "description": "Requests per day (86400s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "rpm": { + "description": "Requests per minute (60s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tpd": { + "description": "Tokens per day (86400s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tpm": { + "description": "Tokens per minute (60s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/schemas/resources/cache_policy.schema.json b/schemas/resources/cache_policy.schema.json new file mode 100644 index 00000000..4634f06e --- /dev/null +++ b/schemas/resources/cache_policy.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CachePolicy", + "description": "Top-level `CachePolicy` resource shape. Mirrors what cp-api writes to kine. `name` is operator-facing; `enabled` flips the policy on without delete + recreate. `applies_to` is parsed into a typed matcher (see `parsed_applies_to`).\n\n`deny_unknown_fields` is intentionally NOT set so cp-api can ship new fields ahead of a DP rollout without a hard reject. New optional fields land at `#[serde(default)]` here on the next DP release.", + "type": "object", + "required": [ + "name" + ], + "properties": { + "applies_to": { + "description": "Free-form scope. v1 understands \"all\", \"model:\", \"api_key:\". See `parsed_applies_to`.", + "default": "all", + "type": "string" + }, + "backend": { + "description": "Backend hint. `memory` is the only enforced backend today; `redis` parses + persists but the DP currently falls back to memory until that backend wires up.", + "default": "memory", + "allOf": [ + { + "$ref": "#/definitions/CacheBackend" + } + ] + }, + "enabled": { + "description": "When false the cache gate skips this policy. Lets operators stage a rule (write it, sanity-check it, then flip it on).", + "default": true, + "type": "boolean" + }, + "name": { + "description": "Operator-facing name; surfaces in metric labels + cache headers.", + "type": "string" + }, + "ttl_seconds": { + "description": "TTL hint in seconds. Per-policy TTL is honored by the cache backend on each entry. Default 3600 matches the cp-api validator.", + "default": 3600, + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "definitions": { + "CacheBackend": { + "description": "Cache backend choice. `Memory` is enforced by the DP today; `Redis` is the kine-level wire-shape stub for the upcoming shared-cluster backend (DP enforcement pending).", + "type": "string", + "enum": [ + "memory", + "redis" + ] + } + } +} diff --git a/schemas/resources/guardrail.schema.json b/schemas/resources/guardrail.schema.json new file mode 100644 index 00000000..4baac713 --- /dev/null +++ b/schemas/resources/guardrail.schema.json @@ -0,0 +1,243 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Guardrail", + "description": "Top-level `Guardrail` resource shape. Mirrors what cp-api writes to kine at `/aisix//guardrails/`.\n\n`deny_unknown_fields` is intentionally NOT set here: serde's `flatten` + `tag = \"kind\"` interaction can't pass the \"I consumed this field\" signal up to the outer struct, so a `deny_unknown_fields` outer would reject the very `kind` the inner enum needs. Strict typo-rejection happens earlier in the JSON Schema (`schema::validate_guardrail`) which the loader runs before deserialise on every watch event.", + "type": "object", + "oneOf": [ + { + "description": "In-process literal/regex blocklist. Always available.", + "type": "object", + "required": [ + "kind", + "patterns" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "keyword" + ] + }, + "patterns": { + "description": "Blocklist patterns. Empty list is legal but pointless — the guardrail will allow every request, same as `enabled: false`.", + "type": "array", + "items": { + "$ref": "#/definitions/KeywordPattern" + } + } + } + }, + { + "description": "AWS Bedrock managed guardrail. Phase 1 parses + persists; the chain builder skips it with a warn log. Phase 2 wires real `ApplyGuardrail` dispatch.", + "type": "object", + "required": [ + "aws_credentials", + "guardrail_id", + "guardrail_version", + "kind", + "latency_mode", + "region" + ], + "properties": { + "aws_credentials": { + "description": "IAM credentials. v1 = static access keys (encrypted).", + "allOf": [ + { + "$ref": "#/definitions/BedrockAWSCredentials" + } + ] + }, + "guardrail_id": { + "description": "AWS-console-issued guardrail identifier (12 chars today).", + "type": "string" + }, + "guardrail_version": { + "description": "Version label: `DRAFT`, `1`, `2`, ...", + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "bedrock" + ] + }, + "latency_mode": { + "description": "`serial` (default) or `timed { timeout_ms }`.", + "allOf": [ + { + "$ref": "#/definitions/BedrockLatencyMode" + } + ] + }, + "region": { + "description": "AWS region the Bedrock endpoint lives in (e.g. `us-east-1`).", + "type": "string" + } + } + } + ], + "required": [ + "name" + ], + "properties": { + "enabled": { + "description": "When false the chain skips this rule entirely. Lets operators stage a rule (write it, sanity-check it via dry runs, then flip it on) without deleting + recreating.", + "default": true, + "type": "boolean" + }, + "fail_open": { + "description": "Behavior when a remote-API guardrail (today `kind=bedrock`) can't reach its upstream. `true` lets the request through (recorded in usage_events.guardrail_bypassed_reason); `false` blocks with 422. No-op for `kind=keyword`. Defaults `true` (matches the PG schema default + PRD-09c §6.4).", + "default": true, + "type": "boolean" + }, + "hook_point": { + "description": "Where in the lifecycle this rule runs. Defaults to `both`.", + "default": "both", + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ] + }, + "name": { + "description": "Operator-facing name; surfaces in metric labels + error reasons.", + "type": "string" + } + }, + "definitions": { + "BedrockAWSCredentials": { + "description": "AWS credentials for `kind: \"bedrock\"`. Phase 2 supports `static` (access-key pair); Phase 4 adds `role_arn` (sts:AssumeRole) under the same tag.\n\nWire shape on the kine path is plaintext: cp-api decrypts the envelope-encrypted secret at projection time (same trust boundary as `provider_keys` — see PRD-09c §6.3). The DP only ever holds plaintext in memory; it does not need a master key.", + "oneOf": [ + { + "type": "object", + "required": [ + "access_key_id", + "kind", + "secret_access_key" + ], + "properties": { + "access_key_id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "static" + ] + }, + "secret_access_key": { + "description": "Decrypted by cp-api before kine projection; plaintext in memory only, never logged. The DP feeds it to the AWS SDK's static credentials provider.", + "type": "string" + } + } + } + ] + }, + "BedrockLatencyMode": { + "description": "Per-guardrail latency policy for `kind: \"bedrock\"`. `serial` waits unconditionally; `timed` aborts at `timeout_ms` and applies the row-level `fail_open` flag. Range matches cp-api's validator (100..5000ms) — see PRD-09c §6.6.", + "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": { + "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 bridge dispatch.", + "type": "string", + "enum": [ + "input" + ] + }, + { + "description": "Run on the upstream response before the cache write + render.", + "type": "string", + "enum": [ + "output" + ] + }, + { + "description": "Run on both. Default for keyword blocklists.", + "type": "string", + "enum": [ + "both" + ] + } + ] + }, + "KeywordPattern": { + "description": "One pattern in a `keyword`-kind guardrail's blocklist. The DP translates `Literal` to a case-insensitive substring match and `Regex` to a compiled `regex::Regex`. Invalid regex at parse time is loader-rejected (the DP refuses to apply a guardrail it can't compile, so a typo doesn't silently disarm the policy).", + "oneOf": [ + { + "type": "object", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "literal" + ] + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "regex" + ] + }, + "value": { + "type": "string" + } + } + } + ] + } + } +} diff --git a/schemas/resources/model.schema.json b/schemas/resources/model.schema.json new file mode 100644 index 00000000..e5f3eae5 --- /dev/null +++ b/schemas/resources/model.schema.json @@ -0,0 +1,436 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Model", + "type": "object", + "required": [ + "display_name" + ], + "properties": { + "background_model_check": { + "description": "Optional direct-model-only background health-check configuration.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundModelCheck" + }, + { + "type": "null" + } + ] + }, + "cooldown": { + "description": "Optional direct-model-only request-path cooldown configuration. When absent, default cooldown semantics apply (see [`CooldownConfig`] field docs for defaults).", + "anyOf": [ + { + "$ref": "#/definitions/CooldownConfig" + }, + { + "type": "null" + } + ] + }, + "cost": { + "description": "Per-token cost for budget tracking. Absent = no cost tracked.", + "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" + }, + "model_name": { + "description": "Upstream model id sent to the provider (e.g. \"gpt-4o\", \"claude-sonnet-4-5\"). None for routing models.", + "type": [ + "string", + "null" + ] + }, + "provider": { + "description": "Upstream provider. None for routing models (the router picks a target whose own `provider` is used at dispatch time).", + "anyOf": [ + { + "$ref": "#/definitions/Provider" + }, + { + "type": "null" + } + ] + }, + "provider_key_id": { + "description": "References a `ProviderKey` row by id. The bridge resolves this against `AisixSnapshot::provider_keys` at dispatch time to fetch the upstream secret + optional `api_base`. None for routing models.", + "type": [ + "string", + "null" + ] + }, + "rate_limit": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimit" + }, + { + "type": "null" + } + ] + }, + "routing": { + "description": "Virtual-router config. When set, the proxy walks `routing.targets` to pick a downstream Model and dispatches against THAT model's `provider` / `model_name` / `provider_key_id`. The fields on this entity are intentionally absent in that case.", + "anyOf": [ + { + "$ref": "#/definitions/Routing" + }, + { + "type": "null" + } + ] + }, + "timeout": { + "description": "Request timeout in ms. 0 or absent = no 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" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "ignore_statuses": { + "type": "array", + "items": { + "type": "integer", + "format": "uint16", + "minimum": 0.0 + } + }, + "interval_seconds": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "max_tokens": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "prompt": { + "type": "string" + }, + "stale_after_seconds": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "timeout_seconds": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "CooldownConfig": { + "description": "Request-path cooldown configuration for a direct model. Controls which upstream failures temporarily exclude this model from routing candidate selection, and for how long.\n\nCooldown is **independent** of request retry semantics — i.e. `Routing.retry_on_429` governs whether a 429 is retried within the current request, but `CooldownConfig.trigger_statuses` governs whether 429 takes the model out of rotation for subsequent requests. The two layers serve different purposes: - retry: short-window in-request recovery - cooldown: medium-window cross-request backpressure\n\nAll fields are optional; defaults preserve a safe behavior for any direct model that doesn't ship a `cooldown` block.", + "type": "object", + "properties": { + "default_seconds": { + "description": "Cooldown TTL in seconds when the upstream did not supply a `Retry-After` header (or `honor_retry_after=false`). Default: 30.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "enabled": { + "description": "Whether cooldown is active for this model. Default: true. Set to `false` to disable cooldown entirely (the model stays in rotation regardless of upstream failures).", + "type": [ + "boolean", + "null" + ] + }, + "honor_retry_after": { + "description": "Whether to use the upstream's `Retry-After` header (seconds form) as the cooldown TTL when present. Default: true.", + "type": [ + "boolean", + "null" + ] + }, + "max_seconds": { + "description": "Upper bound on cooldown TTL. Caps a misbehaving upstream that returns an unreasonable `Retry-After` value. Default: 600 (10 min).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "trigger_on_timeout": { + "description": "Whether request-path timeouts trigger cooldown. Default: true.", + "type": [ + "boolean", + "null" + ] + }, + "trigger_on_transport": { + "description": "Whether transport / decode / stream-abort errors trigger cooldown. Default: true.", + "type": [ + "boolean", + "null" + ] + }, + "trigger_statuses": { + "description": "Status codes that trigger cooldown. Default: `[401, 408, 429, 500, 502, 503, 504]` — auth failures and rate limits + transient server errors. `400/403/422` etc. are caller mistakes and intentionally excluded.", + "type": [ + "array", + "null" + ], + "items": { + "type": "integer", + "format": "uint16", + "minimum": 0.0 + } + } + }, + "additionalProperties": false + }, + "ModelCost": { + "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": "Input (prompt) token cost in USD per 1,000 tokens.", + "type": "number", + "format": "double" + }, + "output_per_1k": { + "description": "Output (completion) token cost in USD per 1,000 tokens.", + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "OnAllFilteredPolicy": { + "description": "Behavior when every candidate target is filtered out by the runtime status layer (all in cooldown or background-unhealthy).\n\n`Fail` is the default because sending traffic to a target we know is currently bad — just because every other target is also bad — amplifies cascading outages. Operators that prefer the legacy behavior (try every candidate regardless of known state) can opt into `OriginalOrder` per routing model.", + "oneOf": [ + { + "description": "Return 503 with a fixed Retry-After hint (currently 30 seconds — see `FALLBACK_ALL_UNHEALTHY_RETRY_AFTER` in `crates/aisix-proxy/src/chat.rs`). Default.\n\nThe hint is intentionally coarse: by the time the filter reaches the all-filtered branch, every candidate is background-unhealthy with no live cooldown timer (cooldown candidates are returned via the Selected branch one tier up). A future version may derive the hint from probe metadata; the current contract is a flat fallback.", + "type": "string", + "enum": [ + "fail" + ] + }, + { + "description": "Send to the original candidate list anyway, in declaration order. Preserves availability over caller-facing correctness. Use only when the operator explicitly accepts the risk of sending traffic to a target the gateway just probed as broken.", + "type": "string", + "enum": [ + "original_order" + ] + } + ] + }, + "Provider": { + "description": "Supported upstream providers.", + "oneOf": [ + { + "type": "string", + "enum": [ + "openai", + "anthropic", + "google", + "deepseek" + ] + }, + { + "description": "Cohere — currently exposed for `/v1/rerank` only (#213 Phase 1). Cohere's chat / generate APIs are not OpenAI-compatible; a future bridge implementation can extend coverage.", + "type": "string", + "enum": [ + "cohere" + ] + }, + { + "description": "Jina AI — currently exposed for `/v1/rerank` only (#213 Phase 2). Jina's rerank wire shape is identity-mapped to the OpenAI-compat shape (`{model, query, documents, top_n}` with Bearer auth at `https://api.jina.ai/v1/rerank`), so the gateway forwards verbatim with no transform. Jina's chat / embeddings APIs are out of scope for this phase.", + "type": "string", + "enum": [ + "jina" + ] + } + ] + }, + "RateLimit": { + "type": "object", + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "rpd": { + "description": "Requests per day (86400s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "rpm": { + "description": "Requests per minute (60s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tpd": { + "description": "Tokens per day (86400s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tpm": { + "description": "Tokens per minute (60s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Routing": { + "type": "object", + "required": [ + "targets" + ], + "properties": { + "max_fallbacks": { + "description": "Max number of later targets to attempt after the initial target fails permanently. Defaults to all later targets.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "on_all_filtered": { + "description": "Policy for the case where every candidate is filtered out by runtime status. See [`OnAllFilteredPolicy`].", + "anyOf": [ + { + "$ref": "#/definitions/OnAllFilteredPolicy" + }, + { + "type": "null" + } + ] + }, + "retries": { + "description": "Retry attempts on the current target before failing over.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "retry_on_429": { + "description": "Whether upstream 429 participates in retries and failover.", + "type": [ + "boolean", + "null" + ] + }, + "strategy": { + "default": "failover", + "allOf": [ + { + "$ref": "#/definitions/RoutingStrategy" + } + ] + }, + "targets": { + "type": "array", + "items": { + "$ref": "#/definitions/RoutingTarget" + } + } + }, + "additionalProperties": false + }, + "RoutingStrategy": { + "oneOf": [ + { + "type": "string", + "enum": [ + "round_robin", + "weighted" + ] + }, + { + "description": "Failover is the safest default — predictable order, no shared state, no surprises on first deploy.", + "type": "string", + "enum": [ + "failover" + ] + } + ] + }, + "RoutingTarget": { + "description": "One destination in a routing config. `model` references another `Model.name` in the snapshot.", + "type": "object", + "required": [ + "model" + ], + "properties": { + "model": { + "type": "string" + }, + "weight": { + "description": "Only meaningful for `weighted`. Optional everywhere else; falls back to 1 when missing.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/schemas/resources/observability_exporter.schema.json b/schemas/resources/observability_exporter.schema.json new file mode 100644 index 00000000..de93572f --- /dev/null +++ b/schemas/resources/observability_exporter.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ObservabilityExporter", + "description": "Top-level `ObservabilityExporter` resource. `deny_unknown_fields` deliberately NOT set — serde's `flatten` + `tag = \"kind\"` interaction makes outer-strict-mode reject the inner discriminator field. Strict typo rejection happens at the JSON Schema layer (`schema::validate_observability_exporter`) which the etcd loader runs before the serde deserialize.", + "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "endpoint", + "kind" + ], + "properties": { + "endpoint": { + "description": "Full URL of the OTLP/HTTP traces endpoint. Must already include the `/v1/traces` path the receiver expects — we don't append it because some vendors (Honeycomb, Grafana) use a different path.", + "type": "string" + }, + "headers": { + "description": "Static headers to attach to every export request. Typical use: `Authorization: Bearer ` or vendor-specific keys like `x-honeycomb-team`. Values are plaintext at this MVP — the kine path is mTLS-only, so the trust boundary matches `provider_keys`. Field-level encryption arrives in Phase 2.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "kind": { + "type": "string", + "enum": [ + "otlp_http" + ] + } + } + } + ], + "required": [ + "name" + ], + "properties": { + "enabled": { + "description": "Soft kill switch. Disabled exporters stay in the snapshot but the fan-out sink skips them. Lets operators pause an exporter without losing the row's headers / endpoint.", + "default": true, + "type": "boolean" + }, + "name": { + "description": "Operator-facing label, surfaced in /logs and the dashboard list. Not used for routing — the etcd-key uuid is the identity.", + "type": "string" + } + } +} diff --git a/schemas/resources/provider_key.schema.json b/schemas/resources/provider_key.schema.json new file mode 100644 index 00000000..41e96d61 --- /dev/null +++ b/schemas/resources/provider_key.schema.json @@ -0,0 +1,252 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProviderKey", + "type": "object", + "required": [ + "display_name", + "secret" + ], + "properties": { + "adapter": { + "description": "Wire-shape adapter (`openai` / `anthropic` / `bedrock` / `vertex` / `azure-openai`). Introduced as a skeleton for issue #302 Phase A. `None` in this PR — no dispatch path consumes it yet; the field exists so future Phase A sub-PRs can populate it without an on-disk schema break. Old payloads that omit `adapter` continue to deserialize via `#[serde(default)]`.", + "anyOf": [ + { + "$ref": "#/definitions/Adapter" + }, + { + "type": "null" + } + ] + }, + "api_base": { + "description": "Override for the upstream base URL. Empty/None means the provider default applies (see `Provider::default_base_url`).", + "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": "Vendor identity (e.g. `\"deepseek\"`, `\"openai\"`). Introduced as a skeleton for issue #302 Phase A. Empty in this PR — no dispatch path consumes it yet; the field exists so future Phase A sub-PRs can populate it without an on-disk schema break. Old payloads that omit `provider` continue to deserialize via `#[serde(default)]`.", + "default": "", + "type": "string" + }, + "request": { + "description": "Per-key request-shape overrides — see issue #302 §5 `RuntimeConfig.request`. `None` until cp-api ships the block. No dispatch path reads it in this PR; #301 already provides the primitive apply functions in `aisix-provider-openai` that Phase D will call once the wire stage cuts over.", + "anyOf": [ + { + "$ref": "#/definitions/RequestOverrides" + }, + { + "type": "null" + } + ] + }, + "response": { + "description": "Per-key response-shape overrides — see issue #302 §5 `RuntimeConfig.response`. `None` until cp-api ships the block. Same Phase D wiring story as [`Self::request`].", + "anyOf": [ + { + "$ref": "#/definitions/ResponseOverrides" + }, + { + "type": "null" + } + ] + }, + "secret": { + "description": "Upstream provider's API key, stored in plaintext on the standalone path (the etcd channel is mTLS-only — same trust boundary as Guardrail credentials and ObservabilityExporter headers). On the AISIX-Cloud path cp-api decrypts the envelope-encrypted secret at projection time and writes the plaintext here.", + "type": "string" + }, + "telemetry_tags": { + "description": "Telemetry tags carried alongside the key for metric/log emission. Introduced as a skeleton for issue #302 Phase A. No metric path consumes these tags yet; the field exists so future Phase A sub-PRs can attribute traffic without an on-disk schema break. Old payloads that omit `telemetry_tags` fall back to the `Default` impl via `#[serde(default)]`.", + "default": { + "featured": false + }, + "allOf": [ + { + "$ref": "#/definitions/TelemetryTags" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Adapter": { + "description": "Wire-shape adapter used to talk to an upstream. This is the closed set of upstream protocols the gateway knows how to encode against — distinct from a vendor identity (which is captured separately on `ProviderKey`).\n\nIntroduced as a skeleton for issue #302 Phase A. This type is purely additive in this PR: nothing in the gateway dispatches off `Adapter` yet, no entity field is changed, and `Provider` continues to drive all runtime behavior. Follow-up PRs in Phase A migrate entities and the Hub to consume `Adapter` directly.\n\nNote on serde casing: `Adapter` uses `kebab-case` so the `AzureOpenai` variant serializes as `\"azure-openai\"`. This intentionally differs from `Provider`'s `lowercase` casing, which produced no hyphens because all current `Provider` names are single tokens.", + "type": "string", + "enum": [ + "openai", + "anthropic", + "bedrock", + "vertex", + "azure-openai" + ] + }, + "ParamConstraints": { + "description": "Numeric range clamps applied to chat-completion request bodies — the on-disk shape of issue #302 §5 `param_constraints`. Phase A scope is `temperature` only; `top_p` / `frequency_penalty` are deferred until a real upstream quirk demands them (YAGNI per `CLAUDE.md` §2).\n\n`f64` not `Eq`: NaN comparisons make a derived `Eq` unsound. [`PartialEq`] is enough for the round-trip test.", + "type": "object", + "properties": { + "temperature_max": { + "description": "Upper bound for `temperature`. Values above this are clamped to this value. `None` means \"no upper clamp\".", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "temperature_min": { + "description": "Lower bound for `temperature`. Values below this are clamped to this value. `None` means \"no lower clamp\".", + "type": [ + "number", + "null" + ], + "format": "double" + } + }, + "additionalProperties": false + }, + "RequestOverrides": { + "description": "Per-`ProviderKey` request-shape overrides — see issue #302 §5 `RuntimeConfig.request`. Each field maps 1:1 onto a primitive apply function in [`aisix-provider-openai`'s `overrides` module](https://github.com/api7/ai-gateway/blob/main/crates/aisix-provider-openai/src/overrides.rs):\n\n- `param_renames` → `apply_param_renames` - `param_constraints` → `apply_param_constraints` - `default_headers` → `apply_default_headers` - `default_body_fields` → `apply_default_body_fields`\n\n`f64` in [`ParamConstraints`] is the reason the parent [`ProviderKey`] derives `PartialEq` rather than `Eq`.", + "type": "object", + "properties": { + "default_body_fields": { + "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 + }, + "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" + } + }, + "param_constraints": { + "description": "`apply_param_constraints` input. `None` means no clamping.", + "anyOf": [ + { + "$ref": "#/definitions/ParamConstraints" + }, + { + "type": "null" + } + ] + }, + "param_renames": { + "description": "`apply_param_renames` input. Top-level body keys named on the left are renamed to the right. Empty map is the default.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "ResponseOverrides": { + "description": "Per-`ProviderKey` response-shape overrides — see issue #302 §5 `RuntimeConfig.response`. Each field maps onto behavior the [`aisix-provider-openai`'s `overrides` module](https://github.com/api7/ai-gateway/blob/main/crates/aisix-provider-openai/src/overrides.rs) already implements:\n\n- `stream_done_marker` → `apply_stream_done_marker_policy` - `content_list_to_string` → `apply_content_list_to_string` (applied to the *request* body before send when the upstream only accepts string content) - `reasoning_field` → `extract_reasoning_field`\n\n`error_envelope` is on-disk only — issue #302 §5 keeps it as a `\"openai\" | \"passthrough\"` string so cp-api can iterate without a Rust-side enum migration. Phase D pins the closed set.", + "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. Defaults to `false` (no flattening).", + "default": false, + "type": "boolean" + }, + "error_envelope": { + "description": "On-disk discriminator for the error-translation strategy. `\"openai\"` projects upstream errors into the OpenAI envelope; `\"passthrough\"` returns the upstream body as-is. Open string in this PR (issue #302 §5 wire shape); Phase D pins the closed set in a follow-up.", + "type": [ + "string", + "null" + ] + }, + "reasoning_field": { + "description": "`extract_reasoning_field` path. Empty / `None` means no lift. Example: `\"delta.reasoning_content\"` (DeepSeek's canonical shape, already aligned with the gateway's emit slot).", + "type": [ + "string", + "null" + ] + }, + "stream_done_marker": { + "description": "Stream `[DONE]` terminator expectation. `None` means \"no opinion\" — same effect as [`StreamDoneMarker::Optional`].", + "anyOf": [ + { + "$ref": "#/definitions/StreamDoneMarker" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "StreamDoneMarker": { + "description": "Stream `[DONE]` terminator policy for an SSE response — the on-disk shape of issue #302 §5 `stream_done_marker`. The wire form is the lowercased variant name (`\"required\"` / `\"optional\"` / `\"none\"`) so cp-api JSON keeps the same set the original spec drafted.\n\nThe runtime apply function lives in `aisix-provider-openai` (`apply_stream_done_marker_policy`) and consumes this enum directly via re-export from `aisix-core`.", + "oneOf": [ + { + "description": "Upstream must emit `data: [DONE]`. Absence is a wire-shape violation. OpenAI proper, DeepSeek, Groq.", + "type": "string", + "enum": [ + "required" + ] + }, + { + "description": "Either presence or absence is acceptable. Used when the upstream is OpenAI-compat but does not promise the terminator.", + "type": "string", + "enum": [ + "optional" + ] + }, + { + "description": "Upstream is expected to *omit* the marker. Some Azure / Vertex flavors terminate cleanly on connection close.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "TelemetryTags": { + "description": "Telemetry attribution tags emitted alongside requests routed through this `ProviderKey`. Introduced as a skeleton for issue #302 Phase A — no metric/log path consumes these fields yet.\n\nThe `#[serde(default)]` on each field plus `#[derive(Default)]` means an omitted block or omitted individual key both yield the zero-value `TelemetryTags`, preserving backward compatibility with existing `ProviderKey` payloads.", + "type": "object", + "properties": { + "branded_provider": { + "description": "Branded provider slug for catalog entries (e.g. `\"openai\"`, `\"anthropic\"`). `None` for byo or until Phase A wires attribution.", + "type": [ + "string", + "null" + ] + }, + "byo_label": { + "description": "Operator-defined label for bring-your-own entries (e.g. an internal team name). `None` for catalog entries or until Phase A wires attribution.", + "type": [ + "string", + "null" + ] + }, + "featured": { + "description": "Whether this provider is surfaced in the featured list. Defaults to `false`.", + "default": false, + "type": "boolean" + }, + "kind": { + "description": "`\"catalog\"` for first-party curated providers, `\"byo\"` for bring-your-own. `None` until Phase A wires attribution.", + "type": [ + "string", + "null" + ] + }, + "pk_label": { + "description": "Operator-defined label for this provider key (e.g. `\"production\"`, `\"shared-test\"`). `None` until Phase A wires attribution.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/schemas/resources/rate_limit.schema.json b/schemas/resources/rate_limit.schema.json new file mode 100644 index 00000000..2f4d76e0 --- /dev/null +++ b/schemas/resources/rate_limit.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RateLimit", + "type": "object", + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "rpd": { + "description": "Requests per day (86400s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "rpm": { + "description": "Requests per minute (60s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tpd": { + "description": "Tokens per day (86400s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tpm": { + "description": "Tokens per minute (60s window).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false +} diff --git a/schemas/resources/rate_limit_policy.schema.json b/schemas/resources/rate_limit_policy.schema.json new file mode 100644 index 00000000..dc7154e3 --- /dev/null +++ b/schemas/resources/rate_limit_policy.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RateLimitPolicy", + "type": "object", + "required": [ + "name", + "scope", + "scope_ref", + "window" + ], + "properties": { + "max_requests": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "max_tokens": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "scope_ref": { + "type": "string" + }, + "window": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/schemas/resources/routing.schema.json b/schemas/resources/routing.schema.json new file mode 100644 index 00000000..16148595 --- /dev/null +++ b/schemas/resources/routing.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Routing", + "type": "object", + "required": [ + "targets" + ], + "properties": { + "max_fallbacks": { + "description": "Max number of later targets to attempt after the initial target fails permanently. Defaults to all later targets.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "on_all_filtered": { + "description": "Policy for the case where every candidate is filtered out by runtime status. See [`OnAllFilteredPolicy`].", + "anyOf": [ + { + "$ref": "#/definitions/OnAllFilteredPolicy" + }, + { + "type": "null" + } + ] + }, + "retries": { + "description": "Retry attempts on the current target before failing over.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "retry_on_429": { + "description": "Whether upstream 429 participates in retries and failover.", + "type": [ + "boolean", + "null" + ] + }, + "strategy": { + "default": "failover", + "allOf": [ + { + "$ref": "#/definitions/RoutingStrategy" + } + ] + }, + "targets": { + "type": "array", + "items": { + "$ref": "#/definitions/RoutingTarget" + } + } + }, + "additionalProperties": false, + "definitions": { + "OnAllFilteredPolicy": { + "description": "Behavior when every candidate target is filtered out by the runtime status layer (all in cooldown or background-unhealthy).\n\n`Fail` is the default because sending traffic to a target we know is currently bad — just because every other target is also bad — amplifies cascading outages. Operators that prefer the legacy behavior (try every candidate regardless of known state) can opt into `OriginalOrder` per routing model.", + "oneOf": [ + { + "description": "Return 503 with a fixed Retry-After hint (currently 30 seconds — see `FALLBACK_ALL_UNHEALTHY_RETRY_AFTER` in `crates/aisix-proxy/src/chat.rs`). Default.\n\nThe hint is intentionally coarse: by the time the filter reaches the all-filtered branch, every candidate is background-unhealthy with no live cooldown timer (cooldown candidates are returned via the Selected branch one tier up). A future version may derive the hint from probe metadata; the current contract is a flat fallback.", + "type": "string", + "enum": [ + "fail" + ] + }, + { + "description": "Send to the original candidate list anyway, in declaration order. Preserves availability over caller-facing correctness. Use only when the operator explicitly accepts the risk of sending traffic to a target the gateway just probed as broken.", + "type": "string", + "enum": [ + "original_order" + ] + } + ] + }, + "RoutingStrategy": { + "oneOf": [ + { + "type": "string", + "enum": [ + "round_robin", + "weighted" + ] + }, + { + "description": "Failover is the safest default — predictable order, no shared state, no surprises on first deploy.", + "type": "string", + "enum": [ + "failover" + ] + } + ] + }, + "RoutingTarget": { + "description": "One destination in a routing config. `model` references another `Model.name` in the snapshot.", + "type": "object", + "required": [ + "model" + ], + "properties": { + "model": { + "type": "string" + }, + "weight": { + "description": "Only meaningful for `weighted`. Optional everywhere else; falls back to 1 when missing.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +}