Skip to content

feat(relay): add Switchyard-owned HTTP dynamic plugin - #270

Open
bbednarski9 wants to merge 10 commits into
NVIDIA-NeMo:mainfrom
bbednarski9:feat/nemo-relay-plugin-owned-http-client
Open

feat(relay): add Switchyard-owned HTTP dynamic plugin#270
bbednarski9 wants to merge 10 commits into
NVIDIA-NeMo:mainfrom
bbednarski9:feat/nemo-relay-plugin-owned-http-client

Conversation

@bbednarski9

@bbednarski9 bbednarski9 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

Adds the external nvidia.switchyard NeMo Relay native plugin as an isolated,
nested Cargo workspace under crates/switchyard-nemo-relay-plugin. The plugin
executes libsy's Algorithm::run_stream lifecycle through the recommended
switchyard-llm-client::run host driver, which serves offloaded model calls
using plugin-owned HTTP clients. This requires neither a Switchyard service nor
changes to core Switchyard crates.

The integration supports:

  • seeded weighted-random routing;
  • capability-mode LLM classifier routing;
  • escalation-mode LLM classifier routing, including confirmation streaks and
    session latching; and
  • staged routing with tool signals, tier prompts, handoff notes, and an optional
    classifier fallback.

Buffered and streaming callers are supported for OpenAI Chat, OpenAI Responses,
and Anthropic Messages. Provider headers are sourced through header_env (per Relay convention), so
literal secrets are not stored in plugin configuration. Routing-only LLM calls
emit ATOF marks with normalized usage so judge and discarded-candidate compute
can be accounted for without double-counting the final serving call.

Router capability matrix

Capability random Classifier: capability Classifier: escalation stage_router
Version-2 configuration and static validation Supported Supported Supported Supported
Buffered responses Supported Supported Supported Supported
Streaming responses Supported Supported after classification Conditional: an unlatched weak stream is buffered while judging Supported after cascade selection
Session state No selection affinity Optional affinity and message-hash fallback Stable session required for multi-confirmation streak/latch No classifier affinity; context-overflow eviction may use session identity
Router-specific prompts N/A Judge prompt Escalation judge prompt Tier prompts, handoff notes, optional classifier prompt
Tool-aware behavior Pass-through Judge tool history is sanitized to bounded text Judge tool history is sanitized to bounded text Native tool signals plus sanitized classifier context
ATOF routing usage Failed/replaced candidates when applicable Judge calls and failed candidates Judge calls and discarded weak candidates Optional judge calls and failed candidates

API-level compatibility

API Caller input Ordinary serving target Structured-output judge Hosted smoke status
OpenAI Chat Supported Supported Supported Verified across all four router modes
OpenAI Responses Supported Supported Known gap Caller and serving paths verified; judge requests currently fail with HTTP 400 because the shared encoder nests name, schema, and strict below text.format.json_schema instead of placing them directly below text.format
Anthropic Messages Supported Supported Rejected during static loading Caller and serving paths verified; structured judges intentionally require OpenAI Chat or OpenAI Responses

Same-protocol streaming preserves parsed provider JSON events when the router
does not aggregate or replace them. Raw SSE framing is not part of the contract.
Cross-protocol streams use normalized chunks.

Why

Switchyard algorithms need to own the complete routing lifecycle so policies can
inspect intermediate responses and make multiple provider calls. Keeping this
inside the plugin preserves that lifecycle while integrating with NeMo Relay's
native plugin API.

The implementation is intentionally isolated from the core library. The PR is
based directly on current main and contains no tracked modifications to
Switchyard core/libsy files; the only non-plugin changes are documentation
entries in README.md, CHANGELOG.md, and docs/index.md.

Related: #192, #220, #271, #274, NVIDIA/NeMo-Relay#594

How tested

  • uv run ruff check . clean (N/A: Rust-only plugin)
  • uv run mypy switchyard clean (N/A: Rust-only plugin)
  • uv run pytest tests/ green (N/A: Rust-only plugin)
  • Standalone plugin tests — 48 passed
  • cargo test --workspace --all-targets
  • cargo clippy --workspace --all-targets -- -D warnings
  • Standalone plugin clippy with -D warnings
  • cargo fmt --all -- --check
  • git diff --check
  • NeMo Relay process-level InferenceHub matrix — 21 of 24 cases passed

The three hosted failures are all OpenAI Responses structured-output judge
variants: capability classification, escalation judging, and the staged
classifier fallback. Each reached the expected judge-failure/fall-open path
after InferenceHub returned Missing required parameter: 'text.format.name'.
Random routing, every caller protocol, every ordinary serving-target protocol,
OpenAI Chat judges, Bedrock Sonnet staged judging with sanitized tool history,
stream reconstruction, escalation streak/latching, missing-session isolation,
stage signal selection, prompts, and handoff notes passed.

Checklist

  • One class per file; filename = snake_case of the primary class. (N/A: no Python classes added.)
  • New public symbols exported from switchyard/__init__.py.__all__ if intended for downstream use. (N/A: Rust plugin surface.)
  • Unit tests added for new components / bug fixes.
  • README / --help updated if customer-facing surface changed.
  • Commits signed off (Signed-off-by: Your Name <email>) per the DCO.

Notes for reviewers

  • The plugin is a nested Cargo workspace with its own lockfile. Root workspace
    manifests and lockfiles are unchanged.
  • Relay 0.7 does not expose a safe Rust facade for its generic asynchronous
    surface, so the plugin currently uses a small raw-FFI ownership adapter.
    NeMo Relay plans to provide the equivalent safe typed facade in 0.8.0; once
    that is available, the raw-FFI compatibility adapter should be removable.
  • Managed provider calls do not re-enter Relay middleware registered after the
    Switchyard intercept. Routing-only model calls are represented by
    switchyard.routing.llm_call ATOF marks; the successful serving call remains
    represented by Relay's outer LLM lifecycle event.
  • Structured judge requests convert retained tool calls and results to bounded,
    provider-neutral text, convert tool-role messages to a neutral role, and send
    no native tool definitions or tool-choice configuration. This avoids Bedrock's
    tool calling without tools rejection while preserving the routing evidence.
  • Escalation streaming delays first-token delivery while an unlatched weak
    response is buffered and judged. If weak wins, the caller receives a
    reconstructed stream; preserved provider-event envelopes are not guaranteed
    on that buffered path.
  • Escalation confirmations > 1 requires a stable session ID. Requests without
    one remain isolated and cannot build a latch streak.
  • Custom target headers use header_env exclusively. Target URLs containing
    credentials, query parameters, or fragments are rejected during loading.

