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
181 changes: 174 additions & 7 deletions crates/aisix-core/src/models/policy_conditions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
//! deliberately includes MCP/A2A traffic;
//! - groups short-circuit (AND on the first false child, OR on the
//! first true child);
//! - the model dimensions (`model` / `model_name`) evaluate against a
//! PAIR on a routing/ensemble/semantic dispatch: the dispatched
//! target and the caller-addressed parent entry (AISIX-Cloud#1267).
//! Positive operators are raw-true when EITHER identity satisfies
//! them (∃); `~=` is raw-true only when BOTH differ (∀ — the two
//! identities are distinct strings, so an ∃ reading would be
//! vacuously true on every routed request), preserving `a ~= b` ≡
//! `!(a == b)`. `negate` flips the combined result, so `!(model in
//! [group])` excludes every request addressed to the group instead
//! of matching all of them;
//! - regexes are compiled once per distinct pattern into a process-wide
//! cache. Load-time validation guarantees compilability, so a cache
//! miss at evaluation time never fails in practice; a pattern that
Expand Down Expand Up @@ -62,11 +72,15 @@ pub enum PolicyDimension {
Member,
/// Authenticated api_key entry id (UUID).
ApiKey,
/// Dispatched model entry id (UUID); routing/model groups match per
/// selected target, never the group entry itself.
/// 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.
Model,
/// Dispatched model display name — the string dimension for
/// regex/prefix matching ("every gpt-4-family alias").
/// 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.
ModelName,
/// Dispatched model's `provider` (models.dev catalog id).
Provider,
Expand Down Expand Up @@ -386,6 +400,17 @@ pub struct ConditionInput<'a> {
pub model: Option<&'a str>,
pub model_name: Option<&'a str>,
pub provider: Option<&'a str>,
/// Entry id of the caller-addressed virtual parent (routing group /
/// ensemble / semantic router) when `model` below is a dispatch
/// target that parent selected. A `model` leaf matches when EITHER
/// id satisfies it (AISIX-Cloud#1267) — the group a caller
/// addressed is as much "the model" as the member it dispatched to.
/// `None` on direct dispatch and at the request gate (where `model`
/// already IS the requested entry).
pub routing_parent_model: Option<&'a str>,
/// Display name of that parent, the `model_name` twin of
/// [`Self::routing_parent_model`].
pub routing_parent_model_name: Option<&'a str>,
}

impl<'a> ConditionInput<'a> {
Expand All @@ -400,6 +425,19 @@ impl<'a> ConditionInput<'a> {
}
}

/// The caller-addressed parent's value for a model-property
/// dimension, when the primary value describes a dispatch target
/// that parent selected. Identity/provider dimensions have no
/// parent variant (the parent shares the caller identity and
/// carries no provider).
fn routing_parent(&self, dimension: PolicyDimension) -> Option<&'a str> {
match dimension {
PolicyDimension::Model => self.routing_parent_model,
PolicyDimension::ModelName => self.routing_parent_model_name,
_ => None,
}
}

