Skip to content

fix: resolve open OSS QA bugs across libsy, v2 server, CLI, and docs#103

Closed
elyasmnvidian wants to merge 9 commits into
mainfrom
emehtabuddin/oss-qa-open-bugs
Closed

fix: resolve open OSS QA bugs across libsy, v2 server, CLI, and docs#103
elyasmnvidian wants to merge 9 commits into
mainfrom
emehtabuddin/oss-qa-open-bugs

Conversation

@elyasmnvidian

@elyasmnvidian elyasmnvidian commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Clears the open QA-filed bugs against the OSS builds, grouped by area. Verified against current main: cargo build, cargo clippy -D warnings, strict rustdoc (RUSTDOCFLAGS="-D warnings" cargo doc), cargo test, ruff, mypy --strict, and the full non-integration pytest suite (one unrelated pre-existing failure in test_verify.py::test_secrets_file_wins_when_no_cli_or_env, which also fails on clean main on this dev box).

libsy (Rust)

  • The LLM classifier drains a streamed classifier response before reading its score, so a streaming classifier target routes on its real verdict instead of always failing open to the strong tier.
  • Invalid-but-parseable scores (NaN, ±inf, out of [0,1]) now fail open to strong instead of being treated as a below-threshold verdict and silently downgraded to weak.
  • cargo package -p switchyard-libsy succeeds: the switchyard-protocol path dependency now carries a version.
  • Strict rustdoc passes: fixed intra-doc links to private items and redundant explicit link targets.

v2 server / routing (Rust)

  • GET /v1/models reports real tool_calling and context_window capability metadata instead of null.
  • A context-window overflow returns HTTP 400 (context_length_exceeded) instead of a 500 server_error, on all inbound APIs.
  • The v2 random-routing profile accepts fallback_target_on_evict and retries once against the fallback target on a context-window overflow, matching stage_router / llm_routing.

CLI

  • launch <agent> -- <args> forwards documented harness args after a single -- (the separator is only stripped before the subcommand, so the doubled -- workaround is no longer needed).
  • Zero-config launch against a non-OpenRouter provider fails fast with guidance instead of launching the OpenRouter-only default trio against an incompatible endpoint.
  • launch --dry-run reports a routing-profiles bundle as route: bundle (with the bundle path and route ids) instead of route: single.
  • configure --list-models and configure honor the saved default_provider (the --provider flag no longer shadows it), and non-interactive configure resolves an API key from the environment/secrets instead of requiring --api-key.
  • serve --config with a duplicate model id prints a one-line diagnostic instead of a raw traceback.
  • serve with no config points at --config first (the v2 path), with --routing-profiles as the legacy option.
  • Local readiness probes (verify round-trip, launcher health) bypass env proxies, so a configured HTTP_PROXY no longer breaks the 127.0.0.1 checks.

Stats

  • noop routes appear in /v1/routing/stats (request count and tokens under the noop model) instead of being invisible.

Docs / examples

  • Corrected the AUTO backend-format probe order (Chat Completions probed first), the [all] extra description, and the in-repo example route.yaml.
  • Documented that saved routing-profile bundles keep ${VAR} references literal, so the referenced env vars must be set at serve / launch time.
  • Removed stale references to the removed SwitchyardRecipes API.

Already correct on main (no change here)

The HTTP error-envelope consolidation, fail_open classifier observability, and v2 Codex Responses token stats were already fixed on main; they only need QA verification.

Summary by CodeRabbit

  • New Features

    • Random routing can retry with a fallback target after context-window eviction.
    • Model listings now report tool-calling support and inferred context-window sizes.
    • No-op requests can contribute to routing statistics.
    • Dry runs now display routing-profile bundle details.
  • Bug Fixes

    • Context-length failures now return clear HTTP 400 client errors.
    • Streaming classifier scores are validated reliably.
    • Local health checks bypass configured proxies.
    • CLI configuration preserves saved providers and can use environment API keys.
  • Documentation

    • Updated installation, configuration, profile, launcher, and routing guidance.

Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
… overflow

Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
…, clarify serve errors

Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
… in --dry-run

Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
…API references

Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
@elyasmnvidian
elyasmnvidian requested a review from a team as a code owner July 21, 2026 18:57
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1

🚀 View preview at
https://NVIDIA-NeMo.github.io/Switchyard/pr-preview/pr-103/

Built to branch gh-pages at 2026-07-21 18:58 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR updates profile-based routing, classifier streaming, context-error handling, model capabilities, CLI provider and launcher behavior, loopback proxy checks, no-op statistics, package metadata, examples, and related documentation and tests.

