Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions crates/aisix-core/src/filesource/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,38 @@ rate_limit_policies:
assert!(errors[0].contains("no-such-model"), "{errors:?}");
}

#[test]
fn dead_knob_on_a_group_is_a_declarative_load_error() {
// The strict write path (declarative resources file) rejects a knob
// the kind never resolves — a silently-dead `cost` on a Model Group
// was the #962 class. Stored etcd rows load leniently instead (the
// loader strips + reports); a file the operator edits fails fast
// with the field named.
let file = r#"
_format_version: "1"

provider_keys:
- display_name: pk
provider: openai
api_key: sk-x
models:
- display_name: gpt-4o
provider: openai
model_name: gpt-4o
provider_key: pk
- display_name: balanced
routing:
targets:
- model: gpt-4o
cost:
input_per_1k: 0.5
output_per_1k: 1.5
"#;
let errors = errors_of(load(file, &env_of(&[])));
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("balanced"), "{errors:?}");
}

#[test]
fn ids_are_deterministic_across_two_loads() {
let env = full_env();
Expand Down
150 changes: 150 additions & 0 deletions crates/aisix-core/src/models/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,40 @@ impl Model {
self.model_name.as_deref()
}

/// Strip the per-kind DEAD knobs from a loaded document, returning
/// the stripped field names for the loader's partially-compatible
/// report. The strict write path rejects these shapes outright
/// ([`model_one_of_strict`]); rows stored before that keep loading —
/// minus the field, so no code path can half-honor a knob the shape
/// never resolved.
pub fn strip_kind_inapplicable(&mut self) -> Vec<&'static str> {
let mut stripped = Vec::new();
if !(self.is_routing() || self.is_ensemble() || self.is_semantic()) {
return stripped;
}
if self.auto_prompt_caching.take().is_some() {
stripped.push("auto_prompt_caching");
}
if self.cost.take().is_some() {
stripped.push("cost");
}
if (self.is_routing() || self.is_ensemble()) && self.retries.take().is_some() {
stripped.push("retries");
}
// Pushed in lexicographic order so a pure-strip row's field list
// is already sorted (the loader's merge path re-sorts a combined
// unknown+inapplicable list, but a strip-only row bypasses that).
if self.is_ensemble() {
if self.stream_timeout.take().is_some() {
stripped.push("stream_timeout");
}
if self.timeout.take().is_some() {
stripped.push("timeout");
}
}
stripped
}