Adjacent hardening

This PR remains self-contained. Broader library work is deliberately kept out
of its review scope:

@bbednarski9
bbednarski9 force-pushed the feat/nemo-relay-plugin-owned-http-client branch from 0e3eb27 to 58b3186 Compare August 3, 2026 23:35
@bbednarski9
bbednarski9 marked this pull request as ready for review August 4, 2026 01:49
@bbednarski9
bbednarski9 requested a review from a team as a code owner August 4, 2026 01:49
@bbednarski9
bbednarski9 force-pushed the feat/nemo-relay-plugin-owned-http-client branch from 58b3186 to 3dcee4d Compare August 4, 2026 01:53
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The pull request adds a native Switchyard NeMo Relay plugin with configuration, routing, translation, asynchronous host integration, streaming support, packaging, and documentation. It also adds HTTP transport limits, redirect rejection, timeout defaults, header redaction, and safer error reporting.

Changes

Transport safety

Layer / File(s) Summary
Provider transport safeguards
crates/libsy-llm-client/*, crates/protocol/src/client.rs, crates/switchyard-translation/src/helpers.rs
Provider requests reject redirects, use connection and read timeouts, limit response bodies and SSE frames, redact header values, and hide raw error content from display messages. Tests cover these limits and error variants.

NeMo Relay plugin

Layer / File(s) Summary
Configuration and provider targets
crates/switchyard-nemo-relay-plugin/config.schema.json, crates/switchyard-nemo-relay-plugin/src/config.rs, crates/switchyard-nemo-relay-plugin/src/client.rs, crates/switchyard-nemo-relay-plugin/src/translation.rs
The plugin validates versioned configuration, secure URLs and headers, prepares routed provider clients, supports random and LLM-classifier routing, and translates requests and responses.
Executor and native host interop
crates/switchyard-nemo-relay-plugin/src/executor.rs, crates/switchyard-nemo-relay-plugin/src/ffi.rs
A dedicated Tokio executor runs plugin tasks. FFI helpers manage host resources, buffered and streaming calls, cancellation, backpressure, completion, and cleanup.
Routing and response runtime
crates/switchyard-nemo-relay-plugin/src/runtime.rs
The runtime decodes requests, performs routed calls and retries, emits routing metadata, handles fallback targets, propagates context, and encodes buffered or streaming responses.
Native registration and packaging
crates/switchyard-nemo-relay-plugin/src/lib.rs, crates/switchyard-nemo-relay-plugin/Cargo.toml, crates/switchyard-nemo-relay-plugin/relay-plugin.toml, crates/switchyard-nemo-relay-plugin/config.schema.json, crates/switchyard-nemo-relay-plugin/scripts/package_bundle.py
The crate registers native middleware, validates host ABI and configuration, forwards unmanaged protocols, and packages the dynamic library with its schema and manifest.
Documentation and workspace wiring
Cargo.toml, README.md, docs/index.md, crates/switchyard-nemo-relay-plugin/README.md, CHANGELOG.md
Workspace, project, package, and changelog documentation now reference the NeMo Relay plugin and its transport behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Poem

A rabbit checks the routes at night,
With tiny paws, the headers right.
Streams stay bounded, errors tame,
Relay hops through every frame.
“Ship the plugin!” the rabbit sings,
While Tokio hums on careful wings.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.71% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding a Switchyard-owned HTTP dynamic Relay plugin.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/nemo-relay-plugin-owned-http-client

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: 6

🧹 Nitpick comments (12)
crates/switchyard-nemo-relay-plugin/src/config.rs (2)

411-421: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Extend the sensitive-header check beyond the exact-match list.

is_sensitive_target_header uses a fixed denylist. A provider credential header outside that list, for example x-provider-token or openai-api-key, still passes validate_headers and gets stored as plaintext in Relay configuration. The guard at Line 92 exists to prevent exactly that.

Add a substring heuristic so unlisted credential headers also route through header_env.

🔒 Proposed change
 fn is_sensitive_target_header(name: &str) -> bool {
     matches!(
         name,
         "authorization"
             | "cookie"
             | "x-api-key"
             | "api-key"
             | "anthropic-api-key"
             | "x-goog-api-key"
     ) || name.contains("api-key")
         || name.contains("api_key")
         || name.contains("token")
         || name.contains("secret")
         || name.contains("password")
 }
🤖 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-nemo-relay-plugin/src/config.rs` around lines 411 - 421,
Update is_sensitive_target_header to retain the existing exact-match denylist
and also return true when the header name contains a credential-related
substring, such as “api-key” or “token,” so unlisted provider credential headers
are routed through header_env by validate_headers instead of stored as
plaintext.

700-726: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add a regression test for an invalid environment-supplied header value.

The tests cover an unset variable and invalid variable names. They do not cover validate_header(name, &value) at Line 129, which rejects a malformed value read from the environment, for example a value that contains a newline or a control character. That path blocks header injection into the provider request.

Add a case that sets a variable to an invalid value and asserts that prepare() fails.

Based on learnings from the coding guidelines: "Define verifiable success criteria, write regression tests for bugs and invalid inputs, and verify each implementation step."

🤖 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-nemo-relay-plugin/src/config.rs` around lines 700 - 726,
Add a regression test alongside
validation_does_not_resolve_environment_backed_headers and
invalid_environment_variable_names_are_rejected_before_resolution that sets the
referenced environment variable to a malformed header value, such as one
containing a newline or control character, then calls config.prepare() and
asserts it returns an error. Keep the existing variable-name validation coverage
unchanged and verify the failure identifies the invalid header value.

Source: Coding guidelines

crates/switchyard-nemo-relay-plugin/src/translation.rs (2)

60-68: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a test for the Anthropic JSON-schema rejection.

request_policy is the runtime backstop that stops a JSON-schema response format from reaching an anthropic_messages target. config.rs at Line 311 rejects only an Anthropic classifier target at configuration time, so this policy is the sole guard for a routed Anthropic target. No test covers it.

Add a case that builds an LlmRequest with a JSON-schema response format, then asserts validate_target_request fails for WireFormat::AnthropicMessages and succeeds for WireFormat::OpenAiChat.

As per coding guidelines: "Define verifiable success criteria, write regression tests for bugs and invalid inputs, and verify each implementation step."

🤖 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-nemo-relay-plugin/src/translation.rs` around lines 60 - 68,
Add a regression test covering request_policy’s JSON-schema capability
restriction: construct an LlmRequest using a JSON-schema response format, assert
validate_target_request rejects it for WireFormat::AnthropicMessages, and assert
validation succeeds for WireFormat::OpenAiChat.

Source: Coding guidelines


1-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two new modules in this crate have no //! module comment. client.rs states its intent at Line 4, but translation.rs and runtime.rs do not. The shared root cause is one missing convention pass over the new modules.

  • crates/switchyard-nemo-relay-plugin/src/translation.rs#L1-L11: add a //! comment stating that the module adapts Relay request and response bodies to Switchyard protocol types and applies the plugin translation policies.
  • crates/switchyard-nemo-relay-plugin/src/runtime.rs#L1-L19: add a //! comment stating that the module decodes inbound requests, drives the libsy algorithm with retries and a trusted fallback, and encodes buffered or streaming responses back to the host.

As per coding guidelines: "For Rust changes, document public items with /// comments and add concise comments for module intent, private helpers with non-obvious behavior, important tests, and complex validation, routing, configuration, async, lifecycle, or concurrency logic."

🤖 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-nemo-relay-plugin/src/translation.rs` around lines 1 - 11,
Add concise //! module documentation to
crates/switchyard-nemo-relay-plugin/src/translation.rs#L1-L11 describing
adaptation between Relay request/response bodies and Switchyard protocol types,
including application of plugin translation policies. Also document
crates/switchyard-nemo-relay-plugin/src/runtime.rs#L1-L19 with its role in
decoding inbound requests, driving libsy with retries and a trusted fallback,
and encoding buffered or streaming responses; no other changes are needed.

Source: Coding guidelines

crates/switchyard-nemo-relay-plugin/src/runtime.rs (2)

365-370: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use tracing instead of eprintln!.

This code runs inside a plugin loaded by the Relay host. eprintln! writes to raw stderr, so the message bypasses the host log pipeline and carries no level or structured fields. The workspace already uses tracing, for example the spans in crates/libsy-llm-client/src/client.rs.

Replace the call with tracing::warn!.

♻️ Proposed change
         if let Err(error) = parent.emit_mark(name, &data, metadata) {
-            eprintln!("Switchyard could not emit routing mark {name:?}: {error}");
+            tracing::warn!(mark = name, %error, "Switchyard could not emit routing mark");
         }
🤖 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-nemo-relay-plugin/src/runtime.rs` around lines 365 - 370,
Update the error handling in mark to replace the raw eprintln! call with
tracing::warn!, preserving the existing routing-mark error message and including
the name and error fields in the structured log.

565-601: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add unit tests for the retry and stream helpers.

This module holds the retry, fallback, and streaming state machine, and has one test that covers a pure helper. libsy_error_retryable, failure_mark_data, and returned_events need no Relay host and are testable now.

Cover at minimum:

  • libsy_error_retryable returns true for each listed status and false for 400, 401, and 404.
  • returned_events rejects an empty LlmResponse::Stream and preserves the first chunk otherwise.
  • failure_mark_data sets failure_kind to http, non_http, and algorithm for the three branches.

These tests would have caught the two defects flagged at Lines 134-197 and Lines 259-266.

As per coding guidelines: "Define verifiable success criteria, write regression tests for bugs and invalid inputs, and verify each implementation step."

🤖 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-nemo-relay-plugin/src/runtime.rs` around lines 565 - 601,
Extend the existing tests module with unit tests for the pure helpers
libsy_error_retryable, returned_events, and failure_mark_data. Verify
retryability for every listed status plus false for 400, 401, and 404; ensure
returned_events rejects an empty LlmResponse::Stream and retains the first chunk
for a non-empty stream; and assert failure_mark_data produces http, non_http,
and algorithm for its three branches without requiring a Relay host.

Source: Coding guidelines

crates/switchyard-nemo-relay-plugin/scripts/package_bundle.py (2)

16-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider hashlib.file_digest instead of the manual chunk loop.

The coding guidelines target Python 3.12+. hashlib.file_digest is available from 3.11 and removes the read loop.

♻️ Proposed change
 def digest(path: Path) -> str:
     """Return the lowercase SHA-256 digest for a file."""
-    value = hashlib.sha256()
-    with path.open("rb") as stream:
-        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
-            value.update(chunk)
-    return value.hexdigest()
+    with path.open("rb") as stream:
+        return hashlib.file_digest(stream, "sha256").hexdigest()
🤖 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-nemo-relay-plugin/scripts/package_bundle.py` around lines
16 - 22, Update the digest function to use hashlib.file_digest with the opened
file stream and SHA-256, replacing the manual chunk-reading loop while
preserving the lowercase hexadecimal digest returned by hexdigest().

Source: Coding guidelines


36-51: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Validate config.schema.json before the script copies files.

The script validates the library and the manifest placeholders before it mutates the output directory. It does not validate config.schema.json. If that file is missing, shutil.copy2 raises FileNotFoundError after the library copy already ran. The output directory is then partially populated and no longer empty, so a rerun fails the emptiness check.

♻️ Proposed change
     manifest = (CRATE_ROOT / "relay-plugin.toml").read_text(encoding="utf-8")
     placeholders = ("<platform-library-file>", "<artifact-sha256>")
     missing = [placeholder for placeholder in placeholders if placeholder not in manifest]
     if missing:
         parser.error(f"plugin manifest is missing placeholders: {', '.join(missing)}")
 
+    schema = CRATE_ROOT / "config.schema.json"
+    if not schema.is_file():
+        parser.error(f"plugin configuration schema does not exist: {schema}")
+
     output = args.output.resolve()
@@
     shutil.copy2(library, artifact)
-    shutil.copy2(CRATE_ROOT / "config.schema.json", output / "config.schema.json")
+    shutil.copy2(schema, output / "config.schema.json")
🤖 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-nemo-relay-plugin/scripts/package_bundle.py` around lines
36 - 51, Validate the existence and required file condition for
config.schema.json before creating or mutating the output directory in the
packaging flow. Update the logic around the existing manifest and library
validation, using the config.schema.json source path, so missing-file errors are
reported through parser.error before shutil.copy2 performs either copy; preserve
the existing output-directory checks and copy behavior otherwise.
crates/switchyard-nemo-relay-plugin/src/executor.rs (1)

113-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a bounded wait in this test.

receiver.recv() blocks forever if the spawned task never runs. The test then hangs CI instead of failing. The second test already uses recv_timeout. Apply the same pattern here.

♻️ Proposed change
-        assert_eq!(receiver.recv().unwrap(), "done");
+        assert_eq!(
+            receiver
+                .recv_timeout(std::time::Duration::from_secs(5))
+                .expect("spawned work must complete"),
+            "done"
+        );
🤖 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-nemo-relay-plugin/src/executor.rs` around lines 113 - 121,
Update executor_runs_buffered_and_spawned_work to replace the unbounded
receiver.recv() call with receiver.recv_timeout(), using the same bounded-wait
pattern and timeout established by the neighboring test.
crates/switchyard-nemo-relay-plugin/src/lib.rs (2)

365-401: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for an unsupported future version.

The tests cover version 1 and a non-integer version. The Some(version) branch for any other integer is untested. Add a case for version = 3 and assert the "unsupported Switchyard config version" message. The coding guidelines require regression tests for invalid inputs.

🤖 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-nemo-relay-plugin/src/lib.rs` around lines 365 - 401, Add a
regression test alongside
version_one_service_config_gets_a_migration_error_before_v2_deserialization and
version_must_be_an_integer that passes {"version": 3} to parse_config, asserts
parsing fails, and verifies the error contains the “unsupported Switchyard
config version” message.

Source: Coding guidelines


4-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add module intent comments to the new modules. The new crate omits module-level comments in two files. ffi.rs includes a //! comment; these two do not.

  • crates/switchyard-nemo-relay-plugin/src/lib.rs#L4-L9: add a crate-level //! comment that states the plugin's purpose and the host ABI it targets.
  • crates/switchyard-nemo-relay-plugin/src/executor.rs#L4-L11: add a //! comment that states why the plugin owns a dedicated Tokio runtime thread.

The coding guidelines require concise comments for module intent in crates/**/*.rs.