Changes

Routing and serving behavior

Layer / File(s) Summary
Classifier score aggregation and validation
crates/libsy/src/algorithms/llm_class.rs
Classifier scores are drained from streamed responses and accepted only within [0.0, 1.0], with tests for invalid and streamed values.
Random routing fallback retry
crates/switchyard-components-v2/src/profiles/random_routing.rs, crates/switchyard-components-v2/tests/*
Random routing validates fallback_target_on_evict, rewrites overflowed requests, and retries once against the fallback target.
Server error and model capability responses
crates/switchyard-server/src/lib.rs, crates/switchyard-server/tests/*
Context errors map to HTTP 400, while model listings expose tool calling and inferred context windows.
No-op profile statistics
switchyard/lib/profiles/noop.py, tests/test_noop_llm_backend.py
No-op calls set the selected model and record successful statistics when enabled.

CLI configuration and execution

Layer / File(s) Summary
Provider persistence and credential resolution
switchyard/cli/configure_command.py, switchyard/cli/switchyard_cli.py, tests/test_user_config.py
Configure preserves saved providers and can source missing API keys from environment connectivity.
Deterministic launch guards and bundle dry runs
switchyard/cli/launch_command.py, switchyard/cli/output.py, tests/test_launch_*
Deterministic launches validate OpenRouter-only defaults, and dry runs display routing-profile bundles.
Serve diagnostics and forwarded arguments
switchyard/cli/switchyard_cli.py, tests/test_launch_claude.py, tests/test_serve_profile_config.py
Serve errors are clearer, duplicate registrations exit cleanly, and arguments after -- are forwarded.
Loopback health probe bypass
switchyard/cli/launchers/*, switchyard/server/verify.py, tests/test_launcher_proxy_bypass.py
Local health checks bypass environment HTTP proxies.

API migration and documentation

Layer / File(s) Summary
Profile-based Python examples and documentation
CHANGELOG.md, DEVELOPMENT.md, INSTALLATION.md, examples/*
Examples and project documentation use ProfileSwitchyard and typed profile configurations.
Installation and launcher guidance
README.md, docs/cli_reference.md, docs/guides/agent_launchers.md
Installation extras, backend probing, environment references, and launcher requirements are updated.
Rust dependency and API documentation
crates/libsy/Cargo.toml, crates/libsy/src/*
The protocol dependency is version-pinned and Rust documentation references are revised.

Estimated code review effort: 4 (Complex) | ~60 minutes

Poem

“Hop, hop!” said the rabbit, “the routes now retry,
Streams gather scores as they leap through the sky.
Local probes dodge proxies with care,
Profiles count calls in the air.
New docs bloom brightly—what a softwarey spring!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the broad scope of fixes across libsy, the v2 server, CLI, and documentation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (1)
crates/switchyard-server/src/lib.rs (1)

549-589: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Keep the context-window fragments in sync with switchyard/lib/model_listing.py — the table is duplicated here, and the first-match order means future model additions can drift silently. A small parity test would help.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/lib.rs` around lines 549 - 589, Keep
inferred_context_window’s TABLE synchronized with the fragment mappings and
precedence defined in switchyard/lib/model_listing.py, including future model
additions and their first-match order. Add a focused parity test that verifies
the Rust inference returns the same context window as the Python model_listing
table for representative matching and fallback model names.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/libsy/src/algorithms/llm_class.rs`:
- Around line 414-458: Remove the parenthetical NvBug tracker references from
the comments in out_of_range_score_defaults_to_strong and
streamed_score_below_threshold_routes_weak, while preserving the behavioral and
edge-case explanations in both comments.

In `@crates/switchyard-components-v2/src/profiles/random_routing.rs`:
- Around line 235-291: Update RandomRoutingProfile::run and
fallback_processed_request so a context-window overflow does not retry the same
target: when the selected target already equals fallback_target_on_evict, skip
the duplicate call and return ContextPoolExhausted with that target. Record the
first attempt’s ContextWindowExceeded via record_error before handling the
fallback, while preserving existing weak-to-strong retry behavior. Extend run’s
tests to cover overflow when the initially selected target is already the
fallback target.

In `@INSTALLATION.md`:
- Line 70: The documentation uses inconsistent extra naming. In
INSTALLATION.md:70-70 and README.md:219-219, update the documented extra name to
affinity-redis to match pyproject.toml, replacing any redis-affinity wording.

In `@switchyard/cli/configure_command.py`:
- Around line 425-437: Update the configure flow around
resolve_provider_connectivity() to retain its resolved provider alongside
env_api_key, and use that provider when persisting the selected configuration
and credentials. Ensure an environment key cannot be saved under a different
provider while retaining the selected provider’s base URL; alternatively
restrict environment-key resolution to the selected provider.

In `@switchyard/cli/launch_command.py`:
- Line 127: Update the endpoint check in the launch command around
_OPENROUTER_URL_MARKER to parse base_url and compare its normalized hostname
against the intended OpenRouter host, rather than checking for a substring.
Ensure case-insensitive matching and reject unrelated hosts such as
notopenrouter.ai so the fail-fast guard remains effective.

In `@switchyard/cli/switchyard_cli.py`:
- Around line 1463-1470: Update the subcommand detection around _SUBCOMMANDS,
sep_idx, and cmd_idx so option values are not mistaken for the command. Identify
the subcommand only from the structurally valid global-option prefix, or strip
the separator only when its following token is a recognized subcommand; preserve
separators intended for forwarding after the actual command.

In `@tests/test_launch_codex.py`:
- Around line 509-511: Remove the “NvBug 6401771” tracker reference from the
source comment near the OpenRouter endpoint logic, while preserving the
explanation that non-OpenRouter providers are rejected.

In `@tests/test_user_config.py`:
- Around line 361-368: Replace function-stubbing monkeypatch.setattr calls with
mocker.patch while preserving each stub’s behavior; retain monkeypatch only for
environment variables. Apply this in tests/test_user_config.py ranges 361-368
and 390-398, tests/test_launch_claude_deterministic.py ranges 271-275, 292-295,
and 319-333, and tests/test_launch_codex.py range 512-515.

---

Nitpick comments:
In `@crates/switchyard-server/src/lib.rs`:
- Around line 549-589: Keep inferred_context_window’s TABLE synchronized with
the fragment mappings and precedence defined in switchyard/lib/model_listing.py,
including future model additions and their first-match order. Add a focused
parity test that verifies the Rust inference returns the same context window as
the Python model_listing table for representative matching and fallback model
names.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 497ad048-3dd5-49fd-95a2-a26aa323a82b

📥 Commits

Reviewing files that changed from the base of the PR and between 2fa6b87 and 72f448e.

📒 Files selected for processing (31)
  • CHANGELOG.md
  • DEVELOPMENT.md
  • INSTALLATION.md
  • README.md
  • crates/libsy/Cargo.toml
  • crates/libsy/src/algorithms/llm_class.rs
  • crates/libsy/src/core/algorithm.rs
  • crates/libsy/src/lib.rs
  • crates/switchyard-components-v2/src/profiles/random_routing.rs
  • crates/switchyard-components-v2/tests/random_routing_profile.rs
  • crates/switchyard-server/src/lib.rs
  • crates/switchyard-server/tests/server.rs
  • docs/cli_reference.md
  • docs/guides/agent_launchers.md
  • examples/minimal.py
  • examples/route.yaml
  • switchyard/cli/configure_command.py
  • switchyard/cli/launch_command.py
  • switchyard/cli/launchers/launcher_runtime.py
  • switchyard/cli/launchers/proxy_health_monitor.py
  • switchyard/cli/output.py
  • switchyard/cli/switchyard_cli.py
  • switchyard/lib/profiles/noop.py
  • switchyard/server/verify.py
  • tests/test_launch_claude.py
  • tests/test_launch_claude_deterministic.py
  • tests/test_launch_codex.py
  • tests/test_launcher_proxy_bypass.py
  • tests/test_noop_llm_backend.py
  • tests/test_serve_profile_config.py
  • tests/test_user_config.py

Comment on lines +414 to +458

#[tokio::test]
async fn out_of_range_score_defaults_to_strong() -> Result<(), Box<dyn Error + Send + Sync>> {
// "NaN" parses as an f64 but is not a usable probability, so it must fail
// open to strong rather than be treated as a below-threshold verdict and
// silently downgraded to weak (NvBug 6485976).
let (algo, _) = algo(0.5, "NaN");
let (trace, response) = orch(algo).run(Context::default(), request("hi")).await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from frontier/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Strong));
assert_eq!(routed.score, None);
Ok(())
}

#[tokio::test]
async fn streamed_score_below_threshold_routes_weak() -> Result<(), Box<dyn Error + Send + Sync>>
{
// A streaming classifier target must have its score drained and read, not
// dropped — otherwise every streamed verdict falls open to strong
// regardless of value (NvBug 6485975).
let (algo, _) = algo_streaming(0.5, "0.2");
let (trace, response) = orch(algo)
.run(Context::default(), request("say hello"))
.await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from cheap/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Weak));
assert_eq!(routed.score, Some(0.2));
Ok(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Drop the bug-tracker IDs from test comments.

Both new test doc-comments end with a parenthetical tracker reference — "(NvBug 6485976)" (line ~419) and "(NvBug 6485975)" (line ~441). Per the coding guideline for **/*.{py,rs}, source comments should explain behavior/invariants, not carry project-management references like issue IDs.

