diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f4bcea1001..bcceac5e40 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,8 +32,8 @@ permissions: jobs: # OSS-style split: - # - Root vMAJOR.MINOR.PATCH tags run validation, build the complete matrix, publish to - # PyPI, and create a GitHub Release. + # - Root vMAJOR.MINOR.PATCH and vMAJOR.MINOR.PATCHaN tags run validation, build the + # complete matrix, publish to PyPI, and create a GitHub Release. # - Manual dev builds create artifacts only. They are useful for branch validation but never # publish to PyPI. # @@ -43,9 +43,12 @@ jobs: name: Validate release ref if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest + outputs: + is_alpha: ${{ steps.release.outputs.is_alpha }} steps: - uses: actions/checkout@v4 - name: Check release tag + id: release if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} run: | python - <<'PY' @@ -54,8 +57,10 @@ jobs: import tomllib tag = os.environ["GITHUB_REF_NAME"] - if not re.fullmatch(r"v\d+\.\d+\.\d+", tag): - raise SystemExit(f"release tags must look like vMAJOR.MINOR.PATCH, got {tag!r}") + match = re.fullmatch(r"v\d+\.\d+\.\d+(?Pa(?:0|[1-9]\d*))?", tag) + if match is None: + raise SystemExit(f"release tags must look like v1.2.3 or v1.2.3a1, got {tag!r}") + is_alpha = match.group("alpha") is not None with open("pyproject.toml", "rb") as file: pyproject = tomllib.load(file) @@ -64,7 +69,7 @@ jobs: package_version = pyproject["project"]["version"] workspace_version = cargo["workspace"]["package"]["version"] - if workspace_version != package_version: + if not is_alpha and workspace_version != package_version: raise SystemExit( "Python package version " f"{package_version!r} does not match Rust workspace version " @@ -76,7 +81,9 @@ jobs: raise SystemExit( f"release tag {tag!r} does not match pyproject version {package_version!r}" ) - print(f"release tag {tag} matches Python and Rust version {package_version}") + with open(os.environ["GITHUB_OUTPUT"], "a") as file: + file.write(f"is_alpha={str(is_alpha).lower()}\n") + print(f"release tag {tag} matches Python version {package_version}") PY python-release-checks: @@ -438,8 +445,13 @@ jobs: publish: name: Publish distributions - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} - needs: [source-dist, wheels, publish-rust-crates] + if: >- + ${{ !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && + needs.validate-release-ref.result == 'success' && + needs.source-dist.result == 'success' && needs.wheels.result == 'success' && + ((needs.validate-release-ref.outputs.is_alpha == 'true' && needs.publish-rust-crates.result == 'skipped') || + (needs.validate-release-ref.outputs.is_alpha == 'false' && needs.publish-rust-crates.result == 'success')) }} + needs: [validate-release-ref, source-dist, wheels, publish-rust-crates] runs-on: ubuntu-latest environment: pypi permissions: @@ -464,11 +476,13 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 + with: + prerelease: ${{ needs.validate-release-ref.outputs.is_alpha == 'true' }} publish-rust-crates: name: Publish Rust crates - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} - needs: [source-dist, wheels] + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.validate-release-ref.outputs.is_alpha == 'false' }} + needs: [validate-release-ref, source-dist, wheels] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index fdff2de676..75f2e0e020 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -4,7 +4,11 @@ //! The terminal controller reloads settings and combines usage and server health into snapshots. //! It retains the log reader and health probe across refreshes. -use crate::{config::Config, history, pricing, rollup, server}; +use crate::{ + config::Config, + history, models, pricing, rollup, server, + server_config::{ALGORITHMS, Choice, ServerConfig}, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::{collections::BTreeMap, path::PathBuf}; @@ -48,6 +52,10 @@ pub struct Snapshot { pub update_status: Value, pub settings: Option, pub data_state: DataState, + + pub routes: Vec, + pub clients: Vec, + pub algorithms: Vec, pub metrics: Metrics, pub today: chrono::NaiveDate, pub analytics: rollup::Usage, @@ -77,6 +85,65 @@ pub struct AppSettings { pub prices_file: Option, } +/// RouteInfo describes one entry under `[routes]` in the server config. +#[derive(Clone, Default, Serialize)] +pub struct RouteInfo { + /// This field names the route's table in the server config. + pub key: String, + /// This field holds the public model ID that harnesses send. + pub id: String, + pub kind: String, + /// Route edits send this token back. The controller rejects it after the file changes. + pub revision: String, + pub label: String, + /// This field lists the models that answer requests, as `client / model`. The runner + /// finds them, so it includes models the editor cannot change, such as sub-agent models. + pub models: Vec, + /// This field lists the models the route calls only to decide, such as a judge or an advisor. + pub judges: Vec, + /// This field lists the editor's current model for each role. It is empty when the apps + /// cannot edit the route's algorithm. + pub choices: Vec, + /// This field lists the tier of each role in role order. It is `None` when the apps + /// cannot edit the route's algorithm. + pub tiers: Option>>, + pub editable: bool, + /// This field holds the marker of the generated block that contains the route. The tool + /// that wrote the block replaces the route the next time it runs. + pub generated: Option, +} + +/// ClientInfo describes one endpoint under `[llm_clients]` in the server config. +#[derive(Clone, Default, Serialize)] +pub struct ClientInfo { + pub name: String, + pub host: String, + /// This field lists the model IDs that targets in the server config name on this endpoint. + pub models: Vec, + /// This field is true when the app cannot ask the endpoint for its model list. + pub unlisted: bool, + pub accepts_key: bool, + /// This field explains why the endpoint cannot list models without a saved key. + pub note: Option, +} + +/// AlgorithmInfo describes one routing algorithm that the apps can write and the roles +/// that a new route of that algorithm needs. +#[derive(Clone, Default, Serialize)] +pub struct AlgorithmInfo { + pub kind: &'static str, + pub title: &'static str, + pub summary: &'static str, + pub roles: Vec, +} + +#[derive(Clone, Default, Serialize)] +pub struct RoleInfo { + pub label: String, + pub hint: &'static str, + pub tier: Option, +} + #[derive(Clone, Default, Serialize)] pub struct DataState { pub usage: Source, @@ -130,11 +197,56 @@ impl Reply { } } +// Review keeps exact file bytes in the controller so later edits require a new review. +// Only the UUID token goes to the frontend; reviewed files can contain credentials. +struct Review { + token: String, + + files: Vec<(PathBuf, Option>)>, +} +impl Review { + fn capture(paths: Vec) -> Result { + let files = paths + .into_iter() + .map(|path| { + let bytes = match std::fs::read(&path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(format!("Read {}: {error}", path.display())), + }; + Ok((path, bytes)) + }) + .collect::, String>>()?; + Ok(Self { + token: uuid::Uuid::now_v7().to_string(), + files, + }) + } + fn original(&self) -> Result<&str, String> { + let (path, bytes) = self + .files + .first() + .ok_or("The app did not read a server config.")?; + let bytes = bytes + .as_deref() + .ok_or_else(|| format!("The server config {} does not exist.", path.display()))?; + std::str::from_utf8(bytes).map_err(|error| { + format!( + "The server config {} is not valid UTF-8: {error}", + path.display() + ) + }) + } +} + pub struct Controller { + health_probe: crate::health::Probe, settings: PathBuf, config: Config, reader: rollup::Reader, - health_probe: crate::health::Probe, + // The generation ties a draft to the server config file selected by the settings. + generation: u64, + route_reviews: std::collections::HashMap, update_review: Option<(String, Value)>, } impl Controller { @@ -142,13 +254,21 @@ impl Controller { Self { config, settings, - reader: rollup::Reader::default(), health_probe: crate::health::Probe::default(), + reader: rollup::Reader::default(), + generation: 0, + route_reviews: Default::default(), update_review: None, } } fn reload(&mut self) -> Result<(), String> { - self.config = Config::load(&self.settings)?; + let config = Config::load(&self.settings)?; + if config.config_file != self.config.config_file { + self.generation += 1; + self.route_reviews.clear(); + } + self.config = config; + Ok(()) } // Key actions accept only names that the server config declares. @@ -163,6 +283,108 @@ impl Controller { )) } } + // Unchanged bytes keep the draft revision when the editor switches algorithms. + fn remember_route(&mut self, route: &str, mut review: Review) -> String { + if let Some(previous) = self.route_reviews.get(route) + && previous.files == review.files + { + review.token = previous.token.clone(); + } + let revision = review.token.clone(); + self.route_reviews.insert(route.to_string(), review); + revision + } + /// This function reads the routes and endpoints in the server config. The runner lists each + /// route's targets, so the Routes page shows every model the server would call. The editor + /// fields come from the same text and do not limit which models the page shows. + fn server_config(&mut self, errors: &mut Vec) -> (Vec, Vec) { + let review = match Review::capture(vec![self.config.config_file.clone()]) { + Ok(review) => review, + Err(error) => { + errors.push(error); + return Default::default(); + } + }; + let text = match review.original() { + Ok(text) => text, + Err(error) => { + errors.push(error); + return Default::default(); + } + }; + // Parse errors can quote config lines, and those lines can hold header credentials, so + // the snapshot names only the action that shows the full error. + let Ok(config) = ServerConfig::parse(text) else { + errors.push( + "The server config contains invalid TOML. Open it in Settings to fix it.".into(), + ); + return Default::default(); + }; + let clients = config + .clients() + .iter() + .map(|c| ClientInfo { + name: c.name.clone(), + host: c.host().to_string(), + models: config.models_on(&c.name), + unlisted: models::unlisted(c), + accepts_key: cfg!(any(target_os = "macos", windows)) + && !models::unlisted(c) + && (c.forward_auth || c.api_key_env.is_some()), + note: models::missing_env(c).map(models::missing_env_note), + }) + .collect(); + let Ok(listed) = switchyard_runner::route_targets(text) else { + errors.push( + "The server cannot read the routes in the server config. \ + Run Check server config in Settings to see why." + .into(), + ); + return (Vec::new(), clients); + }; + self.route_reviews + .retain(|key, _| listed.iter().any(|route| &route.name == key)); + let mut routes = Vec::with_capacity(listed.len()); + for route in listed { + let revision = self.remember_route( + &route.name, + Review { + token: uuid::Uuid::now_v7().to_string(), + files: review.files.clone(), + }, + ); + let models: Vec = route.answer_targets.iter().map(target_label).collect(); + let judges: Vec = route.judge_targets.iter().map(target_label).collect(); + let algorithm = config.algorithm(&route.name); + routes.push(RouteInfo { + label: format!( + "{} · {} · {}", + route.id, + route.kind, + [models.as_slice(), judges.as_slice()].concat().join(" → ") + ), + tiers: algorithm.map(|a| { + config + .roles(&route.name, a) + .iter() + .map(|r| r.tier.map(|t| format!("{t:?}"))) + .collect() + }), + editable: algorithm.is_some(), + generated: config + .generated_by("routes", &route.name) + .map(str::to_string), + choices: config.choices(&route.name), + id: route.id.to_string(), + kind: route.kind.to_string(), + key: route.name, + revision, + models, + judges, + }); + } + (routes, clients) + } pub fn snapshot(&mut self) -> Result { self.reload()?; let mut errors = Vec::new(); @@ -203,6 +425,7 @@ impl Controller { } else { crate::health::ServerStatus::Stopped }; + let (routes, clients) = self.server_config(&mut errors); let mut history_state = Availability::Available; let history = history::load(&self.config.routing_log).unwrap_or_else(|error| { history_state = Availability::Unavailable; @@ -247,6 +470,18 @@ impl Controller { }, health, }, + + routes, + clients, + algorithms: ALGORITHMS + .iter() + .map(|a| AlgorithmInfo { + kind: a.kind, + title: a.title, + summary: a.summary, + roles: a.new_route_roles().iter().map(role_info).collect(), + }) + .collect(), metrics: Metrics { server_url: server_url.clone(), running: server_status == crate::health::ServerStatus::Running, @@ -414,6 +649,21 @@ impl Controller { fn logs_dir() -> Result { crate::config::expand_home(std::path::Path::new("~/.switchyard/logs")) } +/// This function shows a target as `client / model`, or names it when no target entry has its +/// name, which the server rejects at startup. +fn target_label(target: &switchyard_runner::ConfiguredTarget) -> String { + match &target.model { + Some(model) => format!("{} / {}", model.client, model.id), + None => format!("{} (no target has this name)", target.name), + } +} +fn role_info(role: &crate::server_config::Role) -> RoleInfo { + RoleInfo { + label: role.label.clone(), + hint: role.hint, + tier: role.tier.map(|t| format!("{t:?}")), + } +} fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; @@ -491,4 +741,101 @@ mod tests { Some("Review the source update first.") ); } + + // The editor supports only some route fields, so the page must not limit models to them: + // a sub-agent model, a stage router's judge, and a custom classifier's groups all show. + #[test] + fn routes_list_every_model_the_runner_calls() { + let directory = tempfile::tempdir().expect("directory"); + let settings = directory.path().join("desktop.toml"); + let server_config = directory.path().join("server.toml"); + std::fs::write( + &settings, + format!("server_url = 'http://127.0.0.1:9'\nconfig_file = {server_config:?}\n"), + ) + .expect("settings"); + std::fs::write( + &server_config, + r#"schema_version = 1 + +[llm_clients.primary] +format = "openai_chat" +base_url = "https://example.test/v1" + +[targets.strong] +id = "strong/model" +llm_client = "primary" + +[targets.weak] +id = "weak/model" +llm_client = "primary" + +[targets.judge] +id = "judge/model" +llm_client = "primary" + +[routes.direct] +id = "direct" +type = "passthrough" +target = "strong" + +[routes.direct.subagents] +type = "passthrough" +target = "weak" + +[routes.stage] +id = "stage" +type = "stage_router" +capable_target = "strong" +efficient_target = "weak" +picker = "efficient_first" + +[routes.stage.classifier] +target = "judge" + +[routes.custom] +id = "custom" +type = "llm_classifier" +mode = "custom" +models = { judge = ["judge"], capable = ["strong"], efficient = ["weak"], any = ["strong", "weak"] } +default_target = "efficient" +prompt = "Pick a target." +response_schema = '{"type":"object"}' +policy = { type = "target_selector", selector = "/target" } +"#, + ) + .expect("server config"); + let config = Config::load(&settings).expect("config"); + let snapshot = Controller::new(config, settings) + .snapshot() + .expect("snapshot"); + let routes: Vec<_> = snapshot + .routes + .iter() + .map(|route| { + ( + route.key.as_str(), + route.models.clone(), + route.judges.clone(), + ) + }) + .collect(); + let both = || { + vec![ + "primary / strong/model".to_string(), + "primary / weak/model".to_string(), + ] + }; + let judge = || vec!["primary / judge/model".to_string()]; + assert_eq!( + routes, + [ + ("custom", both(), judge()), + ("direct", both(), Vec::new()), + ("stage", both(), judge()), + ], + "{:?}", + snapshot.errors + ); + } } diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index 6e344401cc..e0a9adeaad 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -11,8 +11,10 @@ use switchyard_desktop::{config, health, history, rollup}; mod controller; #[cfg(any(target_os = "macos", windows))] mod gui; +mod models; mod pricing; mod server; +mod server_config; mod summary; mod tui; #[cfg(windows)] diff --git a/crates/switchyard-desktop/src/models.rs b/crates/switchyard-desktop/src/models.rs new file mode 100644 index 0000000000..d643d8c71f --- /dev/null +++ b/crates/switchyard-desktop/src/models.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module lists the models an LLM client offers from its `GET /models` endpoint, +//! and keeps each fetched list in a cache file. +//! +//! The request goes through the system `curl`, the same way the app runs +//! `launchctl`, so the app needs no HTTP or TLS stack of its own. The key is +//! written to curl's stdin, never to its command line, where other processes +//! could read it. +//! +//! The cache file holds model IDs and fetch times, never a key. The app uses +//! a list from the cache file until the user refreshes it, so it fetches a +//! list on its own only when the file does not have it. + +use crate::server_config::Client; + +/// This function explains why model suggestions lack a key. Model-list keys stay separate from +/// the server keys that the installed helper keeps. +pub fn missing_env_note(variable: &str) -> String { + if cfg!(any(target_os = "macos", windows)) { + format!( + "Switchyard does not read {variable} from your shell. \ + To load model suggestions, save a model-list key below. \ + The server's own key is under Settings → Server keys." + ) + } else { + format!( + "{variable} is not set in this terminal, so this endpoint cannot list models. \ + Type the model ID instead." + ) + } +} + +/// This function returns the client's `api_key_env` variable when this process does not +/// have it set. +pub fn missing_env(client: &Client) -> Option<&str> { + client + .api_key_env + .as_deref() + .filter(|_| env_key(client).is_none()) +} + +/// This function returns whether the client's endpoint has no model list that this app can +/// ask for. The ChatGPT Codex endpoint answers only a ChatGPT login, never an +/// API key, and the app does not hold the login. +pub fn unlisted(client: &Client) -> bool { + client.host() == "chatgpt.com" +} + +fn env_key(client: &Client) -> Option { + client + .api_key_env + .as_deref() + .and_then(|variable| std::env::var(variable).ok()) + .filter(|key| !key.trim().is_empty()) +} diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs new file mode 100644 index 0000000000..81d1334f67 --- /dev/null +++ b/crates/switchyard-desktop/src/server_config.rs @@ -0,0 +1,467 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module reads the endpoints, generated blocks, and route editor fields in the server +//! config. +//! +//! `switchyard_runner::route_targets` lists each route and the models it calls, so the apps +//! show what the server would run. This module keeps what the route editor needs: labels, +//! hints, and the key path where each role names its target. + +use std::collections::HashMap; + +use toml_edit::{DocumentMut, Item, TableLike}; + +/// Tier identifies the part a model plays in a route. A choice made for one algorithm carries +/// over to the role with the same tier when the user switches algorithms. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Tier { + Judge, + Capable, + Efficient, +} + +/// Algorithm describes a route `type` the apps can write. +#[derive(Debug)] +pub struct Algorithm { + pub kind: &'static str, + /// This field sets the name shown in both apps. + pub title: &'static str, + pub summary: &'static str, + /// This field lists roles in display order. The list is empty for + /// `random`, whose roles are the entries of its `targets` list. + roles: &'static [RoleSpec], +} + +/// RoleSpec describes one role of an algorithm. +#[derive(Debug)] +struct RoleSpec { + label: &'static str, + /// This field explains what the role does. + hint: &'static str, + tier: Tier, + /// This field names the key path to the role's target under the route. + path: &'static [&'static str], +} + +const RANDOM: &str = "random"; + +/// ALGORITHMS lists supported route types in display order. +/// `noop` and the experimental `prefill_router` are left out: one calls no +/// model, and the other needs a checkpoint file rather than models. +pub const ALGORITHMS: &[Algorithm] = &[ + Algorithm { + kind: "passthrough", + title: "Single model", + summary: "This algorithm sends every request to one model.", + roles: &[RoleSpec { + label: "Model", + hint: "This model answers every request.", + tier: Tier::Capable, + path: &["target"], + }], + }, + Algorithm { + kind: RANDOM, + title: "Random split", + summary: "This algorithm splits requests at random between models \ + for A/B tests and baselines.", + roles: &[], + }, + Algorithm { + kind: "llm_classifier", + title: "Judge per request", + summary: "A judge model reads each request and picks the capable or the efficient model.", + roles: &[ + RoleSpec { + label: "Judge", + hint: "The judge reads each request and picks a model.", + tier: Tier::Judge, + path: &["classifier_target"], + }, + RoleSpec { + label: "Capable", + hint: "The capable model answers the hard requests.", + tier: Tier::Capable, + path: &["strong_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model answers the easy requests.", + tier: Tier::Efficient, + path: &["weak_target"], + }, + ], + }, + Algorithm { + kind: "composite", + title: "Judge plus stage router", + summary: "A judge picks the starting model on each user turn, and tool results move \ + requests between the capable and the efficient model.", + roles: &[ + RoleSpec { + label: "Judge", + hint: "The judge picks the starting model on each user turn.", + tier: Tier::Judge, + path: &["classifier", "target"], + }, + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["stage", "capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes the easy steps.", + tier: Tier::Efficient, + path: &["stage", "efficient_target"], + }, + ], + }, + Algorithm { + kind: "stage_router", + title: "Stage router", + summary: "Tool results and agent progress move each request between the capable and \ + the efficient model.", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes the easy steps.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + }, + Algorithm { + kind: "advisor", + title: "Executor and advisor", + summary: "The executor answers every turn, and a stronger advisor reviews its final \ + answers.", + roles: &[ + RoleSpec { + label: "Executor", + hint: "The executor answers every turn.", + tier: Tier::Efficient, + path: &["executor_target"], + }, + RoleSpec { + label: "Advisor", + hint: "The advisor reviews the executor's final answers.", + tier: Tier::Capable, + path: &["advisor_target"], + }, + ], + }, + Algorithm { + kind: "plan_execute", + title: "Plan, then execute", + summary: "The capable model inspects and plans. The efficient model takes over after \ + the first file edit.", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model inspects the code and plans.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes over after the first edit.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + }, + Algorithm { + kind: "auto", + title: "Recommended preset", + summary: "This stage router starts each request on the efficient model.", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model starts each request.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + }, +]; + +/// Client describes one entry under `[llm_clients]`. +#[derive(Clone, Debug, PartialEq)] +pub struct Client { + pub name: String, + pub format: String, + pub base_url: String, + pub api_key_env: Option, + pub forward_auth: bool, +} + +impl Client { + /// This function returns the host in `base_url`, such as `chatgpt.com`. A port stays. + pub fn host(&self) -> &str { + let rest = self + .base_url + .split_once("://") + .map_or(self.base_url.as_str(), |(_, rest)| rest); + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + authority.rsplit('@').next().unwrap_or(authority) + } +} + +/// Choice identifies a model and the client that serves it. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Choice { + pub client: String, + pub model: String, +} + +/// Role describes a model the algorithm needs, such as the judge. +#[derive(Clone, Debug, PartialEq)] +pub struct Role { + pub label: String, + /// This field explains what the role does. + pub hint: &'static str, + pub tier: Option, + slot: Slot, +} + +/// Slot identifies where a route names a role's target. +#[derive(Clone, Debug, PartialEq)] +enum Slot { + /// Key identifies a key path, such as `stage.capable_target`. + Key(&'static [&'static str]), + /// Listed identifies an entry of a random route's `targets` list. + Listed(usize), +} + +impl Algorithm { + /// This function returns the roles that a new route of this type needs. A new + /// random route starts with two models. + pub fn new_route_roles(&self) -> Vec { + self.roles_for(0) + } + + /// This function returns the roles of this type. `listed` is the number of entries + /// in a random route's `targets` list; other types ignore it. + fn roles_for(&self, listed: usize) -> Vec { + if self.kind != RANDOM { + return self + .roles + .iter() + .map(|spec| Role { + label: spec.label.to_string(), + hint: spec.hint, + tier: Some(spec.tier), + slot: Slot::Key(spec.path), + }) + .collect(); + } + (0..listed.max(2)) + .map(|index| Role { + label: format!("Model {}", index + 1), + hint: "This model gets a share of the requests.", + tier: [Tier::Capable, Tier::Efficient].get(index).copied(), + slot: Slot::Listed(index), + }) + .collect() + } +} + +/// ServerConfig keeps the parsed config and the original text's layout. +#[derive(Clone, Debug)] +pub struct ServerConfig { + doc: DocumentMut, + /// This field maps tables inside generated blocks to each block's first marker. + generated: HashMap, +} + +impl ServerConfig { + pub fn parse(text: &str) -> Result { + text.parse::() + .map(|doc| Self { + doc, + generated: generated_tables(text), + }) + .map_err(|error| { + format!( + "Could not parse the server config. Fix this TOML error and try again:\n\ + {error}" + ) + }) + } + + pub fn clients(&self) -> Vec { + entries(self.table("llm_clients")) + .map(|(name, client)| Client { + name: name.to_string(), + format: text(client, "format").unwrap_or_default().to_string(), + base_url: text(client, "base_url").unwrap_or_default().to_string(), + api_key_env: text(client, "api_key_env").map(str::to_string), + forward_auth: client + .get("forward_auth") + .and_then(Item::as_bool) + .unwrap_or(false), + }) + .collect() + } + + /// This function returns the text of the marker that opens the block another tool + /// writes around `[table.key]`, when the table sits inside one. That tool + /// overwrites the table the next time it runs. + pub fn generated_by(&self, table: &str, key: &str) -> Option<&str> { + self.generated + .get(&format!("{table}.{key}")) + .map(String::as_str) + } + + /// This function returns the model IDs that targets name on `client`, sorted, each once. + pub fn models_on(&self, client: &str) -> Vec { + let mut models: Vec = entries(self.table("targets")) + .filter(|(_, target)| text(*target, "llm_client") == Some(client)) + .filter_map(|(_, target)| text(target, "id").map(str::to_string)) + .collect(); + models.sort(); + models.dedup(); + models + } + + /// This function returns the algorithm a route uses now, or `None` when the picker + /// cannot show it, such as a custom-mode classifier whose groups are + /// free-form. + pub fn algorithm(&self, route: &str) -> Option<&'static Algorithm> { + let route = self.route(route)?; + if text(route, "mode") == Some("custom") { + return None; + } + let kind = text(route, "type")?; + ALGORITHMS.iter().find(|algorithm| algorithm.kind == kind) + } + + /// This function returns the roles that `algorithm` needs on `route`. A random route + /// keeps its number of targets, and has at least two. + pub fn roles(&self, route: &str, algorithm: &Algorithm) -> Vec { + let listed = self + .route(route) + .filter(|route| algorithm.kind == RANDOM && text(*route, "type") == Some(RANDOM)) + .and_then(|route| { + route + .get("targets")? + .as_array() + .map(|targets| targets.len()) + }) + .unwrap_or(0); + algorithm.roles_for(listed) + } + + /// This function returns the model that each role of the route's current algorithm + /// uses. A role whose target is missing gets an empty choice. + pub fn choices(&self, route: &str) -> Vec { + let Some(algorithm) = self.algorithm(route) else { + return Vec::new(); + }; + self.roles(route, algorithm) + .iter() + .map(|role| { + self.target_at(route, &role.slot) + .and_then(|name| self.target(name)) + .unwrap_or_default() + }) + .collect() + } + + fn table(&self, name: &str) -> Option<&dyn TableLike> { + self.doc.get(name)?.as_table_like() + } + + fn route(&self, route: &str) -> Option<&dyn TableLike> { + self.table("routes")?.get(route)?.as_table_like() + } + + fn target(&self, name: &str) -> Option { + let target = self.table("targets")?.get(name)?.as_table_like()?; + Some(Choice { + client: text(target, "llm_client")?.to_string(), + model: text(target, "id")?.to_string(), + }) + } + + fn target_at(&self, route: &str, slot: &Slot) -> Option<&str> { + let route = self.route(route)?; + match slot { + Slot::Key(path) => { + let (last, parents) = path.split_last()?; + let mut table = route; + for key in parents { + table = table.get(key)?.as_table_like()?; + } + text(table, last) + } + Slot::Listed(index) => route.get("targets")?.as_array()?.get(*index)?.as_str(), + } + } +} + +fn entries(table: Option<&dyn TableLike>) -> impl Iterator { + table + .into_iter() + .flat_map(|table| table.iter()) + .filter_map(|(key, item)| Some((key, item.as_table_like()?))) +} + +fn text<'a>(table: &'a dyn TableLike, key: &str) -> Option<&'a str> { + table.get(key)?.as_str() +} + +/// This function finds the route and target tables that sit inside blocks another tool +/// writes. A block starts at a line that begins with `# >>>` and ends at the +/// next line that begins with `# <<<`, the markers that the installer scripts +/// use. Each table maps to the text of its block's first marker, with the `#` +/// and the `>>>` removed. +fn generated_tables(text: &str) -> HashMap { + let mut tables = HashMap::new(); + let mut block: Option = None; + for line in text.lines().map(str::trim) { + if line.starts_with("# >>>") { + block = Some( + line.trim_matches(|c: char| c == '#' || c == '>' || c.is_whitespace()) + .to_string(), + ); + } else if line.starts_with("# <<<") { + block = None; + } else if let Some(block) = &block + && let Some(header) = line.strip_prefix('[') + && let Some((table, rest)) = header.split_once('.') + && matches!(table, "routes" | "targets") + && let Some(key) = table_key(rest) + { + tables + .entry(format!("{table}.{key}")) + .or_insert_with(|| block.clone()); + } + } + tables +} + +/// This function returns the first key of a table header's dotted path, bare or in double +/// quotes: `name` for `name.child]`, and `a.b` for `"a.b"]`. +fn table_key(path: &str) -> Option<&str> { + if let Some(quoted) = path.strip_prefix('"') { + return quoted.split_once('"').map(|(key, _)| key); + } + path.split(['.', ']']).next().filter(|key| !key.is_empty()) +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index fae63a159e..31b417cf8b 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -7,7 +7,7 @@ //! draws and reads input. use crate::controller::{ - Action, ActionKind, AppSettings, Availability, Controller, Reply, Snapshot, + Action, ActionKind, AppSettings, Availability, Controller, Reply, RouteInfo, Snapshot, }; use crate::history::Entry; use crate::pricing::Estimate; @@ -49,7 +49,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-4", + "1-5", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -90,6 +90,21 @@ const MODELS_KEYS: &[Key] = &[( "period", "Switch the period: today, the past 7 days, or the whole log. The [ and ] keys also work.", )]; +const ROUTES_KEYS: &[Key] = &[ + ( + "↑↓", + "select", + "Select a route. j, PgUp, PgDn, g, and G also move it.", + ), + ( + "/", + "filter", + "Filter as you type. Every word must appear in the route's public model ID, config \ + name, algorithm, endpoints, or models. A word with a capital letter must match case \ + exactly.", + ), + ("esc", "clear", "Clear the filter."), +]; const SETTINGS_KEYS: &[Key] = &[ ("↑↓", "select", "Select an action. j, g, and G also move."), ( @@ -236,6 +251,13 @@ const CALL_COLUMNS: [(Column, bool); 8] = [ (("OUT", Constraint::Length(7), true), false), (("COST", Constraint::Length(9), true), false), ]; +/// The routes table drops the columns marked `true` below 80 columns; the route details still +/// show every model. +const ROUTE_COLUMNS: [(Column, bool); 3] = [ + (("ROUTE", Constraint::Fill(1), false), false), + (("ALGORITHM", Constraint::Length(24), false), false), + (("MODELS", Constraint::Fill(2), false), true), +]; /// Theme maps each kind of text to one style. NO_COLOR keeps the text modifiers. #[derive(Clone, Copy)] @@ -288,16 +310,23 @@ enum Tab { Overview, Calls, Models, + Routes, Settings, } -const TABS: [Tab; 4] = [Tab::Overview, Tab::Calls, Tab::Models, Tab::Settings]; +const TABS: [Tab; 5] = [ + Tab::Overview, + Tab::Calls, + Tab::Models, + Tab::Routes, + Tab::Settings, +]; impl Tab { fn name(self) -> &'static str { match self { Tab::Overview => "Overview", Tab::Calls => "Calls", Tab::Models => "Models", - + Tab::Routes => "Routes", Tab::Settings => "Settings", } } @@ -638,7 +667,11 @@ struct App { selected: usize, offset: usize, action: usize, - + route_filter: String, + /// These indexes point into `data.routes`, in config order, after the route filter. + route_visible: Vec, + route_selected: usize, + route_offset: usize, /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -668,7 +701,10 @@ impl App { selected: 0, offset: 0, action: 0, - + route_filter: String::new(), + route_visible: Vec::new(), + route_selected: 0, + route_offset: 0, server_keys: None, popup: None, popup_generation: 0, @@ -705,6 +741,52 @@ impl App { .map(|&index| &self.data.entries[index]) } + /// A route keeps its selection by config name, so a refresh or a rename keeps it selected. + fn selected_route_key(&self) -> Option { + self.selected_route().ok().map(|route| route.key.clone()) + } + + /// This function filters the routes and selects `anchor` if it is still listed. Otherwise + /// the selection keeps its position, so deleting a route selects the next one; `refilter` + /// starts from the first match instead. + fn rebuild_routes(&mut self, anchor: Option) { + let algorithms = &self.data.algorithms; + let filter = self.route_filter.as_str(); + self.route_visible = self + .data + .routes + .iter() + .enumerate() + .filter(|(_, route)| { + let title = algorithms + .iter() + .find(|algorithm| algorithm.kind == route.kind) + .map_or("", |algorithm| algorithm.title); + matches_words(&[&route.label, &route.key, title], filter) + }) + .map(|(index, _)| index) + .collect(); + let routes = &self.data.routes; + self.route_selected = anchor + .and_then(|key| { + self.route_visible + .iter() + .position(|&index| routes[index].key == key) + }) + .unwrap_or(self.route_selected) + .min(self.route_visible.len().saturating_sub(1)); + } + + fn selected_route(&self) -> Result<&RouteInfo, String> { + match self.route_visible.get(self.route_selected) { + Some(&index) => Ok(&self.data.routes[index]), + None if !self.data.routes.is_empty() => { + Err("No route matches the filter. Clear or change the filter first.".into()) + } + None => Err("Choose a route first.".into()), + } + } + // Every replacement or dismissal changes the generation, even when the same form reopens. fn set_popup(&mut self, popup: Option) { self.popup_generation += 1; @@ -770,9 +852,11 @@ impl App { .selected_entry() .filter(|_| self.selected > 0) .map(|entry| (entry.ts.clone(), entry.model.clone(), entry.turn_id.clone())); + let route = self.selected_route_key(); self.data = *data; self.refreshed = Some(Instant::now()); self.rebuild_calls(); + self.rebuild_routes(route); if let Some((ts, model, turn)) = anchor && let Some(position) = self.visible.iter().position(|&index| { let entry = &self.data.entries[index]; @@ -975,19 +1059,29 @@ impl App { (None, false, Tab::Overview) => &[], (None, false, Tab::Calls) => CALLS_KEYS, (None, false, Tab::Models) => MODELS_KEYS, - + (None, false, Tab::Routes) => ROUTES_KEYS, (None, false, Tab::Settings) => SETTINGS_KEYS, } } /// The Calls and Routes pages each keep their own filter; `/` edits the current page's. fn filter_mut(&mut self) -> &mut String { - &mut self.filter + if self.tab == Tab::Routes { + &mut self.route_filter + } else { + &mut self.filter + } } fn refilter(&mut self) { - self.selected = 0; - self.rebuild_calls(); + if self.tab == Tab::Routes { + let anchor = self.selected_route_key(); + self.route_selected = 0; + self.rebuild_routes(anchor); + } else { + self.selected = 0; + self.rebuild_calls(); + } } fn paste(&mut self, text: &str) { @@ -1015,7 +1109,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.set_popup(Some(Popup::Help { scroll: 0 })), - KeyCode::Char(digit @ '1'..='4') => { + KeyCode::Char(digit @ '1'..='5') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -1031,7 +1125,10 @@ impl App { self.session = None; self.rebuild_calls(); } - + Tab::Routes => { + self.route_filter.clear(); + self.refilter(); + } Tab::Overview | Tab::Models | Tab::Settings => {} } } @@ -1055,7 +1152,7 @@ impl App { KeyCode::Char('[') => self.period = self.period.previous(), _ => {} }, - + Tab::Routes => self.routes_key(code), Tab::Settings => return self.settings_key(code), } } @@ -1095,6 +1192,15 @@ impl App { } } + fn routes_key(&mut self, code: KeyCode) { + let last = self.route_visible.len().saturating_sub(1); + if let Some(selected) = step(code, self.route_selected, last) { + self.route_selected = selected; + } else if code == KeyCode::Char('/') { + self.filtering = true; + } + } + fn filter_key(&mut self, key: KeyEvent) { let control = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { @@ -1453,6 +1559,9 @@ fn block(theme: Theme, title: &str) -> Block<'static> { fn draw(frame: &mut Frame, app: &mut App) { // This exhaustive destructuring requires an update when Snapshot gains a field. let Snapshot { + clients: _, // draw_endpoints and the endpoint forms show these clients. + algorithms: _, // draw_routes and the algorithm fields show these algorithms. + routes: _, // draw_routes and the route details show these routes. update_status: _, // draw_settings shows the last update status. settings: _, // draw_settings shows the app settings. data_state: _, // draw_overview shows health; draw_models checks usage @@ -1486,7 +1595,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Overview => draw_overview(frame, app, body), Tab::Calls => draw_calls(frame, app, body), Tab::Models => draw_models(frame, app, body), - + Tab::Routes => draw_routes(frame, app, body), Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); @@ -2222,6 +2331,256 @@ fn draw_models(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(table(theme, &columns, routes), routes_area); } +/// This function returns the algorithm's name, or the route type for a type the apps cannot +/// write. +fn algorithm_title<'a>(data: &'a Snapshot, kind: &'a str) -> &'a str { + data.algorithms + .iter() + .find(|algorithm| algorithm.kind == kind) + .map_or(kind, |algorithm| algorithm.title) +} + +fn draw_routes(frame: &mut Frame, app: &mut App, area: Rect) { + let theme = app.theme; + // A server config that cannot be read lists no routes, so its error explains the empty list. + let errors = if app.data.routes.is_empty() && !app.data.errors.is_empty() { + (app.data.errors.len() as u16 + 2).min(area.height / 3) + } else { + 0 + }; + let notes = endpoint_notes(theme, &app.data); + let endpoints = (app.data.clients.len().max(1) + notes.len() + 3) as u16; + let [errors_area, top, endpoints_area] = Layout::vertical([ + Constraint::Length(errors), + Constraint::Fill(1), + Constraint::Length(endpoints.min(area.height / 3)), + ]) + .areas(area); + if errors > 0 { + draw_errors(frame, app, errors_area); + } + draw_endpoints(frame, app, notes, endpoints_area); + let (title, lines) = match app.selected_route() { + Ok(route) => (route.id.clone(), route_lines(theme, &app.data, route)), + Err(_) => ( + "Route".to_string(), + vec![Line::styled("No route selected.", theme.muted)], + ), + }; + let detail = Paragraph::new(lines).wrap(Wrap { trim: false }); + // Wide terminals put the details beside the list; others put them below it. + let (list, detail_area) = if top.width >= SIDE_DETAIL_WIDTH { + let [list, side] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(52)]).areas(top); + (list, side) + } else { + // The list keeps its borders, header, and the selected row; the details get the rest. + let rows = detail.line_count(top.width.saturating_sub(4)) as u16 + 2; + let [list, below] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(rows.min(top.height.saturating_sub(4))), + ]) + .areas(top); + (list, below) + }; + draw_route_table(frame, app, list); + frame.render_widget( + detail.block(block(theme, &title).padding(Padding::horizontal(1))), + detail_area, + ); +} + +fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { + // The table shows this many routes; the borders and the header row take three rows. + let height = usize::from(area.height.saturating_sub(3)).max(1); + let visible = app.route_visible.len(); + app.route_offset = keep_visible(app.route_selected, app.route_offset, visible, height); + let app = &*app; + let theme = app.theme; + let mut title = vec![ + Span::styled(" Routes ", Modifier::BOLD), + Span::styled(format!("[{}] ", count(visible as u64)), theme.muted), + ]; + if !app.route_filter.is_empty() || app.filtering { + title.push(Span::styled(format!("/{} ", app.route_filter), theme.key)); + } + let mut outer = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(if app.filtering { + theme.key + } else { + theme.border + }) + .title(Line::from(title)); + if visible == 0 { + let text = if app.data.routes.is_empty() { + "No routes in the server config." + } else { + "No routes match the filter. Press Esc to clear it." + }; + frame.render_widget( + Paragraph::new(Line::styled(text, theme.muted)) + .centered() + .block(outer.padding(Padding::top(1))), + area, + ); + return; + } + outer = outer.title_bottom( + Line::styled( + format!(" {} of {} ", app.route_selected + 1, count(visible as u64)), + theme.muted, + ) + .right_aligned(), + ); + let wide = area.width >= 80; + let shown = |column: &(Column, bool)| wide || !column.1; + let rows: Vec = app.route_visible[app.route_offset..] + .iter() + .take(height) + .map(|&index| { + let route = &app.data.routes[index]; + let cells = [ + Cell::from(route.id.clone()), + Cell::from(algorithm_title(&app.data, &route.kind).to_string()), + Cell::from(route.models.join(", ")), + ]; + Row::new( + cells + .into_iter() + .zip(&ROUTE_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = ROUTE_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let mut state = + TableState::default().with_selected(Some(app.route_selected - app.route_offset)); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); + draw_scrollbar(frame, theme, area, visible, app.route_selected); +} + +/// This function shows the selected route's algorithm and every model it calls first, so a +/// short terminal still shows them, then the notes that limit editing it here. +fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec> { + const WIDTH: usize = 11; + let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); + let algorithm = data + .algorithms + .iter() + .find(|known| known.kind == route.kind); + let mut lines = vec![ + field("Config", format!("[routes.{}]", route.key)), + field( + "Algorithm", + algorithm.map_or_else( + || route.kind.clone(), + |known| format!("{} ({})", known.title, known.kind), + ), + ), + ]; + lines.extend( + route + .models + .iter() + .map(|model| field("Model", model.clone())), + ); + lines.extend( + route + .judges + .iter() + .map(|judge| field("Judge", judge.clone())), + ); + if !route.editable { + lines.push(Line::default()); + lines.push(Line::styled( + "This routing method has no editor here. Edit its fields in the server config.", + theme.warn, + )); + } + if let Some(generated) = &route.generated { + lines.push(Line::default()); + lines.push(Line::styled( + format!("Generated by {generated}. That tool can replace edits made here."), + theme.warn, + )); + } + if let Some(known) = algorithm { + lines.push(Line::default()); + lines.push(Line::styled(known.summary, theme.muted)); + } + lines +} + +/// This function explains which endpoints cannot list their models. +fn endpoint_notes(theme: Theme, data: &Snapshot) -> Vec> { + let unlisted = "The app cannot list this endpoint's models. Type model IDs by hand."; + data.clients + .iter() + .flat_map(|client| { + let unlisted = client.unlisted.then(|| unlisted.to_string()); + unlisted.into_iter().chain(client.note.clone()).map(|note| { + Line::from(vec![ + Span::styled(format!("{}: ", client.name), theme.muted), + Span::styled(note, theme.warn), + ]) + }) + }) + .collect() +} + +fn draw_endpoints(frame: &mut Frame, app: &App, notes: Vec>, area: Rect) { + let theme = app.theme; + let clients = &app.data.clients; + let outer = block(theme, &format!("Endpoints [{}]", clients.len())); + let inner = outer.inner(area); + frame.render_widget(outer, area); + if clients.is_empty() { + frame.render_widget( + Paragraph::new(Line::styled( + "The server config names no endpoints.", + theme.muted, + )), + inner, + ); + return; + } + let [table_area, notes_area] = Layout::vertical([ + Constraint::Length(clients.len() as u16 + 1), + Constraint::Fill(1), + ]) + .areas(inner); + let rows: Vec = clients + .iter() + .map(|client| { + Row::new([ + client.name.clone(), + client.host.clone(), + client.models.join(", "), + ]) + }) + .collect(); + let columns = [ + ("NAME", Constraint::Fill(1), false), + ("HOST", Constraint::Fill(1), false), + ("MODELS IN THE SERVER CONFIG", Constraint::Fill(3), false), + ]; + frame.render_widget(table(theme, &columns, rows), table_area); + frame.render_widget(Paragraph::new(notes).wrap(Wrap { trim: false }), notes_area); +} + /// This function lists the server key variables from the last `ServerKeys` reply. fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { let Some(data) = keys else { @@ -2356,7 +2715,10 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let left = if app.filtering { - let (filter, matches) = (&app.filter, app.visible.len()); + let (filter, matches) = match app.tab { + Tab::Routes => (&app.route_filter, app.route_visible.len()), + _ => (&app.filter, app.visible.len()), + }; Line::from(vec![ Span::styled(" / ", theme.key), Span::raw(filter.clone()), @@ -2673,6 +3035,7 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { ("Calls", CALLS_KEYS, Some(Tab::Calls)), ("While filtering calls or routes", FILTER_KEYS, None), ("Models", MODELS_KEYS, Some(Tab::Models)), + ("Routes", ROUTES_KEYS, Some(Tab::Routes)), ("Settings", SETTINGS_KEYS, Some(Tab::Settings)), ] { if !lines.is_empty() { @@ -3509,7 +3872,7 @@ mod tests { match tab { Tab::Calls => &['/', 's'], Tab::Models => &['p', '[', ']'], - + Tab::Routes => &['/'], Tab::Overview | Tab::Settings => &[], } }; diff --git a/crates/switchyard-runner/src/algorithm.rs b/crates/switchyard-runner/src/algorithm.rs index 83551b2f29..07c04c3847 100644 --- a/crates/switchyard-runner/src/algorithm.rs +++ b/crates/switchyard-runner/src/algorithm.rs @@ -21,6 +21,7 @@ use libsy::{ }; use serde::Deserialize; use serde_json::Value; +use strum_macros::IntoStaticStr; use switchyard_protocol::{Category, ModelId}; /// Error returned when an algorithm description cannot be constructed. @@ -335,7 +336,7 @@ pub struct LlmClassifierRouteConfig { } /// Routing policy applied only to delegated sub-agent work, nested inside a -/// `passthrough` or `stage_router` route. +/// `passthrough`, `llm_classifier`, `stage_router` or `composite` route. #[derive(Clone, Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum SubagentRouteConfig { @@ -375,9 +376,11 @@ impl SubagentRouteConfig { } } -/// A routing algorithm described by configured target names. -#[derive(Clone, Debug, Deserialize)] +/// A routing algorithm described by configured target names. Converting it to `&str` gives +/// the route's `type`, such as `stage_router`. +#[derive(Clone, Debug, Deserialize, IntoStaticStr)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +#[strum(serialize_all = "snake_case")] pub enum AlgorithmSpec { /// Replies `OK` without calling any model. Useful for smoke tests. Noop {}, @@ -422,6 +425,9 @@ pub enum AlgorithmSpec { /// Judge and tier settings, written directly in the route table. #[serde(flatten)] config: LlmClassifierRouteConfig, + /// Separate policy for delegated sub-agent work. + #[serde(default)] + subagents: Option, }, /// Picks a tier per turn by scoring signals from recent tool results. StageRouter { @@ -789,25 +795,33 @@ impl AlgorithmSpec { efficient_target, .. } => vec![capable_target.as_str(), efficient_target.as_str()], - Self::LlmClassifier { config, .. } => match config.classifier_mode() { - ClassifierMode::Capability => config - .weak_target - .iter() - .chain(&config.strong_target) - .map(String::as_str) - .collect(), - ClassifierMode::Escalation => config - .strong_target - .iter() - .chain(&config.weak_target) - .map(String::as_str) - .collect(), - ClassifierMode::Custom => config - .models - .as_ref() - .map(CategoryModelConfig::routing_names) - .unwrap_or_default(), - }, + Self::LlmClassifier { + config, subagents, .. + } => { + let mut names: Vec<&str> = match config.classifier_mode() { + ClassifierMode::Capability => config + .weak_target + .iter() + .chain(&config.strong_target) + .map(String::as_str) + .collect(), + ClassifierMode::Escalation => config + .strong_target + .iter() + .chain(&config.weak_target) + .map(String::as_str) + .collect(), + ClassifierMode::Custom => config + .models + .as_ref() + .map(CategoryModelConfig::routing_names) + .unwrap_or_default(), + }; + if let Some(subagents) = subagents { + names.extend(subagents.routing_target_names()); + } + names + } Self::StageRouter { tiers, subagents, .. } => { @@ -857,6 +871,14 @@ impl AlgorithmSpec { /// a classifier also calls its judge, and that call needs a client too. pub fn callable_target_names(&self) -> Vec<&str> { let mut names = self.routing_target_names(); + names.extend(self.judge_target_names()); + names + } + + /// Targets the algorithm calls only to decide, such as a judge or an advisor. A target + /// that also answers requests appears in [`routing_target_names`](Self::routing_target_names) too. + pub fn judge_target_names(&self) -> Vec<&str> { + let mut names = Vec::new(); match self { // Custom mode names its judge in `models.judge`; the other two modes // use the top-level `classifier_target`. @@ -872,7 +894,7 @@ impl AlgorithmSpec { .map(String::as_str), ); } else { - names.push(&config.classifier_target); + names.push(config.classifier_target.as_str()); } } Self::StageRouter { @@ -894,6 +916,10 @@ impl AlgorithmSpec { subagents: Some(subagents), .. } + | Self::LlmClassifier { + subagents: Some(subagents), + .. + } | Self::StageRouter { subagents: Some(subagents), .. @@ -930,7 +956,7 @@ impl AlgorithmSpec { vec![capable_target.clone(), efficient_target.clone()], ), ]), - Self::LlmClassifier { config } => { + Self::LlmClassifier { config, .. } => { classifier_runtime_model_names(config.validated_classifier_mode(route_name)?) } Self::StageRouter { @@ -1006,6 +1032,7 @@ impl AlgorithmSpec { let subagents = match self { Self::Passthrough { subagents, .. } + | Self::LlmClassifier { subagents, .. } | Self::StageRouter { subagents, .. } | Self::Composite { subagents, .. } => subagents.as_ref(), _ => None, @@ -1019,28 +1046,33 @@ impl AlgorithmSpec { Ok(RuntimeModelNames { parent, subagent }) } - /// Response target and routing-only dependency for routers that answer while routing. - pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> { + /// Response target and routing-only dependencies for routers that answer while routing. + pub(crate) fn routing_response_and_dependencies(&self) -> Option<(&str, Vec<&str>)> { match self { - Self::LlmClassifier { config, .. } - if matches!(config.classifier_mode(), ClassifierMode::Escalation) => - { - Some(( - config.weak_target.as_deref()?, - config.classifier_target.as_str(), - )) + Self::LlmClassifier { + config, subagents, .. + } if matches!(config.classifier_mode(), ClassifierMode::Escalation) => { + // A sub-agent judge is routing-only too; on the answer model it would get + // that model's system_prompt. + let mut dependencies = vec![config.classifier_target.as_str()]; + if let Some(subagents) = subagents { + dependencies.extend(subagents.judge_target_names()); + } + Some((config.weak_target.as_deref()?, dependencies)) } Self::Advisor { executor_target, advisor_target, .. - } => Some((executor_target, advisor_target)), + } => Some((executor_target, vec![advisor_target])), Self::Vgr { config } => Some(( &config.efficient_target, - config - .judge_target - .as_deref() - .unwrap_or(&config.efficient_target), + vec![ + config + .judge_target + .as_deref() + .unwrap_or(&config.efficient_target), + ], )), Self::Noop { .. } | Self::Random { .. } @@ -1551,7 +1583,7 @@ fn build_algorithm( } AlgorithmSpec::LlmClassifier { config: classifier_config, - .. + subagents, } => { let mode = classifier_config.validated_classifier_mode(route_name)?; let algorithm = match mode { @@ -1602,7 +1634,8 @@ fn build_algorithm( error, ) })?; - Ok(Arc::new(algorithm)) + let parent: Arc = Arc::new(algorithm); + attach_subagent_router(route_name, parent, subagents.as_ref(), targets) } AlgorithmSpec::StageRouter { tiers, diff --git a/crates/switchyard-runner/src/config.rs b/crates/switchyard-runner/src/config.rs index 26b624819a..4fe352dc1f 100644 --- a/crates/switchyard-runner/src/config.rs +++ b/crates/switchyard-runner/src/config.rs @@ -59,6 +59,59 @@ pub fn api_key_variables(source: &str) -> Result, RunnerError> Ok(variables) } +/// RouteTargets lists the targets one route calls, as the runner reads them from the server config. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouteTargets { + /// This field names the route's table under `[routes]`. + pub name: String, + /// This field holds the public model ID that callers send. + pub id: ModelId, + /// This field holds the route's `type`, such as `stage_router`. + pub kind: &'static str, + /// This field lists the targets that answer requests, in algorithm order. It includes + /// targets that only delegated sub-agent work uses. + pub answer_targets: Vec, + /// This field lists the targets the route calls only to decide, such as a judge or an advisor. + pub judge_targets: Vec, +} + +/// ConfiguredTarget names one target that a route uses. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfiguredTarget { + pub name: String, + /// This field holds the client and model from the `[targets]` or `[decision_targets]` + /// entry with this name. It is `None` when no entry has the name; the server refuses to + /// start with such a config. + pub model: Option, +} + +/// TargetModel identifies the model a target sends requests to and the client that serves it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TargetModel { + pub client: String, + pub id: ModelId, +} + +/// This function lists each route's targets without building clients or reading credentials. +/// It returns the same parse error the server reports for a config that does not match the schema. +pub fn route_targets(source: &str) -> Result, RunnerError> { + let config: DeploymentConfig = toml::from_str(source).map_err(|error| { + RunnerError::configuration_source(format!("failed to parse TOML: {error}"), error) + })?; + config.check_schema_version()?; + Ok(config + .routes + .iter() + .map(|(name, route)| RouteTargets { + name: name.clone(), + id: route.id.clone(), + kind: (&route.algorithm).into(), + answer_targets: config.configured_targets(route.routing_target_names()), + judge_targets: config.configured_targets(route.algorithm.judge_target_names()), + }) + .collect()) +} + type RunnerResult = Result; pub(crate) fn load_runner(path: impl AsRef) -> RunnerResult { @@ -181,6 +234,40 @@ impl RouteConfig { } impl DeploymentConfig { + fn check_schema_version(&self) -> RunnerResult<()> { + if self.schema_version == SUPPORTED_SCHEMA_VERSION { + return Ok(()); + } + Err(RunnerError::configuration(format!( + "unsupported schema_version {}; expected {SUPPORTED_SCHEMA_VERSION}", + self.schema_version + ))) + } + + /// This function looks up each name in `[targets]`, then in `[decision_targets]`. The server + /// rejects a name defined in both, so the lookup order does not change the result. + fn configured_targets(&self, names: Vec<&str>) -> Vec { + names + .into_iter() + .map(|name| ConfiguredTarget { + name: name.to_string(), + model: self + .targets + .get(name) + .map(|target| (&target.llm_client, &target.id)) + .or_else(|| { + self.decision_targets + .get(name) + .map(|target| (&target.decision_client, &target.id)) + }) + .map(|(client, id)| TargetModel { + client: client.clone(), + id: id.clone(), + }), + }) + .collect() + } + fn decision_target(&self, name: &str) -> Option { let target = self.targets.get(name)?; let client = self.llm_clients.get(&target.llm_client)?; @@ -194,12 +281,7 @@ impl DeploymentConfig { } fn build(self) -> RunnerResult { - if self.schema_version != SUPPORTED_SCHEMA_VERSION { - return Err(RunnerError::configuration(format!( - "unsupported schema_version {}; expected {SUPPORTED_SCHEMA_VERSION}", - self.schema_version - ))); - } + self.check_schema_version()?; let mut route_names_by_id = HashMap::new(); for (route_name, config) in &self.routes { @@ -306,7 +388,7 @@ impl DeploymentConfig { .build_with_runtime_models(route_name, &targets) .map_err(|error| RunnerError::configuration_source(error.to_string(), error))?; if let Some(infra) = &config.infra { - if !matches!(&config.algorithm, AlgorithmSpec::LlmClassifier { config } + if !matches!(&config.algorithm, AlgorithmSpec::LlmClassifier { config, .. } if matches!(config.mode, None | Some(crate::ClassifierMode::Capability)) && config.escalation.is_none()) { @@ -622,8 +704,8 @@ impl DeploymentConfig { prompts, routing_answer_target: None, }; - let Some((response_name, dependency_name)) = - route.algorithm.routing_response_and_dependency() + let Some((response_name, dependency_names)) = + route.algorithm.routing_response_and_dependencies() else { return Ok(policy); }; @@ -633,14 +715,18 @@ impl DeploymentConfig { if !policy.prompts.contains_key(&response.id) { return Ok(policy); } - let dependency = self.targets.get(dependency_name).ok_or_else(|| { - RunnerError::configuration(format!("route references unknown target {dependency_name}")) - })?; - if response.id == dependency.id { - return Err(RunnerError::configuration(format!( - "route {route_name} cannot apply system_prompt to target {response_name}: model {} is also used by routing-only target {dependency_name}", - response.id, - ))); + for dependency_name in dependency_names { + let dependency = self.targets.get(dependency_name).ok_or_else(|| { + RunnerError::configuration(format!( + "route references unknown target {dependency_name}" + )) + })?; + if response.id == dependency.id { + return Err(RunnerError::configuration(format!( + "route {route_name} cannot apply system_prompt to target {response_name}: model {} is also used by routing-only target {dependency_name}", + response.id, + ))); + } } policy.routing_answer_target = Some(response.id.clone()); Ok(policy) @@ -1290,20 +1376,27 @@ classify_trigger = "new_session""#, // The sub-agent target is also the parent's capable tier. Merged into one group it // would be indistinguishable from that tier, and delegated work would follow the // parent's ordering instead of its own configured target. - let runner = runner_from_toml(&with_subagent_passthrough(&stage_config(), "stage"))?; - let models = runner - .route("switchyard/stage") - .expect("stage route should exist") - .models(); + for (route, parent_any) in [ + ("stage", ["strong", "weak"]), + ("classifier", ["weak", "strong"]), + ] { + let runner = runner_from_toml(&with_subagent_passthrough(&stage_config(), route))?; + let models = runner + .route(&format!("switchyard/{route}")) + .expect("route should exist") + .models(); - assert_eq!( - models.subagent_models_for(&Category::Any), - [ModelId::from("strong/model")] - ); - assert_eq!( - models.models_for(&Category::Any), - [ModelId::from("strong/model"), ModelId::from("weak/model")] - ); + assert_eq!( + models.subagent_models_for(&Category::Any), + [ModelId::from("strong/model")], + "{route}" + ); + assert_eq!( + models.models_for(&Category::Any), + parent_any.map(|name| ModelId::from(format!("{name}/model"))), + "{route}" + ); + } Ok(()) } @@ -1521,7 +1614,7 @@ classify_trigger"#, } #[test] - fn passthrough_and_stage_accept_subagent_routing() -> RunnerResult<()> { + fn parent_routes_accept_subagent_routing() -> RunnerResult<()> { let stage = stage_config(); let stage_with_classifier = with_subagent_llm_classifier(&stage, "stage", ""); let parsed: DeploymentConfig = toml::from_str(&stage_with_classifier).map_err(|error| { @@ -1535,17 +1628,84 @@ classify_trigger"#, assert!(callable_targets.contains(&expected)); } + // An llm_classifier parent ends up with two judges once it nests a sub-agent route: + // its own and the child's. The child also gets a target of its own (`worker`), which + // only appears in the parent's callable targets if the child's targets are included. + let base = VALID_CONFIG.replace( + "classifier_target = \"classifier\"", + "classifier_target = \"parent_judge\"", + ) + "\n[targets.parent_judge]\nid = \"parent-judge/model\"\nllm_client = \"primary\"\n\n[targets.worker]\nid = \"worker/model\"\nllm_client = \"primary\"\n"; + let classifier_with_classifier = with_subagent_llm_classifier(&base, "classifier", "") + .replace("capable = [\"strong\"]", "capable = [\"worker\"]") + .replace( + "any = [\"strong\", \"weak\"]", + "any = [\"worker\", \"weak\"]", + ); + let parsed: DeploymentConfig = + toml::from_str(&classifier_with_classifier).map_err(|error| { + RunnerError::configuration(format!("failed to parse classifier config: {error}")) + })?; + let Some(classifier_route) = parsed.routes.get("classifier") else { + return Err(RunnerError::configuration("classifier route is missing")); + }; + let callable_targets = classifier_route.callable_target_names(); + for expected in ["weak", "strong", "parent_judge", "classifier", "worker"] { + assert!(callable_targets.contains(&expected), "{expected}"); + } + for configured in [ with_subagent_llm_classifier(VALID_CONFIG, "passthrough", ""), with_subagent_passthrough(VALID_CONFIG, "passthrough"), stage_with_classifier, with_subagent_passthrough(&stage, "stage"), + classifier_with_classifier, + // One llm_classifier-child row per parent shape (capability above, escalation, + // custom); a passthrough child has no judge, so it adds no branch here. + // Escalation answers while routing; a prompted weak target is fine while the + // child's judge runs on a different model. + with_subagent_llm_classifier(&prompted_escalation_config(), "classifier", ""), + with_subagent_llm_classifier(&custom_classifier_parent_config(), "custom", ""), ] { runner_from_toml(&configured)?; } Ok(()) } + fn prompted_escalation_config() -> String { + let escalation = + VALID_CONFIG.replace("base_threshold = 0.5", "escalation = { confirmations = 1 }"); + assert!( + escalation.contains("escalation = "), + "escalation replace missed" + ); + let prompted = escalation.replace( + "id = \"weak/model\"\nllm_client = \"anthropic\"", + "id = \"weak/model\"\nllm_client = \"anthropic\"\nsystem_prompt = \"answer prompt\"", + ); + assert!( + prompted.contains("system_prompt = "), + "system_prompt replace missed" + ); + prompted + } + + fn custom_classifier_parent_config() -> String { + format!( + r#"{VALID_CONFIG} +[routes.custom] +id = "switchyard/custom" +type = "llm_classifier" +mode = "custom" +models = {{ judge = ["classifier"], capable = ["strong"], efficient = ["weak"], any = ["strong", "weak"] }} +default_target = "efficient" +prompt = "Select a target." +response_schema = '{{"type":"object","properties":{{"target":{{"type":"string","enum":["capable","efficient"]}}}},"required":["target"],"additionalProperties":false}}' +policy = {{ type = "target_selector", selector = "/target" }} +classify_trigger = "new_session" +"# + ) + } + #[test] fn aliased_completion_targets_reject_prompt_conflicts() { let configured = stage_config() @@ -1918,6 +2078,8 @@ classifier_magic = true ), "message_hash_fallback requires classify_trigger = new_session", ), + // Sub-agent affinity needs the harness child identity, so nested classifier + // routes reject message hash fallback. ( with_subagent_llm_classifier( VALID_CONFIG, @@ -1926,6 +2088,21 @@ classifier_magic = true ), "cannot use message_hash_fallback", ), + // Nested classifier routes reject the fallback: sub-agent affinity needs + // harness-provided child identity, which a message hash cannot supply. + ( + with_subagent_llm_classifier( + VALID_CONFIG, + "classifier", + "\nmessage_hash_fallback = true", + ), + "cannot use message_hash_fallback", + ), + ( + with_subagent_llm_classifier(&prompted_escalation_config(), "classifier", "") + .replace("judge = [\"classifier\"]", "judge = [\"weak\"]"), + "cannot apply system_prompt to target weak: model weak/model is also used by routing-only target weak", + ), ( with_subagent_llm_classifier(VALID_CONFIG, "passthrough", "") .replace("mode = \"custom\"", "mode = \"capability\""), diff --git a/crates/switchyard-runner/src/lib.rs b/crates/switchyard-runner/src/lib.rs index 48172c60ab..0065333a73 100644 --- a/crates/switchyard-runner/src/lib.rs +++ b/crates/switchyard-runner/src/lib.rs @@ -10,7 +10,7 @@ mod provider_key_redactor; mod route; mod runner; -pub use config::api_key_variables; +pub use config::{ConfiguredTarget, RouteTargets, TargetModel, api_key_variables, route_targets}; pub use algorithm::{ AdvisorTriggerConfig, AlgorithmConfigError, AlgorithmSpec, CategoryModelConfig, ClassifierMode, diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index 4fed60a308..b27dc20b0e 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -1215,8 +1215,8 @@ impl RequestLogContext { requested_model = self.requested_model.as_deref().unwrap_or(""), selected_model, streaming = self.streaming, - session_id = self.session_id.as_deref().unwrap_or(""), - correlation_id = self.correlation_id.as_deref().unwrap_or(""), + session_id = self.session_id.as_deref(), + correlation_id = self.correlation_id.as_deref(), handling_duration_ms = duration_ms, error, $message @@ -1243,8 +1243,8 @@ impl RequestLogContext { requested_model = self.requested_model.as_deref().unwrap_or(""), selected_model = "", streaming = self.streaming, - session_id = self.session_id.as_deref().unwrap_or(""), - correlation_id = self.correlation_id.as_deref().unwrap_or(""), + session_id = self.session_id.as_deref(), + correlation_id = self.correlation_id.as_deref(), handling_duration_ms = self.started.elapsed().as_secs_f64() * 1_000.0, error = "client disconnected before a response was written", "LLM request cancelled" @@ -2044,7 +2044,12 @@ targets = ["only"] // Collects the terminal request events emitted while it is the active subscriber. #[derive(Clone, Default)] - struct CapturedEvents(Arc>>); + struct CapturedEvents(Arc>>); + + struct CapturedEvent { + level: Level, + message: String, + } impl tracing_subscriber::Layer for CapturedEvents { fn on_event( @@ -2061,7 +2066,10 @@ targets = ["only"] message.push_str(&format!("{}={value:?} ", field.name())); }, ); - self.0.lock().push((*event.metadata().level(), message)); + self.0.lock().push(CapturedEvent { + level: *event.metadata().level(), + message, + }); } } @@ -2076,13 +2084,13 @@ targets = ["only"] } } - fn captured_events(run: impl FnOnce()) -> Vec<(Level, String)> { + fn captured_events(run: impl FnOnce()) -> Vec { use tracing_subscriber::layer::SubscriberExt; let captured = CapturedEvents::default(); let subscriber = tracing_subscriber::registry().with(captured.clone()); crate::testing::with_subscriber(subscriber, run); - captured.0.lock().clone() + std::mem::take(&mut *captured.0.lock()) } // A dropped handler future is the only trace an abandoned buffered request leaves, @@ -2094,8 +2102,12 @@ targets = ["only"] }); assert_eq!(events.len(), 1); - assert_eq!(events[0].0, Level::WARN); - assert!(events[0].1.contains("cancelled"), "{:?}", events[0].1); + assert_eq!(events[0].level, Level::WARN); + assert!( + events[0].message.contains("cancelled"), + "{:?}", + events[0].message + ); } // A request that reached a response is not a cancellation, even though the guard @@ -2108,8 +2120,49 @@ targets = ["only"] }); assert_eq!(events.len(), 1); - assert_eq!(events[0].0, Level::INFO); - assert!(events[0].1.contains("handled"), "{:?}", events[0].1); + assert_eq!(events[0].level, Level::INFO); + assert!( + events[0].message.contains("handled"), + "{:?}", + events[0].message + ); + } + + // An operator has to be able to tell "no session was sent" from "a blank one was + // sent": an absent id omits the field, an empty one records an empty value. + #[test] + fn request_log_distinguishes_absent_ids_from_empty_ones() { + let rows = [ + (None, None), + (Some(String::new()), Some("\"\"")), + (Some("abc".to_string()), Some("\"abc\"")), + ]; + + for (id, expected) in rows { + let context = || { + let mut context = request_log_context(); + context.session_id = id.clone(); + context.correlation_id = id.clone(); + context + }; + + let answered = captured_events(|| { + let mut guard = RequestLogGuard(Some(context())); + guard.emit(&StatusCode::OK.into_response()); + }); + let cancelled = captured_events(|| drop(RequestLogGuard(Some(context())))); + + for events in [answered, cancelled] { + assert_eq!(events.len(), 1); + for field in ["session_id", "correlation_id"] { + let rendered = events[0] + .message + .split_whitespace() + .find_map(|part| part.strip_prefix(&format!("{field}="))); + assert_eq!(rendered, expected, "{field} {id:?}: {}", events[0].message); + } + } + } } // Terminal request severity follows HTTP status instead of error-path bookkeeping. @@ -2161,8 +2214,12 @@ targets = ["only"] let response = render_error_response(client_error(&error), wire_format); let events = captured_events(|| request_log_context().emit(&response)); assert_eq!(events.len(), 1); - assert!(!events[0].1.contains(LEAKED), "{}", events[0].1); - assert!(events[0].1.contains("upstream_error"), "{}", events[0].1); + assert!(!events[0].message.contains(LEAKED), "{}", events[0].message); + assert!( + events[0].message.contains("upstream_error"), + "{}", + events[0].message + ); let api_error = response .extensions() .get::() diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index 73062b2514..974b8d648c 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -4386,6 +4386,68 @@ selector = "/decision/target" Ok(()) } +// An llm_classifier parent sends delegated work to its sub-agent route without calling +// its own judge, and keeps judging its own traffic. +#[tokio::test] +async fn llm_classifier_parent_sends_subagent_work_to_the_child_route() -> TestResult { + for mode in [ + "base_threshold = 0.5", + "mode = \"escalation\"\nescalation = { confirmations = 1 }", + ] { + let upstream = MockUpstream::start().await?; + let app = build_switchyard_router(load_test_config(&format!( + r#" +schema_version = 1 +[llm_clients.upstream] +format = "openai_chat" +base_url = "{base_url}" +[targets] +classifier = {{ id = "model/classifier", llm_client = "upstream" }} +strong = {{ id = "model/strong", llm_client = "upstream" }} +weak = {{ id = "model/weak", llm_client = "upstream" }} +worker = {{ id = "model/worker", llm_client = "upstream" }} +[routes.agent] +id = "agent" +type = "llm_classifier" +classifier_target = "classifier" +strong_target = "strong" +weak_target = "weak" +{mode} +[routes.agent.subagents] +type = "passthrough" +target = "worker" +"#, + base_url = upstream.base_url + ))?); + let body = json!({"model":"agent","messages":[{"role":"user","content":"bounded task"}]}); + + let child = [ + ("x-claude-code-session-id", "root-session"), + ("x-claude-code-agent-id", "child-agent"), + ]; + let response = send_with_headers( + &app, + "POST", + "/v1/chat/completions", + Some(body.clone()), + &child, + ) + .await?; + assert_eq!(response.status, StatusCode::OK, "{mode}"); + assert_eq!(upstream.models().await, ["model/worker"], "{mode}"); + + upstream.calls.lock().await.clear(); + let parent = [("x-claude-code-session-id", "root-session")]; + let response = + send_with_headers(&app, "POST", "/v1/chat/completions", Some(body), &parent).await?; + assert_eq!(response.status, StatusCode::OK, "{mode}"); + let mut models = upstream.models().await; + models.sort(); + assert_eq!(models, ["model/classifier", "model/weak"], "{mode}"); + } + Ok(()) +} + // Codex's structured kind takes precedence over the flat `collab_spawn` header: // maintenance uses the parent classifier; delegated work uses the subagent route. #[tokio::test] diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 4bd97fb195..96b86dedad 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -341,6 +341,7 @@ Runs one of three judge-backed modes: `capability`, `escalation`, or `custom`. | `classifier_target` | Capability, escalation | — | Target the judge is called through. Not a routing destination. Custom mode uses `models.judge`. | | `max_output_tokens` | No | `4096` | Maximum completion tokens for the judge verdict. Must be at least `1`. | | `response_format_type` | No | `json_schema` | Structured-output mode for capability and escalation judges. Use `json_object` when the provider does not support JSON Schema; Switchyard adds the schema to the prompt and validates the verdict locally. Custom mode always uses its configured JSON Schema. | +| `subagents` | No | unset | Nested `passthrough` or custom `llm_classifier` policy used only for delegated sub-agent work. See [Sub-Agent-Aware Routing](../routing_algorithms/subagent_routing.md). | Capability mode classifies before serving. See [LLM Classifier Routing](../routing_algorithms/llm_classifier_routing.md). diff --git a/docs/routing_algorithms/overview.md b/docs/routing_algorithms/overview.md index f2e8a0a6a6..93f56bd955 100644 --- a/docs/routing_algorithms/overview.md +++ b/docs/routing_algorithms/overview.md @@ -45,7 +45,7 @@ quality, follow the [tuning loop](../tuning/index.md). | [Prefill](prefill_routing.md) | `prefill_router` | A classifier you train on the prompt's hidden states picks the target. Experimental: Switchyard supplies no checkpoint, exporter, or encoder assets. | | [Random](random_routing.md) | `random` | A fixed traffic split. | | [Fixed](#direct-model-routes) | `passthrough` | One target, no decision. | -| [Sub-agent](subagent_routing.md) | `subagents` table | A separate policy for delegated sub-agent work on `passthrough`, `stage_router`, and `composite` routes. | +| [Sub-agent](subagent_routing.md) | `subagents` table | A separate policy for delegated sub-agent work on `passthrough`, `llm_classifier`, `stage_router`, and `composite` routes. | ### Compare the strategies diff --git a/docs/routing_algorithms/subagent_routing.md b/docs/routing_algorithms/subagent_routing.md index 6183312366..b4760ce376 100644 --- a/docs/routing_algorithms/subagent_routing.md +++ b/docs/routing_algorithms/subagent_routing.md @@ -2,8 +2,8 @@ Sub-agent-aware routing leaves parent-agent traffic with its configured routing algorithm while routing delegated sub-agent work separately. It is available on -`passthrough`, `stage_router`, and `composite` routes through the optional -`subagents` table. +`passthrough`, `llm_classifier`, `stage_router`, and `composite` routes through +the optional `subagents` table. ```toml schema_version = 1