🤖 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-nemo-relay-plugin/src/lib.rs` around lines 4 - 9, Add
concise module-level intent comments at both affected sites: in
crates/switchyard-nemo-relay-plugin/src/lib.rs lines 4-9, add a crate-level //!
comment describing the plugin’s purpose and targeted host ABI; in
crates/switchyard-nemo-relay-plugin/src/executor.rs lines 4-11, add a //!
comment explaining why the plugin owns a dedicated Tokio runtime thread.

Source: Coding guidelines

crates/switchyard-nemo-relay-plugin/src/ffi.rs (1)

323-337: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider the wakeup cost of cancellation polling at high concurrency.

Each in-flight call adds one timer wakeup every 10 ms on the two-worker runtime. With thousands of concurrent calls this becomes a constant background load. The host table exposes no cancellation notification, so polling is reasonable now. Consider a backoff that starts short and grows to a longer interval, or make the interval configurable.

🤖 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-nemo-relay-plugin/src/ffi.rs` around lines 323 - 337,
Reduce cancellation polling overhead in wait_for_completion_cancellation and
wait_for_stream_cancellation by replacing the fixed CANCELLATION_POLL delay with
a short initial interval that backs off to a configurable or bounded maximum.
Preserve prompt cancellation detection while preventing thousands of in-flight
calls from waking every 10 ms indefinitely.
🤖 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-llm-client/src/client.rs`:
- Around line 285-306: The response handling tests need coverage for an
oversized successful response. Add a regression test in the existing client test
suite that returns a 2xx response whose body exceeds
MAX_BUFFERED_RESPONSE_BYTES, then assert the request fails with
LlmClientError::InvalidResponse while preserving the existing oversized
error-body tests.

In `@crates/switchyard-nemo-relay-plugin/src/ffi.rs`:
- Around line 368-423: Bound the Internal-status retry loops in push_stream and
reject_stream using the existing timing imports and a shared
MAX_BACKPRESSURE_WAIT duration near the other limits. Stop retrying once the
deadline is reached and return an error/status that lets the callback settle the
stream, while preserving cancellation handling and normal successful or
non-Internal responses.

In `@crates/switchyard-nemo-relay-plugin/src/runtime.rs`:
- Around line 372-392: The emit_decision method currently sends prompt-derived
decision.reasoning through ParentScope::emit_mark; constrain this field before
including it in the routing mark. Prefer truncating reasoning to a fixed maximum
length (or gate it behind an off-by-default configuration flag), while
preserving identifier-only metadata and existing decision fields.
- Around line 92-100: Add exponential backoff before retry iterations in the
routing retry loops, including both non-streaming and streaming paths such as
the visible retry branch and execute_stream. When the upstream failure includes
a Retry-After value, use that delay instead of the calculated backoff; otherwise
apply the existing retry-attempt count to compute an exponentially increasing
sleep before re-driving the request.
- Around line 259-266: Bound the outer event-processing loop in the runtime flow
around committed and retry handling so a pass that ends with committed == false
and no retry arm cannot restart indefinitely. Track whether a retry occurred
during the pass, or otherwise detect that no progress was made, and return an
appropriate error before re-entering the outer loop; preserve normal retry and
successful commitment behavior.
- Around line 134-197: Make the fallback_used binding mutable in the surrounding
request loop, and set it to true in the Err(failure) if !fallback_used arm
immediately before switching to the trusted fallback stream via
fallback_response. Preserve the existing retry and error handling for failures
that occur before fallback activation.

---

Nitpick comments:
In `@crates/switchyard-nemo-relay-plugin/scripts/package_bundle.py`:
- Around line 16-22: Update the digest function to use hashlib.file_digest with
the opened file stream and SHA-256, replacing the manual chunk-reading loop
while preserving the lowercase hexadecimal digest returned by hexdigest().
- Around line 36-51: Validate the existence and required file condition for
config.schema.json before creating or mutating the output directory in the
packaging flow. Update the logic around the existing manifest and library
validation, using the config.schema.json source path, so missing-file errors are
reported through parser.error before shutil.copy2 performs either copy; preserve
the existing output-directory checks and copy behavior otherwise.

In `@crates/switchyard-nemo-relay-plugin/src/config.rs`:
- Around line 411-421: Update is_sensitive_target_header to retain the existing
exact-match denylist and also return true when the header name contains a
credential-related substring, such as “api-key” or “token,” so unlisted provider
credential headers are routed through header_env by validate_headers instead of
stored as plaintext.
- Around line 700-726: Add a regression test alongside
validation_does_not_resolve_environment_backed_headers and
invalid_environment_variable_names_are_rejected_before_resolution that sets the
referenced environment variable to a malformed header value, such as one
containing a newline or control character, then calls config.prepare() and
asserts it returns an error. Keep the existing variable-name validation coverage
unchanged and verify the failure identifies the invalid header value.

In `@crates/switchyard-nemo-relay-plugin/src/executor.rs`:
- Around line 113-121: Update executor_runs_buffered_and_spawned_work to replace
the unbounded receiver.recv() call with receiver.recv_timeout(), using the same
bounded-wait pattern and timeout established by the neighboring test.

In `@crates/switchyard-nemo-relay-plugin/src/ffi.rs`:
- Around line 323-337: Reduce cancellation polling overhead in
wait_for_completion_cancellation and wait_for_stream_cancellation by replacing
the fixed CANCELLATION_POLL delay with a short initial interval that backs off
to a configurable or bounded maximum. Preserve prompt cancellation detection
while preventing thousands of in-flight calls from waking every 10 ms
indefinitely.

In `@crates/switchyard-nemo-relay-plugin/src/lib.rs`:
- Around line 365-401: Add a regression test alongside
version_one_service_config_gets_a_migration_error_before_v2_deserialization and
version_must_be_an_integer that passes {"version": 3} to parse_config, asserts
parsing fails, and verifies the error contains the “unsupported Switchyard
config version” message.
- Around line 4-9: Add concise module-level intent comments at both affected
sites: in crates/switchyard-nemo-relay-plugin/src/lib.rs lines 4-9, add a
crate-level //! comment describing the plugin’s purpose and targeted host ABI;
in crates/switchyard-nemo-relay-plugin/src/executor.rs lines 4-11, add a //!
comment explaining why the plugin owns a dedicated Tokio runtime thread.

In `@crates/switchyard-nemo-relay-plugin/src/runtime.rs`:
- Around line 365-370: Update the error handling in mark to replace the raw
eprintln! call with tracing::warn!, preserving the existing routing-mark error
message and including the name and error fields in the structured log.
- Around line 565-601: Extend the existing tests module with unit tests for the
pure helpers libsy_error_retryable, returned_events, and failure_mark_data.
Verify retryability for every listed status plus false for 400, 401, and 404;
ensure returned_events rejects an empty LlmResponse::Stream and retains the
first chunk for a non-empty stream; and assert failure_mark_data produces http,
non_http, and algorithm for its three branches without requiring a Relay host.

In `@crates/switchyard-nemo-relay-plugin/src/translation.rs`:
- Around line 60-68: Add a regression test covering request_policy’s JSON-schema
capability restriction: construct an LlmRequest using a JSON-schema response
format, assert validate_target_request rejects it for
WireFormat::AnthropicMessages, and assert validation succeeds for
WireFormat::OpenAiChat.
- Around line 1-11: Add concise //! module documentation to
crates/switchyard-nemo-relay-plugin/src/translation.rs#L1-L11 describing
adaptation between Relay request/response bodies and Switchyard protocol types,
including application of plugin translation policies. Also document
crates/switchyard-nemo-relay-plugin/src/runtime.rs#L1-L19 with its role in
decoding inbound requests, driving libsy with retries and a trusted fallback,
and encoding buffered or streaming responses; no other changes are needed.
🪄 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: 3570ba9b-fc47-4a61-b0ff-3d3a77c017b1

📥 Commits

Reviewing files that changed from the base of the PR and between 091bc89 and 58b3186.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (22)
  • CHANGELOG.md
  • Cargo.toml
  • README.md
  • crates/libsy-llm-client/README.md
  • crates/libsy-llm-client/src/backend.rs
  • crates/libsy-llm-client/src/client.rs
  • crates/libsy/src/core/algorithm.rs
  • crates/protocol/src/client.rs
  • crates/switchyard-nemo-relay-plugin/Cargo.toml
  • crates/switchyard-nemo-relay-plugin/README.md
  • crates/switchyard-nemo-relay-plugin/config.schema.json
  • crates/switchyard-nemo-relay-plugin/relay-plugin.toml
  • crates/switchyard-nemo-relay-plugin/scripts/package_bundle.py
  • crates/switchyard-nemo-relay-plugin/src/client.rs
  • crates/switchyard-nemo-relay-plugin/src/config.rs
  • crates/switchyard-nemo-relay-plugin/src/executor.rs
  • crates/switchyard-nemo-relay-plugin/src/ffi.rs
  • crates/switchyard-nemo-relay-plugin/src/lib.rs
  • crates/switchyard-nemo-relay-plugin/src/runtime.rs
  • crates/switchyard-nemo-relay-plugin/src/translation.rs
  • crates/switchyard-translation/src/helpers.rs
  • docs/index.md

Comment thread crates/libsy-llm-client/src/client.rs Outdated
Comment thread crates/switchyard-nemo-relay-plugin/src/ffi.rs
Comment thread crates/switchyard-nemo-relay-plugin/src/runtime.rs
Comment thread crates/switchyard-nemo-relay-plugin/src/runtime.rs Outdated
Comment thread crates/switchyard-nemo-relay-plugin/src/runtime.rs
Comment thread crates/switchyard-nemo-relay-plugin/src/runtime.rs
@bbednarski9
bbednarski9 force-pushed the feat/nemo-relay-plugin-owned-http-client branch from 3dcee4d to 1815a45 Compare August 4, 2026 02:06
@bbednarski9

Copy link
Copy Markdown
Contributor Author

switchyard-rust-review evidence

I reviewed PR #270 at 3dcee4d6 against main at a9c04b31, following the repository's switchyard-rust-review skill. I completed separate passes for correctness, async/cancellation behavior, streaming and fallback state, protocol/HTTP boundaries, security and credential handling, allocation/dependency choices, comments/naming, tests, and then a second focused pass over every changed hunk.

Verdict: changes requested

High

  1. Managed calls can exhaust Relay's normal Tokio worker pool. PluginExecutor::run synchronously waits on an mpsc receiver, and SwitchyardStream::next blocks in recv_blocking. Relay 0.7 invokes these safe native callbacks on its async runtime workers, not its blocking pool. Enough slow buffered calls or idle streams can therefore occupy every worker and stall unrelated gateway work and disconnect processing. The README accurately documents the limitation and TOKIO_WORKER_THREADS mitigation, but pool sizing does not restore cancellation or non-blocking behavior. This needs explicit maintainer acceptance as a production limitation, or an async/yielding SDK boundary before broad deployment.

  2. A fallback stream can invoke trusted fallback twice. When returned_events rejects the initially selected response, the branch at runtime.rs:175-205 replaces it with a fallback response but leaves fallback_used as false. If that fallback stream then fails before its first event, the !fallback_used guards at runtime.rs:219-256 call the trusted fallback a second time. Model fallback as mutable/explicit state and add a regression test with an invalid selected stream followed by a pre-commit fallback-stream failure.

  3. Buffered final-response translation failures bypass trusted fallback. The success branch returns translation::encode_response(...) directly at runtime.rs:95-100; only errors from drive reach the fallback branch at runtime.rs:102-120. A selected cross-protocol response that cannot be represented losslessly therefore fails the outer call even though no caller response has been committed and a same-protocol trusted fallback is configured. Feed final encode failures through the same error/fallback state machine and test an unsupported cross-protocol response feature.

Medium

  1. Streaming error/fallback marks are lost if the fallback HTTP call itself fails. The code records routing.error, and fallback_response records routing.fallback, but both are only flushed after the awaited fallback returns successfully at runtime.rs:159-173. The ? path sends only StreamMessage::Error, so the resulting trajectory omits the real routing failure and fallback attempt. Ensure accumulated marks are flushed on every terminal path, including fallback setup/HTTP failure.

  2. Plugin transport policy silently changes every existing TranslatingLlmClient consumer. The PR adds fixed global connect/read timeouts in client.rs:54-56 and applies them in the only public constructor at client.rs:98-113, alongside new global response-size and redirect behavior. switchyard-server and other library users call this same constructor, so a plugin-specific safety policy can now terminate an existing stream after 120 seconds without any configuration or opt-out. Preserve prior shared-client behavior and inject/configure the stricter policy for the plugin, or explicitly approve and document this as a crate-wide contract change.

  3. The routing state machine has no checked-in behavioral regression coverage. The 641-line runtime's only test is the metadata-copy test at runtime.rs:605-640; there is no crate tests/ suite that loads the produced cdylib through Relay. Config/helper unit tests and manual E2E evidence do not protect retry reselection, exactly-once fallback, stream commitment, late errors, routing marks, or the SDK/manifest loading boundary in future changes. Add focused runtime tests for the branches above and an automated dynamic-load smoke test using the published Relay 0.7 surface.

Low

  1. Mark-emission failures bypass structured tracing. emit_mark writes directly with eprintln!, which cannot be filtered or correlated and may interleave under concurrency. Use a structured tracing event with the mark name and error as fields.

Positive checks

  • Production code contains no unwrap/expect calls and no custom raw C/FFI adapter; the plugin uses Relay's typed Rust SDK.
  • The provider client clears inbound transport headers before target dispatch, validates target URLs/headers, rejects redirects, bounds response/error data, and redacts configured header values from Debug.
  • Streaming uses a bounded 32-message channel and aborts unfinished producer work when the iterator can be dropped.
  • The source changes are clean under git diff --check.

Validation performed at this head

  • cargo test -p switchyard-translation -p switchyard-llm-client -p switchyard-nemo-relay-plugin — passed.
  • cargo fmt --all -- --check — passed.
  • cargo clippy --workspace --all-targets -- -D warnings — passed.
  • GitHub's aggregate CI Success check is green at 3dcee4d6, including the Ubuntu workspace test job.

Release condition already documented by the PR: replace the development nemo-relay-plugin = 0.7.0-rc.4 dependency and manifest lower bound with stable 0.7.0 before publication.

@bbednarski9

Copy link
Copy Markdown
Contributor Author

comment#1: We documented the limitation in f28309b and clarified it in f035e0d

The durable fix requires an async/yielding Relay SDK surface. This is out of scope for Relay 0.7. If it proves to be an issue after initial integration, we can upstream to Relay

Comment thread crates/switchyard-nemo-relay-plugin/src/runtime.rs
Comment thread crates/switchyard-nemo-relay-plugin/src/config.rs Outdated
@afourniernv

Copy link
Copy Markdown

Blocking follow-up: typed callback refactor causes worker starvation and drops transport cancellation

I validated the existing worker-starvation concern against the current head (c69a8b68) and the immediate completion-based async parent (0319bc33). This is observable runtime behavior, not only a theoretical limitation.

Relay worker starvation

The typed callback refactor in f28309bd changed buffered calls to wait in mpsc::Receiver::recv() and streams to wait in recv_blocking(). Those waits occupy Relay's normal Tokio workers.

With a 500 ms provider:

  • 4 Relay workers / 3 concurrent routed calls: unrelated managed tool latency was about 0.27 ms.
  • 4 Relay workers / 4 concurrent routed calls: unrelated tool latency was about 504 ms.
  • 8 Relay workers / 8 concurrent routed calls: unrelated tool latency was about 509 ms.
  • Immediate async parent at 8 workers / 8 calls: unrelated tool latency was about 1.45 ms.

A separate four-call test with a 1 second provider delayed unrelated work by about 1.008 seconds on current head versus 0.632 ms on the async parent. The threshold is the Relay worker count, so increasing TOKIO_WORKER_THREADS moves rather than removes the failure point.

Provider cancellation regression

Caller cancellation returns promptly, but current-head buffered and streaming requests remain open at the provider and continue until response or timeout. In repeated buffered and streaming tests, the current implementation never closed the provider connection before its delayed response; the async parent closed it promptly every time.

That can leave orphaned, potentially billable provider work after Hermes has cancelled the call.

I think both behaviors need one fix: use a completion-based async Relay boundary rather than synchronously waiting inside the typed callback. Relay already has the raw async ABI; I opened NVIDIA/NeMo-Relay#716 to track a safe typed wrapper. For this PR, the choices are to retain the earlier raw async implementation or coordinate the typed async SDK surface before landing the synchronous refactor.

Nonblocking observability follow-up: physical provider and classifier attempts currently have no nested Relay lifecycle. I filed #299 so that requirement can be decided separately from this blocker.

@afourniernv afourniernv 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.

Requesting changes for the worker-starvation and provider-cancellation regressions documented in my review comment:

#270 (comment)

The current typed callback implementation synchronously waits on Relay's Tokio workers. Controlled testing reproduced unrelated-work latency at the provider-delay boundary once routed concurrency reached the worker count. It also confirmed that cancelling buffered or streaming calls no longer closes the upstream provider request, unlike the immediate completion-based async parent.

Please retain a completion-based async boundary for this plugin or coordinate the safe typed async SDK work tracked in NVIDIA/NeMo-Relay#716 before landing the synchronous refactor.

@afourniernv

Copy link
Copy Markdown

Stable Relay 0.7.0 is now published. I updated the SDK pin, manifest compatibility floor, README, and Cargo.lock and opened a focused PR directly against this PR's head branch: bbednarski9#1

Validation: all 31 switchyard-nemo-relay-plugin tests pass with nemo-relay-plugin and nemo-relay-types 0.7.0.

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@bbednarski9
bbednarski9 force-pushed the feat/nemo-relay-plugin-owned-http-client branch from 2ab971c to 6db7275 Compare August 10, 2026 18:25
@afourniernv

afourniernv commented Aug 11, 2026

Copy link
Copy Markdown

Review note for the Relay 0.8 follow-up:

This PR currently targets Relay 0.7 and therefore implements asynchronous native middleware through the raw completion-based ABI. Relay #751 adds typed, Future-returning native Rust middleware on an SDK-owned Tokio executor. Once that API is released, a separate Switchyard update should be able to remove most of src/ffi.rs, much of the raw callback machinery in src/lib.rs, and the dedicated runtime in src/executor.rs. Relay would then own completion settlement, cancellation propagation, callback state, and executor lifecycle.

I would keep that migration separate rather than pin this PR to unpublished Relay code.

For the current implementation, I would rank the review burden as follows:

  1. FFI and callback lifecycle: unsafe pointers, host-table compatibility, completion ownership, cancellation races, panic containment, and exactly-once release of strings, continuations, streams, and completion handles. Errors here can cause crashes or memory corruption.
  2. Streaming lifecycle: downstream stream opening, backpressure, caller cancellation, partial-response commitment, shutdown, and avoiding leaked work.
  3. Retry and fallback behavior: avoiding duplicate provider calls, preserving routing marks, and distinguishing failures before versus after response commitment.
  4. Translation and credential handling: maintaining caller/provider protocol fidelity, rewriting the selected model correctly, and ensuring caller authorization headers never reach configured provider targets.
  5. Configuration and routing construction: substantial code, but mostly safe Rust with schema and constructor validation.

The underlying libsy routing algorithms are already exercised elsewhere. The native plugin boundary is the hardest and highest-risk review surface. Relay #751 should remove or abstract most of item 1 and the raw-ABI portion of item 2, materially reducing the review burden rather than only improving syntax.

@bbednarski9

Copy link
Copy Markdown
Contributor Author

Current status:

Category Lines Share
Production Rust 2,693 34.2%
Rust tests 1,894 24.1%
Generated Cargo.lock 2,483 31.6%
README/schema/manifests/script/root docs 796 10.1%
Total 7,866 100%

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@bbednarski9

bbednarski9 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

LOC snapshot

Point-in-time breakdown for PR #270 at e48563a. This revision adds 7,614 lines with no deletions.

Category Lines Share
Production Rust 2,590 34.0%
Rust tests 1,785 23.4%
Generated Cargo.lock 2,483 32.6%
README/schema/manifests/script/root docs 756 9.9%
Total 7,614 100%

Rust LOC by module:

Module Production Tests Total
runtime 725 910 1,635
config 585 547 1,132
client 236 233 469
ffi 444 0 444
lib 405 37 442
executor 108 29 137
translation 87 29 116
Total Rust 2,590 1,785 4,375

The remaining 756 lines are 412 lines of documentation/changelog, 282 lines of schema/manifests/Cargo metadata, and 62 lines in the packaging script.

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@bbednarski9

Copy link
Copy Markdown
Contributor Author

Alternative design: Relay 0.8 typed async middleware

NVIDIA/NeMo-Relay#751 is now merged and provides the typed asynchronous native middleware surface that was unavailable when this implementation was written.

Adopting Relay 0.8 would let this plugin remove both src/ffi.rs and src/executor.rs:

  • Typed register_llm_execution_intercept and register_llm_stream_execution_intercept replace raw callback registration and callback-state ownership.
  • LlmNext::call and LlmStreamNext::call replace the custom continuation adapters.
  • The SDK manages host-string conversion, completion and stream handles, cancellation, backpressure, panic isolation, and cleanup.
  • PluginRuntime::emit_mark replaces ParentScope and raw mark emission. Relay restores the captured scope while polling the registered future and returned stream.
  • Relay’s SDK-owned NativeExecutor replaces the plugin’s dedicated Tokio runtime. Its default is two worker threads, matching the current plugin executor.

The managed streaming implementation would retain a small internal bridge because SwitchyardRuntime::execute_stream currently produces StreamMessage values through a bounded channel. The typed callback can spawn that producer on Relay’s executor and return a channel-backed LlmJsonAsyncStream. Event messages become stream items, while mark messages are emitted through PluginRuntime::emit_mark.

Marks must be emitted while the returned stream is being polled, rather than from the spawned producer task, because Relay restores scope context around the registered future and stream but does not automatically propagate it into arbitrary tokio::spawn children. The returned stream should also abort its producer when dropped so caller cancellation stops outstanding Switchyard work.

Expected simplification:

  • Delete all 444 lines of ffi.rs.
  • Delete all 137 lines of executor.rs.
  • Remove most raw ABI imports, callback trampolines, manual handle management, cancellation loops, and panic handling from the current 442-line lib.rs.
  • Add only the typed buffered/stream registrations and a small managed-stream adapter.
  • Expected net reduction: approximately 650–750 lines.

Compatibility changes would be limited to:

  • nemo-relay-plugin = "=0.8.0"
  • compat.relay = ">=0.8.0,<1.0"
  • Keep native_api = "1".
  • Optionally expose Relay’s standard executor.worker_threads configuration; retaining the two-worker default preserves current behavior.

Validation should cover managed and unmanaged buffered/streaming calls, routing-mark parentage, cancellation and producer shutdown, and loading the packaged plugin through Relay 0.8.

This appears to be the preferred design now that the typed async SDK is available: it preserves the asynchronous and cancellation behavior required by this plugin while moving generic native-host ownership and executor concerns back into Relay.

@bbednarski9

Copy link
Copy Markdown
Contributor Author

Complete plugin configuration support matrix

The following is illustrative pseudo-TOML, intended to show the complete configuration surface in one place. It is not directly parseable as written:

  • OptionA || OptionB means choose exactly one value.
  • <value> || omit means the field is optional.
  • Choose exactly one of the four [plugins.dynamic.config.algorithm] alternatives below; do not combine them.
  • Target names are semantic map keys and may be reused by the selected algorithm and default_targets.
# Relay host configuration. This outer version is Relay's plugin-config version.
version = 1

[[plugins.dynamic]]
manifest = "/opt/switchyard-relay-plugin/relay-plugin.toml"

[plugins.dynamic.config]
# This inner version is the Switchyard plugin configuration version.
version = 2
priority = <integer>                         # default: 0
max_retries = <integer from 0 through 10>    # default: 3; each retry starts a fresh libsy run

# Each present key enables that caller protocol and names its trusted fallback.
# Omit a key to leave that inbound protocol unmanaged by this plugin.
[plugins.dynamic.config.default_targets]
openai_chat = "chat_fallback" || omit
openai_responses = "responses_fallback" || omit
anthropic_messages = "anthropic_fallback" || omit

# Repeat this target table under a unique semantic name for every serving or
# judge target referenced above or by the selected algorithm.
[plugins.dynamic.config.targets.<semantic_target_name>]
model = "<provider model id>"
protocol = "openai_chat" || "openai_responses" || "anthropic_messages"
base_url = "http://<provider>" || "https://<provider>"
endpoint = "" || "/<canonical protocol endpoint>" || omit
weight = 0 || <positive number> || omit       # default: 1; used by random only; 0 is fallback-only
drop_caller_extra_body = true || false || omit # default: false
extra_body = { <non-secret provider defaults> } || omit

# header_env is the only supported custom-header source. Values name
# environment variables; they are not literal header values.
[plugins.dynamic.config.targets.<semantic_target_name>.header_env]
authorization = "PROVIDER_AUTHORIZATION" || omit
<another_header_name> = "<ENVIRONMENT_VARIABLE_NAME>" || omit

Algorithm Option A: seeded weighted random

[plugins.dynamic.config.algorithm]
kind = "random"
seed = <nonnegative integer> || omit

# Selection weights come from each target's `weight` field. At least one target
# must have a positive weight. A zero-weight target remains available as a
# trusted fallback but is excluded from random selection.

Algorithm Option B: capability classifier

[plugins.dynamic.config.algorithm]
kind = "llm_classifier"
mode = "capability" || omit                  # omitted mode defaults to capability
classifier_target = "judge"
weak_target = "weak"
strong_target = "strong"
base_threshold = <number from 0 through 1>
threshold_step = <nonnegative number> || omit # default: 0; base + 2*step must be <= 1
recent_turn_window = <nonnegative integer> || omit
max_output_tokens = <positive integer> || omit # default: 4096
prompt = "<custom judge prompt>" || omit
session_affinity = true || false || omit       # default: false
message_hash_fallback = true || false || omit  # default: false

Algorithm Option C: escalation classifier

[plugins.dynamic.config.algorithm]
kind = "llm_classifier"
mode = "escalation"
classifier_target = "judge"
weak_target = "weak"
strong_target = "strong"
prompt = "<custom escalation-judge prompt>" || omit
max_output_tokens = <positive integer> || omit # default: 4096

[plugins.dynamic.config.algorithm.escalation]
confirmations = <integer >= 1> || omit          # default: 2
recent_turn_window = <integer >= 1> || omit     # default: 28
window_message_chars = <integer >= 50> || omit  # default: 500

# confirmations > 1 requires a stable Switchyard session ID for the streak and
# strong-target latch to persist across turns.

Algorithm Option D: stage router

[plugins.dynamic.config.algorithm]
kind = "stage_router"
capable_target = "strong"
efficient_target = "weak"
picker = "capable_first" || "efficient_first"
confidence_threshold = <number from 0 through 1>
recent_turn_window = <nonnegative integer> || omit
capable_system_prompt = "<capable-tier prompt>" || omit
efficient_system_prompt = "<efficient-tier prompt>" || omit

# The complete handoff_notes table is optional. If present, escalation_note is required.
[plugins.dynamic.config.algorithm.handoff_notes]
escalation_note = "<note sent to the capable tier>"
deescalation_note = "<note sent to the efficient tier>" || omit
only_on_wrong_signal_escalation = true || false || omit # default: true

# The complete classifier table is optional and is used only when tool signals
# are ambiguous. Without it, the configured picker supplies the default tier.
[plugins.dynamic.config.algorithm.classifier]
target = "judge"
base_threshold = <number from 0 through 1>
threshold_step = <nonnegative number> || omit # default: 0; base + 2*step must be <= 1
recent_turn_window = <nonnegative integer> || omit
prompt = "<custom fallback-classifier prompt>" || omit
max_output_tokens = <positive integer> || omit # default: 4096

Protocol and execution behavior represented by this configuration:

Area Supported values / behavior
Caller protocol openai_chat || openai_responses || anthropic_messages, enabled by the corresponding default_targets key
Ordinary serving-target protocol openai_chat || openai_responses || anthropic_messages
Structured-output judge protocol openai_chat || openai_responses; anthropic_messages is rejected during static loading
Buffered calls Supported by all four router modes
Streaming calls random: direct; capability: after classification; escalation: an unlatched weak stream is buffered while judging; stage router: after cascade selection
Tool behavior Random passes through tools; both classifiers sanitize judge tool history; stage router consumes native tool signals and sanitizes optional-classifier context
Routing-only usage Classifier judges, discarded escalation candidates, optional stage judges, and failed/replaced candidates emit ATOF usage marks as applicable

Current known limitation: openai_responses is accepted as a structured-output judge target, but the shared Responses encoder currently produces the wrong text.format JSON Schema shape. Until that Switchyard encoder issue is fixed, use protocol = "openai_chat" for classifier_target and the stage router's optional classifier target. OpenAI Responses remains supported for caller input and ordinary serving targets.

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@cjagwani

Copy link
Copy Markdown

I reproduced the current Rust failure at PR head 322461d99f522e23f51df6b4d08bb4705a47879d on a synthetic merge with current main (48b3b71d3cc629aa9eb011852f5a7da90957ba22).

The break is the #373 ModelId migration. A correct compatibility update needs more than wrapping semantic aliases such as weak and strong in ModelId:

  • TargetClient and PreparedTargetBinding retain the configured provider ModelId;
  • algorithm target aliases resolve to that provider ID;
  • stage-router prompts use the resolved IDs;
  • ClientRouter is keyed by those same provider IDs;
  • config rejects two callable aliases that resolve to the same provider ID, avoiding silent client-map overwrite;
  • tests cover alias-to-provider dispatch and duplicate rejection.

A local six-file patch (README.md, client.rs, config.rs, config/tests.rs, runtime.rs, and runtime/tests.rs) is green:

  • cargo fmt --all -- --check
  • git diff --check
  • cargo check -p switchyard-nemo-relay-plugin --all-targets --locked
  • cargo test -p switchyard-nemo-relay-plugin --locked — 49/49
  • bundle packager test — 1/1

I have not pushed to the contributor branch. @bbednarski9, I can provide the exact patch if useful, or a maintainer can apply this narrow migration while updating the branch from main.

Two non-code release gates remain for downstream canonical consumers:

  1. the current head appears to restore completion-based async execution and cancellation after the earlier review, but the active changes-requested review still needs a current-head re-review;
  2. the crate is publish = false, and the existing publish workflow does not yet produce versioned, checksummed Linux plugin bundles. NemoClaw will need official amd64/arm64 bundle assets rather than a source build or PR-head overlay.

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.

6 participants