pub fn get_group_by(&self, dimension: GroupByDimension) -> Option<&'a str> {
match dimension {
GroupByDimension::Team => self.team,
Expand Down Expand Up @@ -437,7 +475,27 @@ fn eval_leaf(leaf: &PolicyCondition, input: &ConditionInput<'_>) -> bool {
let Some(var) = input.get(leaf.dimension) else {
return false;
};
let raw = match (leaf.operator, &leaf.value) {
// Model dimensions carry a {dispatched target, requested parent}
// pair on a routing dispatch — the request's model-identity SET.
// Positive operators (==/in/regex) ask "does ANY identity satisfy"
// (∃); the negative operator `~=` asks "do ALL identities differ"
// (∀) — the two identities are distinct strings, so an ∃ reading
// of `~=` would be vacuously true on every routed request. This
// keeps `a ~= b` ≡ `!(a == b)` over the pair, the equivalence the
// operator vocabulary (and the dashboard's normalization) is
// built on; `negate` then flips the combined result, so a negated
// leaf excluding the parent excludes every request addressed to it.
let parent = input.routing_parent(leaf.dimension);
let raw = if leaf.operator == ConditionOperator::Ne {
eval_operator(leaf, var) && parent.is_none_or(|p| eval_operator(leaf, p))
} else {
eval_operator(leaf, var) || parent.is_some_and(|p| eval_operator(leaf, p))
};
raw != leaf.negate
}

fn eval_operator(leaf: &PolicyCondition, var: &str) -> bool {
match (leaf.operator, &leaf.value) {
(ConditionOperator::Eq, ConditionValue::One(v)) => var == v,
(ConditionOperator::Ne, ConditionValue::One(v)) => var != v,
(ConditionOperator::In, ConditionValue::Many(items)) => {
Expand Down Expand Up @@ -474,8 +532,7 @@ fn eval_leaf(leaf: &PolicyCondition, input: &ConditionInput<'_>) -> bool {
(ConditionOperator::Has | ConditionOperator::IpMatch, _) => false,
// Value shape mismatching the operator (validation rejects it).
_ => false,
};
raw != leaf.negate
}
}

/// Process-wide compiled-regex caches, one per case-sensitivity
Expand Down Expand Up @@ -562,6 +619,19 @@ mod tests {
model: Some("model-1"),
model_name: Some("gpt-4.1-prod"),
provider: Some("openai"),
routing_parent_model: None,
routing_parent_model_name: None,
}
}

/// `input()` as the per-target gate of a routing dispatch sees it:
/// the target as the primary values, the caller-addressed group as
/// the parent pair.
fn routed_input<'a>() -> ConditionInput<'a> {
ConditionInput {
routing_parent_model: Some("group-1"),
routing_parent_model_name: Some("chat-group"),
..input()
}
}

Expand Down Expand Up @@ -656,6 +726,103 @@ mod tests {
assert!(eval_condition_nodes(&nodes, &input()));
}

#[test]
fn model_leaf_matches_routing_parent_id() {
// AISIX-Cloud#1267: `model in [group uuid]` must select requests
// dispatched THROUGH the group even though the per-target gate's
// primary value is the member id.
let nodes = vec![leaf(
PolicyDimension::Model,
ConditionOperator::In,
many(&["group-1"]),
)];
assert!(eval_condition_nodes(&nodes, &routed_input()));
// Direct dispatch to the member (no parent): the group condition
// must NOT capture it.
assert!(!eval_condition_nodes(&nodes, &input()));
}

#[test]
fn model_name_leaf_matches_routing_parent_name() {
let nodes = vec![leaf(
PolicyDimension::ModelName,
ConditionOperator::Regex,
one("^chat-"),
)];
assert!(eval_condition_nodes(&nodes, &routed_input()));
assert!(!eval_condition_nodes(&nodes, &input()));
}

#[test]
fn negated_model_leaf_excludes_parent_dispatch() {
// `!(model in [group-1])` means "everything except the group":
// negate flips the COMBINED pair result, so a request routed via
// the group is excluded — not (absurdly) matched because the
// member id alone misses the set.
let nodes = vec![neg_leaf(
PolicyDimension::Model,
ConditionOperator::In,
many(&["group-1"]),
)];
assert!(!eval_condition_nodes(&nodes, &routed_input()));
// The same member reached directly stays matched.
assert!(eval_condition_nodes(&nodes, &input()));
}

#[test]
fn ne_leaf_requires_both_identities_to_differ() {
// `model ~= group-1` on a request ADDRESSED to group-1: the
// dispatched member differs from the value, but ∃-combining
// would make `~=` vacuously true on every routed request (the
// two identities are distinct strings). ∀-combining keeps the
// exclusion meaningful…
let ne = |v: &str| vec![leaf(PolicyDimension::Model, ConditionOperator::Ne, one(v))];
assert!(!eval_condition_nodes(&ne("group-1"), &routed_input()));
assert!(!eval_condition_nodes(&ne("model-1"), &routed_input()));
assert!(eval_condition_nodes(&ne("other"), &routed_input()));
// …and preserves `a ~= b` ≡ `!(a == b)` over the pair.
let neg_eq = |v: &str| {
vec![neg_leaf(
PolicyDimension::Model,
ConditionOperator::Eq,
one(v),
)]
};
for v in ["group-1", "model-1", "other"] {
assert_eq!(
eval_condition_nodes(&ne(v), &routed_input()),
eval_condition_nodes(&neg_eq(v), &routed_input()),
"~= and !(==) diverged for {v}"
);
}
// Direct dispatch (no parent): plain not-equal, unchanged.
assert!(eval_condition_nodes(&ne("group-1"), &input()));
assert!(!eval_condition_nodes(&ne("model-1"), &input()));
}

#[test]
fn member_leaf_still_matches_through_parent() {
// The 1087 principle survives the pair: a member-id condition
// keeps matching when the member is reached via a group.
let nodes = vec![leaf(
PolicyDimension::Model,
ConditionOperator::In,
many(&["model-1"]),
)];
assert!(eval_condition_nodes(&nodes, &routed_input()));
}

#[test]
fn parent_pair_never_leaks_into_identity_dimensions() {
// A team leaf must not consult the parent pair even when set.
let nodes = vec![leaf(
PolicyDimension::Team,
ConditionOperator::In,
many(&["group-1"]),
)];
assert!(!eval_condition_nodes(&nodes, &routed_input()));
}

#[test]
fn missing_dimension_is_false_even_negated() {
let no_team = ConditionInput {
Expand Down
18 changes: 16 additions & 2 deletions crates/aisix-proxy/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1560,7 +1560,10 @@ async fn dispatch(
state,
snapshot,
auth,
is_routing_request,
is_routing_request.then_some(crate::quota::RoutingParent {
name: &virtual_entry.value.display_name,
entry_id: &virtual_entry.id,
}),
&model.display_name,
&attempt.id,
model,
Expand Down Expand Up @@ -2639,7 +2642,10 @@ async fn dispatch(
state,
snapshot,
auth,
is_routing_request,
is_routing_request.then_some(crate::quota::RoutingParent {
name: &virtual_entry.value.display_name,
entry_id: &virtual_entry.id,
}),
&model.display_name,
&attempt.id,
model,
Expand Down Expand Up @@ -3260,6 +3266,10 @@ async fn dispatch_ensemble(
snapshot,
request_id,
client,
routing_parent: crate::quota::RoutingParent {
name: &virtual_entry.value.display_name,
entry_id: &virtual_entry.id,
},
};

// Streaming ensemble (OPTION A): the panel must be buffered to synthesize,
Expand Down Expand Up @@ -3394,6 +3404,10 @@ async fn dispatch_ensemble(
&ensemble_cfg.judge.model,
&judge_entry.id,
judge_model,
Some(crate::quota::RoutingParent {
name: &virtual_entry.value.display_name,
entry_id: &virtual_entry.id,
}),
)
.await
{
Expand Down
5 changes: 4 additions & 1 deletion crates/aisix-proxy/src/count_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,10 @@ async fn dispatch(
state,
snapshot,
auth,
is_routing_request,
is_routing_request.then_some(crate::quota::RoutingParent {
name: &model_entry.value.display_name,
entry_id: &model_entry.id,
}),
&target.model.display_name,
&target.id,
&target.model,
Expand Down
4 changes: 4 additions & 0 deletions crates/aisix-proxy/src/ensemble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ pub(crate) struct ProxyModelCaller<'a> {
/// the caller's behalf, so they carry the same caller identity and
/// forwardable client headers as a single-upstream dispatch would.
pub client: &'a crate::client_ip::ClientContext,
/// The ensemble entry the caller addressed, so per-member policy
/// matching sees the {member, parent} pair (AISIX-Cloud#1267).
pub routing_parent: crate::quota::RoutingParent<'a>,
}

#[async_trait]
Expand Down Expand Up @@ -159,6 +162,7 @@ impl ModelCaller for ProxyModelCaller<'_> {
target,
&entry.id,
model,
Some(self.routing_parent),
)
.await
.map_err(|e| {
Expand Down
3 changes: 3 additions & 0 deletions crates/aisix-proxy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3783,6 +3783,7 @@ data: [DONE]\n\n"
"mg-member",
"model-id-1",
&target,
None,
)
.await;
assert!(r.is_ok(), "suspended policy must reserve nothing");
Expand All @@ -3805,6 +3806,7 @@ data: [DONE]\n\n"
"mg-member",
"model-id-1",
&target,
None,
)
.await
.is_ok());
Expand All @@ -3816,6 +3818,7 @@ data: [DONE]\n\n"
"mg-member",
"model-id-1",
&target,
None,
)
.await
.is_err(),
Expand Down
5 changes: 4 additions & 1 deletion crates/aisix-proxy/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,10 @@ async fn dispatch(
state,
snapshot,
auth,
is_routing_request,
is_routing_request.then_some(crate::quota::RoutingParent {
name: &model_entry.value.display_name,
entry_id: &model_entry.id,
}),
&target.model.display_name,
&target.id,
&target.model,
Expand Down
Loading