As per coding guidelines, "Source comments must explain code behavior, invariants, or non-obvious edge cases; do not include project-management references such as tracker steps, issue IDs, or plan links."

📝 Proposed fix
-        // "NaN" parses as an f64 but is not a usable probability, so it must fail
-        // open to strong rather than be treated as a below-threshold verdict and
-        // silently downgraded to weak (NvBug 6485976).
+        // "NaN" parses as an f64 but is not a usable probability, so it must fail
+        // open to strong rather than be treated as a below-threshold verdict and
+        // silently downgraded to weak.
-        // A streaming classifier target must have its score drained and read, not
-        // dropped — otherwise every streamed verdict falls open to strong
-        // regardless of value (NvBug 6485975).
+        // A streaming classifier target must have its score drained and read, not
+        // dropped — otherwise every streamed verdict falls open to strong
+        // regardless of value.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[tokio::test]
async fn out_of_range_score_defaults_to_strong() -> Result<(), Box<dyn Error + Send + Sync>> {
// "NaN" parses as an f64 but is not a usable probability, so it must fail
// open to strong rather than be treated as a below-threshold verdict and
// silently downgraded to weak (NvBug 6485976).
let (algo, _) = algo(0.5, "NaN");
let (trace, response) = orch(algo).run(Context::default(), request("hi")).await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from frontier/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Strong));
assert_eq!(routed.score, None);
Ok(())
}
#[tokio::test]
async fn streamed_score_below_threshold_routes_weak() -> Result<(), Box<dyn Error + Send + Sync>>
{
// A streaming classifier target must have its score drained and read, not
// dropped — otherwise every streamed verdict falls open to strong
// regardless of value (NvBug 6485975).
let (algo, _) = algo_streaming(0.5, "0.2");
let (trace, response) = orch(algo)
.run(Context::default(), request("say hello"))
.await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from cheap/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Weak));
assert_eq!(routed.score, Some(0.2));
Ok(())
}
#[tokio::test]
async fn out_of_range_score_defaults_to_strong() -> Result<(), Box<dyn Error + Send + Sync>> {
// "NaN" parses as an f64 but is not a usable probability, so it must fail
// open to strong rather than be treated as a below-threshold verdict and
// silently downgraded to weak.
let (algo, _) = algo(0.5, "NaN");
let (trace, response) = orch(algo).run(Context::default(), request("hi")).await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from frontier/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Strong));
assert_eq!(routed.score, None);
Ok(())
}
#[tokio::test]
async fn streamed_score_below_threshold_routes_weak() -> Result<(), Box<dyn Error + Send + Sync>>
{
// A streaming classifier target must have its score drained and read, not
// dropped — otherwise every streamed verdict falls open to strong
// regardless of value.
let (algo, _) = algo_streaming(0.5, "0.2");
let (trace, response) = orch(algo)
.run(Context::default(), request("say hello"))
.await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from cheap/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Weak));
assert_eq!(routed.score, Some(0.2));
Ok(())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy/src/algorithms/llm_class.rs` around lines 414 - 458, Remove the
parenthetical NvBug tracker references from the comments in
out_of_range_score_defaults_to_strong and
streamed_score_below_threshold_routes_weak, while preserving the behavioral and
edge-case explanations in both comments.

Source: Coding guidelines

Comment on lines 235 to 291
impl Profile for RandomRoutingProfile {
/// Executes random routing while keeping selected-target state local to this call.
/// Executes random routing with one context-window fallback retry.
async fn run(&self, input: ProfileInput) -> Result<ProfileResponse> {
let profile_started_at = Instant::now();
let processed = self.process(input).await?;
let decision = &processed.decision;
let selected_backend = self.selected_backend(decision)?;
let backend_started_at = Instant::now();
let response = match selected_backend
.call(&processed.profile_input.request)
.await
{
Ok(response) => response,
Err(error) => {
self.stats.record_error(
decision.selected_model.as_str(),
Some(decision.tier.as_str()),
let (first_result, first_backend_latency_ms) = self.call_selected(&processed).await;
match first_result {
Ok(response) => {
let response = self.record_success(
&processed.decision,
response,
profile_started_at,
first_backend_latency_ms,
)?;
return Err(error);
let response = self.rprocess(&processed, response).await?;
Ok(ProfileResponse::with_routing_metadata(
response,
self.routing_metadata(&processed.decision),
))
}
};
let backend_latency_ms = backend_started_at.elapsed().as_secs_f64() * 1000.0;
self.stats.record_success(
decision.selected_model.as_str(),
Some(backend_latency_ms),
Some(decision.tier.as_str()),
)?;
let response = record_usage_or_wrap_stream(
&self.stats,
decision.selected_model.as_str(),
Some(decision.tier.as_str()),
profile_started_at,
backend_latency_ms,
response,
)?;
let response = self.rprocess(&processed, response).await?;
Ok(ProfileResponse::with_routing_metadata(
response,
self.routing_metadata(decision),
))
Err(SwitchyardError::ContextWindowExceeded { .. }) => {
let retry = self.fallback_processed_request(&processed)?;
let (retry_result, retry_backend_latency_ms) = self.call_selected(&retry).await;
match retry_result {
Ok(response) => {
let response = self.record_success(
&retry.decision,
response,
profile_started_at,
retry_backend_latency_ms,
)?;
let response = self.rprocess(&retry, response).await?;
Ok(ProfileResponse::with_routing_metadata(
response,
self.routing_metadata(&retry.decision),
))
}
Err(SwitchyardError::ContextWindowExceeded { target_id, .. }) => {
self.record_error(&retry.decision)?;
Err(SwitchyardError::ContextPoolExhausted {
last_target_id: target_id,
reason: "all attempted targets returned context-window overflow"
.to_string(),
})
}
Err(error) => {
self.record_error(&retry.decision)?;
Err(error)
}
}
}
Err(error) => {
self.record_error(&processed.decision)?;
Err(error)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fallback retry can dispatch to the same target that just overflowed.

fallback_processed_request always rewrites the retry to self.fallback_target_on_evict, without checking whether the originally selected target is already that fallback target. Since the default fallback is the strong target, any overflow on the strong target (the common high-probability path) triggers a second, identical call to the same backend — guaranteed to overflow again — before finally returning ContextPoolExhausted. That's a wasted upstream call (latency + cost) with no chance of success.

Two related gaps in the same flow:

  • The first attempt's ContextWindowExceeded is never recorded via record_error when the retry succeeds, so stats never reflect that a fallback occurred.
  • The new test (run_retries_fallback_target_after_context_overflow, lines 696-759) only covers weak→strong fallback; it doesn't cover the case where the originally selected target already equals fallback_target_on_evict.
🐛 Proposed fix
             Err(SwitchyardError::ContextWindowExceeded { .. }) => {
+                if processed.decision.selected_target == self.fallback_target_on_evict {
+                    // Already tried the only configured fallback target; a retry
+                    // would just repeat the identical failing call.
+                    self.record_error(&processed.decision)?;
+                    return Err(SwitchyardError::ContextPoolExhausted {
+                        last_target_id: processed.decision.selected_target.clone(),
+                        reason: "all attempted targets returned context-window overflow"
+                            .to_string(),
+                    });
+                }
                 let retry = self.fallback_processed_request(&processed)?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
impl Profile for RandomRoutingProfile {
/// Executes random routing while keeping selected-target state local to this call.
/// Executes random routing with one context-window fallback retry.
async fn run(&self, input: ProfileInput) -> Result<ProfileResponse> {
let profile_started_at = Instant::now();
let processed = self.process(input).await?;
let decision = &processed.decision;
let selected_backend = self.selected_backend(decision)?;
let backend_started_at = Instant::now();
let response = match selected_backend
.call(&processed.profile_input.request)
.await
{
Ok(response) => response,
Err(error) => {
self.stats.record_error(
decision.selected_model.as_str(),
Some(decision.tier.as_str()),
let (first_result, first_backend_latency_ms) = self.call_selected(&processed).await;
match first_result {
Ok(response) => {
let response = self.record_success(
&processed.decision,
response,
profile_started_at,
first_backend_latency_ms,
)?;
return Err(error);
let response = self.rprocess(&processed, response).await?;
Ok(ProfileResponse::with_routing_metadata(
response,
self.routing_metadata(&processed.decision),
))
}
};
let backend_latency_ms = backend_started_at.elapsed().as_secs_f64() * 1000.0;
self.stats.record_success(
decision.selected_model.as_str(),
Some(backend_latency_ms),
Some(decision.tier.as_str()),
)?;
let response = record_usage_or_wrap_stream(
&self.stats,
decision.selected_model.as_str(),
Some(decision.tier.as_str()),
profile_started_at,
backend_latency_ms,
response,
)?;
let response = self.rprocess(&processed, response).await?;
Ok(ProfileResponse::with_routing_metadata(
response,
self.routing_metadata(decision),
))
Err(SwitchyardError::ContextWindowExceeded { .. }) => {
let retry = self.fallback_processed_request(&processed)?;
let (retry_result, retry_backend_latency_ms) = self.call_selected(&retry).await;
match retry_result {
Ok(response) => {
let response = self.record_success(
&retry.decision,
response,
profile_started_at,
retry_backend_latency_ms,
)?;
let response = self.rprocess(&retry, response).await?;
Ok(ProfileResponse::with_routing_metadata(
response,
self.routing_metadata(&retry.decision),
))
}
Err(SwitchyardError::ContextWindowExceeded { target_id, .. }) => {
self.record_error(&retry.decision)?;
Err(SwitchyardError::ContextPoolExhausted {
last_target_id: target_id,
reason: "all attempted targets returned context-window overflow"
.to_string(),
})
}
Err(error) => {
self.record_error(&retry.decision)?;
Err(error)
}
}
}
Err(error) => {
self.record_error(&processed.decision)?;
Err(error)
}
}
}
impl Profile for RandomRoutingProfile {
/// Executes random routing with one context-window fallback retry.
async fn run(&self, input: ProfileInput) -> Result<ProfileResponse> {
let profile_started_at = Instant::now();
let processed = self.process(input).await?;
let (first_result, first_backend_latency_ms) = self.call_selected(&processed).await;
match first_result {
Ok(response) => {
let response = self.record_success(
&processed.decision,
response,
profile_started_at,
first_backend_latency_ms,
)?;
let response = self.rprocess(&processed, response).await?;
Ok(ProfileResponse::with_routing_metadata(
response,
self.routing_metadata(&processed.decision),
))
}
Err(SwitchyardError::ContextWindowExceeded { .. }) => {
if processed.decision.selected_target == self.fallback_target_on_evict {
// Already tried the only configured fallback target; a retry
// would just repeat the identical failing call.
self.record_error(&processed.decision)?;
return Err(SwitchyardError::ContextPoolExhausted {
last_target_id: processed.decision.selected_target.clone(),
reason: "all attempted targets returned context-window overflow"
.to_string(),
});
}
let retry = self.fallback_processed_request(&processed)?;
let (retry_result, retry_backend_latency_ms) = self.call_selected(&retry).await;
match retry_result {
Ok(response) => {
let response = self.record_success(
&retry.decision,
response,
profile_started_at,
retry_backend_latency_ms,
)?;
let response = self.rprocess(&retry, response).await?;
Ok(ProfileResponse::with_routing_metadata(
response,
self.routing_metadata(&retry.decision),
))
}
Err(SwitchyardError::ContextWindowExceeded { target_id, .. }) => {
self.record_error(&retry.decision)?;
Err(SwitchyardError::ContextPoolExhausted {
last_target_id: target_id,
reason: "all attempted targets returned context-window overflow"
.to_string(),
})
}
Err(error) => {
self.record_error(&retry.decision)?;
Err(error)
}
}
}
Err(error) => {
self.record_error(&processed.decision)?;
Err(error)
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-components-v2/src/profiles/random_routing.rs` around lines
235 - 291, Update RandomRoutingProfile::run and fallback_processed_request so a
context-window overflow does not retry the same target: when the selected target
already equals fallback_target_on_evict, skip the duplicate call and return
ContextPoolExhausted with that target. Record the first attempt’s
ContextWindowExceeded via record_error before handling the fallback, while
preserving existing weak-to-strong retry behavior. Extend run’s tests to cover
overflow when the initially selected target is already the fallback target.