/// This resource's own non-streaming deadline, as one level of the
/// model → group → `upstream.timeout_ms` resolution performed by the
/// proxy's `effective_timeouts`. Tri-state: `None` defers to the next
Expand Down Expand Up @@ -376,6 +410,57 @@ impl Model {
/// `oneOf` into the generated schema, so the published schema and the
/// runtime validator share this single definition.
pub fn model_one_of() -> Value {
model_one_of_variant(false)
}

/// The write-path variant of [`model_one_of`]: additionally forbids the
/// per-kind DEAD knobs — fields the runtime never reads on that shape,
/// which the lenient read path keeps tolerating (loaded rows strip them
/// with a partially-compatible warning instead of dropping the row; see
/// [`Model::strip_kind_inapplicable`]). Kind policy (project decision):
/// generic call knobs (`timeout`/`stream_timeout`/`retries`) resolve
/// member → group → deployment default wherever a group slot exists;
/// model-specific knobs (`auto_prompt_caching`, `cost`) are direct-only.
pub fn model_one_of_strict() -> Value {
model_one_of_variant(true)
}

fn model_one_of_variant(strict: bool) -> Value {
let extend = |base: &mut Value, extra: &[&str]| {
let list = base["not"]["anyOf"].as_array_mut().expect("anyOf array");
for field in extra {
list.push(json!({ "required": [field] }));
}
};
let mut variants = model_one_of_base();
if strict {
let arr = variants.as_array_mut().expect("oneOf array");
// routing: the group slot for timeouts is the top-level pair
// (api7/aisix#844); retries' group slot is `routing.retries`, so a
// top-level value is dead — as are the model-specific knobs.
extend(&mut arr[0], &["retries", "auto_prompt_caching", "cost"]);
// direct (arr[1]): every knob is live.
// ensemble: sub-calls resolve member-level knobs only; the
// parent-level deadline is `ensemble.timeout_ms`.
extend(
&mut arr[2],
&[
"timeout",
"stream_timeout",
"retries",
"auto_prompt_caching",
"cost",
],
);
// semantic: top-level timeout/stream_timeout/retries ARE the group
// slots (no routing block to carry them); the model-specific knobs
// stay direct-only.
extend(&mut arr[3], &["auto_prompt_caching", "cost"]);
}
variants
}

fn model_one_of_base() -> Value {
json!([
{
"required": ["routing"],
Expand Down Expand Up @@ -527,6 +612,71 @@ mod tests {
assert_eq!(m.display_name, "x");
}

#[test]
fn strip_kind_inapplicable_per_kind() {
let load = |v: serde_json::Value| -> Model { serde_json::from_value(v).unwrap() };
// Routing parent: model-specific knobs + top-level retries strip;
// the group-level timeout pair stays (it IS the group slot).
let mut group = load(serde_json::json!({
"display_name": "g",
"routing": {"targets": [{"model": "m"}]},
"retries": 2,
"timeout": 1000,
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0},
"auto_prompt_caching": {"enabled": true}
}));
let mut stripped = group.strip_kind_inapplicable();
stripped.sort_unstable();
assert_eq!(stripped, ["auto_prompt_caching", "cost", "retries"]);
assert!(group.retries.is_none() && group.cost.is_none());
assert_eq!(group.timeout, Some(1000));
// Semantic parent: timeout/retries are the group slots and stay.
let mut sem = load(serde_json::json!({
"display_name": "s",
"semantic": {
"embedding_model": "e",
"routes": [{"name": "r", "target": "t", "examples": ["x"]}],
"default": "d",
"match": {"threshold": 0.5}
},
"retries": 2,
"timeout": 1000,
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
}));
assert_eq!(sem.strip_kind_inapplicable(), ["cost"]);
assert_eq!(sem.retries, Some(2));
assert_eq!(sem.timeout, Some(1000));
// Direct: nothing strips.
let mut direct = load(serde_json::json!({
"display_name": "m",
"provider": "openai",
"model_name": "gpt-4o",
"provider_key_id": "pk-1",
"retries": 2,
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
}));
assert!(direct.strip_kind_inapplicable().is_empty());
assert_eq!(direct.retries, Some(2));
// Ensemble parent: the whole generic set strips (its own
// deadline knob is `ensemble.timeout_ms`).
let mut ens = load(serde_json::json!({
"display_name": "e",
"ensemble": {"panel": [{"model": "m"}], "judge": {"model": "j"}},
"timeout": 1000,
"stream_timeout": 500,
"retries": 1,
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
}));
// Asserted WITHOUT a pre-sort: the strip output is already
// lexicographic (a pure-strip loader row keeps the fields
// "sorted" per PartialCompatRow's contract).
let ens_stripped = ens.strip_kind_inapplicable();
assert_eq!(
ens_stripped,
["cost", "retries", "stream_timeout", "timeout"]
);
}

#[test]
fn ip_allowed_matrix() {
fn model_with(cidrs: Option<Vec<&str>>) -> Model {
Expand Down
15 changes: 12 additions & 3 deletions crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ fn closes_on_write(resource: &str) -> bool {
/// `dump-schema` binary build from.
pub fn resource_root_schema(resource: &str, strict: bool) -> Value {
let mut schema = match resource {
"model" => model_root_schema(),
"model" => model_root_schema(strict),
"api_key" => apikey_root_schema(),
"provider_key" => provider_key_root_schema(),
"guardrail" => guardrail_root_schema(),
Expand Down Expand Up @@ -329,14 +329,23 @@ fn struct_root_schema<T: schemars::JsonSchema>(nullable_options: bool) -> Value
/// Canonical JSON Schema for the `model` resource: the [`Model`] struct plus
/// the one cross-field invariant `schemars` cannot express
/// ([`super::model::model_one_of`] — the direct/routing/ensemble XOR).
/// `strict` picks the write-path variant that additionally forbids the
/// per-kind dead knobs ([`super::model::model_one_of_strict`]); the
/// lenient read path keeps the base XOR so stored rows load (and strip)
/// rather than drop.
///
/// [`Model`]: crate::models::Model
pub fn model_root_schema() -> Value {
pub fn model_root_schema(strict: bool) -> Value {
let mut schema = struct_root_schema::<crate::models::Model>(false);
let one_of = if strict {
super::model::model_one_of_strict()
} else {
super::model::model_one_of()
};
schema
.as_object_mut()
.expect("model root schema is a JSON object")
.insert("oneOf".to_string(), super::model::model_one_of());
.insert("oneOf".to_string(), one_of);
// `OnEmbeddingFailure` is `#[serde(untagged)]` with an object variant
// (`{ "target": … }`): serde buffers untagged content and silently
// swallows unknown fields inside it, invisible to both the write
Expand Down
93 changes: 90 additions & 3 deletions crates/aisix-core/tests/model_schema_characterization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ fn accept_routing_minimal() {
#[test]
fn accept_routing_full() {
accept(
"routing with all knobs + shared optionals",
"routing with all LIVE knobs + shared optionals",
json!({
"display_name": "r",
"routing": {
Expand All @@ -124,8 +124,95 @@ fn accept_routing_full() {
},
"timeout": 1000,
"rate_limit": {"rpm": 10},
"allowed_cidrs": ["10.0.0.0/8"],
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
"allowed_cidrs": ["10.0.0.0/8"]
}),
);
}

#[test]
fn strict_rejects_dead_knobs_per_kind_but_lenient_loads_them() {
// Kind policy (model-kind audit): generic call knobs resolve
// member → group → deployment default wherever a group slot exists;
// model-specific knobs are direct-only. A knob the shape never
// resolves is REJECTED at write time — accepted-but-unread config is
// the #962 class — while the lenient read path keeps loading stored
// rows (the loader strips the field and reports partial compat).
let cases = [
(
"routing + top-level retries",
json!({
"display_name": "r",
"routing": {"targets": [{"model": "m"}]},
"retries": 2
}),
),
(
"routing + cost",
json!({
"display_name": "r",
"routing": {"targets": [{"model": "m"}]},
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
}),
),
(
"routing + auto_prompt_caching",
json!({
"display_name": "r",
"routing": {"targets": [{"model": "m"}]},
"auto_prompt_caching": {"enabled": true}
}),
),
(
"ensemble + timeout",
json!({
"display_name": "e",
"ensemble": {"panel": [{"model": "m"}], "judge": {"model": "j"}},
"timeout": 1000
}),
),
(
"ensemble + retries",
json!({
"display_name": "e",
"ensemble": {"panel": [{"model": "m"}], "judge": {"model": "j"}},
"retries": 1
}),
),
(
"semantic + cost",
json!({
"display_name": "s",
"semantic": {
"embedding_model": "emb",
"routes": [{"name": "r", "target": "t", "examples": ["x"]}],
"default": "d",
"match": {"threshold": 0.5}
},
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
}),
),
];
for (label, value) in cases {
reject(label, value.clone());
if let Err(e) = aisix_core::models::validate_model_lenient(&value) {
panic!("expected lenient ACCEPT for `{label}`, got reject: {e}");
}
}
// The semantic group slots stay LIVE on the strict path — the write
// gate must not outlaw the knobs the runtime resolves.
accept(
"semantic + top-level timeout/retries (the group slots)",
json!({
"display_name": "s",
"semantic": {
"embedding_model": "emb",
"routes": [{"name": "r", "target": "t", "examples": ["x"]}],
"default": "d",
"match": {"threshold": 0.5}
},
"timeout": 1000,
"stream_timeout": 1000,
"retries": 2
}),
);
}
Expand Down
Loading