Our first integration is with NeMo-Relay. It needs a lot of things that switchyard-server already has, so conceptually we want to do this:
HTTP JSON ──> switchyard-server ──┐
├──> switchyard-runner ──> libsy + provider clients
Relay JSON ─> NeMo plugin ────────┘
Both adapters decode into switchyard_protocol::Request, invoke the same runner, and encode the returned Response.
This plan would introduce new crate switchyard-runner with these key types:
AlgorithmSpec → Route → Runner
(what to build) (run it) (named table, server only)
The server code would then look like this:
// Server: TOML → Routes → table
let runner = Runner::load(path)?;
let route = runner.route(&model)?;
route.check_caller_format(wire_format)?;
route.execute(request, Some(observer)).await?;
The plugin would look like this:
let algorithm = AlgorithmSpec::build(...from plugin config...);
let route = Route::new(algorithm, clients);
route.execute(request, Some(observer)).await?;
At setup it reads the Relay JSON config, builds one Route and hold its on SwitchyardRuntime. At intercept time it translates Relay request to switchyard-translation request and calls route.execute.
The full plan follows.
Plan: Extract switchyard-runner as shared routing code
Context
The NeMo-Relay plugin mentioned in this plan is in a different branch. Those files will not
be visible right now. Our goal here is to extract switchyard-runner from switchyard-server
to make the NeMo-Relay plugin much simpler when we work on that.
Why
switchyard-server and the NeMo-Relay plugin are two serving surfaces over one routing engine.
Today they each own a private copy of the configured-routing layer.
The duplication is concentrated in one place — turning a declarative algorithm description into an
Arc<dyn Algorithm>, and then driving it. switchyard-server/src/config.rs:999-1271 and
switchyard-nemo-relay-plugin/src/config.rs:361-479 are the same function written twice against two
schemas. Everything around that (HTTP framing, relay marks, statistics, fallbacks) is genuinely
surface-specific and should stay where it is.
The goal is therefore not to refactor switchyard-server. It is to create a shared crate that both
surfaces execute through, so each becomes a thin adapter:
HTTP JSON -> switchyard-server: axum, headers, decode -\
>-> switchyard_protocol::Request -> switchyard-runner
Relay JSON -> plugin: relay hooks, marks, decode -/
Goal
Create switchyard-runner owning the schema-neutral algorithm builder, the configured route
execution unit, and the server's TOML deployment loader. Preserve switchyard-server behavior and
public entry points.
Branch scope
This work lands on a branch that does not contain
crates/switchyard-nemo-relay-plugin. The plugin cannot be compiled or tested here.
That has one consequence the implementer must respect: the plugin's requirements are stated below as
hard design constraints, not as aspirations to be settled later. They are cheap to satisfy now
and expensive to retrofit. Section "Plugin fitness constraints" is normative, and section
"Plugin adoption sketch" records what the follow-up branch will do so a reviewer can judge whether
the API actually fits.
Success criteria
Sharing (the point of the work)
AlgorithmSpec is public, Deserialize, and has no concept of route ids, model capabilities,
[llm_clients], base URLs, or credentials.
AlgorithmSpec resolves configured target names through a caller-supplied name-to-ModelId
map, so one caller can map names to provider model IDs and another can map names to themselves.
AlgorithmSpec variant fields are public, so a caller can build a spec programmatically instead
of only through serde.
Route executes without a route table, a route model ID, or a TOML file. A unit test proves this
by constructing and executing a single Route the way the plugin will.
switchyard-server's copy of algorithm construction is deleted, not duplicated.
Preservation
switchyard-runner has no dependency on Axum, axum-server, Prometheus, OpenTelemetry, TLS,
nemo-relay-plugin, or server lifecycle code.
switchyard-server stores a Runner instead of its own route map, configured clients, and
retained deployment config.
- The OpenAI Chat, OpenAI Responses, Anthropic Messages, decision, token-counting, and model-list
endpoints preserve their current behavior.
- Existing public
switchyard-server APIs continue to compile: ServerState::new,
ServerState::models, ServerState::caller_auth_kind, ServerState::with_routing_log,
build_switchyard_router, BoundServer, and config::load_server_state. switchyard-py
(crates/switchyard-py/src/server_bindings.rs) uses several of these and must not change.
- Existing server tests pass without weakening assertions, except for the one sanctioned message
change in "Splitting RouteConfig".
Route::execute accepts the existing RunObserver and forwards it unchanged, so callers retain
routing-call, answer-call, latency, usage, and routing-overhead visibility.
- Streaming responses remain lazy. The runner does not buffer, poll, spawn, or proxy the returned
stream.
Non-goals
- Do not change the version-1 deployment TOML schema or its schema version.
- Do not change when environment variables are resolved, or add a secret-resolver abstraction.
- Do not add fallback, retry, routing, or target behavior.
- Do not move request/response translation, HTTP metadata parsing, SSE framing, Prometheus,
statistics, usage accounting, routing logs, or request spans into the runner.
- Do not share client/backend construction in this change. See "Deliberately not shared".
- Do not reorganize unrelated
switchyard-server modules or refactor switchyard-llm-client::run.
Crate layout
crates/switchyard-runner/
├── Cargo.toml
└── src/
├── lib.rs # module declarations and public re-exports
├── algorithm.rs # AlgorithmSpec: schema-neutral algorithm description and builder
├── route.rs # Route, RunOutput, ModelCapabilities, CallerAuthKind, RunnerError
├── runner.rs # Runner: named route table, model metadata, decision description
└── config.rs # version-1 TOML deployment loader (moved from switchyard-server)
algorithm.rs and route.rs are the shared layer. config.rs and runner.rs serve the server
today; the plugin uses algorithm.rs and route.rs only.
Dependencies
switchyard-libsy, switchyard-llm-client, switchyard-protocol, serde, serde_json, toml,
reqwest (for the existing validated URL type), tracing. Test-only additions as needed.
Tokio is not a direct production dependency merely because public methods are async. Verify with
cargo tree -p switchyard-runner that server-only dependencies are absent.
Layer 1 — algorithm.rs
This is the shared code that motivates the change.
/// A routing algorithm described by configured target *names*, independent of any
/// deployment schema, transport, or client construction.
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum AlgorithmSpec {
Noop {},
Random { /* targets, weights, seed */ },
Passthrough { /* target, subagent_classifier */ },
LlmClassifier { /* classifier_target, mode, strong/weak, thresholds, escalation, custom */ },
StageRouter { /* capable_target, efficient_target, picker, threshold, prompts, classifier */ },
Advisor { /* executor_target, advisor_target, gate settings */ },
}
impl AlgorithmSpec {
/// Completion destinations in algorithm order. Judge-only targets are excluded.
pub fn routing_target_names(&self) -> Vec<&str>;
/// Every target the algorithm may call, including judge-only targets.
pub fn callable_target_names(&self) -> Vec<&str>;
/// Builds the algorithm, resolving each configured target name through `targets`
/// to the `ModelId` the caller's `ClientRouter` is keyed by.
///
/// `context` labels the caller's unit of configuration (a route name for the
/// server) and appears in error messages. A name absent from `targets` is an
/// `AlgorithmConfigError`.
pub fn build(
&self,
context: &str,
targets: &BTreeMap<String, ModelId>,
) -> Result<Arc<dyn Algorithm>, AlgorithmConfigError>;
}
The targets map is what makes this shareable. switchyard-server passes the map it already
builds in ServerConfig::build_targets — target name to TargetConfig::id. The plugin passes an
identity map over its own target names, one line at construction. Neither keying policy leaks into
the shared crate, and the "unknown target" error is produced uniformly inside the builder rather
than by each caller.
Move these into algorithm.rs verbatim from switchyard-server/src/config.rs: the algorithm half of
RouteConfig (lines 418-560), routing_target_names / callable_target_names /
classifier_mode (lines 624-901), ClassifierPolicyConfig, ClassifierMode,
LlmClassifierModeConfig, CapabilityClassifierRouteConfig, EscalationClassifierRouteConfig,
CustomClassifierRouteConfig, AdvisorTriggerConfig, StageClassifierConfig,
reject_custom_fields, classifier_field_error, required_classifier_field, build_algorithm
(lines 999-1271), classifier_contract, default_classifier_max_output_tokens, tier_prompts,
default_max_reviews, default_advisor_max_tokens, default_transcript_max_chars,
default_fail_open, and the target-resolution helpers resolve_targets and
resolve_target_model_id (lines 1316-1337). Because build keeps today's &BTreeMap<String, ModelId> parameter, build_algorithm's body moves unchanged apart from matching on &self instead
of &RouteConfig.
AlgorithmConfigError reproduces the server's current message formats verbatim, parameterized by
context — for example "route {context} references unknown target {name}",
"random route {context}: {error}", "llm_classifier route {context} requires {field}". The
server passes the route name, so its configuration error text is unchanged.
The stage_router capable_first experimental warning moves with the builder.
Splitting RouteConfig
The server's [routes.*] table is one flat, internally tagged table that mixes route presentation
(id, context_window, tool_calling, reasoning) with algorithm fields. The schema must not
change, so the server keeps a RouteConfig that holds the four presentation fields plus an
AlgorithmSpec.
Do not use #[serde(flatten)] for this. flatten silently disables deny_unknown_fields, and
the server's configuration tests depend on unknown keys being rejected. Instead, implement
Deserialize for RouteConfig manually:
- deserialize the route table into a
toml::Table (or serde_json::Map);
- remove and deserialize the four presentation keys;
- deserialize the remainder — still carrying
type — into AlgorithmSpec.
AlgorithmSpec's per-variant deny_unknown_fields then rejects unknown keys as it does today.
One sanctioned message change: serde's unknown-field error lists the expected fields, and that
list no longer contains id, context_window, tool_calling, or reasoning. Update the affected
assertions to keep asserting rejection and the offending key name; do not weaken them to "is an
error". This is the only external message change allowed by this plan.
Required tests in algorithm.rs:
- an unknown key inside a route table is still rejected;
id, context_window, tool_calling, and reasoning are accepted and are not treated as
algorithm fields;
build rejects a target name absent from the targets map, with the current message;
- an identity map (name to name) produces a working algorithm — the plugin's case.
Layer 2 — route.rs
One configured algorithm plus the clients its targets resolve to. This is the shared execution unit
and the type the plugin holds directly.
/// A configured algorithm and the per-target clients its calls resolve through.
/// Immutable after construction; cheap to clone.
#[derive(Clone)]
pub struct Route { /* Arc<RouteInner> */ }
pub struct RunOutput {
pub selected_model: ModelId,
pub response: Response,
}
impl Route {
pub fn new(algorithm: Arc<dyn Algorithm>, clients: ClientRouter) -> Self;
pub fn with_caller_auth(self, kind: Option<CallerAuthKind>) -> Self;
pub fn with_capabilities(self, capabilities: ModelCapabilities) -> Self;
pub fn with_count_tokens_target(self, target: Option<CountTokensTarget>) -> Self;
pub fn algorithm_name(&self) -> &str;
pub fn capabilities(&self) -> ModelCapabilities;
pub fn caller_auth(&self) -> Option<CallerAuthKind>;
/// Rejects a caller wire format incompatible with this route's forwarded credentials.
pub fn check_caller_format(&self, input_format: WireFormat) -> Result<(), RunnerError>;
pub async fn execute(
&self,
request: Request,
observer: Option<RunObserver>,
) -> Result<RunOutput, RunnerError>;
/// Completes routing-time calls and returns the outcome without serving its answer target.
pub async fn decide(&self, request: Request) -> Result<RoutingOutcome, RunnerError>;
pub async fn count_tokens(&self, request: Request) -> Result<Value, RunnerError>;
}
Route::new takes only what execution needs; the optional server metadata is set through builder
methods so the plugin ignores all of it.
check_caller_format is a separate method rather than an input_format parameter on execute.
That mirrors today's structure: the check lives in the server's single resolve_route helper
(crates/switchyard-server/src/lib.rs:890-905) and therefore already covers the completion,
decision, and count_tokens endpoints. Folding it into execute would silently drop it from
count_tokens. The server keeps resolve_route, which becomes route lookup plus
check_caller_format.
Move from switchyard-server/src/lib.rs: RouteEntry (becomes RouteInner), ModelCapabilities,
CallerAuthKind, CountTokensTarget, run_decision_only, and serve_decision_dependency.
ModelCapabilities stays a small Clone + Copy + Default value with public fields, since the
server serializes them. CallerAuthKind becomes public — RunnerError carries it, and
CallerAuthKind::as_str backs the public ServerState::caller_auth_kind.
Streaming contract
Route::execute returns the Response produced by switchyard_llm_client::run without consuming
it. For LlmResponse::Stream: do not poll it, proxy it through a channel, spawn a task, aggregate
it, alter event order or terminal behavior, or intercept errors the caller hits while polling.
Dropping the stream must drop the upstream provider response, preserving cancellation and
backpressure. For LlmResponse::Agg, return the aggregate unchanged.
Observability contract
Route::execute passes the supplied RunObserver unchanged to switchyard_llm_client::run and
must not buffer, reorder, rename, or translate observations. Callers keep receiving
RunObservation::LlmCall, RunObservation::AnswerCall, and RunObservation::RoutingOverhead with
their existing normalized fields.
Observers stay request-scoped values owned by callers. Do not add locks or an observer registry to
Route or Runner. The runner initializes no metrics registry and no tracing subscriber.
Request and wire-format invariant
The runner accepts a normalized switchyard_protocol::Request and never decodes raw JSON.
input_format describes the caller protocol and is used only for forwarded-auth validation. It must
not select the provider-facing format; the configured target client stays authoritative.
Preserve current Request.metadata.wire_format behavior: the server must not begin populating it,
the runner must not populate or rewrite it, and a caller that deliberately supplies a provider-format
hint retains existing TranslatingLlmClient behavior. (The plugin does set it deliberately today,
in both directions — runtime.rs:77 and client.rs:77 — which this rule leaves untouched.)
Layer 3 — runner.rs
The named route table. Server-shaped; the plugin never constructs one.
#[derive(Clone)]
pub struct Runner { /* Arc<BTreeMap<ModelId, Route>>, Option<Arc<DeploymentConfig>> */ }
impl Runner {
pub fn load(path: impl AsRef<Path>) -> Result<Self, RunnerError>;
pub fn new(routes: impl IntoIterator<Item = (ModelId, Route)>) -> Result<Self, RunnerError>;
/// A cloned handle, so callers hold no borrow across an await.
pub fn route(&self, model: &str) -> Option<Route>;
pub fn models(&self) -> impl Iterator<Item = ModelInfo<'_>>;
pub fn caller_auth_kind(&self, model: &str) -> Result<Option<&'static str>, RunnerError>;
/// Resolves a routing outcome through the configured target names of `model`'s route.
pub fn describe_decision(
&self,
model: &ModelId,
outcome: &RoutingOutcome,
) -> Option<DecisionDescription>;
}
pub struct ModelInfo<'a> {
pub id: &'a ModelId,
pub algorithm: &'a str,
pub capabilities: ModelCapabilities,
}
pub struct DecisionDescription {
pub selected: DecisionTarget,
pub fallbacks: Vec<DecisionTarget>,
}
pub struct DecisionTarget {
pub target: String,
pub model: ModelId,
pub format: WireFormat,
pub base_url: String,
pub extra_body: BTreeMap<String, Value>,
}
route() returning an owned handle removes the lifetime problem that otherwise forces a borrow
across .await.
describe_decision replaces ServerState::decision_response
(crates/switchyard-server/src/lib.rs:335-368). It returns owned values because the decision
endpoint is low-volume and public lifetime parameters tied to Runner internals are not worth it.
Preserve the existing selected/fallback ordering, the deterministic first-match behavior when
several configured targets share one provider model ID, and the all-or-nothing None when any model
has no callable target configuration. Response translation stays in the server: the server re-nests
format/base_url under llm_client so the endpoint's JSON shape is byte-identical.
Runner::new keeps the current validation from ServerState::new_with_capabilities: trim route
model IDs, reject empty IDs, reject duplicates, and reject an empty route set.
Configuration extraction
Move crates/switchyard-server/src/config.rs into crates/switchyard-runner/src/config.rs:
- keep the TOML schema,
deny_unknown_fields, and schema version unchanged;
- keep URL, retry, header, credential, target-reference, and algorithm validation unchanged;
- keep environment lookup timing and errors unchanged;
- keep
build_backend, build_clients, build_route_clients, build_count_tokens_target, and
count_tokens_priority unchanged;
- delegate algorithm construction to
AlgorithmSpec::build, resolving target names to
TargetConfig::id;
- make the loader construct
Runner instead of ServerState;
- move the existing inline configuration tests with the file.
Runner::load owns the path-context wrapping that load_server_state performs today
(crates/switchyard-server/src/config.rs:34-45) — both failed to read server config {path}: … and
invalid server config {path}: …. The server's compatibility wrapper must not re-wrap, or the path
appears twice.
DeploymentConfig and the serde configuration structs stay private. Only AlgorithmSpec, Route,
Runner, their output and metadata types, and RunnerError form the supported API.
Error model
Two public error types, both implementing Display and Error with typed sources preserved. Do not
add HTTP status codes or public response text to either, and do not add a dependency solely for
error derives.
AlgorithmConfigError — configured target absent from the targets map; missing, conflicting, or invalid
algorithm settings; libsy algorithm construction failure.
RunnerError — must distinguish at least:
- configuration or configuration-file failure (wrapping
AlgorithmConfigError where applicable);
- missing request model;
- unknown route model;
- incompatible caller wire format for forwarded credentials, carrying the
CallerAuthKind so
the server can rebuild its current message naming the provider and expected endpoint;
- unsupported token counting for a route;
- a routing outcome containing a model with no callable target configuration — today's 500 at
crates/switchyard-server/src/lib.rs:706-711;
- algorithm execution failure retaining
LibsyError;
- direct client failure retaining
LlmClientError.
The server stays responsible for converting these into its current status codes and
wire-format-specific error bodies. External error messages must not change.
Server integration
#[derive(Clone)]
pub struct ServerState {
runner: Runner,
metrics: prometheus::Registry,
stats: StatsAccumulator,
routing_log: Option<SharedRoutingLog>,
track_cache_eligibility: bool,
}
Add pub fn from_runner(runner: Runner) -> ServerResult<Self>, which initializes the metrics
registry and StatsAccumulator from Runner::models. Keep the public tuple-based
ServerState::new by building Routes and a Runner, then delegating. Keep ServerState::models
and ServerState::caller_auth_kind as delegations.
Keep switchyard_server::config::load_server_state at its current path as a thin wrapper:
pub fn load_server_state(path: impl AsRef<Path>) -> ServerResult<ServerState> {
ServerState::from_runner(Runner::load(path).map_err(ServerError::from)?)
}
resolve_route keeps its current shape and responsibilities — decode the body, require a non-empty
model, look the route up through Runner::route, call Route::check_caller_format, build the
Request — and returns an owned Route instead of a &RouteEntry. All three endpoint families
keep calling it, so validation order and status codes are unchanged.
- Completion endpoints: replace
switchyard_llm_client::run with Route::execute. The server
still computes the cache probe from the raw body first, builds its statistics/routing-log
observer, derives served_model from RunOutput, accounts usage through usage_metrics::observe,
encodes the response, and attaches routing headers.
- Decision endpoint: replace
run_decision_only with Route::decide and decision_response
with Runner::describe_decision. Response encoding and endpoint JSON serialization stay in the
server.
- Token counting: replace direct
CountTokensTarget access with Route::count_tokens. The
server keeps Anthropic-specific HTTP errors and response serialization.
- Models, dry-run, startup banner: read
Runner::models. Ordering is preserved by the
BTreeMap<ModelId, _> storage. Do not change any output formatting.
Plugin fitness constraints
Normative. Each is cheap now and expensive later. Each is verifiable on this branch.
Route must be constructible and executable without a Runner, a route model ID, or a TOML
file. The plugin dispatches on inbound protocol, and its caller-supplied model is an arbitrary
upstream model name — it has no route table to look that up in.
Route::execute must not read request.llm_request.model to select anything. Route selection is
the caller's job.
AlgorithmSpec::build must accept an identity targets map (name -> ModelId::from(name)) and
must not assume the resolved ModelId differs from the configured name. The plugin keys its
ClientRouter by semantic target name and rewrites to the provider model inside its own client
(crates/switchyard-nemo-relay-plugin/src/client.rs:101-103).
AlgorithmSpec variant fields must be public. The plugin derives Random targets and weights
from per-target weight fields in its own schema rather than from a targets/weights pair, so
it must be able to construct the spec programmatically.
Route must accept any Arc<dyn RoutedLlmClient> through ClientRouter. The plugin's
TargetClient is not a TranslatingLlmClient.
- Nothing on the shared path may require
ModelCapabilities, CallerAuthKind,
CountTokensTarget, or a config name. All are optional server metadata.
Guard rail: route.rs carries a test that builds a Route exactly the way the plugin will — an
identity targets map, a stub RoutedLlmClient keyed by semantic name, no Runner, no route model
ID, no TOML — and executes a request through it. If that test needs a workaround, the API is wrong.
Deliberately not shared
Stated so a reviewer can see these were considered rather than missed.
Client and backend construction. build_backend plus [llm_clients]
(crates/switchyard-server/src/config.rs:942-993) and the plugin's TargetBinding::prepare /
TargetClient implement different policies: one TranslatingLlmClient shared across targets versus
one per target, api_key_env versus header_env, provider-model-ID keying versus semantic-name
keying, and the plugin's drop_caller_extra_body and inbound-header stripping. Sharing these would
force a change to how the server's decision endpoint resolves target names. Left as a follow-up:
adopting the plugin's semantic-name keying in the server would also delete the ambiguous
target/model warning at crates/switchyard-server/src/config.rs:92-98 and the first-match tie-break
in describe_decision.
Translation. Already shared through switchyard-translation. The server uses the default-policy
helpers (decode_request, encode_aggregated_response, encode_stream); the plugin uses
TranslationEngine with a stricter policy that rejects lossy conversions and non-Info diagnostics
(crates/switchyard-nemo-relay-plugin/src/translation.rs:48-82). That is a policy difference, not
duplicated logic.
Fallback, marks, and stream-commitment semantics. Plugin-only. The server has no counterpart and
should not grow one.
Implementation sequence
1. Start
Remember that the plugin crate is absent on this branch.
2. Add the crate and the shared algorithm builder
- Add
crates/switchyard-runner to the workspace with the minimal manifest.
- Add
lib.rs and algorithm.rs; move the algorithm half of RouteConfig and build_algorithm.
- Implement the manual
RouteConfig split described above, still inside switchyard-server, and
point it at AlgorithmSpec::build.
cargo test -p switchyard-runner -p switchyard-server
cargo clippy -p switchyard-runner --all-targets -- -D warnings
Success: the server's configuration tests pass with the algorithm builder deleted from
switchyard-server, and the only assertion churn is the sanctioned unknown-field message.
3. Add Route
- Add
route.rs with Route, RunOutput, RunnerError, ModelCapabilities, CallerAuthKind,
and CountTokensTarget.
- Move
run_decision_only and serve_decision_dependency.
- Add the plugin-shaped guard-rail test.
cargo test -p switchyard-runner
4. Add Runner and move the deployment loader
- Add
runner.rs; move config.rs and its inline tests; make the loader build a Runner.
- Reduce
switchyard-server/src/config.rs to the load_server_state wrapper without changing its
public path.
- Confirm configuration errors keep their contextual path and message, and that the path is not
wrapped twice.
5. Delegate the server
- Add
Runner to ServerState; add from_runner; preserve ServerState::new.
- Rework
resolve_route to return an owned Route.
- Replace completion execution, decision, token counting, model listing, dry-run, and startup output
with delegations.
- Map
RunnerError through the existing HTTP error helpers.
- Remove the duplicated route state from
ServerState only after every call site uses the runner.
cargo test -p switchyard-server
Pay particular attention to existing tests for retries, candidate fallback, streaming, provider
errors, request metadata, usage, statistics, and routing logs.
6. Remove residue
- Remove imports, fields, helpers, and tests made redundant by the move.
- Do not clean up unrelated server comments, formatting, or dead code.
rg 'switchyard_server|axum|prometheus|opentelemetry|routing_log|nemo_relay' crates/switchyard-runner
cargo tree -p switchyard-runner
git diff --find-renames --stat origin/main...HEAD
7. Full validation
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
uv run ruff check .
uv run pytest tests/
The Python checks should be unaffected, but repository policy requires them before a commit. Report
any command not run and why.
Test ownership
switchyard-runner owns tests for: AlgorithmSpec deserialization and the presentation/algorithm
split; unknown-field rejection; algorithm construction under both provider-ID and identity target
maps; AlgorithmConfigError variants; TOML parsing, validation, and client construction;
route lookup and RunnerError variants; caller-format compatibility; observer forwarding;
decision-only execution; token-count target selection; lazy propagation of streaming responses
without polling them; and the plugin-shaped single-Route guard rail.
switchyard-server retains tests for: endpoint paths and methods; JSON and header extraction;
request and response translation; SSE framing and terminal events; HTTP status and error mapping;
selected-model headers; model-list JSON shape; decision endpoint JSON shape; stats, metrics, usage
accounting, and routing logs; TLS and lifecycle; and the compatibility wrappers.
Move tests when they primarily exercise runner behavior. Do not copy the same behavior test into
both crates. Existing end-to-end server tests may keep covering runner behavior transitively.
Plugin adoption sketch
Non-normative; records what the follow-up branch does, so this API can be reviewed for fitness.
// Once, at register(). The plugin's ClientRouter is keyed by semantic target name,
// so its target map is the identity over its configured names.
let by_name: BTreeMap<String, ModelId> =
targets.keys().map(|name| (name.clone(), ModelId::from(name.as_str()))).collect();
let algorithm = spec.build("switchyard", &by_name)?;
let clients = ClientRouter::new(
targets.iter().map(|(name, t)| (ModelId::from(name.as_str()), t.client.clone())).collect(),
);
let route = Route::new(algorithm, clients);
// Per request, replacing SwitchyardRuntime::drive:
let RunOutput { response, .. } = route.execute(request, Some(observer)).await?;
The plugin deletes its AlgorithmConfig, LlmClassifierAlgorithmConfig, StageFallbackConfig,
build_algorithm, and per-request ClientRouter construction — roughly 200 lines — and inherits
passthrough, advisor, and custom-classifier support it does not have today. Its
session_affinity field becomes classify_trigger, which fixes the current compile break. Its
Random handling keeps deriving targets and weights from per-target weight fields, constructing
AlgorithmSpec::Random directly rather than through serde — which is why constraint 4 exists.
Its per-target validation, TargetClient, translation policy, fallback-to-default-target, routing
marks, and stream-commitment logic all stay in the plugin.
Review checklist
- No production
unwrap, expect, or panic was introduced.
- No lock is held across
.await; Route and Runner are immutable after construction.
- No task or channel was added to the streaming path;
LlmResponse::Stream stays lazy and preserves
event order, partial chunks, errors, and cancellation.
RunObserver delivery order and event classification are unchanged.
- Provider retry and candidate-fallback behavior still comes from
switchyard-llm-client.
- Request metadata and target wire-format resolution are unchanged.
- Server error statuses, codes, and redaction are unchanged.
- TOML configuration, defaults, validation, and environment behavior are unchanged, and unknown
fields are still rejected.
- Public server APIs are preserved;
switchyard-py compiles unchanged.
switchyard-runner has no server-only dependencies and no reference to the plugin.
- All six plugin fitness constraints hold, and the guard-rail test needs no workaround.
- No unrelated refactor or formatting churn is included.
Expected change shape
Mostly moved code. algorithm.rs and config.rs are near-verbatim moves out of
switchyard-server/src/config.rs; route.rs is a near-verbatim move out of
switchyard-server/src/lib.rs. New code should be roughly 200-300 lines: the AlgorithmSpec public
surface, the manual RouteConfig deserializer, Route's builder methods,
RunnerError, and server delegation.
The final review should show a new crate containing the shared routing layer, substantial deletions
from switchyard-server, small server delegation changes, unchanged external behavior, and no new
execution or transport mechanism.
If the implementation starts requiring generalized hooks, configurable adapters, an event bus, a new
configuration model, or duplicated tests across crates, stop and reduce the design back to the
existing behavior behind AlgorithmSpec and Route.
Our first integration is with NeMo-Relay. It needs a lot of things that
switchyard-serveralready has, so conceptually we want to do this:Both adapters decode into
switchyard_protocol::Request, invoke the same runner, and encode the returned Response.This plan would introduce new crate
switchyard-runnerwith these key types:The server code would then look like this:
The plugin would look like this:
At setup it reads the Relay JSON config, builds one
Routeand hold its onSwitchyardRuntime. At intercept time it translates Relay request to switchyard-translation request and callsroute.execute.The full plan follows.
Plan: Extract
switchyard-runneras shared routing codeContext
The NeMo-Relay plugin mentioned in this plan is in a different branch. Those files will not
be visible right now. Our goal here is to extract
switchyard-runnerfromswitchyard-serverto make the NeMo-Relay plugin much simpler when we work on that.
Why
switchyard-serverand the NeMo-Relay plugin are two serving surfaces over one routing engine.Today they each own a private copy of the configured-routing layer.
The duplication is concentrated in one place — turning a declarative algorithm description into an
Arc<dyn Algorithm>, and then driving it.switchyard-server/src/config.rs:999-1271andswitchyard-nemo-relay-plugin/src/config.rs:361-479are the same function written twice against twoschemas. Everything around that (HTTP framing, relay marks, statistics, fallbacks) is genuinely
surface-specific and should stay where it is.
The goal is therefore not to refactor
switchyard-server. It is to create a shared crate that bothsurfaces execute through, so each becomes a thin adapter:
Goal
Create
switchyard-runnerowning the schema-neutral algorithm builder, the configured routeexecution unit, and the server's TOML deployment loader. Preserve
switchyard-serverbehavior andpublic entry points.
Branch scope
This work lands on a branch that does not contain
crates/switchyard-nemo-relay-plugin. The plugin cannot be compiled or tested here.That has one consequence the implementer must respect: the plugin's requirements are stated below as
hard design constraints, not as aspirations to be settled later. They are cheap to satisfy now
and expensive to retrofit. Section "Plugin fitness constraints" is normative, and section
"Plugin adoption sketch" records what the follow-up branch will do so a reviewer can judge whether
the API actually fits.
Success criteria
Sharing (the point of the work)
AlgorithmSpecis public,Deserialize, and has no concept of route ids, model capabilities,[llm_clients], base URLs, or credentials.AlgorithmSpecresolves configured target names through a caller-supplied name-to-ModelIdmap, so one caller can map names to provider model IDs and another can map names to themselves.
AlgorithmSpecvariant fields are public, so a caller can build a spec programmatically insteadof only through serde.
Routeexecutes without a route table, a route model ID, or a TOML file. A unit test proves thisby constructing and executing a single
Routethe way the plugin will.switchyard-server's copy of algorithm construction is deleted, not duplicated.Preservation
switchyard-runnerhas no dependency on Axum,axum-server, Prometheus, OpenTelemetry, TLS,nemo-relay-plugin, or server lifecycle code.switchyard-serverstores aRunnerinstead of its own route map, configured clients, andretained deployment config.
endpoints preserve their current behavior.
switchyard-serverAPIs continue to compile:ServerState::new,ServerState::models,ServerState::caller_auth_kind,ServerState::with_routing_log,build_switchyard_router,BoundServer, andconfig::load_server_state.switchyard-py(
crates/switchyard-py/src/server_bindings.rs) uses several of these and must not change.change in "Splitting
RouteConfig".Route::executeaccepts the existingRunObserverand forwards it unchanged, so callers retainrouting-call, answer-call, latency, usage, and routing-overhead visibility.
stream.
Non-goals
statistics, usage accounting, routing logs, or request spans into the runner.
switchyard-servermodules or refactorswitchyard-llm-client::run.Crate layout
algorithm.rsandroute.rsare the shared layer.config.rsandrunner.rsserve the servertoday; the plugin uses
algorithm.rsandroute.rsonly.Dependencies
switchyard-libsy,switchyard-llm-client,switchyard-protocol,serde,serde_json,toml,reqwest(for the existing validated URL type),tracing. Test-only additions as needed.Tokio is not a direct production dependency merely because public methods are async. Verify with
cargo tree -p switchyard-runnerthat server-only dependencies are absent.Layer 1 —
algorithm.rsThis is the shared code that motivates the change.
The
targetsmap is what makes this shareable.switchyard-serverpasses the map it alreadybuilds in
ServerConfig::build_targets— target name toTargetConfig::id. The plugin passes anidentity map over its own target names, one line at construction. Neither keying policy leaks into
the shared crate, and the "unknown target" error is produced uniformly inside the builder rather
than by each caller.
Move these into
algorithm.rsverbatim fromswitchyard-server/src/config.rs: the algorithm half ofRouteConfig(lines 418-560),routing_target_names/callable_target_names/classifier_mode(lines 624-901),ClassifierPolicyConfig,ClassifierMode,LlmClassifierModeConfig,CapabilityClassifierRouteConfig,EscalationClassifierRouteConfig,CustomClassifierRouteConfig,AdvisorTriggerConfig,StageClassifierConfig,reject_custom_fields,classifier_field_error,required_classifier_field,build_algorithm(lines 999-1271),
classifier_contract,default_classifier_max_output_tokens,tier_prompts,default_max_reviews,default_advisor_max_tokens,default_transcript_max_chars,default_fail_open, and the target-resolution helpersresolve_targetsandresolve_target_model_id(lines 1316-1337). Becausebuildkeeps today's&BTreeMap<String, ModelId>parameter,build_algorithm's body moves unchanged apart from matching on&selfinsteadof
&RouteConfig.AlgorithmConfigErrorreproduces the server's current message formats verbatim, parameterized bycontext— for example"route {context} references unknown target {name}","random route {context}: {error}","llm_classifier route {context} requires {field}". Theserver passes the route name, so its configuration error text is unchanged.
The
stage_routercapable_firstexperimental warning moves with the builder.Splitting
RouteConfigThe server's
[routes.*]table is one flat, internally tagged table that mixes route presentation(
id,context_window,tool_calling,reasoning) with algorithm fields. The schema must notchange, so the server keeps a
RouteConfigthat holds the four presentation fields plus anAlgorithmSpec.Do not use
#[serde(flatten)]for this.flattensilently disablesdeny_unknown_fields, andthe server's configuration tests depend on unknown keys being rejected. Instead, implement
Deserialize for RouteConfigmanually:toml::Table(orserde_json::Map);type— intoAlgorithmSpec.AlgorithmSpec's per-variantdeny_unknown_fieldsthen rejects unknown keys as it does today.One sanctioned message change: serde's unknown-field error lists the expected fields, and that
list no longer contains
id,context_window,tool_calling, orreasoning. Update the affectedassertions to keep asserting rejection and the offending key name; do not weaken them to "is an
error". This is the only external message change allowed by this plan.
Required tests in
algorithm.rs:id,context_window,tool_calling, andreasoningare accepted and are not treated asalgorithm fields;
buildrejects a target name absent from thetargetsmap, with the current message;Layer 2 —
route.rsOne configured algorithm plus the clients its targets resolve to. This is the shared execution unit
and the type the plugin holds directly.
Route::newtakes only what execution needs; the optional server metadata is set through buildermethods so the plugin ignores all of it.
check_caller_formatis a separate method rather than aninput_formatparameter onexecute.That mirrors today's structure: the check lives in the server's single
resolve_routehelper(
crates/switchyard-server/src/lib.rs:890-905) and therefore already covers the completion,decision, and
count_tokensendpoints. Folding it intoexecutewould silently drop it fromcount_tokens. The server keepsresolve_route, which becomes route lookup pluscheck_caller_format.Move from
switchyard-server/src/lib.rs:RouteEntry(becomesRouteInner),ModelCapabilities,CallerAuthKind,CountTokensTarget,run_decision_only, andserve_decision_dependency.ModelCapabilitiesstays a smallClone + Copy + Defaultvalue with public fields, since theserver serializes them.
CallerAuthKindbecomes public —RunnerErrorcarries it, andCallerAuthKind::as_strbacks the publicServerState::caller_auth_kind.Streaming contract
Route::executereturns theResponseproduced byswitchyard_llm_client::runwithout consumingit. For
LlmResponse::Stream: do not poll it, proxy it through a channel, spawn a task, aggregateit, alter event order or terminal behavior, or intercept errors the caller hits while polling.
Dropping the stream must drop the upstream provider response, preserving cancellation and
backpressure. For
LlmResponse::Agg, return the aggregate unchanged.Observability contract
Route::executepasses the suppliedRunObserverunchanged toswitchyard_llm_client::runandmust not buffer, reorder, rename, or translate observations. Callers keep receiving
RunObservation::LlmCall,RunObservation::AnswerCall, andRunObservation::RoutingOverheadwiththeir existing normalized fields.
Observers stay request-scoped values owned by callers. Do not add locks or an observer registry to
RouteorRunner. The runner initializes no metrics registry and no tracing subscriber.Request and wire-format invariant
The runner accepts a normalized
switchyard_protocol::Requestand never decodes raw JSON.input_formatdescribes the caller protocol and is used only for forwarded-auth validation. It mustnot select the provider-facing format; the configured target client stays authoritative.
Preserve current
Request.metadata.wire_formatbehavior: the server must not begin populating it,the runner must not populate or rewrite it, and a caller that deliberately supplies a provider-format
hint retains existing
TranslatingLlmClientbehavior. (The plugin does set it deliberately today,in both directions —
runtime.rs:77andclient.rs:77— which this rule leaves untouched.)Layer 3 —
runner.rsThe named route table. Server-shaped; the plugin never constructs one.
route()returning an owned handle removes the lifetime problem that otherwise forces a borrowacross
.await.describe_decisionreplacesServerState::decision_response(
crates/switchyard-server/src/lib.rs:335-368). It returns owned values because the decisionendpoint is low-volume and public lifetime parameters tied to
Runnerinternals are not worth it.Preserve the existing selected/fallback ordering, the deterministic first-match behavior when
several configured targets share one provider model ID, and the all-or-nothing
Nonewhen any modelhas no callable target configuration. Response translation stays in the server: the server re-nests
format/base_urlunderllm_clientso the endpoint's JSON shape is byte-identical.Runner::newkeeps the current validation fromServerState::new_with_capabilities: trim routemodel IDs, reject empty IDs, reject duplicates, and reject an empty route set.
Configuration extraction
Move
crates/switchyard-server/src/config.rsintocrates/switchyard-runner/src/config.rs:deny_unknown_fields, and schema version unchanged;build_backend,build_clients,build_route_clients,build_count_tokens_target, andcount_tokens_priorityunchanged;AlgorithmSpec::build, resolving target names toTargetConfig::id;Runnerinstead ofServerState;Runner::loadowns the path-context wrapping thatload_server_stateperforms today(
crates/switchyard-server/src/config.rs:34-45) — bothfailed to read server config {path}: …andinvalid server config {path}: …. The server's compatibility wrapper must not re-wrap, or the pathappears twice.
DeploymentConfigand the serde configuration structs stay private. OnlyAlgorithmSpec,Route,Runner, their output and metadata types, andRunnerErrorform the supported API.Error model
Two public error types, both implementing
DisplayandErrorwith typed sources preserved. Do notadd HTTP status codes or public response text to either, and do not add a dependency solely for
error derives.
AlgorithmConfigError— configured target absent from thetargetsmap; missing, conflicting, or invalidalgorithm settings; libsy algorithm construction failure.
RunnerError— must distinguish at least:AlgorithmConfigErrorwhere applicable);CallerAuthKindsothe server can rebuild its current message naming the provider and expected endpoint;
crates/switchyard-server/src/lib.rs:706-711;LibsyError;LlmClientError.The server stays responsible for converting these into its current status codes and
wire-format-specific error bodies. External error messages must not change.
Server integration
Add
pub fn from_runner(runner: Runner) -> ServerResult<Self>, which initializes the metricsregistry and
StatsAccumulatorfromRunner::models. Keep the public tuple-basedServerState::newby buildingRoutes and aRunner, then delegating. KeepServerState::modelsand
ServerState::caller_auth_kindas delegations.Keep
switchyard_server::config::load_server_stateat its current path as a thin wrapper:resolve_routekeeps its current shape and responsibilities — decode the body, require a non-emptymodel, look the route up throughRunner::route, callRoute::check_caller_format, build theRequest— and returns an ownedRouteinstead of a&RouteEntry. All three endpoint familieskeep calling it, so validation order and status codes are unchanged.
switchyard_llm_client::runwithRoute::execute. The serverstill computes the cache probe from the raw body first, builds its statistics/routing-log
observer, derives
served_modelfromRunOutput, accounts usage throughusage_metrics::observe,encodes the response, and attaches routing headers.
run_decision_onlywithRoute::decideanddecision_responsewith
Runner::describe_decision. Response encoding and endpoint JSON serialization stay in theserver.
CountTokensTargetaccess withRoute::count_tokens. Theserver keeps Anthropic-specific HTTP errors and response serialization.
Runner::models. Ordering is preserved by theBTreeMap<ModelId, _>storage. Do not change any output formatting.Plugin fitness constraints
Normative. Each is cheap now and expensive later. Each is verifiable on this branch.
Routemust be constructible and executable without aRunner, a route model ID, or a TOMLfile. The plugin dispatches on inbound protocol, and its caller-supplied
modelis an arbitraryupstream model name — it has no route table to look that up in.
Route::executemust not readrequest.llm_request.modelto select anything. Route selection isthe caller's job.
AlgorithmSpec::buildmust accept an identitytargetsmap (name -> ModelId::from(name)) andmust not assume the resolved
ModelIddiffers from the configured name. The plugin keys itsClientRouterby semantic target name and rewrites to the provider model inside its own client(
crates/switchyard-nemo-relay-plugin/src/client.rs:101-103).AlgorithmSpecvariant fields must be public. The plugin derivesRandomtargets and weightsfrom per-target
weightfields in its own schema rather than from atargets/weightspair, soit must be able to construct the spec programmatically.
Routemust accept anyArc<dyn RoutedLlmClient>throughClientRouter. The plugin'sTargetClientis not aTranslatingLlmClient.ModelCapabilities,CallerAuthKind,CountTokensTarget, or a config name. All are optional server metadata.Guard rail:
route.rscarries a test that builds aRouteexactly the way the plugin will — anidentity
targetsmap, a stubRoutedLlmClientkeyed by semantic name, noRunner, no route modelID, no TOML — and executes a request through it. If that test needs a workaround, the API is wrong.
Deliberately not shared
Stated so a reviewer can see these were considered rather than missed.
Client and backend construction.
build_backendplus[llm_clients](
crates/switchyard-server/src/config.rs:942-993) and the plugin'sTargetBinding::prepare/TargetClientimplement different policies: oneTranslatingLlmClientshared across targets versusone per target,
api_key_envversusheader_env, provider-model-ID keying versus semantic-namekeying, and the plugin's
drop_caller_extra_bodyand inbound-header stripping. Sharing these wouldforce a change to how the server's decision endpoint resolves target names. Left as a follow-up:
adopting the plugin's semantic-name keying in the server would also delete the ambiguous
target/model warning at
crates/switchyard-server/src/config.rs:92-98and the first-match tie-breakin
describe_decision.Translation. Already shared through
switchyard-translation. The server uses the default-policyhelpers (
decode_request,encode_aggregated_response,encode_stream); the plugin usesTranslationEnginewith a stricter policy that rejects lossy conversions and non-Infodiagnostics(
crates/switchyard-nemo-relay-plugin/src/translation.rs:48-82). That is a policy difference, notduplicated logic.
Fallback, marks, and stream-commitment semantics. Plugin-only. The server has no counterpart and
should not grow one.
Implementation sequence
1. Start
Remember that the plugin crate is absent on this branch.
2. Add the crate and the shared algorithm builder
crates/switchyard-runnerto the workspace with the minimal manifest.lib.rsandalgorithm.rs; move the algorithm half ofRouteConfigandbuild_algorithm.RouteConfigsplit described above, still insideswitchyard-server, andpoint it at
AlgorithmSpec::build.cargo test -p switchyard-runner -p switchyard-server cargo clippy -p switchyard-runner --all-targets -- -D warningsSuccess: the server's configuration tests pass with the algorithm builder deleted from
switchyard-server, and the only assertion churn is the sanctioned unknown-field message.3. Add
Routeroute.rswithRoute,RunOutput,RunnerError,ModelCapabilities,CallerAuthKind,and
CountTokensTarget.run_decision_onlyandserve_decision_dependency.cargo test -p switchyard-runner4. Add
Runnerand move the deployment loaderrunner.rs; moveconfig.rsand its inline tests; make the loader build aRunner.switchyard-server/src/config.rsto theload_server_statewrapper without changing itspublic path.
wrapped twice.
5. Delegate the server
RunnertoServerState; addfrom_runner; preserveServerState::new.resolve_routeto return an ownedRoute.with delegations.
RunnerErrorthrough the existing HTTP error helpers.ServerStateonly after every call site uses the runner.cargo test -p switchyard-serverPay particular attention to existing tests for retries, candidate fallback, streaming, provider
errors, request metadata, usage, statistics, and routing logs.
6. Remove residue
rg 'switchyard_server|axum|prometheus|opentelemetry|routing_log|nemo_relay' crates/switchyard-runner cargo tree -p switchyard-runner git diff --find-renames --stat origin/main...HEAD7. Full validation
The Python checks should be unaffected, but repository policy requires them before a commit. Report
any command not run and why.
Test ownership
switchyard-runnerowns tests for:AlgorithmSpecdeserialization and the presentation/algorithmsplit; unknown-field rejection; algorithm construction under both provider-ID and identity target
maps;
AlgorithmConfigErrorvariants; TOML parsing, validation, and client construction;route lookup and
RunnerErrorvariants; caller-format compatibility; observer forwarding;decision-only execution; token-count target selection; lazy propagation of streaming responses
without polling them; and the plugin-shaped single-
Routeguard rail.switchyard-serverretains tests for: endpoint paths and methods; JSON and header extraction;request and response translation; SSE framing and terminal events; HTTP status and error mapping;
selected-model headers; model-list JSON shape; decision endpoint JSON shape; stats, metrics, usage
accounting, and routing logs; TLS and lifecycle; and the compatibility wrappers.
Move tests when they primarily exercise runner behavior. Do not copy the same behavior test into
both crates. Existing end-to-end server tests may keep covering runner behavior transitively.
Plugin adoption sketch
Non-normative; records what the follow-up branch does, so this API can be reviewed for fitness.
The plugin deletes its
AlgorithmConfig,LlmClassifierAlgorithmConfig,StageFallbackConfig,build_algorithm, and per-requestClientRouterconstruction — roughly 200 lines — and inheritspassthrough,advisor, and custom-classifier support it does not have today. Itssession_affinityfield becomesclassify_trigger, which fixes the current compile break. ItsRandomhandling keeps deriving targets and weights from per-targetweightfields, constructingAlgorithmSpec::Randomdirectly rather than through serde — which is why constraint 4 exists.Its per-target validation,
TargetClient, translation policy, fallback-to-default-target, routingmarks, and stream-commitment logic all stay in the plugin.
Review checklist
unwrap,expect, or panic was introduced..await;RouteandRunnerare immutable after construction.LlmResponse::Streamstays lazy and preservesevent order, partial chunks, errors, and cancellation.
RunObserverdelivery order and event classification are unchanged.switchyard-llm-client.fields are still rejected.
switchyard-pycompiles unchanged.switchyard-runnerhas no server-only dependencies and no reference to the plugin.Expected change shape
Mostly moved code.
algorithm.rsandconfig.rsare near-verbatim moves out ofswitchyard-server/src/config.rs;route.rsis a near-verbatim move out ofswitchyard-server/src/lib.rs. New code should be roughly 200-300 lines: theAlgorithmSpecpublicsurface, the manual
RouteConfigdeserializer,Route's builder methods,RunnerError, and server delegation.The final review should show a new crate containing the shared routing layer, substantial deletions
from
switchyard-server, small server delegation changes, unchanged external behavior, and no newexecution or transport mechanism.
If the implementation starts requiring generalized hooks, configurable adapters, an event bus, a new
configuration model, or duplicated tests across crates, stop and reduce the design back to the
existing behavior behind
AlgorithmSpecandRoute.