Comment thread INSTALLATION.md
```

Equivalent to: `switchyard[server,cli]`
Equivalent to: `nemo-switchyard[server,cli,tracing,intake,affinity-redis]`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files and inspect the exact snippets.
git ls-files | rg '^(pyproject\.toml|INSTALLATION\.md|README\.md)$'

printf '\n--- pyproject.toml ---\n'
cat -n pyproject.toml | sed -n '1,260p'

printf '\n--- INSTALLATION.md (around line 70) ---\n'
cat -n INSTALLATION.md | sed -n '55,85p'

printf '\n--- README.md (around line 219) ---\n'
cat -n README.md | sed -n '205,230p'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 10838


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for all extra definitions/usages to confirm canonical naming.
rg -n --hidden --glob '!**/.git/**' --glob 'pyproject.toml' --glob 'README.md' --glob 'INSTALLATION.md' \
  '(affinity-redis|redis-affinity|\[all\]|\[server,cli,tracing,intake,affinity-redis\])' .

Repository: NVIDIA-NeMo/Switchyard

Length of output: 729


Use affinity-redis consistently in the docs.

pyproject.toml defines the extra as affinity-redis, but the README describes it as redis-affinity. Update the README wording to match the manifest.

📍 Affects 2 files
  • INSTALLATION.md#L70-L70 (this comment)
  • README.md#L219-L219
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@INSTALLATION.md` at line 70, The documentation uses inconsistent extra
naming. In INSTALLATION.md:70-70 and README.md:219-219, update the documented
extra name to affinity-redis to match pyproject.toml, replacing any
redis-affinity wording.

Comment on lines +425 to +437
env_api_key = resolve_provider_connectivity(
cli_api_key=None,
cli_base_url=None,
api_key_env_vars=("OPENROUTER_API_KEY", "NVIDIA_API_KEY", "OPENAI_API_KEY"),
base_url_env_vars=("OPENROUTER_BASE_URL", "NVIDIA_BASE_URL", "OPENAI_BASE_URL"),
secrets=load_secrets(),
secrets_section_priority=DEFAULT_SECRETS_SECTION_PRIORITY,
default_provider=provider,
).api_key
prompt_default_api_key = (
existing_api_key
or request.prompt_default_api_key
or env_api_key

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the resolved provider with its environment credential.

resolve_provider_connectivity() can select openai/nvidia from the API-key environment variable, but this code discards .provider. For example, an OPENAI_API_KEY can be saved under nvidia while retaining NVIDIA’s base URL. Carry the resolved provider through config/credential persistence, or only accept an environment key matching the selected provider.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@switchyard/cli/configure_command.py` around lines 425 - 437, Update the
configure flow around resolve_provider_connectivity() to retain its resolved
provider alongside env_api_key, and use that provider when persisting the
selected configuration and credentials. Ensure an environment key cannot be
saved under a different provider while retaining the selected provider’s base
URL; alternatively restrict environment-key resolution to the selected provider.

or getattr(args, "classifier_model", None)
):
return
if _OPENROUTER_URL_MARKER in base_url:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Parse the endpoint hostname instead of using a substring check.

"openrouter.ai" in base_url accepts unrelated hosts such as https://notopenrouter.ai (and can reject uppercase hostnames), bypassing the fail-fast guard and launching an unreachable default trio.

Proposed fix
+from urllib.parse import urlsplit
+
-    if _OPENROUTER_URL_MARKER in base_url:
+    hostname = urlsplit(base_url).hostname or ""
+    if (
+        hostname == _OPENROUTER_URL_MARKER
+        or hostname.endswith(f".{_OPENROUTER_URL_MARKER}")
+    ):
         return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if _OPENROUTER_URL_MARKER in base_url:
from urllib.parse import urlsplit
hostname = urlsplit(base_url).hostname or ""
if (
hostname == _OPENROUTER_URL_MARKER
or hostname.endswith(f".{_OPENROUTER_URL_MARKER}")
):
return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@switchyard/cli/launch_command.py` at line 127, Update the endpoint check in
the launch command around _OPENROUTER_URL_MARKER to parse base_url and compare
its normalized hostname against the intended OpenRouter host, rather than
checking for a substring. Ensure case-insensitive matching and reject unrelated
hosts such as notopenrouter.ai so the fail-fast guard remains effective.

Comment on lines +1463 to +1470
# Only strip a '--' that precedes the subcommand token; a '--' after it is
# the harness separator (e.g. `launch claude ... -- --version`) and must
# survive so forwarded args reach the launcher instead of tripping argparse.
_SUBCOMMANDS = ("serve", "configure", "launch", "verify")
sep_idx = argv.index("--") if "--" in argv else len(argv)
cmd_idx = next((i for i, t in enumerate(argv) if t in _SUBCOMMANDS), len(argv))
if sep_idx < cmd_idx:
argv.pop(sep_idx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not infer the subcommand from arbitrary option values.

Line 1468 treats any token named serve, launch, etc. as the command. switchyard --routing-profiles launch -- serve therefore leaves the separator in place and fails parsing. Strip the visual separator only when the following token is a recognized subcommand, or parse the global-option prefix structurally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@switchyard/cli/switchyard_cli.py` around lines 1463 - 1470, Update the
subcommand detection around _SUBCOMMANDS, sep_idx, and cmd_idx so option values
are not mistaken for the command. Identify the subcommand only from the
structurally valid global-option prefix, or strip the separator only when its
following token is a recognized subcommand; preserve separators intended for
forwarding after the actual command.

Comment on lines +509 to +511
# OpenRouter endpoint: the zero-flag default trio is OpenRouter-only, so
# the deterministic default only dispatches when the resolved endpoint is
# OpenRouter (a non-OpenRouter provider is rejected — see NvBug 6401771).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the tracker reference from this source comment.

NvBug 6401771 is a project-management reference rather than behavior documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_launch_codex.py` around lines 509 - 511, Remove the “NvBug
6401771” tracker reference from the source comment near the OpenRouter endpoint
logic, while preserving the explanation that non-OpenRouter providers are
rejected.

Source: Coding guidelines

Comment thread tests/test_user_config.py
Comment on lines +361 to +368
monkeypatch.setattr(
"switchyard.cli.configure_command.fetch_model_ids",
lambda base_url, api_key: captured.update(base_url=base_url, api_key=api_key) or [],
)
monkeypatch.setattr(
"switchyard.cli.configure_command.render_models",
lambda model_ids, request: "",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Relevant test files\n'
git ls-files 'tests/test_user_config.py' 'tests/test_launch_claude_deterministic.py' 'tests/test_launch_codex.py' 'conftest.py' 'tests/conftest.py'

printf '\n## File sizes\n'
wc -l tests/test_user_config.py tests/test_launch_claude_deterministic.py tests/test_launch_codex.py 2>/dev/null || true

printf '\n## Search for mocker usage and monkeypatch patterns\n'
rg -n '\bmocker\b|monkeypatch\.setattr|monkeypatch\.setenv|pytest-mock|pytest_plugins' tests/test_user_config.py tests/test_launch_claude_deterministic.py tests/test_launch_codex.py tests/conftest.py conftest.py 2>/dev/null || true

printf '\n## Read around the cited regions\n'
sed -n '330,420p' tests/test_user_config.py
printf '\n---\n'
sed -n '250,350p' tests/test_launch_claude_deterministic.py
printf '\n---\n'
sed -n '480,540p' tests/test_launch_codex.py

printf '\n## Search for any repo guidance on monkeypatch vs mocker in tests\n'
rg -n 'pytest-mock|mocker.patch|use .*mocker|monkeypatch' tests README.md pyproject.toml setup.cfg tox.ini 2>/dev/null || true

Repository: NVIDIA-NeMo/Switchyard

Length of output: 50378


Use mocker.patch for these function stubs.

  • tests/test_user_config.py#L361-L398, tests/test_launch_claude_deterministic.py#L271-L333, and tests/test_launch_codex.py#L512-L515: replace the monkeypatch.setattr calls that stub functions with mocker.patch; keep monkeypatch for env vars.
📍 Affects 3 files
  • tests/test_user_config.py#L361-L368 (this comment)
  • tests/test_user_config.py#L390-L398
  • tests/test_launch_claude_deterministic.py#L271-L275
  • tests/test_launch_claude_deterministic.py#L292-L295
  • tests/test_launch_claude_deterministic.py#L319-L333
  • tests/test_launch_codex.py#L512-L515
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_user_config.py` around lines 361 - 368, Replace function-stubbing
monkeypatch.setattr calls with mocker.patch while preserving each stub’s
behavior; retain monkeypatch only for environment variables. Apply this in
tests/test_user_config.py ranges 361-368 and 390-398,
tests/test_launch_claude_deterministic.py ranges 271-275, 292-295, and 319-333,
and tests/test_launch_codex.py range 512-515.

Source: Coding guidelines

@elyasmnvidian

Copy link
Copy Markdown
Contributor Author

Superseded — split into per-area PRs for independent review/merge: #104 (libsy), #105 (v2 server), #106 (v2 random-routing), #107 (cli), #108 (launch), #109 (stats), #110 (docs).

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.

1 participant