Skip to content

feat(switchyard_route): add MoM routing filter POC - #16

Open
yehuditkerido wants to merge 1 commit into
praxis-proxy:mainfrom
yehuditkerido:switchyard_route_filter
Open

feat(switchyard_route): add MoM routing filter POC#16
yehuditkerido wants to merge 1 commit into
praxis-proxy:mainfrom
yehuditkerido:switchyard_route_filter

Conversation

@yehuditkerido

Copy link
Copy Markdown

Wire NVIDIA Switchyard Capability-mode classification into Praxis as a decision-only weak/strong router, with a local mock demo so reviewers can verify the plumbing without a cluster.

What?

  • Add switchyard_route HTTP filter (crates/praxis-experimental-filters): buffer OpenAI chat body, run Switchyard Capability run_stream, serve judge CallLlm via SubRequestClient, map weak/strong to configured (cluster, model), rewrite body model, set ctx.cluster for load_balancer.
  • Add validated YAML config (config.rs): judge, threshold, targets.weak|strong, on_failure (open/closed); secrets only via judge.auth.value_env.
  • Register the filter in lib.rs; bump toolchain 1.96.01.96.1 (Switchyard 0.2.0); update Cargo.lock.
  • Add local mock demo (demos/switchyard-route/): judge + weak/strong echo upstreams, run-demo.sh, template Praxis YAML — no cluster required.
  • Add English docs (docs/switchyard-route.md) and link the demo from demos/README.md.

Why?

Mixture-of-Models gateways need a clear place to decide which model tier should handle a request before it hits an upstream. Without that, every request either always pays for a strong model or always risks under-serving hard work on a weak one.

This PR shows that path in Praxis using NVIDIA Switchyard’s Capability classifier: classify the task, pick weak or strong, rewrite the request, and select the cluster—decision-only, with a local mock demo so the flow is easy to verify.

Testing

  • cargo test -p praxis-experimental-filters (registry + config parse)
  • cd demos/switchyard-route && ./run-demo.sh — expect 3× served_by=weak-upstream, then 3× served_by=strong-upstream, plus switchyard_route: judge verdict / routed in gateway logs

Note: behavioral coverage for fail-open/closed, bad path, judge timeout, etc. is intentionally thin for this POC; demo is the main E2E check.

Related Issues

Relates to #2

@usize usize left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is experimental, I'm OK landing this as-is, after an automated review for test gaps and other nits.

Importantly, it would be great to see a follow-up that shows us what happens if Switchyard dies mid-session. What we want to avoid is thrashing between a "Strong" and "Weak" model.

@usize
usize requested a review from praxis-bot August 27, 2026 20:28
@yehuditkerido
yehuditkerido force-pushed the switchyard_route_filter branch from 311f4b3 to dd7b7f9 Compare August 31, 2026 08:08
@yehuditkerido
yehuditkerido marked this pull request as ready for review August 31, 2026 08:08
@github-actions

Copy link
Copy Markdown

PR too large: 1091 lines added (limit: 750, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. Add skip/pr-conventions label to override.

@github-actions

Copy link
Copy Markdown

Non-conforming commit subjects (expected type(scope): summary, ≤72 chars, types: build/chore/ci/docs/feat/fix/perf/refactor/test):

  • dd7b7f9: feat(switchyard_route): add MoM routing filter POC

Amend with git commit --amend or rewrite with git rebase -i.

@yehuditkerido
yehuditkerido force-pushed the switchyard_route_filter branch 2 times, most recently from 90cf365 to 02d3cc1 Compare August 31, 2026 08:37
Wire NVIDIA Switchyard Capability-mode classification into Praxis
as a decision-only weak/strong router, with a local mock demo so
reviewers can verify the plumbing without a cluster.

Signed-off-by: Yehudit Kerido <ykerido@redhat.com>
@yehuditkerido
yehuditkerido force-pushed the switchyard_route_filter branch from 02d3cc1 to d693aa5 Compare August 31, 2026 08:53
@yehuditkerido

Copy link
Copy Markdown
Author

This PR is intentionally over the size limit (filter + demo together).
Please keep it as one reviewable POC. If CI blocks on size, please add the
skip/pr-conventions label.

Follow-ups I’ll open separately:

  • Mid-session Switchyard failure: show behavior when the judge dies mid-chat and avoid thrashing between Strong and Weak (sticky last tier / clearer fail policy)
  • Session continuity when Switchyard is healthy (decision floor / no downgrade across turns), if we want that in this filter
  • Unit tests and coverage to meet the CI floor (and address automated-review gaps)
  • Smaller nits as needed (fail-open observability, judge timeout budget, demo process cleanup)

Happy to split further if reviewers prefer the demo in its own PR.

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Summary: Clean POC with well-structured config parsing, good judge retry logic, and appropriate error types. Three findings below -- one about fail-open semantics, two about edge-case handling.

Severity Count
Critical 0
Large 1
Medium 2

FailureMode::Open => {
// Demo: `run-demo.sh` greps `switchyard_route: fail-open`.
debug!("switchyard_route: fail-open, passing through");
Ok(FilterAction::Continue)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Large] Fail-open returns Continue but no cluster has been set in metadata, while selects_cluster() returns true (line 292). The downstream load_balancer will receive ctx.cluster = None and either error or pick an arbitrary cluster -- neither is "pass through unchanged." Either set a documented fallback cluster here (e.g. ctx.set_metadata(METADATA_CLUSTER, self.config.weak.cluster.clone()) with a comment explaining the default), or make selects_cluster() return false when on_failure is Open so the pipeline does not expect this filter to have selected one.

pub(crate) fn parse(yaml: &serde_yaml::Value) -> Result<RouteConfig, FilterError> {
let raw: RawConfig = serde_yaml::from_value(yaml.clone()).map_err(|err| FilterError::from(err.to_string()))?;

if !(0.0..=1.0).contains(&raw.threshold) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] (0.0..=1.0).contains(&f64::NAN) returns false, so !false passes NaN through validation. YAML's .nan literal deserializes to f64::NAN via serde, so threshold: .nan in a config file would silently poison every Switchyard comparison. Add || raw.threshold.is_nan() to the guard, or switch to raw.threshold.is_finite() && (0.0..=1.0).contains(&raw.threshold).


/// Truncates a body for log-safe error previews.
fn body_preview(body: &Bytes) -> String {
String::from_utf8_lossy(body).chars().take(200).collect()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] String::from_utf8_lossy(body) allocates (or borrows) the full body -- up to DEFAULT_MAX_BODY_BYTES (1 MiB) -- before .chars().take(200) truncates it. On error paths with large payloads this is a needless allocation. Slice first:

let cap = body.len().min(800);
String::from_utf8_lossy(&body[..cap]).chars().take(200).collect()

(800 bytes is enough for 200 chars even with 4-byte UTF-8 sequences.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants