diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f3cc73d..186a7808 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,11 +43,23 @@ jobs: - uses: ./.github/actions/setup-protoc - uses: Swatinem/rust-cache@v2 - name: regenerate schemas - run: cargo run -p aisix-core --bin dump-schema + run: | + # The dump only ever writes, so a file it STOPPED emitting (a + # resource dropped from RESOURCES) would sit in the tree forever and + # keep being vendored. Clear both sets first so a vanished file shows + # up in `git diff` as a deletion. + rm -f schemas/resources/*.schema.json schemas/resources-lenient/*.schema.json + cargo run -p aisix-core --bin dump-schema - name: assert no drift run: | + # --intent-to-add so a file the dump newly creates (a new resource, + # or a whole set) shows up in `git diff` instead of passing as + # untracked. Both published sets are checked: `schemas/resources/` + # (strict write contract) and `schemas/resources-lenient/` (the + # etcd loader's read contract). + git add --intent-to-add schemas/ if ! git diff --exit-code schemas/; then - echo "::error::Resource JSON Schemas in 'schemas/resources/' drift from the Rust types in 'crates/aisix-core/src/models/'." + echo "::error::Resource JSON Schemas in 'schemas/resources/' or 'schemas/resources-lenient/' drift from the Rust types in 'crates/aisix-core/src/models/'." echo "::error::Fix: run 'cargo run -p aisix-core --bin dump-schema' locally and commit the diff." exit 1 fi diff --git a/CLAUDE.md b/CLAUDE.md index d1538b90..42a63144 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,12 +148,16 @@ This repo reads its config from etcd, but users never write etcd directly — th (Lesson from AISIX-Cloud#873 routing: `least_cost` / `least_latency` / `least_busy`, per-target `tags`, and `sticky` canary all shipped DP-only across #681/#682/#684/#686/#687 while `cp-admin.yaml` still pinned the closed `[round_robin, weighted, failover]` enum and the dashboard had no fields — so none of it was actually usable until the matching CP integration landed. The meta-repo `AGENTS.md` carries the same rule for cross-plane agents.) +## A Control-Plane Response Decodes Loosely + +**A data plane may run against a control plane many releases newer than itself, and the etcd read tolerance covers only the resources it loads — the gateway's direct HTTP channels to the control plane need the same tolerance stated for them.** Any struct that decodes a control-plane response must never carry `#[serde(deny_unknown_fields)]`, and every field except the one the decision hinges on must be `#[serde(default)]`, so a field the newer control plane added, or an optional one it stopped sending, cannot turn into a hard error on the request path. A response the gateway does not parse today — one it reads only the status of — stays unparsed unless it is decoded under the same rule. The startup `Config` and its blocks are the deliberate exception: they are `deny_unknown_fields` because they parse the gateway's own `config.yaml`, which nothing but the operator writes. + ## The Resource Model Is Canonical in cp-admin.yaml **When this repo and the control plane disagree about a resource field's name, enum values, or nesting, the control plane's spec (`AISIX-Cloud: openapi/cp-admin.yaml`) wins by definition — this repo converges to it.** - Adding or renaming a user-facing resource field starts by defining its name and shape in `cp-admin.yaml` (in the paired CP PR — see the config-knob rule above); the Rust model then implements exactly that name. The naming decision happens once, in the spec — never independently here. -- Renames converge with `#[serde(alias = "…")]` so stored documents and existing callers keep loading through the deprecation window; never hard-rename a shipped field in one step (an unreleased field with no consumers may rename outright, as #657 did). Regenerate `schemas/resources/` afterwards (`cargo run -p aisix-core --bin dump-schema`). +- Renames converge with `#[serde(alias = "…")]` so stored documents and existing callers keep loading through the deprecation window; never hard-rename a shipped field in one step (an unreleased field with no consumers may rename outright, as #657 did). Regenerate `schemas/` afterwards (`cargo run -p aisix-core --bin dump-schema`, which writes both the strict and the lenient set). - Exactly four divergence axes are registered as intentional and allowed: reference style (names here vs UUIDs in the CP), tenancy scoping (flat here vs org/environment there), credential custody (`key_hash` in documents here vs server-generated plaintext-once there), and CP-derived fields (`cost`, `telemetry_tags`). Anything else that diverges from cp-admin.yaml is drift — the planned cross-plane contract check will fail it. - Why the CP spec and not this repo's schemas: the CP is spec-first behind a closed validator (its spec already is the authoritative field shape on that side), the spec renders into the customer-facing API reference, and this repo's schemas are generated from the implementation — a schema that follows the implementation cannot lead it. Naming drift has already cost real churn: #644 (the generated schema advertised `rps`/`rph` the validator rejected) and #657 (a wire-breaking rename because the field was named DP-first). diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 91178153..0594bdd6 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -12,6 +12,27 @@ //! `schemars` 0.8) — nested types live in the `definitions/` section //! of the same document, no cross-file `$ref` required. //! +//! Every run writes BOTH published sets, under the same file names: +//! +//! - `schemas/resources/` — the **strict** write contract +//! (`resource_root_schema(name, true)`), what `aisix validate` and the +//! resources-file source enforce. +//! - `schemas/resources-lenient/` — the **read** contract +//! (`resource_root_schema(name, false)`), the schema the etcd loader +//! actually validates stored documents against. It is free of +//! `additionalProperties: false` at every depth, so a document written by +//! a newer control plane loads with its extra fields ignored instead of +//! the whole row being skipped. Published so a consumer that needs to know +//! what this build will LOAD can read it instead of deriving it from the +//! strict files. It is NOT a write contract, and for `model`, `api_key`, +//! `guardrail` and `mcp_policy` it relaxes more than unknown fields — +//! `schemas/README.md` lists what. +//! +//! The five nested struct types have no standalone validator on either path, +//! so their standalone files (in both sets) document the struct's shape +//! rather than anything enforced; the authoritative copy of one is the +//! embedding resource's own `definitions` entry. +//! //! 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). @@ -34,8 +55,11 @@ use aisix_core::models::schema; use aisix_core::models::{EmbeddingConfig, EnsembleConfig, RateLimit, Routing, Semantic}; fn main() { - let out_dir = workspace_root().join("schemas").join("resources"); + let schemas_dir = workspace_root().join("schemas"); + let out_dir = schemas_dir.join("resources"); + let lenient_dir = schemas_dir.join("resources-lenient"); fs::create_dir_all(&out_dir).expect("create schemas/resources dir"); + fs::create_dir_all(&lenient_dir).expect("create schemas/resources-lenient dir"); // Every resource with a runtime validator goes through the SAME // `resource_root_schema(name, strict: true)` producer the strict @@ -44,8 +68,10 @@ fn main() { // STRICT shape: they document the declarative write contract (unknown // fields are rejected by `aisix validate` and the file source wherever // a resource closes them) and the - // etcd loader's lenient read tolerance is a runtime behavior, not a - // contract callers may write against. + // etcd loader's lenient read tolerance is published beside them, as + // `schemas/resources-lenient/`, from the same producer with + // `strict: false` — the exact value `LENIENT_SCHEMAS` compiles, so the + // published read contract cannot drift from the enforced one either. // `ensemble`/`rate_limit`/`routing` have no standalone validator (they // are nested struct types) so they dump straight from the struct via // `schema_for!`, closed the same way. @@ -55,23 +81,39 @@ fn main() { resource, schema::resource_root_schema(resource, true), ); + dump_value( + &lenient_dir, + resource, + schema::resource_root_schema(resource, false), + ); } - dump::(&out_dir, "ensemble"); - dump::(&out_dir, "rate_limit"); - dump::(&out_dir, "routing"); - dump::(&out_dir, "semantic"); - dump::(&out_dir, "embedding"); + dump::(&out_dir, &lenient_dir, "ensemble"); + dump::(&out_dir, &lenient_dir, "rate_limit"); + dump::(&out_dir, &lenient_dir, "routing"); + dump::(&out_dir, &lenient_dir, "semantic"); + dump::(&out_dir, &lenient_dir, "embedding"); } -fn dump(out_dir: &Path, name: &str) { +fn dump(out_dir: &Path, lenient_dir: &Path, name: &str) { + let mut root = schemars::schema_for!(T); + + // The lenient twin comes off the SAME producer, run through + // `schema::open_unknown_fields` — the pass `LENIENT_SCHEMAS` compiles the + // resource roots with — before the closing pass below runs. These nested + // types have no standalone validator on either path, so neither file is a + // contract; the pair documents the struct's shape under each strictness, + // and the enforced copy is the embedding resource's `definitions` entry. + let mut lenient = serde_json::to_value(&root).expect("serialize schema"); + schema::open_unknown_fields(&mut lenient); + dump_value(lenient_dir, name, lenient); + // Serialize the `RootSchema` directly to preserve schemars' native key // ordering. (Routing through `serde_json::Value` would re-sort keys.) // These nested types belong to closed resources, so re-close the root // and every struct-shaped definition on the typed schema — the same // strictness `schema::close_unknown_fields` applies to the resource // documents, kept typed here so the key order stays schemars-native. - let mut root = schemars::schema_for!(T); close_object_schema(&mut root.schema); for def in root.definitions.values_mut() { if let schemars::schema::Schema::Object(obj) = def { diff --git a/crates/aisix-core/src/models/policy_conditions.rs b/crates/aisix-core/src/models/policy_conditions.rs index 9a3ae496..ab548bec 100644 --- a/crates/aisix-core/src/models/policy_conditions.rs +++ b/crates/aisix-core/src/models/policy_conditions.rs @@ -279,10 +279,10 @@ pub struct ConditionGroup { /// A slot in a condition list: leaf or nested group. Untagged — the /// shapes are disjoint (a leaf requires `dimension`/`operator`/`value`, -/// a group `logic`/`children`), and the schema closes both variants -/// against unknown fields in **both** validator sets because serde -/// silently swallows unknown fields inside untagged content (same -/// reasoning as `OnEmbeddingFailure` in the model schema). +/// a group `logic`/`children`), and the write contract rejects unknown +/// fields inside either variant, because an unknown field placed there +/// is otherwise ignored without being reported (same reasoning as +/// `OnEmbeddingFailure` in the model schema). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(untagged)] pub enum ConditionNode { diff --git a/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs index 1b9a77d0..5e418620 100644 --- a/crates/aisix-core/tests/resource_schema_characterization.rs +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -8,11 +8,13 @@ //! case is obvious. New resources append their own table as they migrate. use aisix_core::models::schema::{ - unknown_field_paths, validate_apikey, validate_cache_policy, validate_guardrail, - validate_guardrail_attachment, validate_observability_exporter, validate_provider_key, - validate_rate_limit_policy, + resource_root_schema, unknown_field_paths, validate_apikey, validate_cache_policy, + validate_guardrail, validate_guardrail_attachment, validate_observability_exporter, + validate_provider_key, validate_rate_limit_policy, RESOURCES, }; use serde_json::{json, Value}; +use std::fs; +use std::path::Path; /// Run a corpus of `(label, expect_accept, payload)` against `validate`. /// @@ -837,3 +839,491 @@ fn guardrail_attachment_corpus() { ], ); } + +// --------------------------------------------------------------------------- +// Published schema files — `schemas/resources/` (strict, the write contract) +// and `schemas/resources-lenient/` (the etcd loader's read contract). +// +// The corpora above pin what the in-process validators do. This section pins +// that the files a downstream consumer vendors ARE those validators, so the +// control plane can read the read contract instead of re-deriving it from the +// strict files. +// --------------------------------------------------------------------------- + +/// Parse one published schema file out of `schemas//`. +fn schemas_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("CARGO_MANIFEST_DIR has two ancestors") + .join("schemas") +} + +/// Sorted file names published under `schemas//`. Read off the directory +/// rather than a hard-coded list so a file the dump starts (or stops) emitting +/// reaches the checks below. +fn published_file_names(sub: &str) -> Vec { + let mut names: Vec = fs::read_dir(schemas_dir().join(sub)) + .unwrap_or_else(|e| panic!("read schemas/{sub}: {e}")) + .map(|e| { + e.expect("dir entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + names +} + +fn published_schema(dir: &str, resource: &str) -> Value { + let path = schemas_dir() + .join(dir) + .join(format!("{resource}.schema.json")); + let bytes = + fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_str(&bytes).unwrap_or_else(|e| panic!("parse {}: {e}", path.display())) +} + +/// Every published lenient file is exactly what `LENIENT_SCHEMAS` compiles. +/// +/// `Schemas::compile(false)` builds each validator from +/// `resource_root_schema(resource, false)`, so equality here is the whole +/// provenance claim: the file is the loader's schema, not a transformation of +/// the strict one that happens to agree today. +#[test] +fn published_lenient_schemas_are_what_the_loader_compiles() { + for resource in RESOURCES { + assert_eq!( + published_schema("resources-lenient", resource), + resource_root_schema(resource, false), + "schemas/resources-lenient/{resource}.schema.json is not \ + resource_root_schema({resource:?}, false) — re-run \ + `cargo run -p aisix-core --bin dump-schema`" + ); + } +} + +/// The strict twin of the check above: every published strict file is exactly +/// what the write validators compile. Those files are also `include_str!`ed +/// into the DP admin OpenAPI document, so they are worth pinning here and not +/// only in the CI drift job. +#[test] +fn published_strict_schemas_are_what_the_write_path_compiles() { + for resource in RESOURCES { + assert_eq!( + published_schema("resources", resource), + resource_root_schema(resource, true), + "schemas/resources/{resource}.schema.json is not \ + resource_root_schema({resource:?}, true) — re-run \ + `cargo run -p aisix-core --bin dump-schema`" + ); + } +} + +/// Where the two published sets differ BEYOND unknown fields, pinned as the +/// exhaustive list of JSON paths at which the strict file (with every +/// `additionalProperties: false` stripped) and the lenient file disagree. +/// +/// A consumer that models the lenient set as "the strict set with the +/// closures removed" is wrong for these four, and the difference is not +/// cosmetic: the loader accepts an `mcp_policy` with no `allow` and reads the +/// field's default. Registering the paths rather than a prose reason is what +/// makes the table checkable — a claim that some OTHER field relaxed, or that +/// one of these stopped relaxing, moves a path and fails. +/// +/// Two shapes appear here, and `schemas/README.md` must keep telling them +/// apart: +/// +/// - a `required` / `not` change, which really does let the loader accept a +/// document the write path rejects (`api_key`, `mcp_policy`, `model`, and +/// the `semantic` guardrail branch); +/// - a `default` annotation the STRICT producer strips on purpose and the +/// lenient one keeps, which changes nothing about what validates but does +/// feed a schema-driven form generator a value the same branch would refuse +/// (the `custom` guardrail's `script`, whose `default: ""` sits beside +/// `minLength: 1`; the semantic thresholds' `default: 0.75`). `script` +/// itself is required on BOTH sets. +const EXTRA_RELAXATIONS: &[(&str, &[&str])] = &[ + ("api_key", &["/definitions/McpAccess/required"]), + ( + "guardrail", + &[ + "/oneOf/10/allOf", + "/oneOf/10/properties/allow_threshold/default", + "/oneOf/10/properties/deny_threshold/default", + "/oneOf/10/required", + "/oneOf/11/properties/script/default", + ], + ), + ("mcp_policy", &["/required"]), + ( + "model", + &[ + "/oneOf/0/not/anyOf", + "/oneOf/1/not/anyOf", + "/oneOf/2/not/anyOf", + "/oneOf/3/not/anyOf", + ], + ), +]; + +/// The published files are exactly the ones `dump-schema` emits today. +/// +/// `dump-schema` only ever writes, so a file it STOPPED emitting would sit in +/// the tree, keep matching its twin, and go on being vendored as a contract +/// this build no longer has. Every other check here is driven off `RESOURCES` +/// or off the directory itself, and neither can see such an orphan. +#[test] +fn published_directories_hold_exactly_what_the_dump_emits() { + // The nested struct types `dump-schema` publishes beside the resources. + // They have no runtime validator, so `RESOURCES` does not name them. + const NESTED: [&str; 5] = ["embedding", "ensemble", "rate_limit", "routing", "semantic"]; + + let mut expected: Vec = RESOURCES + .iter() + .chain(NESTED.iter()) + .map(|n| format!("{n}.schema.json")) + .collect(); + expected.sort(); + for dir in ["resources", "resources-lenient"] { + assert_eq!( + published_file_names(dir), + expected, + "schemas/{dir}/ holds a file dump-schema no longer emits, or is \ + missing one it does" + ); + } +} + +/// The two published sets differ ONLY by `additionalProperties: false`, +/// except at the paths [`EXTRA_RELAXATIONS`] registers. +/// +/// Strips every closure out of the strict file and requires the result to +/// equal the lenient one. This is the claim `schemas/README.md` makes to +/// downstream consumers, and the claim the control plane cannot make +/// unconditionally when it derives one set from the other. +#[test] +fn published_sets_differ_only_where_registered() { + fn without_closures(node: &Value) -> Value { + match node { + Value::Object(obj) => Value::Object( + obj.iter() + .filter(|(k, v)| !(k.as_str() == "additionalProperties" && *v == &json!(false))) + .map(|(k, v)| (k.clone(), without_closures(v))) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(without_closures).collect()), + other => other.clone(), + } + } + + /// Every JSON path at which `a` and `b` disagree, deepest name that still + /// differs. A length mismatch reports the array itself. + fn diff_paths(a: &Value, b: &Value, at: &str, out: &mut Vec) { + match (a, b) { + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect(); + keys.sort(); + keys.dedup(); + for k in keys { + match (x.get(k), y.get(k)) { + (Some(l), Some(r)) => diff_paths(l, r, &format!("{at}/{k}"), out), + _ => out.push(format!("{at}/{k}")), + } + } + } + (Value::Array(x), Value::Array(y)) if x.len() == y.len() => { + for (i, (l, r)) in x.iter().zip(y).enumerate() { + diff_paths(l, r, &format!("{at}/{i}"), out); + } + } + _ if a != b => out.push(at.to_string()), + _ => {} + } + } + + let mut found: Vec<(String, Vec)> = Vec::new(); + for name in published_file_names("resources-lenient") { + let resource = name.trim_end_matches(".schema.json").to_string(); + let opened = without_closures(&published_schema("resources", &resource)); + let mut paths = Vec::new(); + diff_paths( + &opened, + &published_schema("resources-lenient", &resource), + "", + &mut paths, + ); + paths.sort(); + if !paths.is_empty() { + found.push((resource, paths)); + } + } + found.sort(); + + let mut registered: Vec<(String, Vec)> = EXTRA_RELAXATIONS + .iter() + .map(|(r, paths)| { + ( + (*r).to_string(), + paths.iter().map(|p| (*p).to_string()).collect(), + ) + }) + .collect(); + registered.sort(); + assert_eq!( + found, registered, + "the published sets differ beyond `additionalProperties: false` at a \ + path EXTRA_RELAXATIONS does not register (or register one that no \ + longer differs). schemas/README.md describes this list in prose — \ + update both." + ); +} + +/// No published lenient file closes anything, at any depth. +/// +/// This is the property `open_unknown_fields` exists for and the one a +/// consumer of these files relies on: a closure left standing anywhere — a +/// `definitions` entry, a `oneOf` branch, a nested property — is a whole +/// stored row lost the first time a newer control plane writes a field under +/// it (#1014). Walks the directory rather than a hard-coded list so a file +/// the dump starts emitting cannot skip the check. +#[test] +fn published_lenient_schemas_close_nothing_at_any_depth() { + fn closed_paths(node: &Value, path: &str, out: &mut Vec) { + match node { + Value::Object(obj) => { + if obj.get("additionalProperties") == Some(&Value::Bool(false)) { + out.push(path.to_string()); + } + for (k, v) in obj { + closed_paths(v, &format!("{path}/{k}"), out); + } + } + Value::Array(items) => { + for (i, v) in items.iter().enumerate() { + closed_paths(v, &format!("{path}/{i}"), out); + } + } + _ => {} + } + } + + // The two sets publish the same resources under the same file names — the + // lenient set is a full twin, not a subset of interesting cases. + assert_eq!( + published_file_names("resources"), + published_file_names("resources-lenient") + ); + + for name in published_file_names("resources-lenient") { + let resource = name.trim_end_matches(".schema.json"); + let schema = published_schema("resources-lenient", resource); + let mut closed = Vec::new(); + closed_paths(&schema, "", &mut closed); + assert!( + closed.is_empty(), + "{name} still closes unknown fields at {closed:?}" + ); + } +} + +/// One probe case: a valid `resource` document, and the JSON pointer to the +/// object the unknown field is inserted into (`""` selects the root). +struct Probe { + resource: &'static str, + pointer: &'static str, + document: fn() -> Value, +} + +/// One valid document per resource, pointed at a position that resource's +/// write contract closes — nested wherever the resource has a nested closure. +/// The test inserts a field no build knows there: the strict file must then +/// reject the document it accepted a moment ago, and the lenient file must +/// still accept it. +/// +/// Asserting the un-probed document passes the strict file is what makes the +/// rejection attributable to the unknown field rather than to anything else in +/// the fixture (a missing credential, an unsatisfied `oneOf`). +/// +/// A non-empty pointer is the case a root-only check cannot see, and the one +/// an older gateway got wrong before #1014 — a field added inside a nested +/// config object took the whole row down. Six resources close only their root +/// and say so with `""` rather than silently testing the weaker property. +const UNKNOWN_FIELD_TOLERANCE: &[Probe] = &[ + Probe { + resource: "model", + pointer: "/rate_limit", + document: || { + json!({"display_name": "m", "provider": "openai", "model_name": "gpt-4o", + "provider_key_id": "pk-1", "rate_limit": {"rpm": 10}}) + }, + }, + Probe { + resource: "api_key", + pointer: "/rate_limit", + document: || json!({"key_hash": "h", "allowed_models": ["a"], "rate_limit": {"rpm": 10}}), + }, + Probe { + resource: "provider_key", + pointer: "/tls", + document: || json!({"display_name": "pk", "api_key": "sk-x", "tls": {"verify": false}}), + }, + Probe { + resource: "guardrail", + pointer: "/detectors/0", + document: || json!({"name": "g", "kind": "pii", "detectors": [{"type": "email"}]}), + }, + Probe { + resource: "rate_limit_policy", + pointer: "/limits", + document: || json!({"name": "p", "limits": {"rpm": 10}}), + }, + Probe { + resource: "claim_mapping", + pointer: "/resolve", + document: || { + json!({"name": "c", "jwt_provider": "p", + "match": [{"claim": "sub", "op": "exact", "values": ["a"]}], + "resolve": {"api_key_id": "k"}}) + }, + }, + Probe { + resource: "mcp_auth_settings", + pointer: "/anonymous", + document: || { + json!({"anonymous": {"api_key_id": "k", "servers": ["s"], + "source_cidrs": ["10.0.0.0/8"]}}) + }, + }, + // Root-only: these resources embed no object their write contract closes. + Probe { + resource: "observability_exporter", + pointer: "", + document: || { + json!({"name": "e", "kind": "otlp_http", + "endpoint": "https://collector.example/v1/traces"}) + }, + }, + Probe { + resource: "mcp_server", + pointer: "", + document: || json!({"name": "s", "url": "https://example.com/mcp"}), + }, + Probe { + resource: "mcp_policy", + pointer: "", + document: || json!({"scope": "env", "allow": ["*"]}), + }, + Probe { + resource: "a2a_agent", + pointer: "", + document: || json!({"name": "ag", "url": "https://example.com/a2a"}), + }, + Probe { + resource: "oidc_provider", + pointer: "", + document: || json!({"name": "o", "issuer": "https://issuer.example", "audiences": ["a"]}), + }, + Probe { + resource: "passthrough_route", + pointer: "", + document: || { + json!({"name": "r", "path_prefix": "/proxy", + "target_url": "https://upstream.example", + "provider_key_id": "pk-1"}) + }, + }, +]; + +/// The two resources whose write contract closes NOTHING — not the root, not +/// a nested object — so no document can separate their strict and lenient +/// files. They are checked in the opposite direction: the strict file must +/// still accept the unknown field. The day one of them closes, that assertion +/// fails and it moves into the table above. +const OPEN_ON_WRITE: &[Probe] = &[ + Probe { + resource: "guardrail_attachment", + pointer: "", + document: || json!({"guardrail_id": "gid", "scope_type": "env", "priority": 1}), + }, + Probe { + resource: "cache_policy", + pointer: "", + document: || json!({"name": "c"}), + }, +]; + +/// The field name every probe inserts. Long and unmistakable so a failure +/// message says which key the schema tripped over. +const PROBE: &str = "from_a_newer_control_plane"; + +/// Insert [`PROBE`] into the object `pointer` selects (the root for `""`). +fn probed(mut document: Value, pointer: &str) -> Value { + let target = document + .pointer_mut(pointer) + .unwrap_or_else(|| panic!("pointer {pointer:?} resolves in the fixture")) + .as_object_mut() + .unwrap_or_else(|| panic!("pointer {pointer:?} selects an object")); + target.insert(PROBE.to_string(), json!(1)); + document +} + +#[test] +fn published_lenient_schemas_tolerate_what_the_strict_ones_reject() { + let compile = |dir: &str, resource: &str| { + jsonschema::validator_for(&published_schema(dir, resource)) + .unwrap_or_else(|e| panic!("schemas/{dir}/{resource}.schema.json compiles: {e}")) + }; + + for probe in UNKNOWN_FIELD_TOLERANCE { + let resource = probe.resource; + let pointer = probe.pointer; + let base = (probe.document)(); + let with_unknown = probed(base.clone(), pointer); + let strict = compile("resources", resource); + let lenient = compile("resources-lenient", resource); + + if let Err(e) = strict.validate(&base) { + panic!("{resource}: the fixture is not a valid document to begin with: {e}"); + } + assert!( + strict.validate(&with_unknown).is_err(), + "{resource}: the strict file accepted `{PROBE}` at {pointer:?} — \ + that position is no longer closed on write" + ); + if let Err(e) = lenient.validate(&with_unknown) { + panic!( + "{resource}: the lenient file rejected `{PROBE}` at {pointer:?}, so the \ + loader would skip a row a newer control plane wrote: {e}" + ); + } + } + + for probe in OPEN_ON_WRITE { + let resource = probe.resource; + let with_unknown = probed((probe.document)(), probe.pointer); + assert!( + compile("resources", resource) + .validate(&with_unknown) + .is_ok(), + "{resource} now closes unknown fields on write — move it into \ + UNKNOWN_FIELD_TOLERANCE with a pointer at the closed position" + ); + assert!(compile("resources-lenient", resource) + .validate(&with_unknown) + .is_ok()); + } + + // Exhaustive over the resource list, so a new resource cannot be added + // without deciding which of the two tables it belongs in. + let mut covered: Vec<&str> = UNKNOWN_FIELD_TOLERANCE + .iter() + .chain(OPEN_ON_WRITE) + .map(|p| p.resource) + .collect(); + covered.sort_unstable(); + let mut all = RESOURCES.to_vec(); + all.sort_unstable(); + assert_eq!(covered, all); +} diff --git a/crates/aisix-proxy/src/budget.rs b/crates/aisix-proxy/src/budget.rs index 02741237..4d245ebb 100644 --- a/crates/aisix-proxy/src/budget.rs +++ b/crates/aisix-proxy/src/budget.rs @@ -289,6 +289,10 @@ fn apply_fail_mode(prev: &Decision) -> Decision { // // We surface only `message` to ProxyError::BudgetExceeded; the other // fields exist for the dashboard banner once we plumb them through. +/// A data plane can run against a control plane many releases newer than +/// itself, so this struct — and every other one that decodes a control-plane +/// response — never carries `#[serde(deny_unknown_fields)]`, and every field +/// except `allow`, the one the decision hinges on, is `#[serde(default)]`. #[derive(Debug, Deserialize)] struct WireDecision { allow: bool, diff --git a/schemas/README.md b/schemas/README.md index 6b7eaa4e..59dec8d9 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -8,20 +8,21 @@ types. The files are **auto-generated** from the Rust type definitions in ```text schemas/ -└── resources/ - ├── api_key.schema.json - ├── cache_policy.schema.json - ├── embedding.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 - └── semantic.schema.json +├── resources/ # strict — the write contract +│ ├── api_key.schema.json +│ ├── cache_policy.schema.json +│ ├── guardrail.schema.json +│ ├── model.schema.json +│ ├── provider_key.schema.json +│ └── … # one per resource, plus the nested struct types +└── resources-lenient/ # lenient — the etcd read contract, same file names ``` +Both directories hold the same file names. The listing above is a sample; +the set is whatever `dump-schema` emits, which is every entry of +`schema::RESOURCES` plus `ensemble`, `rate_limit`, `routing`, `semantic` +and `embedding`. + 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 @@ -44,6 +45,24 @@ resource closes them — is rejected on those paths. They are generated from the same producers the in-repo strict validators compile, so the published files and the gateway's own validators cannot drift. +Four top-level resources intentionally **omit** +`additionalProperties: false` even on the write contract (the list is +`closes_on_write` in `crates/aisix-core/src/models/schema.rs`): + +- `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` — historically open on write as well. +- `guardrail_attachment.schema.json` — likewise. +- `observability_exporter.schema.json` — the top level is open, but the + per-`kind` branches stay closed on the write path, so a misspelled + field is rejected rather than dropped. On the read path they open like + every other closure (see `resources-lenient/` below); the tolerance is + not silent there because serde cannot report ignored fields inside a + tagged union, so the loader takes this resource's unknown-field report + from the strict schema instead (`unknown_field_paths`). + Two write paths sit outside this enforcement: the AISIX Cloud control plane validates requests against its own API schema before writing etcd, and a **raw direct etcd put gets no synchronous validation** — @@ -59,19 +78,96 @@ older gateway serving documents written by a newer control plane. Every other constraint in these files — types, required fields, ranges, closed enum value sets — applies on both paths. -Three top-level resources intentionally **omit** -`additionalProperties: false` even on the write contract: - -- `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` — historically open on write as well. -- `observability_exporter.schema.json` — the top level is open, but the - per-`kind` branches stay closed on both paths: an unknown field there - could smuggle a plaintext credential past the `credential_ref` - indirection, and serde cannot report ignored fields inside the - tagged union, so an open branch would be a silent tolerance. +That read contract is published too, as `resources-lenient/` — see below. + +## `resources-lenient/`: what this build will LOAD + +`resources-lenient/` carries the same resources under the same file +names, generated from the same producers with `strict: false` — the +exact schemas the etcd snapshot loader compiles into `LENIENT_SCHEMAS` +and validates every stored document against. A consumer that needs to +know what a given gateway release will accept from etcd reads these +files rather than deriving them from the strict ones. + +**Do not validate writes against these files.** They are deliberately +open, and for four resources they relax more than that (below), so a +consumer that swaps `resources/` for `resources-lenient/` in a +vendoring script silently turns its input validation into an +accept-almost-anything gate. `resources/` stays the schema for anything +a user submits; `resources-lenient/` answers only "will this build load +this stored document". + +### How the two sets differ + +For **every** resource, a lenient file carries no +`additionalProperties: false`, at **any** depth — not on the root, not on +a `definitions` entry, not on a `oneOf` branch, not on a nested property. +That is the tolerance the split exists for: an optional field a newer +control plane adds inside a nested config object is ignored and reported, +instead of taking the whole row down. + +For **four** resources the read contract relaxes a requirement as well, +so a consumer that models the lenient set as "the strict set with +`additionalProperties` stripped" is wrong about them: + +| resource | additionally relaxed on read | +| --- | --- | +| `api_key` | `McpAccess.allow` is not required | +| `guardrail` | the `semantic` kind requires neither `embedding_model` nor a threshold beside each example list | +| `mcp_policy` | `allow` is not required | +| `model` | the per-kind `not`/`anyOf` lists that forbid a knob a kind never resolves are shorter — a stored row keeps loading and `Model::strip_kind_inapplicable` drops the dead knob | + +Note what is NOT in that table: the `custom` guardrail's `script` is +required on **both** sets. A scriptless `custom` row screens nothing +either way, so rejecting it is what makes it visible in +`GET /status/config`'s `rejected[]`. + +These come from the four producers that take a `strict` flag in +`crates/aisix-core/src/models/schema.rs` and are deliberate. + +Separately, the lenient files keep three `default` annotations the +strict producer strips on purpose — `default: 0.75` on the `semantic` +guardrail's `allow_threshold`/`deny_threshold`, and `default: ""` on the +`custom` kind's `script`, which sits beside `minLength: 1`. They change +nothing about what validates, but a form generator that honours them +pre-fills a threshold the operator was deliberately asked to choose, or +a script value the same branch refuses. Generate forms from +`resources/`. + +The exact paths at which the two sets diverge are pinned by +`published_sets_differ_only_where_registered` in +`crates/aisix-core/tests/resource_schema_characterization.rs`, so a new +divergence — or a change to one of these — has to be registered before +the suite goes green. Everything else is identical: field names, types, +ranges, enum value sets, the `$ref`/`definitions` layout, and the +`if`/`then`/`oneOf` structure. + +### Two gates sit behind the lenient schema + +Passing a lenient file is necessary, not sufficient. After the schema +gate the loader still deserialises the document into the Rust type, and +a value the schema does not constrain (an integer past `u64`, say) fails +there and takes the row; and `rate_limit_policy` runs a semantic pass +(`validate_semantics`) for cross-field rules JSON Schema cannot express, +which also rejects a row whole. Treat these files as the necessary +condition for a row to load, not the complete one. + +### The five nested struct types are documentation, not a contract + +`ensemble`, `rate_limit`, `routing`, `semantic` and `embedding` have no +standalone validator on either path — they are only ever validated as +part of the resource that embeds them — so their standalone files, in +**both** sets, document the struct's shape rather than anything that is +enforced. They are also generated with schemars' default `Option` +rendering, which the embedding resources do not all use: `rate_limit` +standalone renders `rpm` as `["integer", "null"]`, and +`model.schema.json#/definitions/RateLimit` renders it as `"integer"`, so +a `model` document writing an explicit `null` there is accepted by the +standalone file and skipped by the loader. The authoritative copy of a +nested type is always +`.schema.json#/definitions/` — read it there. (Their key +order also differs between the two sets, since only the lenient side +round-trips through a sorted JSON map.) ## Regenerating @@ -106,11 +202,15 @@ configured in the repository. files. (Follow-up PR.) - Documentation sites can consume the hosted Admin API OpenAPI document for the AISIX AI Gateway Admin API reference. -- Control-plane services can pin these files for REST input validation - against the same shape the data plane consumes from etcd. -- Dashboards can render forms from these schemas with +- Control-plane services can pin `resources/` for REST input validation + against the same shape the data plane consumes from etcd, and pin + `resources-lenient/` to reason about what an already-deployed gateway + release will still load — never the other way round. +- Dashboards can render forms from `resources/` with [RJSF](https://github.com/rjsf-team/react-jsonschema-form) or - equivalent, instead of hand-coded validators. + equivalent, instead of hand-coded validators — from `resources/` and + not its lenient twin, which keeps `default` annotations the write + contract deliberately drops (above). Refs api7/ai-gateway#304 item #1 (canonical JSON Schema as config source of truth). diff --git a/schemas/resources-lenient/a2a_agent.schema.json b/schemas/resources-lenient/a2a_agent.schema.json new file mode 100644 index 00000000..6e4e32f0 --- /dev/null +++ b/schemas/resources-lenient/a2a_agent.schema.json @@ -0,0 +1,187 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "if": { + "properties": { + "auth_type": { + "const": "bearer" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "secret": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret" + ] + } + }, + { + "if": { + "properties": { + "auth_type": { + "const": "api_key" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "secret": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "name" + ], + "title": "name" + }, + { + "required": [ + "display_name" + ], + "title": "display_name" + } + ], + "definitions": { + "A2aAuthType": { + "description": "How the gateway authenticates to an upstream A2A agent.", + "oneOf": [ + { + "description": "No authentication; the agent is reached as-is.", + "enum": [ + "none" + ], + "title": "No authentication", + "type": "string" + }, + { + "description": "Bearer token authentication. The token is supplied in `secret` and sent as `Authorization: Bearer `.", + "enum": [ + "bearer" + ], + "title": "Bearer token", + "type": "string" + }, + { + "description": "API key authentication. The key is supplied in `secret` and sent as an `x-api-key: ` header on every upstream request.", + "enum": [ + "api_key" + ], + "title": "API key", + "type": "string" + } + ] + }, + "A2aProtocolVersion": { + "description": "The A2A wire-format version pinned for an upstream agent.", + "oneOf": [ + { + "description": "A2A 1.0 wire format with protobuf-JSON envelopes and PascalCase methods.", + "enum": [ + "1.0" + ], + "title": "A2A 1.0", + "type": "string" + }, + { + "description": "A2A 0.3 wire format with `kind`-discriminated JSON-RPC objects.", + "enum": [ + "0.3" + ], + "title": "A2A 0.3", + "type": "string" + } + ] + } + }, + "properties": { + "auth_type": { + "allOf": [ + { + "$ref": "#/definitions/A2aAuthType" + } + ], + "default": "none", + "description": "How the gateway authenticates to the upstream agent. The credential is held by the gateway and is not exposed to the calling client. The one case where the agent sees a caller-supplied credential instead is when `forward_client_headers` names the slot this `auth_type` fills, which substitutes the caller's own value for the gateway's rather than sending both." + }, + "display_name": { + "description": "Accepted as an alternative spelling of `name`. Provide the label under exactly one of the two names.", + "minLength": 1, + "pattern": "^[^/?#%\\s\\x00-\\x1f\\x7f€-Ÿ]+$", + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this agent is active. When `false`, it is not served and cannot be reached.", + "type": "boolean" + }, + "forward_client_headers": { + "description": "Inbound client headers forwarded to this agent, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to the agent-card fetch at `/a2a//.well-known/agent-card.json` as well as to every JSON-RPC method served at `/a2a/`, so an agent receives them on `message/send`, `message/stream` and every task operation alike.\n\nA header named here reaches the agent whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer`, `x-api-key` for `api_key` — hands the agent the caller's own credential in place of the gateway's, never both. That is what lets an internal agent that already authorizes on the end user's `Authorization` keep doing so unchanged. An agent that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and `a2a-version`, which is the gateway's own announcement of the wire version pinned in `protocol_version` and which a caller's value would override.", + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "description": "Operator-facing label, unique within the gateway. It is the path segment under which the agent is exposed to callers as `/a2a/`, so it must be a single non-empty URL path segment. The name is interpolated into the advertised agent-card URL without percent-encoding, so `/`, `?`, `#`, `%` and whitespace are rejected: `a?b` would advertise a URL whose path is just `/a2a/a`, and the lookup is an exact match on the stored name.", + "minLength": 1, + "pattern": "^[^/?#%\\s\\x00-\\x1f\\x7f€-Ÿ]+$", + "type": "string" + }, + "protocol_version": { + "allOf": [ + { + "$ref": "#/definitions/A2aProtocolVersion" + } + ], + "default": "1.0", + "description": "The A2A wire-format version this agent speaks. AISIX announces it to the agent in the `A2A-Version` header on every request, so it must match what the agent actually serves: an agent reads an absent or mismatched version as a protocol error and rejects the call." + }, + "secret": { + "description": "Credential AISIX uses to authenticate to the upstream agent. For `bearer`, AISIX sends it as `Authorization: Bearer `; for `api_key`, AISIX sends it as `x-api-key: `. Leave unset for `none`.", + "type": [ + "string", + "null" + ] + }, + "timeout_ms": { + "description": "Maximum time, in milliseconds, to wait for a single upstream operation, including fetching the agent card or invoking the agent. When omitted, AISIX applies a built-in default.", + "format": "uint64", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "url": { + "description": "The upstream agent's A2A service endpoint, such as `https://agents.example.com/a2a`, where AISIX sends JSON-RPC 2.0 requests over HTTP. AISIX looks for the agent card at the well-known path under this URL's own path first, then under its origin, so both an agent that owns its domain and one published under a path prefix are reachable without extra configuration.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "A2aAgent", + "type": "object" +} diff --git a/schemas/resources-lenient/api_key.schema.json b/schemas/resources-lenient/api_key.schema.json new file mode 100644 index 00000000..c1e0dd4f --- /dev/null +++ b/schemas/resources-lenient/api_key.schema.json @@ -0,0 +1,275 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpAccess": { + "description": "The API key's own layer of the MCP tool ACL, the same `allow`/`deny` shape an MCP access policy carries. Present means the key constrains its grant; omitted means the key adds no constraint of its own and takes whatever the environment and team layers leave.", + "properties": { + "allow": { + "default": [], + "description": "Namespaced `__` patterns this key allows, intersected with the environment and team layers. Same single-`*` glob matching a policy's `allow` uses; an empty list leaves the key no MCP access, and `[\"*\"]` narrows nothing (useful with `deny` alone).\n\nRequired on the write path and defaulted by the runtime loader, for the reason given on [`McpPolicy::allow`].", + "items": { + "type": "string" + }, + "type": "array" + }, + "deny": { + "description": "Namespaced `__` patterns subtracted from this key's effective grant, using the same single-`*` glob matching as `allow`.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "McpRateLimit": { + "description": "Limits for one API key's calls to one MCP server (`ApiKey::mcp_rate_limits`).\n\nCarries only the request-count dimensions of [`RateLimit`]: an MCP `tools/call` commits no tokens, so a token-rate cap here would never be consumed — the shape leaves `tpm`/`tpd` out rather than accepting a knob that is silently inert.", + "properties": { + "concurrency": { + "description": "Max concurrent in-flight tool calls.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpd": { + "description": "Tool calls per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rph": { + "description": "Tool calls per 3,600-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpm": { + "description": "Tool calls per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rps": { + "description": "Tool calls per 1-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "RateLimit": { + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpd": { + "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rph": { + "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpm": { + "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rps": { + "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "tpd": { + "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "tpm": { + "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "allowed_agents": { + "description": "A2A agents this key may reach, named by their registered names. Entries are matched as single-`*` globs, mirroring `allowed_models`: `\"*\"` grants every agent and an entry without a `*` matches one agent exactly. When omitted, set to `null`, or set to an empty list, the key has no A2A agent access — access is granted explicitly.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "allowed_models": { + "description": "Model identifiers this key may use. An empty array denies access to every model.", + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_routes": { + "description": "Passthrough routes this key may use, named by their registered names. Entries are matched as single-`*` globs, mirroring `allowed_models`: `\"*\"` grants every route and an entry without a `*` matches one route exactly. When omitted, set to `null`, or set to an empty list, the key may use no passthrough route — access is granted explicitly.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "disabled": { + "description": "Administratively disabled. A disabled key is rejected with `401` until it is enabled again; the key itself is preserved. Treated as `false` when omitted.", + "type": "boolean" + }, + "display_name": { + "description": "Operator-facing label for this key, as shown in the dashboard. Read only by the `${request.api_key.name}` header template (AISIX-Cloud#1112); never used for authentication or routing.", + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "description": "RFC 3339 timestamp after which the key stops authenticating. Requests presenting an expired key are rejected with `401`. When omitted or set to `null`, the key never expires.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "jwt_provider": { + "description": "Name of the `oidc_providers` entry permitted to assert this key's `jwt_subject`. A subject is only ever resolved for the trust provider named here, so a second trusted provider cannot mint a token impersonating this provider's identity of the same name. Required whenever `jwt_subject` is set; ignored otherwise.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "jwt_subject": { + "description": "External identity bound to this key for JWT authentication. When a request presents a valid JWT issued by the `oidc_providers` entry named in `jwt_provider`, the value of that provider's `identity_claim` selects the key whose `jwt_subject` equals it, and the request proceeds with this key's permissions, rate limits, and budget. The `(jwt_provider, jwt_subject)` pair is unique within the environment. When omitted, the key is never selected by JWT authentication.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "key_hash": { + "description": "SHA-256 hexadecimal hash of the plaintext bearer. The proxy hashes incoming bearer tokens before lookup.", + "minLength": 1, + "type": "string" + }, + "mcp_access": { + "anyOf": [ + { + "$ref": "#/definitions/McpAccess" + }, + { + "type": "null" + } + ], + "description": "This key's own layer of the MCP tool ACL, as namespaced `__` glob patterns. It is intersected with the environment and team MCP access policies: every present layer must allow a tool and no layer may deny it. When omitted the key adds no constraint of its own — but with no layer present anywhere the grant is empty, so MCP access is always granted explicitly." + }, + "mcp_rate_limits": { + "additionalProperties": { + "$ref": "#/definitions/McpRateLimit" + }, + "description": "Per-MCP-server limits for this key, keyed by the registered MCP server name — the `` half of the `__` names the gateway exposes. A `tools/call` is metered against the entry for the server it targets **and** the key's own `rate_limit`, each in its own counter, so a burst against one server never consumes another's budget. A server with no entry here is bounded by `rate_limit` alone. Only tool calls are metered; the `initialize` / `tools/list` handshake is not.", + "type": [ + "object", + "null" + ] + }, + "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" + ] + }, + "user_name": { + "description": "Readable display name of the owning member. Used only for telemetry labels alongside `user_id`; never used for authentication or routing. When omitted, telemetry reports the user name as `\"unknown\"`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "allowed_models", + "key_hash" + ], + "title": "ApiKey", + "type": "object" +} diff --git a/schemas/resources-lenient/cache_policy.schema.json b/schemas/resources-lenient/cache_policy.schema.json new file mode 100644 index 00000000..703463d7 --- /dev/null +++ b/schemas/resources-lenient/cache_policy.schema.json @@ -0,0 +1,135 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "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" + }, + "CacheScope": { + "description": "Sharing boundary for cache entries created under a policy. Applies to both matching layers: an entry written in one scope bucket is never served to a request in another.", + "oneOf": [ + { + "description": "Entries are private to the API key that created them. The safe default: one caller's answers are never replayed to another.", + "enum": [ + "api_key" + ], + "type": "string" + }, + { + "description": "Entries are shared by every API key in the environment. Pick this for shared-knowledge traffic (FAQ, documentation Q&A) where cross-caller reuse is the point.", + "enum": [ + "env" + ], + "type": "string" + } + ] + }, + "SemanticCacheConfig": { + "description": "Embedding-similarity matching for a cache policy. When present, a request that misses the exact layer is embedded and compared against stored entries; the nearest entry at or above `threshold` cosine similarity is served. Only requests whose messages are entirely text participate — requests containing images or audio never match by similarity.\n\nOn `backend: redis`, similarity matching requires a Redis server with vector search (Redis 8 or later, or the search module) in `single` or `sentinel` mode; without it — or in `cluster` mode — the policy keeps serving exact matches only and a warning is logged.", + "properties": { + "embedding_model": { + "description": "Name of the `embedding` model used to embed requests. The model must exist in the same environment and carry an `embedding` block; its `dimensions` value fixes the vector size for this policy's entries.", + "minLength": 1, + "type": "string" + }, + "embedding_timeout_ms": { + "description": "Per-call deadline for the embedding request in milliseconds. `0` or absent disables the embedding-specific deadline. On timeout the request proceeds to the upstream uncached.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "max_entries": { + "default": 1000, + "description": "Upper bound on stored entries for this policy on the `memory` backend; the oldest entry is evicted first. Shared backends bound growth by TTL instead and ignore this value. The ceiling keeps the per-request similarity scan and the per-policy vector memory bounded; workloads needing more entries belong on a shared backend.", + "format": "uint32", + "maximum": 10000.0, + "minimum": 1.0, + "type": "integer" + }, + "threshold": { + "description": "Minimum cosine similarity for a stored entry to be served, in `[0, 1]`. Higher is stricter. Values below `0.9` noticeably increase wrong-answer risk for most embedding models.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "embedding_model", + "threshold" + ], + "type": "object" + } + }, + "description": "A prompt-response cache rule. Requests covered by an enabled policy are served from cache when an identical request was answered before (exact matching), and — when `semantic` is configured — when a sufficiently similar request was.", + "properties": { + "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": { + "allOf": [ + { + "$ref": "#/definitions/CacheBackend" + } + ], + "default": "memory", + "description": "Cache backend used for matching requests." + }, + "enabled": { + "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" + }, + "purge_generation": { + "default": 0, + "description": "Invalidation counter. Entries are readable only while their stored generation matches; a purge bumps this value, making every earlier entry unreachable at once. Managed by the purge operation — not set directly. Full-document updates must carry the current value forward: writing a lower (or omitted, i.e. `0`) value re-exposes entries stored under that earlier generation until their TTL passes.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "api_key", + "description": "Sharing boundary for entries created under this policy: `api_key` (default) keeps entries private to the caller that created them; `env` shares them across the environment." + }, + "semantic": { + "allOf": [ + { + "$ref": "#/definitions/SemanticCacheConfig" + } + ], + "description": "Embedding-similarity matching. Absent: the policy matches exactly-identical requests only." + }, + "ttl_seconds": { + "default": 3600, + "description": "Cache entry TTL in seconds.", + "format": "uint32", + "maximum": 604800.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "name" + ], + "title": "CachePolicy", + "type": "object" +} diff --git a/schemas/resources-lenient/claim_mapping.schema.json b/schemas/resources-lenient/claim_mapping.schema.json new file mode 100644 index 00000000..9ce05a5f --- /dev/null +++ b/schemas/resources-lenient/claim_mapping.schema.json @@ -0,0 +1,118 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ClaimMatch": { + "description": "One claim condition. A mapping matches only when every condition holds (logical AND); within one condition, `values` are alternatives (logical OR).", + "properties": { + "claim": { + "description": "Claim to inspect. Dots traverse nested objects (for example `realm_access.roles`). A missing claim never matches.", + "minLength": 1, + "type": "string" + }, + "op": { + "allOf": [ + { + "$ref": "#/definitions/ClaimMatchOp" + } + ], + "description": "Comparison operator. A claim whose JSON type does not fit the operator (an array for `exact`, a string for `contains`) never matches — mistyped claims deny rather than surprise." + }, + "values": { + "description": "Accepted values; the condition holds when any one matches.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "claim", + "op", + "values" + ], + "type": "object" + }, + "ClaimMatchOp": { + "description": "How one [`ClaimMatch`] compares the claim's value against `values`.", + "oneOf": [ + { + "description": "The claim must be a string equal to one of `values`. An array claim never matches `exact`.", + "enum": [ + "exact" + ], + "type": "string" + }, + { + "description": "The claim must be an array containing one of `values` among its string items; non-string items are ignored, matching the `bound_claims` array semantics. A string claim never matches `contains`.", + "enum": [ + "contains" + ], + "type": "string" + } + ] + }, + "ClaimResolve": { + "description": "What a matched mapping resolves to. Targets always reference existing resources — a dangling reference rejects the request.", + "properties": { + "api_key_id": { + "description": "Id of the API key the request runs as. The key's model and tool access, rate limits, and budget apply exactly as if the caller had presented the key itself.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "api_key_id" + ], + "type": "object" + } + }, + "properties": { + "enabled": { + "default": true, + "description": "Whether the mapping participates in evaluation. A disabled mapping is kept but skipped. Treated as `true` when omitted.", + "type": "boolean" + }, + "jwt_provider": { + "description": "Name of the OIDC provider whose tokens this mapping applies to. A mapping never matches a token verified by a different provider, so two providers cannot select each other's keys.", + "minLength": 1, + "type": "string" + }, + "match": { + "description": "Claim conditions, all of which must hold for the mapping to match.", + "items": { + "$ref": "#/definitions/ClaimMatch" + }, + "minItems": 1, + "type": "array" + }, + "name": { + "description": "Human-readable mapping name, unique within the environment.", + "minLength": 1, + "type": "string" + }, + "priority": { + "default": 0, + "description": "Evaluation order among the provider's mappings: lower values are evaluated first, ties are broken by `name`. Defaults to 0.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "resolve": { + "allOf": [ + { + "$ref": "#/definitions/ClaimResolve" + } + ], + "description": "The API key a matching token resolves to." + } + }, + "required": [ + "jwt_provider", + "match", + "name", + "resolve" + ], + "title": "ClaimMapping", + "type": "object" +} diff --git a/schemas/resources-lenient/embedding.schema.json b/schemas/resources-lenient/embedding.schema.json new file mode 100644 index 00000000..81032093 --- /dev/null +++ b/schemas/resources-lenient/embedding.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Embedding-modality metadata for a direct Model.", + "properties": { + "dimensions": { + "description": "Output vector dimensionality. Used to validate vectors, key the example-vector cache, and (for endpoints that support it) request a reduced output size.", + "format": "uint32", + "minimum": 1.0, + "type": "integer" + }, + "normalize": { + "default": true, + "description": "Whether the endpoint already returns L2-normalized vectors. When `false`, the gateway normalizes before computing cosine similarity. Defaults to `true`.", + "type": "boolean" + } + }, + "required": [ + "dimensions" + ], + "title": "EmbeddingConfig", + "type": "object" +} diff --git a/schemas/resources-lenient/ensemble.schema.json b/schemas/resources-lenient/ensemble.schema.json new file mode 100644 index 00000000..530e4fef --- /dev/null +++ b/schemas/resources-lenient/ensemble.schema.json @@ -0,0 +1,110 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Judge": { + "description": "The judge model that synthesizes the panel responses into one answer. `model` references a direct model alias.", + "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.", + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "PanelMember": { + "description": "One member of an ensemble panel. `model` references a direct model alias.", + "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.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "temperature": { + "description": "Sampling temperature for this panel member. Omit it to keep the request's temperature.", + "format": "float", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "weight": { + "description": "Reserved for a future voting/quorum strategy. AISIX currently ignores this field.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "model" + ], + "type": "object" + } + }, + "properties": { + "judge": { + "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.", + "format": "uint32", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "panel": { + "description": "Direct models called concurrently for each ensemble request.", + "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.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "judge", + "panel" + ], + "title": "EnsembleConfig", + "type": "object" +} diff --git a/schemas/resources-lenient/guardrail.schema.json b/schemas/resources-lenient/guardrail.schema.json new file mode 100644 index 00000000..aaf02c12 --- /dev/null +++ b/schemas/resources-lenient/guardrail.schema.json @@ -0,0 +1,1677 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "BedrockAWSCredentials": { + "description": "AWS credentials for a Bedrock guardrail.", + "oneOf": [ + { + "properties": { + "access_key_id": { + "description": "AWS access key ID for static Bedrock guardrail credentials.", + "minLength": 1, + "type": "string" + }, + "kind": { + "description": "Credential mode for explicitly configured AWS access keys.", + "enum": [ + "static" + ], + "type": "string" + }, + "secret_access_key": { + "description": "AWS secret access key used to authenticate requests to Amazon Bedrock. The gateway does not log the plaintext value.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "access_key_id", + "kind", + "secret_access_key" + ], + "type": "object" + } + ] + }, + "BedrockLatencyMode": { + "description": "Per-guardrail latency policy for `kind: \"bedrock\"`. Serial mode waits for the guardrail response; timed mode aborts at `timeout_ms` and applies `fail_open`.", + "oneOf": [ + { + "properties": { + "kind": { + "description": "Latency mode that waits for the Bedrock guardrail response.", + "enum": [ + "serial" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "description": "Latency mode that stops waiting after `timeout_ms`.", + "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": [ + { + "description": "Literal string to match.", + "properties": { + "kind": { + "description": "Pattern type for matching the value as plain text.", + "enum": [ + "literal" + ], + "type": "string" + }, + "value": { + "description": "Literal string to match.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "description": "Regular expression pattern to match.", + "properties": { + "kind": { + "description": "Pattern type for matching the value as a regular expression.", + "enum": [ + "regex" + ], + "type": "string" + }, + "value": { + "description": "Regular expression pattern to match.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PiiCustomPattern": { + "description": "One operator-supplied regex detector for `kind: \"pii\"`.", + "properties": { + "action": { + "description": "Per-pattern action override. Falls back to the guardrail's `default_action` when omitted.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "name": { + "description": "Detector name surfaced in the mask token (`[_REDACTED]`), telemetry counts, and block reasons. Never the matched value.", + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + "regex": { + "description": "Regular expression AISIX compiles when building the guardrail chain. An invalid pattern makes AISIX log and skip the guardrail.\n\nWhen the expression declares at least one capture group, a `mask` action rewrites only the first capture group of each match and keeps the rest of the match unchanged. Use this to replace a value while preserving its surrounding key or label, for example `\"version\"\\s*:\\s*\"([^\"]*)\"`. Without capture groups, the whole match is rewritten.", + "minLength": 1, + "type": "string" + }, + "replacement": { + "description": "Literal text that replaces the masked span, such as `***`. When omitted, the span is rewritten to `[_REDACTED]`. An empty string removes the span. Only valid when the pattern's effective action is `mask`.", + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "name", + "regex" + ], + "type": "object" + }, + "PiiDetectorConfig": { + "description": "One built-in detector selection for `kind: \"pii\"`. The `type` names the detector to enable; `action` optionally overrides the guardrail-level `default_action` for this detector only.", + "properties": { + "action": { + "description": "Per-detector action override. Falls back to the guardrail's `default_action` when omitted.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "type": { + "description": "Built-in detector to enable for this PII guardrail entry.", + "enum": [ + "email", + "china_mobile", + "china_id_card", + "bank_card", + "us_ssn", + "ip_address", + "api_key", + "jwt", + "private_key" + ], + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PresidioEntityConfig": { + "description": "One entity selection for `kind: \"presidio\"`. The `type` names a Presidio entity (`EMAIL_ADDRESS`, `PHONE_NUMBER`, `PERSON`, `CREDIT_CARD`, …); `action` optionally overrides the guardrail-level `default_action` for this entity only — the same per-detector shape as `kind: \"pii\"`.", + "properties": { + "action": { + "description": "Per-entity action override. Falls back to the guardrail's `default_action` when omitted.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "type": { + "description": "Presidio entity type, e.g. `EMAIL_ADDRESS`, `PERSON`, `US_SSN`.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "description": "Content policy evaluated before or after upstream calls.", + "oneOf": [ + { + "description": "In-process literal/regex blocklist. Always available.", + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for literal and regular expression matching.", + "enum": [ + "keyword" + ], + "type": "string" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "patterns": { + "description": "Blocklist patterns. An empty list is valid and allows every request, equivalent to `enabled: false`.", + "items": { + "$ref": "#/definitions/KeywordPattern" + }, + "type": "array" + } + }, + "required": [ + "kind", + "patterns" + ], + "type": "object" + }, + { + "description": "AWS Bedrock managed guardrail using `ApplyGuardrail` on input, output, or both.", + "properties": { + "aws_credentials": { + "allOf": [ + { + "$ref": "#/definitions/BedrockAWSCredentials" + } + ], + "description": "IAM credentials for Bedrock requests." + }, + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "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" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for Amazon Bedrock Guardrails.", + "enum": [ + "bedrock" + ], + "type": "string" + }, + "latency_mode": { + "allOf": [ + { + "$ref": "#/definitions/BedrockLatencyMode" + } + ], + "description": "Bedrock guardrail latency policy. Timed mode caps wait time with `timeout_ms`." + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), a Bedrock outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "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.", + "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" + }, + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "description": "Azure Cognitive Services resource endpoint, e.g. `https://my-resource.cognitiveservices.azure.com`. The gateway appends `/contentsafety/text:shieldPrompt?api-version=2024-09-01`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for Azure Prompt Shield.", + "enum": [ + "azure_content_safety" + ], + "type": "string" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), an Azure outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + } + }, + "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": { + "default": [], + "description": "Azure CS blocklist names to match against.", + "items": { + "description": "Azure blocklist name to match against.", + "type": "string" + }, + "type": "array" + }, + "categories": { + "default": [ + "Hate", + "Sexual", + "SelfHarm", + "Violence" + ], + "description": "Categories to analyze.", + "items": { + "description": "Azure content category to analyze.", + "enum": [ + "Hate", + "Sexual", + "SelfHarm", + "Violence" + ], + "type": "string" + }, + "type": "array" + }, + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "description": "Azure Cognitive Services resource endpoint. The gateway appends `/contentsafety/text:analyze?api-version=2024-09-01`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "halt_on_blocklist_hit": { + "default": false, + "description": "Forwarded to Azure's `haltOnBlocklistHit`.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for Azure text moderation.", + "enum": [ + "azure_content_safety_text_moderation" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "output_fail_open": { + "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": { + "default": "FourSeverityLevels", + "description": "Severity scale used for Azure category scores. The four-level scale returns even severities; the eight-level scale returns every integer severity.", + "enum": [ + "FourSeverityLevels", + "EightSeverityLevels" + ], + "type": "string" + }, + "severity_threshold": { + "default": 2, + "description": "General severity threshold. A category at or above it blocks.", + "format": "uint8", + "maximum": 7.0, + "minimum": 0.0, + "type": "integer" + }, + "severity_threshold_by_category": { + "additionalProperties": { + "description": "Severity threshold for the category key.", + "format": "uint8", + "minimum": 0.0, + "type": "integer" + }, + "default": {}, + "description": "Per-category threshold overrides. These take precedence over the general threshold.", + "type": "object" + }, + "stream_processing_mode": { + "default": "window", + "description": "Streaming output moderation mode: sliding-window incremental release or whole-response hold-back.", + "enum": [ + "window", + "buffer_full" + ], + "type": "string" + }, + "text_source": { + "default": "concatenate_user_content", + "description": "Input-hook text selection. The default scans user messages only; the alternate mode includes all message content. Ignored on the output hook.", + "enum": [ + "concatenate_user_content", + "concatenate_all_content" + ], + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "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", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + }, + "window_overlap_size": { + "default": 256, + "description": "Chars carried between windows so a span split across a boundary is still caught.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "window_size": { + "default": 10000, + "description": "Sliding-window size in characters for window mode.", + "format": "uint32", + "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.", + "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" + }, + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "default": null, + "description": "Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for Aliyun text moderation.", + "enum": [ + "aliyun_text_moderation" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "output_fail_open": { + "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 gateway builds the endpoint `https://green-cip..aliyuncs.com`.", + "minLength": 1, + "type": "string" + }, + "risk_level_threshold": { + "default": "high", + "description": "Minimum risk level that triggers a block. A returned level at or above this threshold blocks.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "stream_processing_mode": { + "default": "window", + "description": "Streaming output moderation mode: sliding-window incremental release or whole-response hold-back.", + "enum": [ + "window", + "buffer_full" + ], + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "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", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + }, + "window_overlap_size": { + "default": 128, + "description": "Chars carried between windows so a span split across a boundary is still caught.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "window_size": { + "default": 2000, + "description": "Sliding-window size in characters when window mode is used. Aliyun limits each `llm_response_moderation` call to 2,000 characters.", + "format": "uint32", + "maximum": 2000.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "access_key_id", + "access_key_secret", + "kind", + "region" + ], + "type": "object" + }, + { + "description": "Aliyun AI Guardrails. Policy-driven moderation through the `MultiModalGuard` action: the verdict follows the policy configured in the provider's console. Content that policy marks for masking is released with the provider's desensitized text in place of the original, and blocked when no desensitized text is returned. Applies on input and/or output, including streaming output.", + "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" + }, + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "default": null, + "description": "Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for Aliyun AI Guardrails policy-driven moderation.", + "enum": [ + "aliyun_ai_guardrail" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "output_fail_open": { + "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 gateway builds the endpoint `https://green-cip..aliyuncs.com`.", + "minLength": 1, + "type": "string" + }, + "service_level": { + "default": "pro", + "description": "Which AI Guardrails service tier to call: `pro` uses `query_security_check_pro` / `response_security_check_pro`, `basic` uses `query_security_check` / `response_security_check`. Must match the tier activated on the Aliyun account.", + "type": "string" + }, + "stream_processing_mode": { + "default": "window", + "description": "Streaming output moderation mode: sliding-window incremental release or whole-response hold-back.", + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "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", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + }, + "window_overlap_size": { + "default": 128, + "description": "Chars carried between windows so a span split across a boundary is still caught.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "window_size": { + "default": 2000, + "description": "Sliding-window size in characters when window mode is used. Aliyun limits each MultiModalGuard call to 2,000 characters of text.", + "format": "uint32", + "maximum": 2000.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "access_key_id", + "access_key_secret", + "kind", + "region" + ], + "type": "object" + }, + { + "description": "Built-in sensitive-data detection and redaction inside AISIX. Built-in detectors and custom regex patterns can mask matched spans or block traffic on input, output, or both, including buffered streaming output. Always available and does not call an external service.", + "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.", + "format": "date-time", + "type": "string" + }, + "custom_patterns": { + "default": [], + "description": "Operator-supplied regex detectors, evaluated after the built-ins.", + "items": { + "$ref": "#/definitions/PiiCustomPattern" + }, + "type": "array" + }, + "default_action": { + "default": "mask", + "description": "Action for detectors that do not set their own override.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "detectors": { + "default": [], + "description": "Built-in detectors to enable. The resource schema rejects unknown detector ids, so a typo cannot silently disable the policy.", + "items": { + "$ref": "#/definitions/PiiDetectorConfig" + }, + "type": "array" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for in-process sensitive-data detection and redaction.", + "enum": [ + "pii" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes buffered for a streamed response before `on_buffer_exceeded` applies.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "description": "Lakera Guard screening via `POST /v2/guard`. Prompt-injection, jailbreak, and content-policy detections block traffic. Detections that involve only PII mask spans using the returned offsets. Applies on input, output, or both, including buffered streaming output.", + "properties": { + "api_key": { + "description": "Lakera API key sent as a `Authorization: Bearer` header. Decrypted before projection. Plaintext is held in memory only and is not logged.", + "minLength": 1, + "type": "string" + }, + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "default": null, + "description": "Endpoint override, e.g. a regional or self-hosted Lakera deployment. The gateway appends `/v2/guard`. Defaults to `https://api.lakera.ai`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for Lakera Guard screening.", + "enum": [ + "lakera" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes buffered for a streamed response before `on_buffer_exceeded` applies.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), a Lakera outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "project_id": { + "description": "Lakera project whose policy applies (`project-...`). Omitted → the account's default policy.", + "minLength": 1, + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "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", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "api_key", + "kind" + ], + "type": "object" + }, + { + "description": "OpenAI Moderation API category screening via `POST /moderations`. Flagged content or configured category thresholds block traffic. This guardrail is detection-only and never rewrites content. Applies on input, output, or both, including buffered streaming output.", + "properties": { + "api_key": { + "description": "OpenAI API key sent as a `Authorization: Bearer` header. Decrypted before projection. Plaintext is held in memory only and is not logged.", + "minLength": 1, + "type": "string" + }, + "category_thresholds": { + "additionalProperties": { + "description": "Score threshold for the category key.", + "format": "double", + "type": "number" + }, + "default": {}, + "description": "Per-category score thresholds, e.g. `{\"violence\": 0.5}`. When set, only the listed categories are enforced and a category blocks when its score reaches the threshold. When empty (the default), the provider's `flagged` decision determines whether to block.", + "type": "object" + }, + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "default": null, + "description": "Endpoint override, such as an Azure OpenAI deployment. AISIX appends `/moderations`. Defaults to `https://api.openai.com/v1`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for the OpenAI Moderation API.", + "enum": [ + "openai_moderation" + ], + "type": "string" + }, + "model": { + "default": "omni-moderation-latest", + "description": "Moderation model sent to the provider. The default is `omni-moderation-latest`; provider model names are not restricted by AISIX.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), an OpenAI outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "timeout_ms": { + "default": 5000, + "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", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "api_key", + "kind" + ], + "type": "object" + }, + { + "description": "PII detection and anonymization by a customer-run Presidio. Analyzer entities can mask or block per entity, and masked entities use the selected anonymize operator. Applies on input, output, or both, including buffered streaming output.", + "properties": { + "analyzer_url": { + "description": "Presidio analyzer base URL, e.g. `http://presidio-analyzer:3000`. The gateway appends `/analyze`.", + "minLength": 1, + "type": "string" + }, + "anonymizer_url": { + "description": "Presidio anonymizer base URL, e.g. `http://presidio-anonymizer:3000`. The gateway appends `/anonymize`. Only called when a detected entity's effective action is `mask`.", + "minLength": 1, + "type": "string" + }, + "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.", + "format": "date-time", + "type": "string" + }, + "default_action": { + "default": "mask", + "description": "Action for entities that do not set their own override.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "entities": { + "default": [], + "description": "Entities to detect. Empty (the default) analyzes with Presidio's full recognizer set and applies `default_action` to every hit.", + "items": { + "$ref": "#/definitions/PresidioEntityConfig" + }, + "type": "array" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for PII detection and anonymization by a customer-run Presidio.", + "enum": [ + "presidio" + ], + "type": "string" + }, + "language": { + "default": "en", + "description": "Analyzer language code.", + "minLength": 1, + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes buffered for a streamed response before `on_buffer_exceeded` applies.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "operator": { + "default": "replace", + "description": "Anonymize operator applied to masked entities.", + "enum": [ + "replace", + "mask", + "hash", + "redact" + ], + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), a Presidio outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "score_threshold": { + "description": "Minimum analyzer confidence for a hit to count. Omitted → every result the analyzer returns counts (Presidio's own per-recognizer defaults apply).", + "format": "double", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds, applied per analyzer/anonymizer call. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "analyzer_url", + "anonymizer_url", + "kind" + ], + "type": "object" + }, + { + "description": "Embedding-similarity screening against operator-supplied example texts. Deny examples block on a close match; an allow-list, when present, blocks everything that matches none of it. Calls an `embedding`-kind Model through the gateway's provider bridges. Detection-only — never rewrites content. Applies on input, output, or both, including buffered streaming output.", + "properties": { + "allow_examples": { + "default": [], + "description": "Example texts whose meaning is PERMITTED. When non-empty, a screened text that clears none of them blocks — an allow-list narrows traffic to the listed topics. Empty (the default) means \"no allow-list\": only `deny_examples` can block.", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + }, + "allow_threshold": { + "default": 0.75, + "description": "Cosine-similarity threshold for `allow_examples`, in `[-1, 1]`. RAISE it to block more — a text must reach it to be admitted. Required whenever `allow_examples` is non-empty, and only then: a row with no allow-list has nothing for this number to decide. See `deny_threshold` for why there is no portable value.", + "format": "float", + "maximum": 1.0, + "minimum": -1.0, + "type": "number" + }, + "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.", + "format": "date-time", + "type": "string" + }, + "deny_examples": { + "default": [], + "description": "Example texts whose meaning must be REFUSED. A screened text scoring at or above `deny_threshold` against any of them blocks.", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + }, + "deny_threshold": { + "default": 0.75, + "description": "Cosine-similarity threshold for `deny_examples`, in `[-1, 1]`. Lower it to block more. Required whenever `deny_examples` is non-empty.\n\nThere is no portable value. Cosine scores are not comparable across embedding models — the same pair of texts can fall on opposite sides of a fixed threshold depending on which model produced the vectors, and a threshold carried over from another model under-screens without any sign that it is doing so. Measure one against the model named in `embedding_model`, on your own traffic: a request that emits a usage event reports what it scored in `guardrail_scores`, including the requests this guardrail allowed. Not every surface can produce that sample. `/a2a`, `rerank`, `/v1/embeddings`, `/v1/images/*`, `/v1/videos`, `/v1/audio/speech` and `/v1/messages/count_tokens` run the INPUT hook only, so an output-hook row scores nothing on them (audio transcription and translation do run both); `/a2a` also resolves no model and no MCP server, so only a row attached at the environment, API-key or team scope reaches it at all.", + "format": "float", + "maximum": 1.0, + "minimum": -1.0, + "type": "number" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "embedding_model": { + "default": "", + "description": "Alias of an `embedding`-kind Model used to embed both the examples and the screened text. Must resolve in the same environment as this guardrail.\n\nDefaulted at the TYPE level and required by the strict write schema instead: a row the loader cannot deserialize is skipped whole, and a screening row that vanishes is a guardrail that stopped screening. Empty resolves to nothing, so the row degrades per `fail_open` — fail-closed by default — rather than disappearing.", + "minLength": 1, + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for embedding-similarity screening against example texts, using an embedding-kind Model.", + "enum": [ + "semantic" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes buffered for a streamed response before `on_buffer_exceeded` applies.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "max_screened_texts": { + "default": 8, + "description": "Cap on how many texts ONE request screens, bounding the embedding tokens a single request can spend. The input hook screens the MOST RECENT messages first and drops the rest: an earlier turn was already screened as the latest message of an earlier request, so the dropped tail is seen history rather than unread content.", + "format": "uint32", + "maximum": 64.0, + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "enum": [ + "fail_closed", + "fail_open" + ], + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled, an embedding outage does not release unscreened model output.", + "type": "boolean" + }, + "text_source": { + "default": "user_messages", + "description": "Input-hook text selection. The default screens user messages only; the alternate mode screens every message. Ignored on the output hook.", + "enum": [ + "user_messages", + "all_messages" + ], + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "description": "Per-call deadline for the embedding request in milliseconds.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "description": "Screening by an operator-supplied script the gateway runs in a sandboxed engine, for a screening service that speaks its own protocol. The script can allow, block, or rewrite content. Applies on input, output, or both, including streaming output.", + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "kind": { + "description": "Guardrail provider type for screening by an operator-supplied script the gateway runs in a sandboxed engine.", + "enum": [ + "custom" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "max_memory_bytes": { + "default": 16777216, + "description": "Memory ceiling for the script engine, in bytes. A script that exceeds it is terminated and the hook's fail-open policy applies.", + "format": "uint64", + "maximum": 536870912.0, + "minimum": 1048576.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "enum": [ + "fail_closed", + "fail_open" + ], + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), a script failure blocks model output instead of releasing unscanned content. The input hook uses the top-level `fail_open` policy.", + "type": "boolean" + }, + "script": { + "default": "", + "description": "The script source, as an ES module exporting `checkInput` and/or `checkOutput`. A hook whose function the module does not export is skipped, so a script may cover one direction only.\n\nRequired on both the write schema and the read one, so a row that omits it is refused rather than loaded: unlike the fields that are write-path-only, a `custom` row with no script screens nothing either way, and rejecting it is what puts it in `/status/config`'s `rejected` list where an operator can see it.\n\nA script that is whitespace-only or does not compile passes the schema — `minLength` counts characters, so a whitespace-only value is non-empty — and is refused when the chain is built instead. `aisix validate` reports that and exits non-zero; a serving gateway reports the runtime rejection through config status (api7/aisix#1084).", + "minLength": 1, + "type": "string" + }, + "secrets": { + "additionalProperties": { + "description": "Value the script reads as ctx.secrets under this name.", + "type": "string" + }, + "description": "Values the script reads as `ctx.secrets.`, for credentials the screening service requires. Stored encrypted and decrypted before projection; plaintext is held in memory only and is never logged.", + "type": "object" + }, + "stream_processing_mode": { + "default": "window", + "description": "Streaming output moderation mode: sliding-window incremental release or whole-response hold-back.", + "enum": [ + "window", + "buffer_full" + ], + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "description": "Wall-clock budget for one hook invocation, in milliseconds, covering the script's own execution and every call it makes. `fail_open` and `output_fail_open` govern the verdict when it elapses. Per-call timeouts within the budget are the script's own to set.", + "format": "uint32", + "maximum": 300000.0, + "minimum": 1.0, + "type": "integer" + }, + "window_overlap_size": { + "default": 256, + "description": "Chars carried between windows so a span split across a boundary is still caught.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "window_size": { + "default": 10000, + "description": "Sliding-window size in characters for window mode.", + "format": "uint32", + "maximum": 10000.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "kind", + "script" + ], + "type": "object" + } + ], + "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.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Guardrail", + "type": "object" +} diff --git a/schemas/resources-lenient/guardrail_attachment.schema.json b/schemas/resources-lenient/guardrail_attachment.schema.json new file mode 100644 index 00000000..ba737401 --- /dev/null +++ b/schemas/resources-lenient/guardrail_attachment.schema.json @@ -0,0 +1,64 @@ +{ + "$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 narrower scopes let operators attach a guardrail to only the models, MCP servers, API keys, or teams that need it.\n\n`Model`, `McpServer` and `PassthroughRoute` select dimensions a request carries only one of: an MCP tool call resolves no model, an LLM request routes to no MCP server, and a passthrough-route request resolves neither. A `Model`-scoped guardrail therefore never inspects MCP or passthrough traffic, an `McpServer`-scoped one never inspects model traffic, and a `PassthroughRoute`-scoped one inspects only the traffic of that route.", + "enum": [ + "env", + "model", + "mcp_server", + "api_key", + "team", + "passthrough_route" + ], + "type": "string" + } + }, + "description": "Guardrail attachment that scopes one guardrail to an environment, model, MCP server, caller API key, or team. AISIX loads attachments with the guardrail definitions and uses `scope_type` plus `scope_id` to decide which guardrails apply to each request.", + "properties": { + "enabled": { + "default": true, + "description": "When `false`, AISIX ignores this attachment.", + "type": "boolean" + }, + "env_id": { + "description": "Environment the attachment belongs to. Written by the managed control plane for its own scoping; the gateway does not read it.", + "type": [ + "string", + "null" + ] + }, + "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 / mcp_server / api_key / team / passthrough_route). `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-lenient/mcp_auth_settings.schema.json b/schemas/resources-lenient/mcp_auth_settings.schema.json new file mode 100644 index 00000000..b27ab8e4 --- /dev/null +++ b/schemas/resources-lenient/mcp_auth_settings.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpAnonymousAccess": { + "description": "Anonymous access configuration for this environment's `/mcp` entries.\n\nA request that carries NO gateway credential and arrives from `source_cidrs` runs as the `api_key_id` principal: its MCP tool grant, rate limits, budget, guardrails and usage attribution all apply, so anonymous traffic stays governable instead of bypassing the pipeline. A request carrying a credential is authenticated normally and a bad one is rejected — never downgraded to anonymous.", + "properties": { + "aggregate_entry": { + "description": "Whether the aggregated `/mcp` endpoint ALSO serves anonymous callers. It exposes the same `servers`, under their `__` namespaced names.\n\nOff by default: it is the entry a standard MCP client uses for OAuth discovery, and the namespaced names are not what a client migrating from a single-server endpoint uses. Turning it on suppresses the `WWW-Authenticate` discovery hint there, since a no-credential request succeeds instead of producing the 401 that carries it.", + "type": "boolean" + }, + "api_key_id": { + "description": "The API key anonymous traffic runs as. Everything keyed on a principal — MCP tool ACL, per-server and per-key rate limits, budget, guardrail scopes, usage events — resolves through this key, which is why anonymous callers stay attributable. The key must carry an explicit MCP grant: a key left on `inherit` would pick up the environment-default policy, so an `all` default would silently hand every registered tool to anonymous callers (the control plane rejects that at write time).", + "minLength": 1, + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether anonymous access is served. `false` keeps the configuration but closes the door, so an operator can suspend it without losing the principal and allowlists.", + "type": "boolean" + }, + "servers": { + "description": "Registered MCP server names anonymous callers may reach.\n\nThis is the anonymous principal's CEILING, not merely the list of scoped entries to open: the listed servers' tools are intersected with the key's own grant on BOTH entries. Without that, a key whose grant is wider than the list would let an anonymous caller reach an unlisted server's tools through the aggregated endpoint by naming `__` directly — `/mcp/{server}` closed, aggregated `/mcp` open.\n\nRequired and non-empty, because an empty ceiling admits no tool on either entry: an anonymous block listing no server could never serve a useful request, including through `aggregate_entry`. It is also why a newly registered server is never anonymous by default — reaching anonymous callers is always a name added here.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "source_cidrs": { + "description": "Client source CIDRs allowed to enter anonymously. Required and non-empty: with no credential to check, network reachability is the only gate in front of the principal. Matched against the source IP the proxy's real-ip chain resolves, never against a caller-supplied header value.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "api_key_id", + "servers", + "source_cidrs" + ], + "type": "object" + } + }, + "properties": { + "anonymous": { + "allOf": [ + { + "$ref": "#/definitions/McpAnonymousAccess" + } + ], + "description": "Anonymous access to named `/mcp` entries. Unset (the default) means every `/mcp` request needs a valid gateway credential." + }, + "resource_url": { + "description": "Canonical URI of this environment's `/mcp` endpoint, e.g. `https://gw.example.com/mcp`. Published verbatim as the PRM document's `resource` (never derived from the request Host header) and the value the trust providers' `audiences` must include for OAuth-for-MCP tokens to validate. The URL path must be exactly `/mcp` — the gateway's fixed MCP route. Unset leaves the OAuth discovery surface dormant.", + "minLength": 1, + "type": "string" + } + }, + "title": "McpAuthSettings", + "type": "object" +} diff --git a/schemas/resources-lenient/mcp_policy.schema.json b/schemas/resources-lenient/mcp_policy.schema.json new file mode 100644 index 00000000..9715a40b --- /dev/null +++ b/schemas/resources-lenient/mcp_policy.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "if": { + "properties": { + "scope": { + "const": "team" + } + } + }, + "then": { + "properties": { + "scope_ref": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "scope_ref" + ] + } + } + ], + "definitions": { + "McpPolicyScope": { + "description": "Which API keys an MCP access policy applies to.", + "oneOf": [ + { + "description": "Applied to every key in the environment.", + "enum": [ + "env" + ], + "type": "string" + }, + { + "description": "Applied to the keys belonging to the team named by `scope_ref`, on top of the environment policy rather than in place of it.", + "enum": [ + "team" + ], + "type": "string" + } + ] + } + }, + "properties": { + "allow": { + "default": [], + "description": "Namespaced `__` patterns this layer allows. Entries are matched as single-`*` globs: `\"*\"` allows every tool, `\"__*\"` every tool on one server, and an entry without a `*` matches one tool exactly. An empty list allows nothing, which is how a policy blocks all MCP access; a policy that only means to subtract tools writes `[\"*\"]` here and lists them under `deny`.\n\nThe write path requires the field (the strict schema adds it to `required`), so a layer never allows something by omission. The runtime loader defaults it to empty instead of rejecting the row: a document written before the layered shape would otherwise fail to deserialize, and a skipped `api_key` row stops authenticating altogether rather than merely losing MCP access.", + "items": { + "type": "string" + }, + "type": "array" + }, + "deny": { + "description": "Namespaced `__` patterns subtracted from the effective grant of every key the policy applies to, using the same single-`*` glob matching as `allow`. Deny always wins: a tool matched here stays unavailable however the other layers allow it.", + "items": { + "type": "string" + }, + "type": "array" + }, + "enabled": { + "default": true, + "description": "Whether the policy is applied. A disabled policy is kept but contributes neither its allow nor its deny side. Treated as `true` when omitted.", + "type": "boolean" + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/McpPolicyScope" + } + ], + "description": "Which API keys the policy applies to: the whole environment or one team." + }, + "scope_ref": { + "description": "Team identifier the policy targets. Required when `scope` is `team`; omitted for an environment policy.", + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "scope" + ], + "title": "McpPolicy", + "type": "object" +} diff --git a/schemas/resources-lenient/mcp_server.schema.json b/schemas/resources-lenient/mcp_server.schema.json new file mode 100644 index 00000000..8fb43fd0 --- /dev/null +++ b/schemas/resources-lenient/mcp_server.schema.json @@ -0,0 +1,397 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "if": { + "properties": { + "auth_type": { + "const": "bearer" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "secret": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret" + ] + } + }, + { + "if": { + "properties": { + "auth_type": { + "const": "api_key" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "secret": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret" + ] + } + }, + { + "if": { + "properties": { + "auth_type": { + "const": "oauth2" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "client_id": { + "minLength": 1, + "type": "string" + }, + "secret": { + "minLength": 1, + "type": "string" + }, + "token_url": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret", + "client_id", + "token_url" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "openapi" + } + }, + "required": [ + "type" + ] + }, + "then": { + "allOf": [ + { + "if": { + "properties": { + "api_key_header": { + "type": "string" + } + }, + "required": [ + "api_key_header" + ] + }, + "then": { + "properties": { + "auth_type": { + "const": "api_key" + } + }, + "required": [ + "auth_type" + ] + } + } + ], + "properties": { + "api_key_header": { + "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" + }, + "spec": { + "not": { + "required": [ + "swagger" + ] + }, + "type": "object" + } + }, + "required": [ + "spec" + ] + } + }, + { + "if": { + "anyOf": [ + { + "properties": { + "type": { + "const": "mcp" + } + }, + "required": [ + "type" + ], + "title": "type is mcp" + }, + { + "not": { + "required": [ + "type" + ] + }, + "title": "type is absent (defaults to mcp)" + } + ] + }, + "then": { + "properties": { + "api_key_header": { + "type": "null" + }, + "spec": { + "type": "null" + } + } + } + } + ], + "anyOf": [ + { + "required": [ + "name" + ], + "title": "name" + }, + { + "required": [ + "display_name" + ], + "title": "display_name" + } + ], + "definitions": { + "McpAuthType": { + "description": "How the gateway authenticates to an upstream MCP server.", + "oneOf": [ + { + "description": "No authentication; the server is reached as-is.", + "enum": [ + "none" + ], + "title": "No authentication", + "type": "string" + }, + { + "description": "Bearer token authentication. The token is supplied in `secret` and sent as `Authorization: Bearer `.", + "enum": [ + "bearer" + ], + "title": "Bearer token", + "type": "string" + }, + { + "description": "API key authentication. The key is supplied in `secret` and sent as an `x-api-key: ` header on every upstream request.", + "enum": [ + "api_key" + ], + "title": "API key", + "type": "string" + }, + { + "description": "OAuth 2.0 client credentials grant. The gateway exchanges `client_id`, the client secret in `secret`, and the optional `scopes` at `token_url` for an access token, and sends it as `Authorization: Bearer ` on every upstream request. Access tokens are cached until shortly before their reported expiry.", + "enum": [ + "oauth2" + ], + "title": "OAuth 2.0 client credentials", + "type": "string" + } + ] + }, + "McpProtocolVersion": { + "description": "MCP protocol revision used for an upstream connection. Values are the specification's dated version identifiers; revisions before `2026-07-28` need no entry here because the `initialize` handshake negotiates among them automatically when `protocol_version` is omitted.", + "oneOf": [ + { + "description": "The stateless MCP `2026-07-28` revision: handshake-free startup via `server/discover`, with self-contained per-request metadata.", + "enum": [ + "2026-07-28" + ], + "type": "string" + } + ] + }, + "McpServerType": { + "description": "What backs a registered MCP server entry.", + "oneOf": [ + { + "description": "A real upstream MCP server the gateway connects to.", + "enum": [ + "mcp" + ], + "title": "Upstream MCP server", + "type": "string" + }, + { + "description": "A REST API described by an OpenAPI document; the gateway generates the tools itself and issues plain HTTP requests against `url`.", + "enum": [ + "openapi" + ], + "title": "REST API described by an OpenAPI document", + "type": "string" + } + ] + }, + "McpTransport": { + "description": "Transport used to reach an upstream MCP server.", + "oneOf": [ + { + "description": "Streamable HTTP transport: a single endpoint that serves both POST and GET.", + "enum": [ + "streamable_http" + ], + "title": "Streamable HTTP", + "type": "string" + } + ] + } + }, + "properties": { + "api_key_header": { + "description": "Header name the API key is sent under when `type` is `openapi` and `auth_type` is `api_key`. Defaults to `x-api-key` when unset. Ignored for `type: mcp`, whose API-key header is fixed.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "auth_type": { + "allOf": [ + { + "$ref": "#/definitions/McpAuthType" + } + ], + "default": "none", + "description": "How the gateway authenticates to the upstream server. The credential is held by the gateway and is never forwarded from or exposed to the calling client." + }, + "client_id": { + "description": "OAuth client identifier used for the OAuth 2.0 client credentials grant. Required when `auth_type` is `oauth2`; ignored otherwise.", + "type": [ + "string", + "null" + ] + }, + "display_name": { + "description": "Accepted as an alternative spelling of `name`. Provide the label under exactly one of the two names.", + "minLength": 1, + "pattern": "^(?:[^_]|_[^_])*$", + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this server is active. When `false`, its tools are not listed and cannot be called.", + "type": "boolean" + }, + "forward_client_headers": { + "description": "Inbound client headers forwarded to this server, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to both `type: mcp` and `type: openapi`, so a REST API exposed here as tools receives them on every tool call.\n\nA header named here reaches the server whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer` and `oauth2`, `api_key_header` for `api_key` — hands the server the caller's own credential in place of the gateway's, never both. That is what lets an internal server that already authorizes on the end user's `Authorization` keep doing so unchanged. A server that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and the MCP session slots (`mcp-session-id`, `mcp-protocol-version`, `last-event-id`), which name the caller's session with this gateway and which an upstream MCP server refuses outright when they carry a foreign value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "description": "Operator-facing label, unique within the gateway. It is used as the namespace prefix for this server's tools, which are exposed to clients as `__`, so it must not contain the reserved separator `__`.", + "minLength": 1, + "pattern": "^(?:[^_]|_[^_])*$", + "type": "string" + }, + "protocol_version": { + "anyOf": [ + { + "$ref": "#/definitions/McpProtocolVersion" + }, + { + "type": "null" + } + ], + "description": "MCP protocol revision the gateway uses when connecting to this upstream server. When omitted, the gateway opens the session with the `initialize` handshake, which negotiates among the pre-2026 protocol revisions — the right choice for most servers, including `2026-07-28` servers that keep backward compatibility. Set `2026-07-28` for a server that requires the stateless MCP `2026-07-28` revision (handshake-free `server/discover` startup); the connection fails rather than silently downgrading when the server does not support the configured revision. Only used when `type` is `mcp`; ignored for `type: openapi`." + }, + "scopes": { + "description": "OAuth scopes to request. Joined with spaces into the `scope` parameter of the token request. Only used when `auth_type` is `oauth2`.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "secret": { + "description": "Authentication credential for the upstream server. Its meaning follows `auth_type`: the bearer token when `auth_type` is `bearer` (sent as `Authorization: Bearer `), the API key when `auth_type` is `api_key` (sent as `x-api-key: `, or under `api_key_header` for `type: openapi`), or the OAuth client secret when `auth_type` is `oauth2`. Leave unset when `auth_type` is `none`.", + "type": [ + "string", + "null" + ] + }, + "spec": { + "description": "The OpenAPI 3.x document (as a JSON object) whose operations become this server's tools. Required when `type` is `openapi`; ignored otherwise." + }, + "timeout_ms": { + "description": "Maximum time, in milliseconds, to wait for a single upstream operation (establishing the session, listing tools, or calling a tool). Must be at least `1` when set. When omitted, the gateway applies a built-in default.", + "format": "uint64", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "token_url": { + "description": "OAuth token endpoint URL where the gateway exchanges the client credentials for an access token, such as `https://auth.example.com/oauth/token`. Required when `auth_type` is `oauth2`; ignored otherwise.", + "type": [ + "string", + "null" + ] + }, + "transport": { + "allOf": [ + { + "$ref": "#/definitions/McpTransport" + } + ], + "default": "streamable_http", + "description": "Transport used to reach the upstream server. Streamable HTTP is the only supported transport." + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/McpServerType" + } + ], + "default": "mcp", + "description": "What backs this server: a real upstream MCP server (`mcp`, the default), or a plain REST API described by an OpenAPI document (`openapi`) whose operations the gateway itself exposes as MCP tools." + }, + "url": { + "description": "For `type: mcp`, the upstream server's MCP endpoint URL, reached over the Streamable HTTP transport, such as `https://api.example.com/mcp`. For `type: openapi`, the REST API's base URL that generated tool calls are issued against, such as `https://erp.internal/api/v1`.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "McpServer", + "type": "object" +} diff --git a/schemas/resources-lenient/model.schema.json b/schemas/resources-lenient/model.schema.json new file mode 100644 index 00000000..3f636799 --- /dev/null +++ b/schemas/resources-lenient/model.schema.json @@ -0,0 +1,1038 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Aggregation": { + "description": "How a request's per-example similarity scores collapse into one score for the route. v1 uses `max` (the single best-matching example), chosen for explainability — the UI surfaces \"the top matching example\" — over semantic-router's default sum/mean-over-top_k.", + "oneOf": [ + { + "description": "Route score = the highest cosine across its examples.", + "enum": [ + "max" + ], + "type": "string" + } + ] + }, + "AutoPromptCaching": { + "description": "Automatic prompt-cache breakpoint injection for a direct Anthropic model. When enabled, the gateway adds cache-control markers to requests that carry none of their own, so callers get provider-side prompt-cache discounts without changing their requests. Requests that already set their own cache-control markers are forwarded unchanged.", + "properties": { + "enabled": { + "description": "Whether automatic prompt-cache injection is active for this model.", + "type": "boolean" + }, + "ttl": { + "allOf": [ + { + "$ref": "#/definitions/CacheTtl" + } + ], + "description": "Cache lifetime for injected breakpoints: `5m` (default when omitted) or `1h`. A `1h` cache write costs 2x the base input rate versus 1.25x for `5m`, so it pays off only when the cached prefix is reused across a longer session." + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "BackgroundModelCheck": { + "properties": { + "enabled": { + "description": "Whether background health checks are enabled for this model.", + "type": "boolean" + }, + "ignore_statuses": { + "description": "Upstream status codes to ignore when evaluating background check failures.", + "items": { + "format": "uint16", + "maximum": 599.0, + "minimum": 100.0, + "type": "integer" + }, + "type": "array" + }, + "interval_seconds": { + "description": "Seconds between background health checks. Minimum: 5.", + "format": "uint64", + "minimum": 5.0, + "type": "integer" + }, + "max_tokens": { + "description": "Maximum completion tokens requested during each background health check.", + "format": "uint32", + "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.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "timeout_seconds": { + "description": "Request timeout in seconds for each background health check. Minimum: 1.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "enabled", + "interval_seconds", + "max_tokens", + "prompt", + "stale_after_seconds", + "timeout_seconds" + ], + "type": "object" + }, + "CacheTtl": { + "description": "Cache lifetime for gateway-injected prompt-cache breakpoints.", + "enum": [ + "5m", + "1h" + ], + "type": "string" + }, + "CooldownConfig": { + "description": "Request-path cooldown settings for a direct model after retryable upstream failures. Cooldown is opt-in: it runs only when `enabled` is `true`.", + "properties": { + "default_seconds": { + "description": "Cooldown TTL in seconds when the upstream did not supply a `Retry-After` header or `honor_retry_after` is `false`.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "enabled": { + "description": "Whether cooldown is active for this model. Cooldown is off unless this is set to `true`, so a model that omits it stays in rotation regardless of upstream failures.", + "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" + }, + "max_seconds": { + "description": "Upper bound on cooldown TTL when `Retry-After` is used.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "trigger_on_timeout": { + "description": "Whether request-path timeouts trigger cooldown.", + "type": "boolean" + }, + "trigger_on_transport": { + "description": "Whether transport, decode, or stream-abort errors trigger cooldown.", + "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.", + "items": { + "format": "uint16", + "maximum": 599.0, + "minimum": 100.0, + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, + "DistanceMetric": { + "description": "Distance metric used to compare the request embedding against route example embeddings. v1 supports cosine only.", + "oneOf": [ + { + "description": "Cosine similarity — higher is more similar.", + "enum": [ + "cosine" + ], + "type": "string" + } + ] + }, + "EmbeddingConfig": { + "description": "Embedding-modality metadata for a direct Model.", + "properties": { + "dimensions": { + "description": "Output vector dimensionality. Used to validate vectors, key the example-vector cache, and (for endpoints that support it) request a reduced output size.", + "format": "uint32", + "minimum": 1.0, + "type": "integer" + }, + "normalize": { + "default": true, + "description": "Whether the endpoint already returns L2-normalized vectors. When `false`, the gateway normalizes before computing cosine similarity. Defaults to `true`.", + "type": "boolean" + } + }, + "required": [ + "dimensions" + ], + "type": "object" + }, + "EmbeddingFailureMode": { + "description": "Mode for an enum-only failure policy: route to the router's `default`, or fail the request with `503`.", + "oneOf": [ + { + "description": "Route to the semantic router's `default` model.", + "enum": [ + "default" + ], + "type": "string" + }, + { + "description": "Reject the request with `503`.", + "enum": [ + "fail" + ], + "type": "string" + } + ] + }, + "EnsembleConfig": { + "properties": { + "judge": { + "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.", + "format": "uint32", + "minimum": 1.0, + "type": "integer" + }, + "panel": { + "description": "Direct models called concurrently for each ensemble request.", + "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.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "judge", + "panel" + ], + "type": "object" + }, + "HashOnSource": { + "description": "One source for the `consistent_hash` hash key. Sources are tried in order; the first one that yields a non-empty value wins.", + "properties": { + "name": { + "description": "The header or cookie name to read. Required for `header` and `cookie` sources; not accepted for `api_key` or `client_ip`.", + "minLength": 1, + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/HashOnType" + } + ], + "description": "Which request attribute supplies the hash key." + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "HashOnType": { + "description": "Which request attribute a [`HashOnSource`] reads the hash key from.", + "oneOf": [ + { + "description": "A request header, named by `name`.", + "enum": [ + "header" + ], + "type": "string" + }, + { + "description": "A cookie from the request's `Cookie` header, named by `name`.", + "enum": [ + "cookie" + ], + "type": "string" + }, + { + "description": "The caller's API key id.", + "enum": [ + "api_key" + ], + "type": "string" + }, + { + "description": "The caller's resolved client IP (honouring the trusted-proxy configuration).", + "enum": [ + "client_ip" + ], + "type": "string" + } + ] + }, + "Judge": { + "description": "The judge model that synthesizes the panel responses into one answer. `model` references a direct model alias.", + "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.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "ModelCost": { + "description": "Per-token cost for budget tracking. Both values are in USD per 1,000 tokens.", + "properties": { + "input_per_1k": { + "description": "Prompt token cost in USD per 1,000 tokens.", + "format": "double", + "minimum": 0.0, + "type": "number" + }, + "output_per_1k": { + "description": "Completion token cost in USD per 1,000 tokens.", + "format": "double", + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "input_per_1k", + "output_per_1k" + ], + "type": "object" + }, + "OnEmbeddingFailure": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/definitions/EmbeddingFailureMode" + } + ], + "description": "`\"default\"` or `\"fail\"`." + }, + { + "description": "`{ \"target\": \"\" }` — route to a specific safe model.", + "properties": { + "target": { + "description": "Direct-model alias to route to when embedding fails.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "target" + ], + "type": "object" + } + ], + "description": "What the router does when the embedding call errors or times out. Use `\"default\"` to route to the router's default model, `\"fail\"` to reject the request with `503`, or `{ \"target\": \"\" }` to route to a specific fallback model." + }, + "PanelMember": { + "description": "One member of an ensemble panel. `model` references a direct model alias.", + "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.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "temperature": { + "description": "Sampling temperature for this panel member. Omit it to keep the request's temperature.", + "format": "float", + "minimum": 0.0, + "type": "number" + }, + "weight": { + "description": "Reserved for a future voting/quorum strategy. AISIX currently ignores this field.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "RateLimit": { + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "rpd": { + "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rph": { + "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rpm": { + "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rps": { + "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tpd": { + "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tpm": { + "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "Routing": { + "properties": { + "fallback_on_statuses": { + "description": "Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures.", + "items": { + "format": "uint16", + "maximum": 599.0, + "minimum": 400.0, + "type": "integer" + }, + "type": "array" + }, + "hash_on": { + "description": "Where the `consistent_hash` hash key comes from: an ordered chain of sources, the first non-empty value winning. Defaults to the `x-aisix-routing-key` request header, falling back to the caller's API key id. Only valid with `strategy: consistent_hash`.", + "items": { + "$ref": "#/definitions/HashOnSource" + }, + "minItems": 1, + "type": "array" + }, + "max_fallbacks": { + "description": "Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "retries": { + "description": "Retry attempts on the current target before failing over, applied to every target that does not set its own `retries`. Absent falls back to the deployment-wide `upstream.retries` default.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "retry_on_429": { + "description": "Whether upstream 429 participates in retries and failover.", + "type": "boolean" + }, + "strategy": { + "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.", + "items": { + "$ref": "#/definitions/RoutingTarget" + }, + "minItems": 1, + "type": "array" + }, + "when_all_unavailable": { + "allOf": [ + { + "$ref": "#/definitions/WhenAllUnavailablePolicy" + } + ], + "description": "Policy to apply when every target is unavailable because of runtime health or cooldown state." + } + }, + "required": [ + "targets" + ], + "type": "object" + }, + "RoutingStrategy": { + "oneOf": [ + { + "description": "Smooth weighted round-robin over target `weight`s. Equal (or absent) weights degrade to a plain declaration-order cycle.", + "enum": [ + "round_robin" + ], + "type": "string" + }, + { + "description": "Ketama-style consistent hashing of the request's hash key (see `hash_on`) over the targets, `weight` scaling each target's share of the ring. The same key keeps landing on the same target while it is healthy; on failure the walk follows the ring so only the failed target's keys move.", + "enum": [ + "consistent_hash" + ], + "type": "string" + }, + { + "description": "Always start with the first target and move to later targets only after failure.", + "enum": [ + "failover" + ], + "type": "string" + }, + { + "description": "Rank targets cheapest-first by the target model's `cost` (combined input+output per-1K price), then fall forward. Targets without a configured `cost` rank last.", + "enum": [ + "least_cost" + ], + "type": "string" + }, + { + "description": "Rank targets fastest-first by a moving average of recent observed upstream latency (time-to-first-token for streaming), then fall forward. Targets with no samples yet rank first so they get probed.", + "enum": [ + "least_latency" + ], + "type": "string" + }, + { + "description": "Rank targets least-loaded-first by in-flight requests divided by target `weight` (the APISIX least_conn score), then fall forward.", + "enum": [ + "least_busy" + ], + "type": "string" + } + ] + }, + "RoutingTarget": { + "description": "One destination in a routing configuration. `model` references a direct model alias.", + "properties": { + "model": { + "description": "Model alias for a direct model that can receive routed traffic.", + "minLength": 1, + "type": "string" + }, + "priority": { + "description": "Priority tier, default `0`; a higher value is preferred (the APISIX node-priority convention — give backup targets `-1`). The strategy orders targets within each tier; a lower tier is only tried when every higher-tier target failed or is unavailable.", + "format": "int32", + "type": "integer" + }, + "tags": { + "description": "Tags for tag/metadata-conditional routing. When a request carries routing tags, only targets whose tags intersect the request's are eligible; a target tagged `\"default\"` is the fallback used when nothing matches and for untagged requests. Absent/empty means the target opts out of tag filtering (eligible only via the default fallback once any sibling target is tagged). The configured strategy then orders whatever set survives.", + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "weight": { + "description": "Target weight, default `1`. Used by `round_robin` (rotation share), `consistent_hash` (share of the hash ring), and `least_busy` (in-flight divided by weight). `failover`, `least_cost`, and `least_latency` accept the field but do not use it.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "Semantic": { + "description": "Semantic-routing config: pick a target by request meaning.", + "properties": { + "default": { + "description": "Direct model alias used when no route clears its threshold.", + "minLength": 1, + "type": "string" + }, + "embedding_model": { + "description": "Alias of an `embedding`-modality Model used to embed the request and (at apply time) the route examples.", + "minLength": 1, + "type": "string" + }, + "embedding_timeout_ms": { + "description": "Per-call deadline for the embedding request in milliseconds. `0` or absent disables the embedding-specific deadline.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "match": { + "allOf": [ + { + "$ref": "#/definitions/SemanticMatch" + } + ], + "description": "Shared matching parameters (metric, aggregation, default threshold)." + }, + "on_embedding_failure": { + "allOf": [ + { + "$ref": "#/definitions/OnEmbeddingFailure" + } + ], + "description": "Behavior when the embedding call fails or times out. Defaults to routing to `default`." + }, + "routes": { + "description": "Routes evaluated for each request. At least one is required.", + "items": { + "$ref": "#/definitions/SemanticRoute" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "default", + "embedding_model", + "match", + "routes" + ], + "type": "object" + }, + "SemanticMatch": { + "description": "Matching parameters shared across every route in a semantic router.", + "properties": { + "aggregation": { + "allOf": [ + { + "$ref": "#/definitions/Aggregation" + } + ], + "default": "max", + "description": "Per-example score aggregation. v1: max." + }, + "distance_metric": { + "allOf": [ + { + "$ref": "#/definitions/DistanceMetric" + } + ], + "default": "cosine", + "description": "Similarity metric. v1: cosine." + }, + "threshold": { + "description": "Default similarity threshold for routes that do not set their own `threshold`. Higher is stricter.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "threshold" + ], + "type": "object" + }, + "SemanticRoute": { + "description": "One semantic route: a labeled set of example utterances whose embeddings define the route. A request that scores high enough against them dispatches to `target`.", + "properties": { + "description": { + "description": "Human-facing description. Documentation only — v1 matches on `examples`, not on this field.", + "minLength": 1, + "type": "string" + }, + "examples": { + "description": "Example utterances that define this route. AISIX embeds each example when applying the configuration and caches the vector. A request is matched against these examples. At least one example is required.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "name": { + "description": "Operator-facing route label. Surfaced in the `x-aisix-route` response header and access logs (e.g. `prod-chat -> route:legal`).", + "minLength": 1, + "type": "string" + }, + "target": { + "description": "Direct model alias that receives traffic matching this route.", + "minLength": 1, + "type": "string" + }, + "threshold": { + "description": "Per-route similarity threshold. A request matches this route only when its aggregated score is `>=` this value. When omitted, the router-level threshold applies.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "examples", + "name", + "target" + ], + "type": "object" + }, + "WhenAllUnavailablePolicy": { + "description": "Behavior when every routing target is unavailable because of runtime health or cooldown state.", + "oneOf": [ + { + "description": "Return `503` with a fixed `Retry-After` hint.", + "enum": [ + "fail" + ], + "type": "string" + }, + { + "description": "Try every target in declaration order even when all of them are currently unavailable because of health or cooldown status. Use only when maintaining availability is preferred over avoiding recently unhealthy targets.", + "enum": [ + "try_anyway" + ], + "type": "string" + } + ] + } + }, + "oneOf": [ + { + "not": { + "anyOf": [ + { + "required": [ + "provider" + ] + }, + { + "required": [ + "model_name" + ] + }, + { + "required": [ + "provider_key_id" + ] + }, + { + "required": [ + "background_model_check" + ] + }, + { + "required": [ + "cooldown" + ] + }, + { + "required": [ + "ensemble" + ] + }, + { + "required": [ + "semantic" + ] + }, + { + "required": [ + "embedding" + ] + } + ] + }, + "required": [ + "routing" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "routing" + ] + }, + { + "required": [ + "ensemble" + ] + }, + { + "required": [ + "semantic" + ] + } + ] + }, + "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": [ + "semantic" + ] + }, + { + "required": [ + "embedding" + ] + } + ] + }, + "required": [ + "ensemble" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "provider" + ] + }, + { + "required": [ + "model_name" + ] + }, + { + "required": [ + "provider_key_id" + ] + }, + { + "required": [ + "routing" + ] + }, + { + "required": [ + "ensemble" + ] + }, + { + "required": [ + "background_model_check" + ] + }, + { + "required": [ + "cooldown" + ] + }, + { + "required": [ + "embedding" + ] + } + ] + }, + "required": [ + "semantic" + ] + } + ], + "properties": { + "allowed_cidrs": { + "description": "Client IP allowlist in CIDR notation. Empty or absent allows all clients.", + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "auto_prompt_caching": { + "allOf": [ + { + "$ref": "#/definitions/AutoPromptCaching" + } + ], + "description": "Automatic prompt-cache breakpoint injection for direct Anthropic models. Omit to leave injection off." + }, + "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. Cooldown is opt-in: omit this field, or leave `enabled` unset, and the model is never taken out of rotation by request-path failures." + }, + "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" + }, + "effort_mapping": { + "additionalProperties": { + "type": "string" + }, + "description": "Direct-model-only mapping from a client-requested reasoning effort to the value sent upstream. The gateway applies one exact lookup after resolving the final target; unlisted values pass through unchanged.", + "type": "object" + }, + "embedding": { + "allOf": [ + { + "$ref": "#/definitions/EmbeddingConfig" + } + ], + "description": "Embedding-modality metadata. Present on direct Models that serve an OpenAI-compatible `/v1/embeddings` endpoint (and can be referenced by a semantic router's `embedding_model`)." + }, + "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." + }, + "retries": { + "description": "Retry attempts against this model after a retryable upstream failure, before the request gives up (or, inside a model group, fails over to the next target). Absent falls back to the group's `routing.retries`, then to the deployment-wide `upstream.retries` default.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "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." + }, + "semantic": { + "allOf": [ + { + "$ref": "#/definitions/Semantic" + } + ], + "description": "Semantic-routing configuration. When set, the gateway embeds the request and dispatches to the route whose examples it matches best, using that route's target Model for upstream dispatch." + }, + "stream_timeout": { + "description": "Maximum gap in milliseconds between upstream streaming chunks. `0` or absent falls back to the group's `stream_timeout`, then to the model's (or group's) `timeout`, then to the deployment-wide `upstream.stream_timeout_ms` / `timeout_ms` defaults.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "timeout": { + "description": "End-to-end timeout in milliseconds for non-streaming upstream calls. Absent falls back to the group's `timeout`, then to the deployment-wide `upstream.timeout_ms` default. `0` disables the non-streaming timeout for this model.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "display_name" + ], + "title": "Model", + "type": "object" +} diff --git a/schemas/resources-lenient/observability_exporter.schema.json b/schemas/resources-lenient/observability_exporter.schema.json new file mode 100644 index 00000000..4acda2ea --- /dev/null +++ b/schemas/resources-lenient/observability_exporter.schema.json @@ -0,0 +1,406 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ObjectStoreAuthMode": { + "description": "How the gateway obtains credentials for the object-storage bucket.", + "oneOf": [ + { + "description": "Resolve `credential_ref` to static keys from gateway environment variables named `OBJSTORE_CRED__`.", + "enum": [ + "credential_ref" + ], + "type": "string" + }, + { + "description": "Use the gateway 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.", + "oneOf": [ + { + "description": "OTLP/HTTP trace exporter configuration.", + "properties": { + "content_max_bytes": { + "default": 131072, + "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`.", + "format": "uint32", + "maximum": 1048576.0, + "minimum": 1.0, + "type": "integer" + }, + "content_mode": { + "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": { + "additionalProperties": { + "type": "string" + }, + "description": "Static headers attached to every export request, such as authorization or vendor-specific API-key headers.", + "type": "object" + }, + "kind": { + "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`.", + "format": "double", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "endpoint", + "kind" + ], + "type": "object" + }, + { + "description": "Aliyun SLS PutLogs exporter configuration.", + "properties": { + "content_max_bytes": { + "default": 131072, + "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`.", + "format": "uint32", + "maximum": 1048576.0, + "minimum": 1.0, + "type": "integer" + }, + "content_mode": { + "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 gateway 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": { + "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" + } + }, + "required": [ + "credential_ref", + "endpoint", + "kind", + "logstore", + "project" + ], + "type": "object" + }, + { + "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": { + "allOf": [ + { + "$ref": "#/definitions/ObjectStoreAuthMode" + } + ], + "default": "credential_ref", + "description": "How the gateway 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": { + "allOf": [ + { + "$ref": "#/definitions/ObjectStoreCompression" + } + ], + "default": "gzip", + "description": "Compression applied to each NDJSON file before upload." + }, + "credential_ref": { + "description": "Credential reference resolved by the gateway 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.", + "pattern": "^https://.+|^http://(minio|azurite|fake-gcs-server|fake-gcs|127\\.0\\.0\\.1|localhost)(:[0-9]+)?(/.*)?$", + "type": "string" + }, + "kind": { + "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": { + "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.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "bucket", + "kind", + "prefix", + "provider" + ], + "type": "object" + }, + { + "description": "Datadog native Logs HTTP intake exporter configuration.", + "properties": { + "content_max_bytes": { + "default": 131072, + "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, + "type": "integer" + }, + "content_mode": { + "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 gateway at delivery time. The plaintext Datadog API key is not stored in this resource.", + "minLength": 1, + "type": "string" + }, + "ddsource": { + "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": { + "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": { + "default": [], + "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" + } + ], + "properties": { + "enabled": { + "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" + } + }, + "required": [ + "name" + ], + "title": "ObservabilityExporter", + "type": "object" +} diff --git a/schemas/resources-lenient/oidc_provider.schema.json b/schemas/resources-lenient/oidc_provider.schema.json new file mode 100644 index 00000000..40a07e9c --- /dev/null +++ b/schemas/resources-lenient/oidc_provider.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "BoundClaimExpect": { + "anyOf": [ + { + "description": "The claim must equal (or, for array claims, contain) this value.", + "type": "string" + }, + { + "description": "The claim must equal (or, for array claims, contain) at least one of these values.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + ], + "description": "Expected value(s) for one bound claim: a single string, or a list matched as any-of." + } + }, + "properties": { + "audiences": { + "description": "Accepted `aud` values. The token's audience (a string or an array) must contain at least one of these. Every token must carry an audience claim.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "bound_claims": { + "additionalProperties": { + "$ref": "#/definitions/BoundClaimExpect" + }, + "description": "Additional claim requirements, all of which must hold. Keys name claims (dots traverse nested objects, e.g. `\"realm_access.roles\"`); each requirement is satisfied when the claim equals — or, for array claims, contains — one of the expected values.", + "type": "object" + }, + "enabled": { + "default": true, + "description": "Whether the provider participates in JWT authentication. A disabled provider is kept but ignored. Treated as `true` when omitted.", + "type": "boolean" + }, + "identity_claim": { + "default": "sub", + "description": "Claim whose value selects the API key to act as: the request is bound to the key whose `jwt_subject` equals this claim's value. Dots traverse nested objects (e.g. `\"resource_access.account\"`). Defaults to `sub`.", + "minLength": 1, + "type": "string" + }, + "issuer": { + "description": "Expected `iss` claim, compared byte-for-byte against the token's issuer. A JWT whose issuer matches no enabled provider is rejected.", + "minLength": 1, + "type": "string" + }, + "jwks_uri": { + "description": "JWKS endpoint URL the signing keys are fetched from. When omitted, the endpoint is resolved once from the issuer's OIDC discovery document (`/.well-known/openid-configuration`) and cached.", + "minLength": 1, + "type": "string" + }, + "leeway_secs": { + "description": "Clock-skew allowance in seconds applied to time-based claims (`exp`, `nbf`). Defaults to 0.", + "format": "uint64", + "maximum": 300.0, + "minimum": 0.0, + "type": "integer" + }, + "name": { + "description": "Human-readable provider name, unique within the environment (e.g. `\"corp-keycloak\"`).", + "minLength": 1, + "type": "string" + }, + "required_scopes": { + "description": "Scopes that must all be present in the token's `scope` claim (a space-delimited string or an array of strings). An empty list requires nothing.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "audiences", + "issuer", + "name" + ], + "title": "OidcProvider", + "type": "object" +} diff --git a/schemas/resources-lenient/passthrough_route.schema.json b/schemas/resources-lenient/passthrough_route.schema.json new file mode 100644 index 00000000..0e17a78e --- /dev/null +++ b/schemas/resources-lenient/passthrough_route.schema.json @@ -0,0 +1,569 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "anyOf": [ + { + "required": [ + "path_prefix" + ], + "title": "Path-prefix match" + }, + { + "required": [ + "hosts" + ], + "title": "Host match" + } + ] + }, + { + "if": { + "required": [ + "path_prefix" + ] + }, + "then": { + "properties": { + "path_prefix": { + "minLength": 1, + "type": "string" + } + } + } + }, + { + "if": { + "required": [ + "hosts" + ] + }, + "then": { + "properties": { + "hosts": { + "items": { + "minLength": 1, + "pattern": "^(\\*\\.)?([A-Za-z0-9-]+\\.)+[A-Za-z0-9-]+$|^[A-Za-z0-9-]+$", + "type": "string" + }, + "minItems": 1, + "type": "array" + } + } + } + }, + { + "if": { + "not": { + "required": [ + "hosts" + ] + }, + "required": [ + "path_prefix" + ] + }, + "then": { + "properties": { + "path_prefix": { + "not": { + "pattern": "^/(v1|mcp|a2a|admin|livez|readyz|metrics)(/|$)" + } + } + } + } + }, + { + "oneOf": [ + { + "not": { + "properties": { + "preserve_host": { + "const": true, + "description": "Set when the route derives its target from the inbound Host instead of target_url." + } + }, + "required": [ + "preserve_host" + ] + }, + "required": [ + "target_url" + ], + "title": "Explicit target URL" + }, + { + "not": { + "required": [ + "target_url" + ] + }, + "properties": { + "preserve_host": { + "const": true, + "description": "Set when the route derives its target from the inbound Host instead of target_url." + } + }, + "required": [ + "preserve_host" + ], + "title": "Preserve inbound host" + } + ] + }, + { + "if": { + "properties": { + "preserve_host": { + "const": true + } + }, + "required": [ + "preserve_host" + ] + }, + "then": { + "required": [ + "hosts" + ] + } + }, + { + "if": { + "required": [ + "target_url" + ] + }, + "then": { + "properties": { + "target_url": { + "pattern": "^https?://", + "type": "string" + } + } + } + }, + { + "if": { + "required": [ + "auth_header_name" + ] + }, + "then": { + "properties": { + "auth_header_name": { + "not": { + "enum": [ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key" + ] + }, + "type": "string" + } + } + } + }, + { + "if": { + "required": [ + "identity_header" + ] + }, + "then": { + "properties": { + "identity_header": { + "not": { + "enum": [ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key" + ] + } + } + } + } + }, + { + "if": { + "properties": { + "auth_mode": { + "const": "header_key" + } + }, + "required": [ + "auth_mode" + ] + }, + "then": { + "properties": { + "auth_header_name": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "auth_header_name" + ] + } + }, + { + "if": { + "properties": { + "auth_mode": { + "const": "anonymous" + } + }, + "required": [ + "auth_mode" + ] + }, + "then": { + "properties": { + "anonymous_key_id": { + "minLength": 1, + "type": "string" + }, + "source_cidrs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "anonymous_key_id", + "source_cidrs" + ] + } + }, + { + "if": { + "anyOf": [ + { + "not": { + "required": [ + "auth_mode" + ] + }, + "title": "auth_mode omitted (defaults to gateway_key)" + }, + { + "properties": { + "auth_mode": { + "enum": [ + "gateway_key", + "anonymous" + ] + } + }, + "required": [ + "auth_mode" + ], + "title": "auth_mode: gateway_key or anonymous" + } + ] + }, + "then": { + "not": { + "required": [ + "auth_header_name" + ] + } + }, + "title": "auth_header_name only in header_key mode" + }, + { + "if": { + "anyOf": [ + { + "not": { + "required": [ + "auth_mode" + ] + }, + "title": "auth_mode omitted (defaults to gateway_key)" + }, + { + "properties": { + "auth_mode": { + "enum": [ + "gateway_key", + "header_key" + ] + } + }, + "required": [ + "auth_mode" + ], + "title": "auth_mode: gateway_key or header_key" + } + ] + }, + "then": { + "not": { + "required": [ + "anonymous_key_id" + ] + } + }, + "title": "anonymous_key_id only in anonymous mode" + }, + { + "if": { + "anyOf": [ + { + "not": { + "required": [ + "credential_mode" + ] + }, + "title": "credential_mode omitted (defaults to inject)" + }, + { + "properties": { + "credential_mode": { + "const": "inject" + } + }, + "required": [ + "credential_mode" + ], + "title": "credential_mode: inject" + } + ] + }, + "then": { + "properties": { + "provider_key_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "provider_key_id" + ] + } + }, + { + "if": { + "properties": { + "credential_mode": { + "const": "forward_client" + } + }, + "required": [ + "credential_mode" + ] + }, + "then": { + "not": { + "required": [ + "provider_key_id" + ] + } + } + } + ], + "anyOf": [ + { + "required": [ + "name" + ], + "title": "name" + }, + { + "required": [ + "display_name" + ], + "title": "display_name" + } + ], + "definitions": { + "PassthroughAuthMode": { + "description": "How the gateway authenticates callers of a passthrough route.", + "oneOf": [ + { + "description": "Standard gateway auth: an API key or JWT in `Authorization: Bearer` or `x-api-key`, exactly like the typed endpoints.", + "enum": [ + "gateway_key" + ], + "title": "Standard gateway credential", + "type": "string" + }, + { + "description": "Gateway credential in the route's `auth_header_name` header; `Authorization` is left untouched for the upstream credential.", + "enum": [ + "header_key" + ], + "title": "Gateway credential in a dedicated header", + "type": "string" + }, + { + "description": "No inbound gateway credential. The request runs as the route's `anonymous_key_id` principal, restricted to `source_cidrs`.", + "enum": [ + "anonymous" + ], + "title": "Anonymous (bound principal)", + "type": "string" + } + ] + }, + "PassthroughCredentialMode": { + "description": "How the upstream credential of a passthrough route is produced.", + "oneOf": [ + { + "description": "Strip inbound credential headers and inject the configured ProviderKey's secret (the legacy tunnel's behavior, now explicit).", + "enum": [ + "inject" + ], + "title": "Inject the ProviderKey secret", + "type": "string" + }, + { + "description": "Forward the caller's own `Authorization` (and other credential headers) verbatim — bring-your-own-credential. Gateway side-channel headers are still stripped so the gateway credential never leaks upstream.", + "enum": [ + "forward_client" + ], + "title": "Forward the caller's own credential", + "type": "string" + } + ] + } + }, + "properties": { + "anonymous_key_id": { + "description": "The API key this route's traffic runs as when `auth_mode` is `anonymous`: its `allowed_routes`, rate limits, budget and usage attribution all apply, so anonymous traffic keeps a stable, governable principal. Required for `anonymous`; ignored otherwise.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "auth_header_name": { + "description": "Header carrying the gateway credential (API key or JWT) when `auth_mode` is `header_key`, e.g. `x-aisix-api-key`. Lets `Authorization` carry the caller's own upstream credential. The header is stripped before forwarding. Lowercase-only so the forbidden credential-slot list in the coupling is exhaustive (matching is case-insensitive on the wire regardless). Required for `header_key`; ignored otherwise.", + "minLength": 1, + "pattern": "^[!#$%&'*+.^_`|~0-9a-z-]+$", + "type": [ + "string", + "null" + ] + }, + "auth_mode": { + "allOf": [ + { + "$ref": "#/definitions/PassthroughAuthMode" + } + ], + "default": "gateway_key", + "description": "How the gateway authenticates the caller on this route." + }, + "credential_mode": { + "allOf": [ + { + "$ref": "#/definitions/PassthroughCredentialMode" + } + ], + "default": "inject", + "description": "How the upstream credential is produced." + }, + "display_name": { + "description": "Accepted as an alternative spelling of `name`. Provide the label under exactly one of the two names.", + "minLength": 1, + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this route is active. A disabled route matches nothing.", + "type": "boolean" + }, + "forward_client_headers": { + "description": "Inbound client headers forwarded to the upstream even when this route would otherwise strip them, as single-`*` glob patterns matched case-insensitively against the header name (`\"authorization\"`, `\"x-trace-*\"`). Empty — the default — overrides no stripping.\n\nA route forwards the caller's headers by default, so this field only matters for the ones it removes: the ProviderKey's `strip_headers` under `credential_mode: inject`, and the slot the gateway consumed to authenticate the caller. Naming `authorization` under `auth_mode: gateway_key` therefore puts the caller's own credential back on the upstream request in place of the one this route would inject, never both — which is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged.\n\nA credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — and `traceparent` / `tracestate` are forwarded only when a pattern names them exactly. A glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry, so a broad pattern overrides the rest of the strip set and leaves those alone.\n\nThis route's own `auth_header_name` and `identity_header` are read the same way. Both are slots this route chose rather than ones the gateway owns — under `auth_mode: header_key` the first carries the gateway credential the caller authenticated with, and the second carries an end-user identity this route records and strips — so a glob does not sweep either, and a pattern that names one in full forwards it.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are stripped whatever the patterns say: `host`, `content-length`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace.", + "items": { + "type": "string" + }, + "type": "array" + }, + "hosts": { + "description": "Inbound `Host` values this route serves (the forward-proxy entry: a TLS-terminating device delivers plaintext traffic with the original host, e.g. `api.githubcopilot.com`). Matched case-insensitively, ignoring any `:port` suffix; a leading `*.` wildcard matches exactly one extra label (`*.githubcopilot.com` matches `proxy.githubcopilot.com`). Host-matched requests keep their full path (no prefix stripping unless `path_prefix` also matched).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "identity_header": { + "description": "Optional header carrying the end-user identity injected by the upstream network device (e.g. `x-aisix-user`). Its value is recorded on the usage event for per-employee audit attribution and stripped before forwarding. Lowercase-only so the forbidden credential-slot list in the coupling is exhaustive; credential-bearing names (`authorization`, `cookie`, …) are rejected outright — their value on the usage event would be credential retention, not identity.", + "minLength": 1, + "pattern": "^[!#$%&'*+.^_`|~0-9a-z-]+$", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Operator-facing label, unique within the gateway. Referenced by API keys' `allowed_routes` globs and used for usage attribution.", + "minLength": 1, + "type": "string" + }, + "path_prefix": { + "description": "Gateway path prefix this route serves, e.g. `/passthrough/openai`. Must start with `/`.\n\nHow the prefix is treated depends on the target shape: a `target_url` route MOUNTS at the prefix, so it is stripped before the remainder is joined to the target base; a `preserve_host` route MIRRORS an upstream that owns its own path space, so the prefix only selects which requests the route claims and the complete path is forwarded unchanged.\n\nA route WITHOUT `hosts` must not claim a reserved gateway namespace (`/v1`, `/mcp`, `/a2a`, health probes) — the typed routes would shadow it. A route WITH `hosts` may use any prefix: host-matched requests dispatch ahead of the typed routes, which is what lets a forward proxy relay an upstream's own namespace (e.g. Copilot's `/mcp/...` on its chat host). At least one of `path_prefix` / `hosts` is required; when both are set the request must satisfy both.", + "minLength": 1, + "pattern": "^/", + "type": [ + "string", + "null" + ] + }, + "preserve_host": { + "description": "Derive the target from the request's own `Host` header (`https://`), for forward-proxy routes that fan one route out over several official hosts. Only legal when `hosts` is set — the matched allowlist is what makes the derived target non-attacker- controlled (SSRF guard).", + "type": "boolean" + }, + "provider_key_id": { + "description": "ProviderKey whose secret is injected upstream when `credential_mode` is `inject` (per-provider auth shape: `x-api-key` + `anthropic-version` for Anthropic, `Authorization: Bearer` otherwise; its `strip_headers` and TLS settings apply). Required for `inject`; forbidden for `forward_client`.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "source_cidrs": { + "description": "Client source CIDRs allowed to use this route. Required (non-empty) when `auth_mode` is `anonymous` — network reachability is the only gate left in front of the anonymous principal. Optional hardening for the other modes; unset means no route-level restriction.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "target_url": { + "description": "Explicit upstream base URL, e.g. `https://api.openai.com`. The matched request's remainder path and query are appended. Exactly one of `target_url` / `preserve_host` must be configured.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "timeout_ms": { + "description": "Maximum time, in milliseconds, for the upstream exchange. Bounds the response-header phase and any non-SSE body read, but never a healthy SSE relay (which ends with the upstream stream or the client hanging up). When omitted, the gateway default request timeout applies the same way.", + "format": "uint64", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [], + "title": "PassthroughRoute", + "type": "object" +} diff --git a/schemas/resources-lenient/provider_key.schema.json b/schemas/resources-lenient/provider_key.schema.json new file mode 100644 index 00000000..573d7fc3 --- /dev/null +++ b/schemas/resources-lenient/provider_key.schema.json @@ -0,0 +1,380 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "required": [ + "api_key" + ], + "title": "api_key" + }, + { + "required": [ + "secret" + ], + "title": "secret" + } + ], + "definitions": { + "Adapter": { + "description": "Upstream API protocol family used for provider dispatch.", + "enum": [ + "openai", + "anthropic", + "bedrock", + "vertex", + "azure-openai" + ], + "type": "string" + }, + "ApiEndpoint": { + "description": "One declared entry point.", + "properties": { + "base": { + "description": "Base URL this surface is served at. Omit when it is the same one `api_base` names — an entry with no `base` still carries the declaration that the surface exists.\n\nDeliberately NOT length-constrained, matching `api_base`. The lenient read schema keeps every constraint but the open-object one, so a `minLength` here would make an empty string skip the whole Provider Key row — and with it every model that references the key — where the same empty string on `api_base` is the control plane's documented way to clear an override. An empty value is treated as \"no override\" at resolution time instead.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ParamConstraints": { + "description": "Numeric range clamps applied to chat-completion request bodies.", + "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" + ] + }, + "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" + ] + } + }, + "type": "object" + }, + "ProviderApis": { + "description": "Per-surface native entry points on one upstream.\n\nThe two surfaces resolve differently, because the evidence for them differs:\n\n- `messages` is **additive**. An `adapter: anthropic` key (or the `anthropic` vendor) speaks that wire by declaration and keeps serving `/v1/messages` natively whatever this map says; listing it here adds the route to a key whose adapter is something else. That is the DeepSeek/Zhipu shape — an OpenAI-compatible key whose vendor also fronts an Anthropic-compatible path. An entry's own `base` always decides WHERE the Anthropic wire lives, for the verbatim passthrough and for the bridge that translates into it alike; without one it is `api_base`. - `responses` is **authoritative**. Once this map exists, `/v1/responses` is served natively only if it is listed. The Responses API is a strict superset of chat completions rather than a rename, so an OpenAI-compatible endpoint serving one does not necessarily serve the other, and `adapter: openai` is not evidence either way. Leaving it out is how an operator says \"this endpoint has no `/v1/responses`\" and gets the request translated to chat completions instead of 404'd upstream.\n\nWith no map at all, each falls back to what the gateway inferred before this field existed: `/v1/messages` from the vendor id or the `anthropic` adapter, `/v1/responses` from the vendor id alone.", + "properties": { + "messages": { + "anyOf": [ + { + "$ref": "#/definitions/ApiEndpoint" + }, + { + "type": "null" + } + ], + "description": "Anthropic-wire `/v1/messages` (and `/v1/messages/count_tokens`)." + }, + "responses": { + "anyOf": [ + { + "$ref": "#/definitions/ApiEndpoint" + }, + { + "type": "null" + } + ], + "description": "OpenAI-wire `/v1/responses`." + } + }, + "type": "object" + }, + "ProviderKeyTls": { + "description": "TLS settings for connections to one Provider Key's `api_base`.\n\nUse this when a single upstream endpoint needs trust settings that differ from the gateway's deployment-wide ones — typically a self-hosted model endpoint whose certificate is signed by a private certificate authority.\n\nThe certificate is supplied inline rather than as a file path, because the endpoint is declared here rather than in the gateway's own configuration file. For a certificate authority that applies to every upstream, prefer the gateway's `upstream.tls.ca_file` setting.", + "properties": { + "ca_cert": { + "description": "PEM-encoded certificate authority certificates trusted as issuers for this endpoint, in addition to the gateway's default trust store. A bundle containing several certificates is accepted.", + "type": [ + "string", + "null" + ] + }, + "verify": { + "default": true, + "description": "Whether the endpoint's certificate is verified. Setting this to `false` accepts any certificate, including one presented by an intercepting party, and is intended only for test environments.", + "type": "boolean" + } + }, + "type": "object" + }, + "RequestOverrides": { + "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.", + "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" + }, + "default_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Top-level headers added to the outbound request when the caller did not set them. Values may reference the request context with `${...}` variables, such as `\"${request.api_key.team_id}\"`; a header whose variables do not all resolve is dropped rather than sent blank. See [`crate::header_template`] for the closed variable vocabulary.\n\n\"When the caller did not set them\" includes the gateway itself: an entry naming the slot this ProviderKey's credential occupies is not applied. Use `forward_client_headers` to put the caller's own credential there instead.", + "type": "object" + }, + "forward_client_headers": { + "description": "Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`\"anthropic-beta\"`, `\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167.\n\nA header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nTwo cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature.\n\nNaming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry.", + "items": { + "type": "string" + }, + "type": "array" + }, + "param_constraints": { + "anyOf": [ + { + "$ref": "#/definitions/ParamConstraints" + }, + { + "type": "null" + } + ], + "description": "Parameter constraints applied to the outbound request. If omitted, no clamping is applied." + }, + "param_renames": { + "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" + } + }, + "type": "object" + }, + "ResponseOverrides": { + "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.", + "properties": { + "content_list_to_string": { + "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": { + "description": "Stored error-envelope preference for compatibility with control-plane configuration. The proxy does not currently apply this field.", + "type": [ + "string", + "null" + ] + }, + "reasoning_field": { + "description": "Path used to extract reasoning content from the provider response. If omitted or empty, no reasoning field is lifted. Example: `\"delta.reasoning_content\"`.", + "type": [ + "string", + "null" + ] + }, + "stream_done_marker": { + "anyOf": [ + { + "$ref": "#/definitions/StreamDoneMarker" + }, + { + "type": "null" + } + ], + "description": "Stream `[DONE]` terminator expectation. If omitted, either presence or absence of the terminator is accepted." + } + }, + "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.", + "enum": [ + "required" + ], + "type": "string" + }, + { + "description": "Either presence or absence is acceptable. Used when the upstream is OpenAI-compatible but does not require the terminator.", + "enum": [ + "optional" + ], + "type": "string" + }, + { + "description": "Upstream is expected to omit the marker and terminate on connection close.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "TelemetryKind": { + "description": "Provider-key category: `catalog` for curated providers, `byo` for bring-your-own.", + "enum": [ + "catalog", + "byo" + ], + "type": "string" + }, + "TelemetryTags": { + "description": "Telemetry attribution tags emitted with requests routed through this provider key.", + "properties": { + "branded_provider": { + "description": "Branded provider slug for catalog entries, such as `\"openai\"` or `\"anthropic\"`. Bring-your-own providers leave this field unset.", + "type": [ + "string", + "null" + ] + }, + "byo_label": { + "description": "Operator-defined label for bring-your-own entries, such as an internal team name. Catalog entries leave this field unset.", + "type": [ + "string", + "null" + ] + }, + "featured": { + "default": false, + "description": "Whether this provider is surfaced in the featured list.", + "type": "boolean" + }, + "kind": { + "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\"`.", + "type": [ + "string", + "null" + ] + } + }, + "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" + ] + }, + "api_key": { + "description": "Upstream provider's API key. The gateway receives plaintext so it can authenticate to the upstream provider. Protect the configuration store and transport accordingly.", + "minLength": 1, + "type": "string" + }, + "apis": { + "anyOf": [ + { + "$ref": "#/definitions/ProviderApis" + }, + { + "type": "null" + } + ], + "description": "API surfaces this upstream serves natively, beyond the one its `adapter` already implies, and the base URL each one lives at.\n\nOne upstream account often exposes more than one protocol, on different paths of the same host — an OpenAI-compatible `/v1/chat/completions` under `…/v1` and an Anthropic-compatible `/v1/messages` under `…/anthropic`, both authenticated by the same credential. `api_base` can only name one of them, so without this field every request the declared path cannot serve gets translated instead — losing whatever the target protocol carries that the canonical chat shape does not (prompt-cache breakpoints, thinking blocks). Declaring the second entry here lets each inbound protocol reach its own native path under the one credential.\n\nEach surface resolves on its own terms; see [`ProviderApis`]. Surfaces this map has no key for — embeddings, audio, images, videos, files/batches/fine-tuning, rerank — always use `api_base`, exactly as before." + }, + "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": "Accepted as an alternative spelling of `api_key`. Provide the credential under exactly one of the two names.", + "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." + }, + "tls": { + "anyOf": [ + { + "$ref": "#/definitions/ProviderKeyTls" + }, + { + "type": "null" + } + ], + "description": "TLS settings for connections to this key's `api_base`. Omit to use the gateway's deployment-wide trust settings." + } + }, + "required": [ + "display_name" + ], + "title": "ProviderKey", + "type": "object" +} diff --git a/schemas/resources-lenient/rate_limit.schema.json b/schemas/resources-lenient/rate_limit.schema.json new file mode 100644 index 00000000..c270202f --- /dev/null +++ b/schemas/resources-lenient/rate_limit.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpd": { + "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rph": { + "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpm": { + "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rps": { + "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "tpd": { + "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "tpm": { + "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "RateLimit", + "type": "object" +} diff --git a/schemas/resources-lenient/rate_limit_policy.schema.json b/schemas/resources-lenient/rate_limit_policy.schema.json new file mode 100644 index 00000000..2b705e6a --- /dev/null +++ b/schemas/resources-lenient/rate_limit_policy.schema.json @@ -0,0 +1,448 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ConditionGroup": { + "description": "A group node combining child nodes under an explicit AND/OR.", + "properties": { + "children": { + "items": { + "$ref": "#/definitions/ConditionNode" + }, + "type": "array" + }, + "logic": { + "$ref": "#/definitions/ConditionLogic" + }, + "negate": { + "type": "boolean" + } + }, + "required": [ + "children", + "logic" + ], + "type": "object" + }, + "ConditionLogic": { + "description": "Group combinator — lua-resty-expr `AND`/`OR` (with `negate` for `!AND`/`!OR`).", + "enum": [ + "and", + "or" + ], + "type": "string" + }, + "ConditionNode": { + "anyOf": [ + { + "$ref": "#/definitions/PolicyCondition" + }, + { + "$ref": "#/definitions/ConditionGroup" + } + ], + "description": "A slot in a condition list: leaf or nested group. Untagged — the shapes are disjoint (a leaf requires `dimension`/`operator`/`value`, a group `logic`/`children`), and the write contract rejects unknown fields inside either variant, because an unknown field placed there is otherwise ignored without being reported (same reasoning as `OnEmbeddingFailure` in the model schema)." + }, + "ConditionOperator": { + "description": "Condition leaf operator — lua-resty-expr tokens, verbatim.", + "enum": [ + "==", + "~=", + "~~", + "~*", + "in", + "has", + ">", + ">=", + "<", + "<=", + "ipmatch" + ], + "type": "string" + }, + "ConditionValue": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "A leaf's comparison value: `in` (and the reserved list operators) carry a string list, every scalar operator a single string." + }, + "GroupByDimension": { + "description": "Dimension a policy's counters split on (`group_by`). The subset of [`PolicyDimension`] with a stable per-request value to key a bucket segment on — `model_name` is excluded (it duplicates `model` as a bucket identity, less precisely).", + "enum": [ + "team", + "member", + "api_key", + "model", + "provider" + ], + "type": "string" + }, + "PolicyAction": { + "description": "What the policy does past its limits. v1 has the single `reject` (429); the enum reserves the field for `fallback`/`queue`/`alert`.", + "enum": [ + "reject" + ], + "type": "string" + }, + "PolicyCondition": { + "description": "One condition leaf: `dimension operator value`, with `negate` as the lua-resty-expr `!` prefix.", + "properties": { + "dimension": { + "$ref": "#/definitions/PolicyDimension" + }, + "negate": { + "type": "boolean" + }, + "operator": { + "$ref": "#/definitions/ConditionOperator" + }, + "value": { + "$ref": "#/definitions/ConditionValue" + } + }, + "required": [ + "dimension", + "operator", + "value" + ], + "type": "object" + }, + "PolicyDimension": { + "description": "Request dimension a condition leaf matches on.", + "oneOf": [ + { + "description": "`ApiKey.team_id` (UUID).", + "enum": [ + "team" + ], + "type": "string" + }, + { + "description": "`ApiKey.user_id` (UUID).", + "enum": [ + "member" + ], + "type": "string" + }, + { + "description": "Authenticated api_key entry id (UUID).", + "enum": [ + "api_key" + ], + "type": "string" + }, + { + "description": "Model entry id (UUID). Matches the dispatched model; on a Model-Group / semantic-router / ensemble dispatch it also matches the requested parent entry, so a group's own id selects every request addressed to that group.", + "enum": [ + "model" + ], + "type": "string" + }, + { + "description": "Model display name — the string dimension for regex/prefix matching (\"every gpt-4-family alias\"). Matches the dispatched model's name, and on a virtual-parent dispatch also the requested parent's name.", + "enum": [ + "model_name" + ], + "type": "string" + }, + { + "description": "Dispatched model's `provider` (models.dev catalog id).", + "enum": [ + "provider" + ], + "type": "string" + } + ] + }, + "PolicySchedule": { + "description": "One recurring wall-clock window during which the owning policy is suspended (not enforced). Days are selected by `days_of_week` OR by an explicit `dates` list (exactly one selector; the JSON Schema's injected `oneOf` enforces this — see [`crate::models::schema::rate_limit_policy_root_schema`]), evaluated in `timezone`. Time bounds compare as wall-clock minutes: `start_time < end_time` is a same-day window; `start_time > end_time` crosses midnight and belongs to its **start** day (`days_of_week: [fri], 22:00 → 09:00` covers Friday 22:00 through Saturday 09:00); equal times are an empty window that never matches.", + "oneOf": [ + { + "required": [ + "days_of_week" + ] + }, + { + "required": [ + "dates" + ] + } + ], + "properties": { + "dates": { + "description": "Explicit calendar dates (`YYYY-MM-DD`, in `timezone`) the window opens on — for holidays and other irregular days.", + "items": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "days_of_week": { + "description": "Recurring weekly selector: the window opens on each listed day.", + "items": { + "$ref": "#/definitions/ScheduleWeekday" + }, + "minItems": 1, + "type": "array" + }, + "end_time": { + "description": "Window end, `HH:MM` wall clock (exclusive); `24:00` = end of day. An end before the start crosses into the following day; an end equal to the start is an empty window that never matches.", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$|^24:00$", + "type": "string" + }, + "start_time": { + "description": "Window start, `HH:MM` wall clock (inclusive).", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", + "type": "string" + }, + "timezone": { + "description": "IANA timezone the window's wall-clock fields are interpreted in (e.g. `Asia/Shanghai`).", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "end_time", + "start_time", + "timezone" + ], + "type": "object" + }, + "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", + "day" + ], + "type": "string" + }, + "RateLimit": { + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "rpd": { + "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rph": { + "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rpm": { + "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rps": { + "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tpd": { + "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tpm": { + "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "ScheduleWeekday": { + "description": "Day-of-week selector for a [`PolicySchedule`], in the schedule's timezone.", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "type": "string" + } + }, + "oneOf": [ + { + "anyOf": [ + { + "required": [ + "max_requests" + ] + }, + { + "required": [ + "max_tokens" + ] + } + ], + "not": { + "anyOf": [ + { + "required": [ + "conditions" + ] + }, + { + "required": [ + "group_by" + ] + }, + { + "required": [ + "limits" + ] + }, + { + "required": [ + "action" + ] + } + ] + }, + "required": [ + "scope", + "scope_ref", + "window" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "scope" + ] + }, + { + "required": [ + "scope_ref" + ] + }, + { + "required": [ + "window" + ] + }, + { + "required": [ + "max_requests" + ] + }, + { + "required": [ + "max_tokens" + ] + } + ] + }, + "required": [ + "limits" + ] + } + ], + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/definitions/PolicyAction" + } + ], + "description": "Over-limit action; v1 only `reject` (429), absent = `reject`." + }, + "conditions": { + "description": "Condition node tree the request must satisfy (implicit AND across the top level; `[]`/absent = every request in the env).", + "items": { + "$ref": "#/definitions/ConditionNode" + }, + "type": "array" + }, + "group_by": { + "description": "Dimensions the counters split on; `[]`/absent = one shared bucket for every matched request. A matched request missing a `group_by` dimension is not subject to the policy (mirrors `team_member` only applying to keys that carry a `user_id`).", + "items": { + "$ref": "#/definitions/GroupByDimension" + }, + "type": "array" + }, + "limits": { + "allOf": [ + { + "$ref": "#/definitions/RateLimit" + } + ], + "description": "Full 7-field limits (`rps/rpm/rph/rpd/tpm/tpd/concurrency`) — same shape and storage semantics as the inline model/api_key rate limits. Present on every conditional row (it is the form discriminator) and carries at least one field." + }, + "max_requests": { + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "max_tokens": { + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "schedules": { + "description": "Recurring windows during which this policy is suspended — the quota gate skips it while `now` falls in any listed window and enforcement resumes automatically afterwards (AISIX-Cloud#1104). The counters' bucket key never changes, so suspension does not reset the surrounding window's counts. Empty/absent = always enforced. cp-api omits the field when empty, keeping schedule-less rows parseable by pre-`schedules` strict data planes.", + "items": { + "$ref": "#/definitions/PolicySchedule" + }, + "type": "array" + }, + "scope": { + "$ref": "#/definitions/PolicyScope" + }, + "scope_ref": { + "minLength": 1, + "type": "string" + }, + "window": { + "$ref": "#/definitions/PolicyWindow" + } + }, + "required": [ + "name" + ], + "title": "RateLimitPolicy", + "type": "object" +} diff --git a/schemas/resources-lenient/routing.schema.json b/schemas/resources-lenient/routing.schema.json new file mode 100644 index 00000000..ddea924d --- /dev/null +++ b/schemas/resources-lenient/routing.schema.json @@ -0,0 +1,254 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "HashOnSource": { + "description": "One source for the `consistent_hash` hash key. Sources are tried in order; the first one that yields a non-empty value wins.", + "properties": { + "name": { + "description": "The header or cookie name to read. Required for `header` and `cookie` sources; not accepted for `api_key` or `client_ip`.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/HashOnType" + } + ], + "description": "Which request attribute supplies the hash key." + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "HashOnType": { + "description": "Which request attribute a [`HashOnSource`] reads the hash key from.", + "oneOf": [ + { + "description": "A request header, named by `name`.", + "enum": [ + "header" + ], + "type": "string" + }, + { + "description": "A cookie from the request's `Cookie` header, named by `name`.", + "enum": [ + "cookie" + ], + "type": "string" + }, + { + "description": "The caller's API key id.", + "enum": [ + "api_key" + ], + "type": "string" + }, + { + "description": "The caller's resolved client IP (honouring the trusted-proxy configuration).", + "enum": [ + "client_ip" + ], + "type": "string" + } + ] + }, + "RoutingStrategy": { + "oneOf": [ + { + "description": "Smooth weighted round-robin over target `weight`s. Equal (or absent) weights degrade to a plain declaration-order cycle.", + "enum": [ + "round_robin" + ], + "type": "string" + }, + { + "description": "Ketama-style consistent hashing of the request's hash key (see `hash_on`) over the targets, `weight` scaling each target's share of the ring. The same key keeps landing on the same target while it is healthy; on failure the walk follows the ring so only the failed target's keys move.", + "enum": [ + "consistent_hash" + ], + "type": "string" + }, + { + "description": "Always start with the first target and move to later targets only after failure.", + "enum": [ + "failover" + ], + "type": "string" + }, + { + "description": "Rank targets cheapest-first by the target model's `cost` (combined input+output per-1K price), then fall forward. Targets without a configured `cost` rank last.", + "enum": [ + "least_cost" + ], + "type": "string" + }, + { + "description": "Rank targets fastest-first by a moving average of recent observed upstream latency (time-to-first-token for streaming), then fall forward. Targets with no samples yet rank first so they get probed.", + "enum": [ + "least_latency" + ], + "type": "string" + }, + { + "description": "Rank targets least-loaded-first by in-flight requests divided by target `weight` (the APISIX least_conn score), then fall forward.", + "enum": [ + "least_busy" + ], + "type": "string" + } + ] + }, + "RoutingTarget": { + "description": "One destination in a routing configuration. `model` references a direct model alias.", + "properties": { + "model": { + "description": "Model alias for a direct model that can receive routed traffic.", + "minLength": 1, + "type": "string" + }, + "priority": { + "description": "Priority tier, default `0`; a higher value is preferred (the APISIX node-priority convention — give backup targets `-1`). The strategy orders targets within each tier; a lower tier is only tried when every higher-tier target failed or is unavailable.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "tags": { + "description": "Tags for tag/metadata-conditional routing. When a request carries routing tags, only targets whose tags intersect the request's are eligible; a target tagged `\"default\"` is the fallback used when nothing matches and for untagged requests. Absent/empty means the target opts out of tag filtering (eligible only via the default fallback once any sibling target is tagged). The configured strategy then orders whatever set survives.", + "items": { + "minLength": 1, + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "weight": { + "description": "Target weight, default `1`. Used by `round_robin` (rotation share), `consistent_hash` (share of the hash ring), and `least_busy` (in-flight divided by weight). `failover`, `least_cost`, and `least_latency` accept the field but do not use it.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "WhenAllUnavailablePolicy": { + "description": "Behavior when every routing target is unavailable because of runtime health or cooldown state.", + "oneOf": [ + { + "description": "Return `503` with a fixed `Retry-After` hint.", + "enum": [ + "fail" + ], + "type": "string" + }, + { + "description": "Try every target in declaration order even when all of them are currently unavailable because of health or cooldown status. Use only when maintaining availability is preferred over avoiding recently unhealthy targets.", + "enum": [ + "try_anyway" + ], + "type": "string" + } + ] + } + }, + "properties": { + "fallback_on_statuses": { + "description": "Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures.", + "items": { + "format": "uint16", + "maximum": 599.0, + "minimum": 400.0, + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "hash_on": { + "description": "Where the `consistent_hash` hash key comes from: an ordered chain of sources, the first non-empty value winning. Defaults to the `x-aisix-routing-key` request header, falling back to the caller's API key id. Only valid with `strategy: consistent_hash`.", + "items": { + "$ref": "#/definitions/HashOnSource" + }, + "minItems": 1, + "type": [ + "array", + "null" + ] + }, + "max_fallbacks": { + "description": "Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "retries": { + "description": "Retry attempts on the current target before failing over, applied to every target that does not set its own `retries`. Absent falls back to the deployment-wide `upstream.retries` default.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "retry_on_429": { + "description": "Whether upstream 429 participates in retries and failover.", + "type": [ + "boolean", + "null" + ] + }, + "strategy": { + "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.", + "items": { + "$ref": "#/definitions/RoutingTarget" + }, + "minItems": 1, + "type": "array" + }, + "when_all_unavailable": { + "anyOf": [ + { + "$ref": "#/definitions/WhenAllUnavailablePolicy" + }, + { + "type": "null" + } + ], + "description": "Policy to apply when every target is unavailable because of runtime health or cooldown state." + } + }, + "required": [ + "targets" + ], + "title": "Routing", + "type": "object" +} diff --git a/schemas/resources-lenient/semantic.schema.json b/schemas/resources-lenient/semantic.schema.json new file mode 100644 index 00000000..199a818e --- /dev/null +++ b/schemas/resources-lenient/semantic.schema.json @@ -0,0 +1,211 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Aggregation": { + "description": "How a request's per-example similarity scores collapse into one score for the route. v1 uses `max` (the single best-matching example), chosen for explainability — the UI surfaces \"the top matching example\" — over semantic-router's default sum/mean-over-top_k.", + "oneOf": [ + { + "description": "Route score = the highest cosine across its examples.", + "enum": [ + "max" + ], + "type": "string" + } + ] + }, + "DistanceMetric": { + "description": "Distance metric used to compare the request embedding against route example embeddings. v1 supports cosine only.", + "oneOf": [ + { + "description": "Cosine similarity — higher is more similar.", + "enum": [ + "cosine" + ], + "type": "string" + } + ] + }, + "EmbeddingFailureMode": { + "description": "Mode for an enum-only failure policy: route to the router's `default`, or fail the request with `503`.", + "oneOf": [ + { + "description": "Route to the semantic router's `default` model.", + "enum": [ + "default" + ], + "type": "string" + }, + { + "description": "Reject the request with `503`.", + "enum": [ + "fail" + ], + "type": "string" + } + ] + }, + "OnEmbeddingFailure": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/definitions/EmbeddingFailureMode" + } + ], + "description": "`\"default\"` or `\"fail\"`." + }, + { + "description": "`{ \"target\": \"\" }` — route to a specific safe model.", + "properties": { + "target": { + "description": "Direct-model alias to route to when embedding fails.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "target" + ], + "type": "object" + } + ], + "description": "What the router does when the embedding call errors or times out. Use `\"default\"` to route to the router's default model, `\"fail\"` to reject the request with `503`, or `{ \"target\": \"\" }` to route to a specific fallback model." + }, + "SemanticMatch": { + "description": "Matching parameters shared across every route in a semantic router.", + "properties": { + "aggregation": { + "allOf": [ + { + "$ref": "#/definitions/Aggregation" + } + ], + "default": "max", + "description": "Per-example score aggregation. v1: max." + }, + "distance_metric": { + "allOf": [ + { + "$ref": "#/definitions/DistanceMetric" + } + ], + "default": "cosine", + "description": "Similarity metric. v1: cosine." + }, + "threshold": { + "description": "Default similarity threshold for routes that do not set their own `threshold`. Higher is stricter.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "threshold" + ], + "type": "object" + }, + "SemanticRoute": { + "description": "One semantic route: a labeled set of example utterances whose embeddings define the route. A request that scores high enough against them dispatches to `target`.", + "properties": { + "description": { + "description": "Human-facing description. Documentation only — v1 matches on `examples`, not on this field.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "examples": { + "description": "Example utterances that define this route. AISIX embeds each example when applying the configuration and caches the vector. A request is matched against these examples. At least one example is required.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "name": { + "description": "Operator-facing route label. Surfaced in the `x-aisix-route` response header and access logs (e.g. `prod-chat -> route:legal`).", + "minLength": 1, + "type": "string" + }, + "target": { + "description": "Direct model alias that receives traffic matching this route.", + "minLength": 1, + "type": "string" + }, + "threshold": { + "description": "Per-route similarity threshold. A request matches this route only when its aggregated score is `>=` this value. When omitted, the router-level threshold applies.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "examples", + "name", + "target" + ], + "type": "object" + } + }, + "description": "Semantic-routing config: pick a target by request meaning.", + "properties": { + "default": { + "description": "Direct model alias used when no route clears its threshold.", + "minLength": 1, + "type": "string" + }, + "embedding_model": { + "description": "Alias of an `embedding`-modality Model used to embed the request and (at apply time) the route examples.", + "minLength": 1, + "type": "string" + }, + "embedding_timeout_ms": { + "description": "Per-call deadline for the embedding request in milliseconds. `0` or absent disables the embedding-specific deadline.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "match": { + "allOf": [ + { + "$ref": "#/definitions/SemanticMatch" + } + ], + "description": "Shared matching parameters (metric, aggregation, default threshold)." + }, + "on_embedding_failure": { + "allOf": [ + { + "$ref": "#/definitions/OnEmbeddingFailure" + } + ], + "description": "Behavior when the embedding call fails or times out. Defaults to routing to `default`." + }, + "routes": { + "description": "Routes evaluated for each request. At least one is required.", + "items": { + "$ref": "#/definitions/SemanticRoute" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "default", + "embedding_model", + "match", + "routes" + ], + "title": "Semantic", + "type": "object" +} diff --git a/schemas/resources/rate_limit_policy.schema.json b/schemas/resources/rate_limit_policy.schema.json index 0598db6a..94dfe571 100644 --- a/schemas/resources/rate_limit_policy.schema.json +++ b/schemas/resources/rate_limit_policy.schema.json @@ -42,7 +42,7 @@ "$ref": "#/definitions/ConditionGroup" } ], - "description": "A slot in a condition list: leaf or nested group. Untagged — the shapes are disjoint (a leaf requires `dimension`/`operator`/`value`, a group `logic`/`children`), and the schema closes both variants against unknown fields in **both** validator sets because serde silently swallows unknown fields inside untagged content (same reasoning as `OnEmbeddingFailure` in the model schema)." + "description": "A slot in a condition list: leaf or nested group. Untagged — the shapes are disjoint (a leaf requires `dimension`/`operator`/`value`, a group `logic`/`children`), and the write contract rejects unknown fields inside either variant, because an unknown field placed there is otherwise ignored without being reported (same reasoning as `OnEmbeddingFailure` in the model schema)." }, "ConditionOperator": { "description": "Condition leaf operator — lua-resty-expr tokens, verbatim.",