From 3bf742eaae8bd55689f718058edeedd6a2978d67 Mon Sep 17 00:00:00 2001 From: Brent Salisbury Date: Sun, 12 Jul 2026 12:30:05 +0000 Subject: [PATCH 1/2] feat(filter): add grid route inference routing Signed-off-by: Brent Salisbury --- docs/filters/http/ai/grid_route.md | 29 + docs/filters/reference.md | 6 + examples/README.md | 6 + examples/configs/ai/grid-route-inference.yaml | 60 ++ .../src/builtins/http/ai/grid/descriptor.rs | 404 ++++++++++ filter/src/builtins/http/ai/grid/mod.rs | 11 + filter/src/builtins/http/ai/grid/route.rs | 726 ++++++++++++++++++ filter/src/builtins/http/ai/mod.rs | 7 + filter/src/builtins/http/mod.rs | 2 + .../http/traffic_management/router/mod.rs | 4 + filter/src/builtins/mod.rs | 10 +- filter/src/registry.rs | 7 +- .../suite/examples/ai/grid_route_inference.rs | 37 + tests/schema/tests/suite/examples/ai/mod.rs | 6 + tests/schema/tests/suite/examples/mod.rs | 1 + 15 files changed, 1308 insertions(+), 8 deletions(-) create mode 100644 docs/filters/http/ai/grid_route.md create mode 100644 examples/configs/ai/grid-route-inference.yaml create mode 100644 filter/src/builtins/http/ai/grid/descriptor.rs create mode 100644 filter/src/builtins/http/ai/grid/mod.rs create mode 100644 filter/src/builtins/http/ai/grid/route.rs create mode 100644 filter/src/builtins/http/ai/mod.rs create mode 100644 tests/schema/tests/suite/examples/ai/grid_route_inference.rs create mode 100644 tests/schema/tests/suite/examples/ai/mod.rs diff --git a/docs/filters/http/ai/grid_route.md b/docs/filters/http/ai/grid_route.md new file mode 100644 index 000000000..0ca98fd3e --- /dev/null +++ b/docs/filters/http/ai/grid_route.md @@ -0,0 +1,29 @@ + + + +# `grid_route` + +Selects an upstream cluster from a static site/capability descriptor by matching the inference model name from a configured request header. + +## Configuration Notes + +**Behavior:** - If `ctx.cluster` is already set by an earlier filter, the selection is preserved and no metadata is written. - If the model header is absent, the filter returns `Continue` without routing. - If the header is blank, oversized, or non-UTF-8, the filter rejects with 400. - If a matching candidate is found, `ctx.cluster` is set and bounded route-decision metadata is written. - If no matching candidate is found, the filter rejects with 404. + +**Scoring:** candidates are scored deterministically. Fresh candidates score 0; stale candidates receive −100. Candidates on `local_site` receive +10. Freshness is stronger than local preference. First configured candidate wins on equal scores. + +**Metadata:** on successful selection, bounded in-process filter metadata is written under the `grid.route.` namespace (`kind`, `name`, `site`, `cluster`, `local_site`). No HTTP forwarding headers are written. No request-time database, control-plane, or metrics lookups are performed. + +**Scope:** only `inference_model` candidates are matched in this release. MCP tool routing and A2A agent routing are separate PRs. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `candidates` | CandidateConfig[] | yes | Static list of route candidates. | +| `candidates[].cluster` | string | yes | Cluster name to select when this candidate is chosen. | +| `candidates[].fresh` | bool | no | Whether this candidate is fresh (default: `true`). | +| `candidates[].kind` | `inference_model` | yes | Capability kind. | +| `candidates[].name` | string | yes | Capability name (model name, tool name, or agent name). | +| `candidates[].site` | string | yes | Site that owns this capability. | +| `local_site` | string | yes | Name of the local site. | +| `model_header` | string | no | Header name that carries the model name (default: `X-Model`). | diff --git a/docs/filters/reference.md b/docs/filters/reference.md index 53e0957f1..830e48f5d 100644 --- a/docs/filters/reference.md +++ b/docs/filters/reference.md @@ -5,6 +5,12 @@ Built-in filters organized by protocol and category. +## HTTP / AI + +| Filter | Feature | Description | +|--------|---------|-------------| +| [`grid_route`](http/ai/grid_route.md) | - | Selects an upstream cluster from a static site/capability descriptor by matching the inference model name from a configured request header. | + ## HTTP / Observability | Filter | Feature | Description | diff --git a/examples/README.md b/examples/README.md index df326c473..efa196f40 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,6 +18,12 @@ page. ## Configs +### Ai + +| File | Description | +| ------ | ------------- | +| [grid-route-inference.yaml](configs/ai/grid-route-inference.yaml) | Routes requests to different upstream clusters based on the inference model name extracted from a configured request header. The header value is set by an earlier filter such as `json_body_field` | + ### Branching | File | Description | diff --git a/examples/configs/ai/grid-route-inference.yaml b/examples/configs/ai/grid-route-inference.yaml new file mode 100644 index 000000000..c9305e623 --- /dev/null +++ b/examples/configs/ai/grid-route-inference.yaml @@ -0,0 +1,60 @@ +# Grid Route: Inference Model Routing +# +# Routes requests to different upstream clusters based on the inference +# model name extracted from a configured request header. The header +# value is set by an earlier filter such as `json_body_field`. +# +# `grid_route` selects a candidate by static scoring and writes the +# cluster name into `ctx.cluster`. The downstream `load_balancer` reads +# that cluster. No `router` filter is required between them: if a +# `router` filter is present in the chain, it will see that +# `ctx.cluster` is already set and skip its own selection. +# +# Usage: +# cargo run -p praxis-proxy -- -c examples/configs/ai/grid-route-inference.yaml + +listeners: + - name: proxy + address: "0.0.0.0:8080" + filter_chains: + - main + +filter_chains: + - name: main + filters: + # Extract model name from the JSON body into a request header. + - filter: json_body_field + field: model + header: X-Model + + # Route to the cluster that serves the requested model. + # Selects by highest score: fresh > stale, local > remote. + - filter: grid_route + local_site: site-a + model_header: X-Model + candidates: + - kind: inference_model + name: granite-3.3-8b + site: site-a + cluster: granite-local + fresh: true + + - kind: inference_model + name: llama-3.2-8b + site: site-b + cluster: llama-remote + fresh: true + + # Forward to whichever cluster grid_route selected. + - filter: load_balancer + clusters: + - name: granite-local + endpoints: + - "127.0.0.1:8001" + - name: llama-remote + endpoints: + - "127.0.0.1:8002" + +admin: + address: "127.0.0.1:9901" +shutdown_timeout_secs: 5 diff --git a/filter/src/builtins/http/ai/grid/descriptor.rs b/filter/src/builtins/http/ai/grid/descriptor.rs new file mode 100644 index 000000000..49583ab60 --- /dev/null +++ b/filter/src/builtins/http/ai/grid/descriptor.rs @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Static site/capability descriptor model for gateway-to-gateway routing. +//! +//! Defines the local routing records consumed by the `grid_route` +//! filter. Records are validated at parse time and read immutably +//! during request handling. +//! +//! This module defines the data model only. Scoring and route +//! extraction logic live in the `route` sibling module. + +use std::{collections::HashSet, sync::Arc}; + +use serde::Deserialize; + +use crate::FilterError; + +/// Maximum number of route candidates. +const MAX_CANDIDATES: usize = 1024; + +/// Maximum length for identifier strings. +const MAX_NAME_LEN: usize = 256; + +/// Header prefixes reserved for internal gateway/protocol metadata. +pub(crate) const RESERVED_HEADER_PREFIXES: &[&str] = &["x-praxis-"]; + +// ----------------------------------------------------------------------------- +// CapabilityKind +// ----------------------------------------------------------------------------- + +/// Capability kind for descriptor matching. +/// +/// Categorises what a route candidate offers. The route filter +/// uses this to decide which request metadata can match a candidate. +/// +/// Only `InferenceModel` is routed by the current filter. +/// MCP tool routing and A2A agent routing are separate follow-on PRs; +/// those kind values are not accepted in this release. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CapabilityKind { + /// OpenAI-compatible inference model. + InferenceModel, +} + +impl CapabilityKind { + /// Short string for diagnostics and route metadata. + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::InferenceModel => "inference_model", + } + } +} + +// ----------------------------------------------------------------------------- +// CandidateConfig (serde) +// ----------------------------------------------------------------------------- + +/// A single route candidate as written in YAML config. +/// +/// ```yaml +/// candidates: +/// - kind: inference_model +/// name: llama-3.1-8b +/// site: site-b +/// cluster: grid-site-b +/// - kind: inference_model +/// name: granite-3.3-8b +/// site: site-a +/// cluster: grid-site-a +/// ``` +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct CandidateConfig { + /// Cluster name to select when this candidate is chosen. + pub cluster: String, + + /// Whether this candidate is fresh (default: `true`). + #[serde(default = "default_fresh")] + pub fresh: bool, + + /// Capability kind. + pub kind: CapabilityKind, + + /// Capability name (model name, tool name, or agent name). + pub name: String, + + /// Site that owns this capability. + pub site: String, +} + +/// Default freshness state for candidates. +fn default_fresh() -> bool { + true +} + +// ----------------------------------------------------------------------------- +// RouteCandidate (validated) +// ----------------------------------------------------------------------------- + +/// A validated route candidate ready for runtime matching. +/// +/// Created by [`validate_candidates`] from raw config entries. +/// All string fields are bounded and non-blank. +#[derive(Debug)] +pub(crate) struct RouteCandidate { + /// Cluster name to select. + pub cluster: Arc, + + /// Whether this candidate is fresh. + pub fresh: bool, + + /// Capability kind. + pub kind: CapabilityKind, + + /// Capability name. + pub name: Arc, + + /// Site that owns this capability. + pub site: Arc, +} + +// ----------------------------------------------------------------------------- +// Validation +// ----------------------------------------------------------------------------- + +/// Validate and build the candidate list from raw config entries. +/// +/// # Errors +/// +/// Returns [`FilterError`] if: +/// - the candidate list is empty or exceeds [`MAX_CANDIDATES`] +/// - any name/site/cluster field is blank or oversized +/// - duplicate (kind, name, site) tuples exist +pub(crate) fn validate_candidates(raw: Vec) -> Result, FilterError> { + if raw.is_empty() { + return Err("grid: candidates list must not be empty".into()); + } + if raw.len() > MAX_CANDIDATES { + return Err(format!("grid: candidates exceeds maximum of {MAX_CANDIDATES}").into()); + } + + let mut candidates = Vec::with_capacity(raw.len()); + let mut seen: HashSet<(CapabilityKind, String, String)> = HashSet::with_capacity(raw.len()); + + for (i, c) in raw.into_iter().enumerate() { + validate_name(&format!("candidates[{i}].name"), &c.name)?; + validate_name(&format!("candidates[{i}].site"), &c.site)?; + validate_name(&format!("candidates[{i}].cluster"), &c.cluster)?; + + if !seen.insert((c.kind, c.name.clone(), c.site.clone())) { + return Err(format!("grid: duplicate candidate '{}/{}/{}'", c.kind.as_str(), c.name, c.site).into()); + } + + candidates.push(RouteCandidate { + cluster: Arc::from(c.cluster.as_str()), + fresh: c.fresh, + kind: c.kind, + name: Arc::from(c.name.as_str()), + site: Arc::from(c.site.as_str()), + }); + } + + Ok(candidates) +} + +/// Validate the promoted model header name. +/// +/// Rejects blank, unparseable, or reserved-prefix header names. +pub(crate) fn validate_model_header(raw: &str) -> Result { + if raw.trim().is_empty() { + return Err("grid: model_header must not be empty".into()); + } + let header: http::header::HeaderName = raw + .parse() + .map_err(|e| -> FilterError { format!("grid: invalid model_header: {e}").into() })?; + if RESERVED_HEADER_PREFIXES.iter().any(|p| header.as_str().starts_with(p)) { + return Err("grid: model_header must not use a reserved internal header prefix".into()); + } + Ok(header) +} + +/// Validate a local site identifier. +pub(crate) fn validate_local_site(value: &str) -> Result<(), FilterError> { + validate_name("local_site", value) +} + +/// Validate a bounded, non-blank identifier. +fn validate_name(field: &str, value: &str) -> Result<(), FilterError> { + if value.trim().is_empty() || value.len() > MAX_NAME_LEN { + return Err(format!("grid: {field} must be 1-{MAX_NAME_LEN} non-blank characters").into()); + } + Ok(()) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] +mod tests { + use super::*; + + // ---- Valid configs ---- + + #[test] + fn valid_minimal_inference_candidate() { + let candidates = + validate_candidates(vec![candidate("inference_model", "llama", "site-a", "gateway-a")]).unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].kind, CapabilityKind::InferenceModel); + assert_eq!(&*candidates[0].name, "llama"); + assert_eq!(&*candidates[0].site, "site-a"); + assert_eq!(&*candidates[0].cluster, "gateway-a"); + assert!(candidates[0].fresh); + } + + #[test] + fn multiple_candidates_accepted() { + let candidates = validate_candidates(vec![ + candidate("inference_model", "llama", "site-a", "c1"), + candidate("inference_model", "granite", "site-b", "c2"), + ]) + .unwrap(); + assert_eq!(candidates.len(), 2); + } + + #[test] + fn fresh_defaults_to_true() { + let candidates = validate_candidates(vec![candidate("inference_model", "m", "s", "c")]).unwrap(); + assert!(candidates[0].fresh); + } + + #[test] + fn stale_candidate_preserved() { + let mut c = candidate("inference_model", "m", "s", "c"); + c.fresh = false; + let candidates = validate_candidates(vec![c]).unwrap(); + assert!(!candidates[0].fresh); + } + + // ---- Rejections ---- + + #[test] + fn empty_candidates_rejected() { + let err = validate_candidates(vec![]).expect_err("should fail"); + assert!(err.to_string().contains("must not be empty"), "{err}"); + } + + #[test] + fn blank_name_rejected() { + let err = validate_candidates(vec![candidate("inference_model", "", "s", "c")]).expect_err("should fail"); + assert!(err.to_string().contains("name must be"), "{err}"); + } + + #[test] + fn whitespace_name_rejected() { + let err = validate_candidates(vec![candidate("inference_model", " ", "s", "c")]).expect_err("should fail"); + assert!(err.to_string().contains("name must be"), "{err}"); + } + + #[test] + fn blank_site_rejected() { + let err = validate_candidates(vec![candidate("inference_model", "m", "", "c")]).expect_err("should fail"); + assert!(err.to_string().contains("site must be"), "{err}"); + } + + #[test] + fn blank_cluster_rejected() { + let err = validate_candidates(vec![candidate("inference_model", "m", "s", "")]).expect_err("should fail"); + assert!(err.to_string().contains("cluster must be"), "{err}"); + } + + #[test] + fn oversized_name_rejected() { + let long = "a".repeat(MAX_NAME_LEN + 1); + let err = validate_candidates(vec![candidate("inference_model", &long, "s", "c")]).expect_err("should fail"); + assert!(err.to_string().contains("name must be"), "{err}"); + } + + #[test] + fn oversized_site_rejected() { + let long = "a".repeat(MAX_NAME_LEN + 1); + let err = validate_candidates(vec![candidate("inference_model", "m", &long, "c")]).expect_err("should fail"); + assert!(err.to_string().contains("site must be"), "{err}"); + } + + #[test] + fn oversized_cluster_rejected() { + let long = "a".repeat(MAX_NAME_LEN + 1); + let err = validate_candidates(vec![candidate("inference_model", "m", "s", &long)]).expect_err("should fail"); + assert!(err.to_string().contains("cluster must be"), "{err}"); + } + + #[test] + fn duplicate_candidate_rejected() { + let err = validate_candidates(vec![ + candidate("inference_model", "llama", "site-a", "c1"), + candidate("inference_model", "llama", "site-a", "c2"), + ]) + .expect_err("should fail"); + assert!(err.to_string().contains("duplicate candidate"), "{err}"); + } + + #[test] + fn same_name_different_site_not_duplicate() { + let result = validate_candidates(vec![ + candidate("inference_model", "llama", "site-a", "c1"), + candidate("inference_model", "llama", "site-b", "c2"), + ]); + assert!(result.is_ok()); + } + + #[test] + fn same_model_different_site_not_duplicate() { + let result = validate_candidates(vec![ + candidate("inference_model", "llama", "site-a", "c1"), + candidate("inference_model", "llama", "site-b", "c2"), + ]); + assert!(result.is_ok(), "same model on different sites is not a duplicate"); + } + + #[test] + fn unknown_capability_kind_rejected() { + let yaml = "- kind: unknown_thing\n name: x\n site: s\n cluster: c"; + let err: Result, _> = serde_yaml::from_str(yaml); + assert!(err.is_err(), "unknown capability kind should be rejected by serde"); + } + + #[test] + fn mcp_tool_kind_rejected() { + let yaml = "- kind: mcp_tool\n name: x\n site: s\n cluster: c"; + let err: Result, _> = serde_yaml::from_str(yaml); + assert!(err.is_err(), "mcp_tool is not a supported kind in this release"); + } + + #[test] + fn a2a_agent_kind_rejected() { + let yaml = "- kind: a2a_agent\n name: x\n site: s\n cluster: c"; + let err: Result, _> = serde_yaml::from_str(yaml); + assert!(err.is_err(), "a2a_agent is not a supported kind in this release"); + } + + #[test] + fn deny_unknown_fields_on_candidate() { + let yaml = "- kind: inference_model\n name: x\n site: s\n cluster: c\n extra: bad"; + let err: Result, _> = serde_yaml::from_str(yaml); + assert!(err.is_err(), "unknown fields should be rejected"); + } + + // ---- Model header validation ---- + + #[test] + fn valid_model_header() { + assert!(validate_model_header("X-Model").is_ok()); + } + + #[test] + fn blank_model_header_rejected() { + let err = validate_model_header("").expect_err("should fail"); + assert!(err.to_string().contains("must not be empty"), "{err}"); + } + + #[test] + fn reserved_prefix_model_header_rejected() { + let err = validate_model_header("x-praxis-model").expect_err("should fail"); + assert!(err.to_string().contains("reserved"), "{err}"); + } + + // ---- Local site validation ---- + + #[test] + fn valid_local_site() { + assert!(validate_local_site("site-a").is_ok()); + } + + #[test] + fn blank_local_site_rejected() { + let err = validate_local_site("").expect_err("should fail"); + assert!(err.to_string().contains("local_site must be"), "{err}"); + } + + // ---- Test utilities ---- + + fn candidate(kind_str: &str, name: &str, site: &str, cluster: &str) -> CandidateConfig { + let kind: CapabilityKind = serde_yaml::from_str(&format!("\"{kind_str}\"")).unwrap(); + CandidateConfig { + cluster: cluster.to_owned(), + fresh: true, + kind, + name: name.to_owned(), + site: site.to_owned(), + } + } +} diff --git a/filter/src/builtins/http/ai/grid/mod.rs b/filter/src/builtins/http/ai/grid/mod.rs new file mode 100644 index 000000000..820113131 --- /dev/null +++ b/filter/src/builtins/http/ai/grid/mod.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Grid gateway-to-gateway routing filters. +//! +//! Currently provides the `grid_route` inference model routing filter. + +pub(crate) mod descriptor; +mod route; + +pub use route::GridRouteFilter; diff --git a/filter/src/builtins/http/ai/grid/route.rs b/filter/src/builtins/http/ai/grid/route.rs new file mode 100644 index 000000000..af3a47b53 --- /dev/null +++ b/filter/src/builtins/http/ai/grid/route.rs @@ -0,0 +1,726 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Grid route filter: selects an upstream cluster for the request +//! based on the inference model name extracted from a configured +//! request header. +//! +//! Candidate selection is deterministic. Fresh candidates score 0; +//! stale candidates receive −100. Candidates on `local_site` receive +//! +10. First configured candidate wins when scores are equal. +//! +//! No request-time metrics or control-plane lookups are performed. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde::Deserialize; + +use super::descriptor::{self, CandidateConfig, CapabilityKind, RouteCandidate}; +use crate::{ + FilterAction, FilterError, Rejection, + factory::parse_filter_config, + filter::{HttpFilter, HttpFilterContext}, +}; + +/// Maximum length for header values read from the request. +const MAX_HEADER_VALUE_LEN: usize = 256; + +/// Score penalty for stale candidates. +const STALE_PENALTY: i32 = 100; + +/// Score bonus for candidates on the local site. +const LOCAL_PREFERENCE: i32 = 10; + +// ----------------------------------------------------------------------------- +// Config +// ----------------------------------------------------------------------------- + +/// Deserialized YAML config for the grid route filter. +/// +/// ```yaml +/// filter: grid_route +/// local_site: site-a +/// model_header: x-model +/// candidates: +/// - kind: inference_model +/// name: local-model +/// site: site-a +/// cluster: local-inference +/// ``` +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct GridRouteConfig { + /// Static list of route candidates. + candidates: Vec, + + /// Name of the local site. + local_site: String, + + /// Header name that carries the model name (default: `X-Model`). + #[serde(default = "default_model_header")] + model_header: String, +} + +/// Default model header name. +fn default_model_header() -> String { + "X-Model".to_owned() +} + +// ----------------------------------------------------------------------------- +// GridRouteFilter +// ----------------------------------------------------------------------------- + +/// Selects an upstream cluster from a static site/capability descriptor +/// by matching the inference model name from a configured request header. +/// +/// **Behavior:** +/// - If `ctx.cluster` is already set by an earlier filter, the selection is preserved and no metadata is written. +/// - If the model header is absent, the filter returns `Continue` without routing. +/// - If the header is blank, oversized, or non-UTF-8, the filter rejects with 400. +/// - If a matching candidate is found, `ctx.cluster` is set and bounded route-decision metadata is written. +/// - If no matching candidate is found, the filter rejects with 404. +/// +/// **Scoring:** candidates are scored deterministically. Fresh candidates +/// score 0; stale candidates receive −100. Candidates on `local_site` +/// receive +10. Freshness is stronger than local preference. First +/// configured candidate wins on equal scores. +/// +/// **Metadata:** on successful selection, bounded in-process filter +/// metadata is written under the `grid.route.` namespace (`kind`, `name`, +/// `site`, `cluster`, `local_site`). No HTTP forwarding headers are +/// written. No request-time database, control-plane, or metrics +/// lookups are performed. +/// +/// **Scope:** only `inference_model` candidates are matched in this +/// release. MCP tool routing and A2A agent routing are separate PRs. +pub struct GridRouteFilter { + /// Validated route candidates. + candidates: Vec, + /// Local site identifier for scoring and metadata. + local_site: Arc, + /// Header that carries the model name. + model_header: http::header::HeaderName, +} + +impl GridRouteFilter { + /// Create a grid route filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the candidate list is empty, + /// any name field is blank, or the model header is invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: GridRouteConfig = parse_filter_config("grid_route", config)?; + + descriptor::validate_local_site(&cfg.local_site)?; + let model_header = descriptor::validate_model_header(&cfg.model_header)?; + let candidates = descriptor::validate_candidates(cfg.candidates)?; + + Ok(Box::new(Self { + candidates, + local_site: Arc::from(cfg.local_site.as_str()), + model_header, + })) + } +} + +#[async_trait] +impl HttpFilter for GridRouteFilter { + fn name(&self) -> &'static str { + "grid_route" + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + if ctx.cluster.is_some() { + tracing::debug!("grid_route: cluster already set; preserving"); + return Ok(FilterAction::Continue); + } + + let lookup = extract_model_lookup(ctx, &self.model_header); + + let (kind, name) = match lookup { + Lookup::Route { kind, name } => (kind, name), + Lookup::Skip => return Ok(FilterAction::Continue), + Lookup::Invalid => return Ok(FilterAction::Reject(Rejection::status(400))), + }; + + if let Some(c) = select_candidate(&self.candidates, kind, &name, &self.local_site) { + tracing::debug!( + kind = kind.as_str(), + name = %name, + site = &*c.site, + cluster = &*c.cluster, + fresh = c.fresh, + score = score_candidate(c, &self.local_site), + "grid_route: selected" + ); + ctx.cluster = Some(Arc::clone(&c.cluster)); + record_route_decision(ctx, &self.local_site, c); + Ok(FilterAction::Continue) + } else { + tracing::debug!(kind = kind.as_str(), name = %name, "grid_route: no candidate"); + Ok(FilterAction::Reject(Rejection::status(404))) + } + } +} + +// ----------------------------------------------------------------------------- +// Lookup Extraction +// ----------------------------------------------------------------------------- + +/// Result of extracting a routable model from the request. +enum Lookup { + /// A routable model was found. + Route { + /// Capability kind. + kind: CapabilityKind, + /// Capability name. + name: String, + }, + /// Model header absent; continue without routing. + Skip, + /// Header present but invalid; fail closed. + Invalid, +} + +/// Extract an inference model lookup from the promoted model header. +fn extract_model_lookup(ctx: &HttpFilterContext<'_>, model_header: &http::header::HeaderName) -> Lookup { + let Some(value) = ctx.request.headers.get(model_header) else { + tracing::debug!("grid_route: no model header; skipping"); + return Lookup::Skip; + }; + let Ok(model) = value.to_str() else { + tracing::debug!("grid_route: model header is not valid UTF-8; rejecting"); + return Lookup::Invalid; + }; + if model.trim().is_empty() || model.len() > MAX_HEADER_VALUE_LEN { + tracing::debug!("grid_route: model header blank or oversized; rejecting"); + return Lookup::Invalid; + } + Lookup::Route { + kind: CapabilityKind::InferenceModel, + name: model.to_owned(), + } +} + +// ----------------------------------------------------------------------------- +// Candidate Selection +// ----------------------------------------------------------------------------- + +/// Select the best candidate by deterministic scoring. +/// +/// Returns `None` when no candidate of the given `kind` matches +/// `name`. When multiple candidates match, the highest-scored wins; +/// ties are broken by config order (first configured wins). +fn select_candidate<'a>( + candidates: &'a [RouteCandidate], + kind: CapabilityKind, + name: &str, + local_site: &str, +) -> Option<&'a RouteCandidate> { + let mut best: Option<(i32, &RouteCandidate)> = None; + for c in candidates { + if c.kind != kind || &*c.name != name { + continue; + } + let s = score_candidate(c, local_site); + match best { + Some((best_score, _)) if s <= best_score => {}, + _ => best = Some((s, c)), + } + } + best.map(|(_, c)| c) +} + +/// Deterministic score for a candidate. Higher is better. +fn score_candidate(candidate: &RouteCandidate, local_site: &str) -> i32 { + let mut s: i32 = 0; + if !candidate.fresh { + s -= STALE_PENALTY; + } + if *candidate.site == *local_site { + s += LOCAL_PREFERENCE; + } + s +} + +// ----------------------------------------------------------------------------- +// Route Decision Metadata +// ----------------------------------------------------------------------------- + +/// Write bounded route-decision metadata on successful selection. +/// +/// Keys use `grid.route.` namespace. All values are bounded by the +/// existing `set_metadata` limits. No HTTP forwarding headers are +/// written by this function. +fn record_route_decision(ctx: &mut HttpFilterContext<'_>, local_site: &Arc, candidate: &RouteCandidate) { + ctx.set_metadata("grid.route.kind", candidate.kind.as_str()); + ctx.set_metadata("grid.route.name", &*candidate.name); + ctx.set_metadata("grid.route.site", &*candidate.site); + ctx.set_metadata("grid.route.cluster", &*candidate.cluster); + ctx.set_metadata("grid.route.local_site", &**local_site); +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] +mod tests { + use http::Method; + + use super::*; + + // ---- Config validation ---- + + #[test] + fn valid_minimal_config() { + let yaml = "local_site: site-a\ncandidates:\n - kind: inference_model\n name: llama\n site: site-a\n cluster: inf\n fresh: true\n"; + assert!(parse(yaml).is_ok(), "minimal valid config should parse"); + } + + #[tokio::test] + async fn default_model_header_is_x_model() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "default model header X-Model should route" + ); + assert_eq!(ctx.cluster.as_deref(), Some("inf"), "cluster should be set"); + } + + #[test] + fn blank_local_site_rejected() { + let err = parse_err( + "local_site: \"\"\ncandidates:\n - kind: inference_model\n name: m\n site: s\n cluster: c\n fresh: true\n", + ); + assert!( + err.to_string().contains("blank"), + "blank local_site should be rejected: {err}" + ); + } + + #[test] + fn missing_candidates_rejected() { + let err = parse_err("local_site: site-a\ncandidates: []\n"); + assert!( + err.to_string().contains("empty"), + "empty candidates should be rejected: {err}" + ); + } + + #[test] + fn blank_model_header_rejected() { + let err = parse_err( + "local_site: site-a\nmodel_header: \"\"\ncandidates:\n - kind: inference_model\n name: m\n site: s\n cluster: c\n fresh: true\n", + ); + assert!( + err.to_string().contains("blank") || err.to_string().contains("empty"), + "blank model_header should be rejected: {err}" + ); + } + + #[test] + fn reserved_model_header_rejected() { + let err = parse_err( + "local_site: site-a\nmodel_header: x-praxis-foo\ncandidates:\n - kind: inference_model\n name: m\n site: s\n cluster: c\n fresh: true\n", + ); + assert!( + err.to_string().contains("reserved"), + "reserved model_header should be rejected: {err}" + ); + } + + #[test] + fn invalid_candidate_rejected() { + let err = parse_err( + "local_site: site-a\ncandidates:\n - kind: inference_model\n name: \"\"\n site: s\n cluster: c\n fresh: true\n", + ); + assert!( + err.to_string().contains("blank"), + "blank candidate name should be rejected: {err}" + ); + } + + // ---- Model header extraction ---- + + #[tokio::test] + async fn absent_model_header_continues() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let req = crate::test_utils::make_request(Method::POST, "/chat"); + let mut ctx = crate::test_utils::make_filter_context(&req); + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "absent model header should continue without routing" + ); + assert!(ctx.cluster.is_none(), "no cluster should be set"); + assert_no_route_metadata(&ctx); + } + + #[tokio::test] + async fn blank_model_header_rejects() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 400), + "blank model header should reject 400" + ); + assert_no_route_metadata(&ctx); + } + + #[tokio::test] + async fn oversized_model_header_rejects_no_metadata() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + let big = "a".repeat(MAX_HEADER_VALUE_LEN + 1); + req.headers + .insert("X-Model", http::HeaderValue::from_str(&big).unwrap()); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 400), + "oversized model header should reject 400" + ); + assert_no_route_metadata(&ctx); + } + + #[tokio::test] + async fn invalid_utf8_model_header_rejects_no_metadata() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers + .insert("X-Model", http::HeaderValue::from_bytes(b"\xff\xfe").unwrap()); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 400), + "non-UTF-8 model header should reject 400" + ); + assert_no_route_metadata(&ctx); + } + + // ---- Candidate selection ---- + + #[tokio::test] + async fn unknown_model_rejects_404() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers + .insert("X-Model", http::HeaderValue::from_static("unknown-model")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 404), + "unknown model should reject 404" + ); + assert_no_route_metadata(&ctx); + } + + #[tokio::test] + async fn local_inference_sets_cluster() { + let f = make_filter(&[("inference_model", "llama", "site-a", "local-inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(ctx.cluster.as_deref(), Some("local-inf"), "cluster should be set"); + } + + #[tokio::test] + async fn remote_inference_sets_gateway_cluster() { + let f = make_filter(&[("inference_model", "llama", "site-b", "remote-gw")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(ctx.cluster.as_deref(), Some("remote-gw")); + } + + // ---- Cluster preservation ---- + + #[tokio::test] + async fn preserves_existing_cluster() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.cluster = Some(Arc::from("pre-set-cluster")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert_eq!( + ctx.cluster.as_deref(), + Some("pre-set-cluster"), + "pre-set cluster should be preserved" + ); + assert_no_route_metadata(&ctx); + } + + // ---- Scoring ---- + + #[tokio::test] + async fn local_fresh_beats_remote_fresh() { + let f = make_filter(&[ + ("inference_model", "llama", "site-b", "remote-inf"), + ("inference_model", "llama", "site-a", "local-inf"), + ]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + // cluster checked below + assert_eq!( + ctx.cluster.as_deref(), + Some("local-inf"), + "local candidate should win over remote" + ); + } + + #[tokio::test] + async fn config_order_breaks_equal_score_ties() { + let f = make_filter(&[ + ("inference_model", "llama", "site-b", "first-remote"), + ("inference_model", "llama", "site-c", "second-remote"), + ]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + // cluster checked below + assert_eq!( + ctx.cluster.as_deref(), + Some("first-remote"), + "first configured candidate wins on equal score" + ); + } + + #[tokio::test] + async fn fresh_remote_beats_stale_remote() { + let f = make_scored_filter(&[ + ("inference_model", "llama", "site-b", "stale-remote", false), + ("inference_model", "llama", "site-c", "fresh-remote", true), + ]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + // cluster checked below + assert_eq!( + ctx.cluster.as_deref(), + Some("fresh-remote"), + "fresh candidate should beat stale" + ); + } + + #[tokio::test] + async fn fresh_remote_beats_stale_local() { + let f = make_scored_filter(&[ + ("inference_model", "llama", "site-a", "stale-local", false), + ("inference_model", "llama", "site-b", "fresh-remote", true), + ]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + // cluster checked below + assert_eq!( + ctx.cluster.as_deref(), + Some("fresh-remote"), + "fresh remote beats stale local" + ); + } + + #[tokio::test] + async fn stale_local_beats_stale_remote() { + let f = make_scored_filter(&[ + ("inference_model", "llama", "site-b", "stale-remote", false), + ("inference_model", "llama", "site-a", "stale-local", false), + ]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + // cluster checked below + assert_eq!( + ctx.cluster.as_deref(), + Some("stale-local"), + "stale local beats stale remote" + ); + } + + // ---- Route metadata ---- + + #[tokio::test] + async fn scored_route_metadata_reflects_winner() { + let f = make_filter(&[ + ("inference_model", "llama", "site-b", "remote-inf"), + ("inference_model", "llama", "site-a", "local-inf"), + ]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + assert_eq!(ctx.get_metadata("grid.route.cluster"), Some("local-inf")); + assert_eq!(ctx.get_metadata("grid.route.kind"), Some("inference_model")); + assert_eq!(ctx.get_metadata("grid.route.name"), Some("llama")); + assert_eq!(ctx.get_metadata("grid.route.site"), Some("site-a")); + assert_eq!(ctx.get_metadata("grid.route.local_site"), Some("site-a")); + } + + #[tokio::test] + async fn local_route_writes_metadata() { + let f = make_filter(&[("inference_model", "llama", "site-a", "local-inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + assert_eq!(ctx.get_metadata("grid.route.cluster"), Some("local-inf")); + assert_eq!(ctx.get_metadata("grid.route.local_site"), Some("site-a")); + } + + #[tokio::test] + async fn remote_route_writes_metadata() { + let f = make_filter(&[("inference_model", "llama", "site-b", "remote-gw")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("llama")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + assert_eq!(ctx.get_metadata("grid.route.cluster"), Some("remote-gw")); + assert_eq!(ctx.get_metadata("grid.route.site"), Some("site-b")); + } + + #[tokio::test] + async fn unknown_model_writes_no_metadata() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("unknown")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + assert_no_route_metadata(&ctx); + } + + #[tokio::test] + async fn blank_model_writes_no_metadata() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let mut req = crate::test_utils::make_request(Method::POST, "/chat"); + req.headers.insert("X-Model", http::HeaderValue::from_static("")); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + assert_no_route_metadata(&ctx); + } + + #[tokio::test] + async fn missing_header_writes_no_metadata() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let req = crate::test_utils::make_request(Method::POST, "/chat"); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let _unused = f.on_request(&mut ctx).await.unwrap(); + assert_no_route_metadata(&ctx); + } + + #[tokio::test] + async fn preserved_cluster_writes_no_metadata() { + let f = make_filter(&[("inference_model", "llama", "site-a", "inf")]); + let req = crate::test_utils::make_request(Method::POST, "/chat"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.cluster = Some(Arc::from("pre-set")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + assert!( + ctx.get_metadata("grid.route.kind").is_none(), + "preserved cluster path should not write route metadata" + ); + } + + // ---- Test utilities ---- + + fn assert_no_route_metadata(ctx: &HttpFilterContext<'_>) { + assert!( + ctx.get_metadata("grid.route.kind").is_none(), + "grid.route.kind should be absent" + ); + assert!( + ctx.get_metadata("grid.route.name").is_none(), + "grid.route.name should be absent" + ); + assert!( + ctx.get_metadata("grid.route.site").is_none(), + "grid.route.site should be absent" + ); + assert!( + ctx.get_metadata("grid.route.cluster").is_none(), + "grid.route.cluster should be absent" + ); + assert!( + ctx.get_metadata("grid.route.local_site").is_none(), + "grid.route.local_site should be absent" + ); + } + + fn parse(yaml: &str) -> Result, FilterError> { + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + GridRouteFilter::from_config(&val) + } + + fn parse_err(yaml: &str) -> FilterError { + parse(yaml).err().expect("config should have been rejected") + } + + fn make_filter(candidates: &[(&str, &str, &str, &str)]) -> Box { + let scored: Vec<(&str, &str, &str, &str, bool)> = + candidates.iter().map(|(k, n, s, c)| (*k, *n, *s, *c, true)).collect(); + make_scored_filter(&scored) + } + + fn make_scored_filter(candidates: &[(&str, &str, &str, &str, bool)]) -> Box { + use std::fmt::Write as _; + + let mut yaml = String::from("local_site: site-a\ncandidates:\n"); + for (kind, name, site, cluster, fresh) in candidates { + writeln!( + yaml, + " - kind: {kind}\n name: {name}\n site: {site}\n cluster: {cluster}\n fresh: {fresh}" + ) + .expect("String write is infallible"); + } + parse(&yaml).unwrap() + } +} diff --git a/filter/src/builtins/http/ai/mod.rs b/filter/src/builtins/http/ai/mod.rs new file mode 100644 index 000000000..084a39952 --- /dev/null +++ b/filter/src/builtins/http/ai/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! AI-infrastructure HTTP filters. + +pub mod grid; +pub use grid::GridRouteFilter; diff --git a/filter/src/builtins/http/mod.rs b/filter/src/builtins/http/mod.rs index a11ae15e4..658420c32 100644 --- a/filter/src/builtins/http/mod.rs +++ b/filter/src/builtins/http/mod.rs @@ -3,6 +3,7 @@ //! HTTP protocol filters, organized by category. +pub mod ai; mod observability; pub mod payload_processing; mod security; @@ -10,6 +11,7 @@ mod traffic_management; mod transformation; pub mod value_safety; +pub use ai::GridRouteFilter; pub use observability::{AccessLogFilter, RequestIdFilter}; pub use payload_processing::{CompressionFilter, JsonBodyFieldFilter, JsonRpcFilter}; #[cfg(feature = "cpex-policy-engine")] diff --git a/filter/src/builtins/http/traffic_management/router/mod.rs b/filter/src/builtins/http/traffic_management/router/mod.rs index 68ed2e062..96e99fa29 100644 --- a/filter/src/builtins/http/traffic_management/router/mod.rs +++ b/filter/src/builtins/http/traffic_management/router/mod.rs @@ -377,6 +377,10 @@ impl HttpFilter for RouterFilter { } async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + if ctx.cluster.is_some() { + tracing::debug!("router: cluster already set by earlier filter; preserving"); + return Ok(FilterAction::Continue); + } let path = ctx.rewritten_path.as_deref().unwrap_or_else(|| ctx.request.uri.path()); let host = ctx .request diff --git a/filter/src/builtins/mod.rs b/filter/src/builtins/mod.rs index 1527fce6c..70cf01a20 100644 --- a/filter/src/builtins/mod.rs +++ b/filter/src/builtins/mod.rs @@ -10,10 +10,10 @@ mod tcp; pub use http::PolicyFilter; pub use http::{ AccessLogFilter, CircuitBreakerFilter, CompressionFilter, ContainsValue, CorsFilter, CredentialInjectionFilter, - CsrfFilter, DisallowedOriginMode, EndpointSelectorFilter, ForwardedHeadersFilter, GrpcDetectionFilter, - GuardrailsAction, GuardrailsFilter, HeaderFilter, IpAclFilter, JsonBodyFieldFilter, JsonRpcFilter, - LoadBalancerFilter, PathRewriteFilter, PiiKind, RateLimitFilter, RateLimitMode, RedirectFilter, RedirectStatus, - RequestIdFilter, RouterFilter, RuleTargetKind, StaticResponseFilter, TimeoutFilter, UrlRewriteFilter, - has_dot_dot_traversal, normalize_rewritten_path, + CsrfFilter, DisallowedOriginMode, EndpointSelectorFilter, ForwardedHeadersFilter, GridRouteFilter, + GrpcDetectionFilter, GuardrailsAction, GuardrailsFilter, HeaderFilter, IpAclFilter, JsonBodyFieldFilter, + JsonRpcFilter, LoadBalancerFilter, PathRewriteFilter, PiiKind, RateLimitFilter, RateLimitMode, RedirectFilter, + RedirectStatus, RequestIdFilter, RouterFilter, RuleTargetKind, StaticResponseFilter, TimeoutFilter, + UrlRewriteFilter, has_dot_dot_traversal, normalize_rewritten_path, }; pub use tcp::{SniRouterFilter, TcpAccessLogFilter, TcpLoadBalancerFilter}; diff --git a/filter/src/registry.rs b/filter/src/registry.rs index 59e118940..3267af210 100644 --- a/filter/src/registry.rs +++ b/filter/src/registry.rs @@ -110,9 +110,9 @@ impl FilterRegistry { fn register_http_builtins(factories: &mut HashMap) { use crate::builtins::{ AccessLogFilter, CircuitBreakerFilter, CompressionFilter, CorsFilter, CredentialInjectionFilter, CsrfFilter, - ForwardedHeadersFilter, GrpcDetectionFilter, HeaderFilter, IpAclFilter, JsonBodyFieldFilter, JsonRpcFilter, - PathRewriteFilter, RateLimitFilter, RedirectFilter, RequestIdFilter, StaticResponseFilter, TimeoutFilter, - UrlRewriteFilter, + ForwardedHeadersFilter, GridRouteFilter, GrpcDetectionFilter, HeaderFilter, IpAclFilter, JsonBodyFieldFilter, + JsonRpcFilter, PathRewriteFilter, RateLimitFilter, RedirectFilter, RequestIdFilter, StaticResponseFilter, + TimeoutFilter, UrlRewriteFilter, }; register_http(factories, "access_log", AccessLogFilter::from_config); @@ -148,6 +148,7 @@ fn register_http_builtins(factories: &mut HashMap) { register_http(factories, "url_rewrite", UrlRewriteFilter::from_config); register_http(factories, "json_body_field", JsonBodyFieldFilter::from_config); register_http(factories, "json_rpc", JsonRpcFilter::from_config); + register_http(factories, "grid_route", GridRouteFilter::from_config); } /// Register a single HTTP filter factory by name. diff --git a/tests/schema/tests/suite/examples/ai/grid_route_inference.rs b/tests/schema/tests/suite/examples/ai/grid_route_inference.rs new file mode 100644 index 000000000..c1a385770 --- /dev/null +++ b/tests/schema/tests/suite/examples/ai/grid_route_inference.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Grid route inference example tests. + +use std::collections::HashMap; + +use praxis_test_utils::{free_port, http_send, json_post, parse_body, parse_status, start_backend}; + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[test] +fn grid_route_inference_routes_by_model() { + let local_port = start_backend("local-model"); + let remote_port = start_backend("remote-model"); + let proxy_port = free_port(); + let config = crate::example_utils::load_example_config( + "ai/grid-route-inference.yaml", + proxy_port, + HashMap::from([("127.0.0.1:8001", local_port), ("127.0.0.1:8002", remote_port)]), + ); + let proxy = praxis_test_utils::start_proxy(&config); + + let raw = http_send( + proxy.addr(), + &json_post("/v1/chat/completions", r#"{"model":"llama-3.2-8b"}"#), + ); + + assert_eq!(parse_status(&raw), 200, "known model should route"); + assert_eq!( + parse_body(&raw), + "remote-model", + "grid_route should select the candidate cluster for the requested model" + ); +} diff --git a/tests/schema/tests/suite/examples/ai/mod.rs b/tests/schema/tests/suite/examples/ai/mod.rs new file mode 100644 index 000000000..f629601ec --- /dev/null +++ b/tests/schema/tests/suite/examples/ai/mod.rs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! AI example configuration tests. + +mod grid_route_inference; diff --git a/tests/schema/tests/suite/examples/mod.rs b/tests/schema/tests/suite/examples/mod.rs index 8239e593d..1ed25ef3b 100644 --- a/tests/schema/tests/suite/examples/mod.rs +++ b/tests/schema/tests/suite/examples/mod.rs @@ -6,6 +6,7 @@ //! Directory structure mirrors `examples/configs/`. mod additional_configs; +mod ai; mod api_key_filter; mod max_body_guard; mod parse_configs; From 441cfc3e181e9b4d1b2ca4fc8c0e30cc3b04e09e Mon Sep 17 00:00:00 2001 From: Brent Salisbury Date: Mon, 13 Jul 2026 16:02:38 +0000 Subject: [PATCH 2/2] fix(filter): address grid route review feedback Signed-off-by: Brent Salisbury --- .../src/builtins/http/ai/grid/descriptor.rs | 9 -------- filter/src/builtins/http/ai/grid/route.rs | 8 +++++++ .../http/traffic_management/router/tests.rs | 23 +++++++++++++++++++ filter/src/registry.rs | 1 + .../suite/examples/payload_processing.rs | 1 - .../suite/examples/ai/grid_route_inference.rs | 14 +++++++++++ 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/filter/src/builtins/http/ai/grid/descriptor.rs b/filter/src/builtins/http/ai/grid/descriptor.rs index 49583ab60..ef41a5728 100644 --- a/filter/src/builtins/http/ai/grid/descriptor.rs +++ b/filter/src/builtins/http/ai/grid/descriptor.rs @@ -320,15 +320,6 @@ mod tests { assert!(result.is_ok()); } - #[test] - fn same_model_different_site_not_duplicate() { - let result = validate_candidates(vec![ - candidate("inference_model", "llama", "site-a", "c1"), - candidate("inference_model", "llama", "site-b", "c2"), - ]); - assert!(result.is_ok(), "same model on different sites is not a duplicate"); - } - #[test] fn unknown_capability_kind_rejected() { let yaml = "- kind: unknown_thing\n name: x\n site: s\n cluster: c"; diff --git a/filter/src/builtins/http/ai/grid/route.rs b/filter/src/builtins/http/ai/grid/route.rs index af3a47b53..d969d8355 100644 --- a/filter/src/builtins/http/ai/grid/route.rs +++ b/filter/src/builtins/http/ai/grid/route.rs @@ -131,6 +131,14 @@ impl HttpFilter for GridRouteFilter { "grid_route" } + fn selects_cluster(&self) -> bool { + true + } + + fn selected_clusters(&self) -> Vec { + self.candidates.iter().map(|c| c.cluster.to_string()).collect() + } + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { if ctx.cluster.is_some() { tracing::debug!("grid_route: cluster already set; preserving"); diff --git a/filter/src/builtins/http/traffic_management/router/tests.rs b/filter/src/builtins/http/traffic_management/router/tests.rs index 4106887b8..7a81ba9fa 100644 --- a/filter/src/builtins/http/traffic_management/router/tests.rs +++ b/filter/src/builtins/http/traffic_management/router/tests.rs @@ -194,6 +194,29 @@ async fn on_request_rejects_on_no_match() { assert!(ctx.cluster.is_none(), "cluster should remain unset on no match"); } +#[tokio::test] +async fn on_request_preserves_cluster_already_set_by_earlier_filter() { + // When ctx.cluster is already set (e.g. by grid_route), the router should + // not overwrite it. This is the skip-on-pre-set behavior added to prevent + // competing cluster selectors. + let router = make_router(vec![prefix_route("/", "router-cluster")]); + let req = crate::test_utils::make_request(http::Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.cluster = Some(std::sync::Arc::from("pre-set-cluster")); + + let action = router.on_request(&mut ctx).await.unwrap(); + + assert!( + matches!(action, FilterAction::Continue), + "router should return Continue when cluster is already set" + ); + assert_eq!( + ctx.cluster.as_deref(), + Some("pre-set-cluster"), + "router must not overwrite a cluster set by an earlier filter" + ); +} + #[tokio::test] async fn on_request_combined_host_and_path() { let router = make_router(vec![ diff --git a/filter/src/registry.rs b/filter/src/registry.rs index 3267af210..16d1d24f6 100644 --- a/filter/src/registry.rs +++ b/filter/src/registry.rs @@ -260,6 +260,7 @@ mod tests { "json_body_field should be registered" ); assert!(names.contains(&"json_rpc"), "json_rpc should be registered"); + assert!(names.contains(&"grid_route"), "grid_route should be registered"); #[cfg(feature = "cpex-policy-engine")] assert!(names.contains(&"policy"), "policy should be registered"); } diff --git a/tests/integration/tests/suite/examples/payload_processing.rs b/tests/integration/tests/suite/examples/payload_processing.rs index 4c33cc1c1..7183a88b9 100644 --- a/tests/integration/tests/suite/examples/payload_processing.rs +++ b/tests/integration/tests/suite/examples/payload_processing.rs @@ -15,7 +15,6 @@ use praxis_test_utils::{ // Tests // ----------------------------------------------------------------------------- - #[test] fn multi_field_extraction_extracts_both_fields() { let backend_guard = start_header_echo_backend(); diff --git a/tests/schema/tests/suite/examples/ai/grid_route_inference.rs b/tests/schema/tests/suite/examples/ai/grid_route_inference.rs index c1a385770..6ba92b954 100644 --- a/tests/schema/tests/suite/examples/ai/grid_route_inference.rs +++ b/tests/schema/tests/suite/examples/ai/grid_route_inference.rs @@ -23,6 +23,7 @@ fn grid_route_inference_routes_by_model() { ); let proxy = praxis_test_utils::start_proxy(&config); + // llama-3.2-8b → llama-remote cluster → remote-model backend let raw = http_send( proxy.addr(), &json_post("/v1/chat/completions", r#"{"model":"llama-3.2-8b"}"#), @@ -34,4 +35,17 @@ fn grid_route_inference_routes_by_model() { "remote-model", "grid_route should select the candidate cluster for the requested model" ); + + // granite-3.3-8b → granite-local cluster → local-model backend + let raw = http_send( + proxy.addr(), + &json_post("/v1/chat/completions", r#"{"model":"granite-3.3-8b"}"#), + ); + + assert_eq!(parse_status(&raw), 200, "granite model should route"); + assert_eq!( + parse_body(&raw), + "local-model", + "grid_route should select the local cluster for granite-3.3-8b" + ); }