1use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10use std::time::Duration;
11
12use parking_lot::Mutex;
13use serde::de::DeserializeOwned;
14use serde::{Deserialize, Deserializer};
15use serde_json::Value;
16use switchyard_llm_client::{
17 AuxiliaryOperation, Backend, ClientRouter, DEFAULT_MAX_RETRIES, HttpBackendConfig, ModelConfig,
18 OpenAiDecisionClient, ResponsesCompatibility, ResponsesReasoningPolicy, SystemOneClient,
19 TranslatingLlmClient,
20};
21use switchyard_protocol::{
22 LlmClientError, ModelId, RoutedDecisionClient, RoutedLlmClient, WireFormat,
23};
24
25use crate::algorithm_factory::AlgorithmAndModels;
26use crate::eligibility::{EligibilityPolicy, validate_labels};
27use crate::route::ExecutionLane;
28use crate::runner::ModelPropertiesProbe;
29use crate::{
30 AlgorithmFactory, AlgorithmSpec, AuxiliaryTarget, CallerAuthKind, DecisionTarget,
31 ModelCapabilities, Route, Runner, RunnerError,
32};
33
34const SUPPORTED_SCHEMA_VERSION: u32 = 1;
35const MAX_CONFIGURED_RETRIES: u32 = 10;
36
37pub fn api_key_variables(source: &str) -> Result<BTreeSet<String>, RunnerError> {
40 let config: DeploymentConfig = toml::from_str(source).map_err(|_| {
41 RunnerError::configuration(
42 "Could not parse the server config to read credential references.",
43 )
44 })?;
45 if config.schema_version != SUPPORTED_SCHEMA_VERSION {
46 return Err(RunnerError::configuration(
47 "Unsupported server config schema version.",
48 ));
49 }
50 let mut variables: BTreeSet<_> = config
51 .llm_clients
52 .values()
53 .filter_map(|client| client.api_key_env.clone())
54 .collect();
55 for client in config.decision_clients.values() {
56 variables.insert(client.api_key_env.clone());
57 }
58 if variables
59 .iter()
60 .any(|name| name.trim().is_empty() || name.contains(['\0', '=']))
61 {
62 return Err(RunnerError::configuration(
63 "Invalid credential environment variable name.",
64 ));
65 }
66 Ok(variables)
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct RouteTargets {
72 pub name: String,
74 pub id: ModelId,
76 pub kind: &'static str,
78 pub answer_targets: Vec<ConfiguredTarget>,
81 pub judge_targets: Vec<ConfiguredTarget>,
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct ConfiguredTarget {
88 pub name: String,
89 pub model: Option<TargetModel>,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct TargetModel {
98 pub client: String,
99 pub id: ModelId,
100}
101
102pub fn route_targets(source: &str) -> Result<Vec<RouteTargets>, RunnerError> {
105 let config: DeploymentConfig = toml::from_str(source).map_err(|error| {
106 RunnerError::configuration_source(format!("failed to parse TOML: {error}"), error)
107 })?;
108 config.check_schema_version()?;
109 Ok(config
110 .routes
111 .iter()
112 .map(|(name, route)| RouteTargets {
113 name: name.clone(),
114 id: route.id.clone(),
115 kind: (&route.algorithm).into(),
116 answer_targets: config.configured_targets(route.routing_target_names()),
117 judge_targets: config.configured_targets(route.algorithm.judge_target_names()),
118 })
119 .collect())
120}
121
122type RunnerResult<T> = Result<T, RunnerError>;
123
124pub(crate) fn load_runner(path: impl AsRef<Path>) -> RunnerResult<Runner> {
125 let path = path.as_ref();
126 let source = fs::read_to_string(path).map_err(|error| {
127 RunnerError::configuration_source(
128 format!("failed to read server config {}: {error}", path.display()),
129 error,
130 )
131 })?;
132 runner_from_toml(&source).map_err(|error| {
133 RunnerError::configuration_source(
134 format!("invalid server config {}: {error}", path.display()),
135 error,
136 )
137 })
138}
139
140pub(crate) fn runner_from_toml(source: &str) -> RunnerResult<Runner> {
141 Arc::new(AlgorithmFactory::from_toml(source)?.config).build()
142}
143
144#[derive(Debug, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub(crate) struct DeploymentConfig {
147 schema_version: u32,
148 fallback_client: Option<String>,
149 #[serde(default)]
150 llm_clients: BTreeMap<String, LlmClientConfig>,
151 targets: BTreeMap<String, TargetConfig>,
152 #[serde(default)]
153 decision_clients: BTreeMap<String, DecisionClientConfig>,
154 #[serde(default)]
155 decision_targets: BTreeMap<String, DecisionTargetConfig>,
156 routes: BTreeMap<String, RouteConfig>,
157}
158
159#[derive(Debug)]
160pub(crate) struct RouteConfig {
161 pub(crate) id: ModelId,
162 context_window: Option<u32>,
163 tool_calling: Option<bool>,
164 reasoning: Option<bool>,
165 vision: Option<bool>,
166 algorithm: AlgorithmSpec,
167 infra: Option<libsy::InfraPolicy>,
168 eligibility: Option<TargetEligibilityConfig>,
169}
170
171#[derive(Debug, Deserialize)]
172#[serde(deny_unknown_fields)]
173struct TargetEligibilityConfig {
174 #[serde(default)]
175 require_explicit_requirements: bool,
176 #[serde(default)]
177 target_alternatives: BTreeMap<String, Vec<String>>,
178 #[serde(default)]
179 scope: RequirementScope,
180}
181
182#[derive(Debug, Default, Deserialize)]
183#[serde(rename_all = "snake_case")]
184enum RequirementScope {
185 #[default]
186 Request,
187}
188
189struct TargetPromptPolicy {
190 prompts: HashMap<ModelId, String>,
191 routing_answer_target: Option<ModelId>,
192}
193
194struct ResolvedLlmTarget<'a> {
196 name: &'a str,
197 config: &'a TargetConfig,
198}
199
200type LaneTargets<'a> = BTreeMap<&'a str, ResolvedLlmTarget<'a>>;
201type TargetOverrides<'a> = BTreeMap<&'a str, &'a str>;
202type LaneDecisions<'a> = BTreeMap<&'a str, ResolvedDecisionTarget<'a>>;
203
204fn bound_target<'a>(name: &'a str, overrides: &TargetOverrides<'a>) -> &'a str {
206 overrides.get(name).copied().unwrap_or(name)
207}
208
209fn callable_targets(algorithm: &AlgorithmSpec) -> BTreeSet<&str> {
210 algorithm.callable_target_names().into_iter().collect()
211}
212
213type TargetBindings = Vec<(String, String)>;
214
215pub(crate) struct EligibleLanes {
217 deployment: Arc<DeploymentConfig>,
218 route_name: String,
219 clients: Arc<BTreeMap<String, Arc<TranslatingLlmClient>>>,
220 decision_clients: Arc<BTreeMap<String, Arc<dyn RoutedDecisionClient>>>,
221 lanes: Mutex<BTreeMap<TargetBindings, Arc<ExecutionLane>>>,
222}
223
224impl EligibleLanes {
225 const MAX_LANES: usize = 128;
226
227 pub(crate) fn select(
228 &self,
229 requirements: &BTreeSet<String>,
230 standard: &Arc<ExecutionLane>,
231 ) -> RunnerResult<Arc<ExecutionLane>> {
232 let route = &self.deployment.routes[&self.route_name];
233 let config = route
234 .eligibility
235 .as_ref()
236 .ok_or_else(|| RunnerError::configuration("route has no eligibility configuration"))?;
237 let callable = callable_targets(&route.algorithm);
238 let mut allowed = BTreeSet::new();
239 let mut overrides = TargetOverrides::new();
240 for name in &callable {
241 let candidates = std::iter::once(*name).chain(
242 config
243 .target_alternatives
244 .get(*name)
245 .into_iter()
246 .flatten()
247 .map(String::as_str),
248 );
249 if let Some(target) = candidates.into_iter().find(|name| {
250 self.deployment
251 .target_labels(name)
252 .is_some_and(|labels| requirements.is_subset(labels))
253 }) {
254 allowed.insert(*name);
255 if target != *name {
256 overrides.insert(*name, target);
257 }
258 }
259 }
260 if overrides.is_empty() && allowed.len() == callable.len() {
261 return Ok(Arc::clone(standard));
262 }
263 let key = allowed
264 .iter()
265 .map(|name| {
266 (
267 (*name).to_owned(),
268 bound_target(name, &overrides).to_owned(),
269 )
270 })
271 .collect();
272 let mut lanes = self.lanes.lock();
273 if let Some(lane) = lanes.get(&key) {
274 return Ok(Arc::clone(lane));
275 }
276 if lanes.len() >= Self::MAX_LANES {
277 return Err(LlmClientError::TemporarilyUnavailable.into());
278 }
279 let algorithm = route.algorithm.restricted_to(&allowed).map_err(|error| {
280 LlmClientError::InvalidRequest {
281 message: error.to_string(),
282 }
283 })?;
284 let targets = self
285 .deployment
286 .resolve_lane_targets(&algorithm, &overrides)?;
287 if matches!(algorithm, AlgorithmSpec::Random { .. }) && !overrides.is_empty() {
288 let mut models = BTreeSet::new();
289 for name in algorithm.routing_target_names() {
290 let target = targets[name].name;
291 if !models.insert(target) {
292 return Err(LlmClientError::InvalidRequest {
293 message: format!(
294 "random route alternatives map multiple pool entries to target {target}; use distinct targets"
295 ),
296 }
297 .into());
298 }
299 }
300 }
301 let decisions = self.deployment.resolve_decision_targets(
302 &self.route_name,
303 &algorithm,
304 &overrides,
305 &self.decision_clients,
306 )?;
307 let (lane, _) = self.deployment.build_lane(
309 &self.route_name,
310 &algorithm,
311 &targets,
312 &decisions,
313 &self.clients,
314 None,
315 )?;
316 let lane = Arc::new(lane);
317 lanes.insert(key, Arc::clone(&lane));
318 Ok(lane)
319 }
320}
321
322#[derive(Clone, Copy)]
323struct ResolvedDecisionTarget<'a> {
324 model: &'a ModelId,
325 client: &'a Arc<dyn RoutedDecisionClient>,
326}
327
328impl<'de> Deserialize<'de> for RouteConfig {
329 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
330 where
331 D: Deserializer<'de>,
332 {
333 let mut table = toml::Table::deserialize(deserializer)?;
334 let id = take_required(&mut table, "id")?;
335 let context_window = take_optional(&mut table, "context_window")?;
336 let tool_calling = take_optional(&mut table, "tool_calling")?;
337 let reasoning = take_optional(&mut table, "reasoning")?;
338 let vision = take_optional(&mut table, "vision")?;
339 let infra = take_optional(&mut table, "infra")?;
340 let eligibility = take_optional(&mut table, "eligibility")?;
341 let algorithm = AlgorithmSpec::deserialize(toml::Value::Table(table))
342 .map_err(serde::de::Error::custom)?;
343 Ok(Self {
344 id,
345 context_window,
346 tool_calling,
347 reasoning,
348 vision,
349 algorithm,
350 infra,
351 eligibility,
352 })
353 }
354}
355
356fn take_required<T, E>(table: &mut toml::Table, name: &'static str) -> Result<T, E>
357where
358 T: DeserializeOwned,
359 E: serde::de::Error,
360{
361 let value = table.remove(name).ok_or_else(|| E::missing_field(name))?;
362 T::deserialize(value).map_err(E::custom)
363}
364
365fn take_optional<T, E>(table: &mut toml::Table, name: &'static str) -> Result<Option<T>, E>
366where
367 T: DeserializeOwned,
368 E: serde::de::Error,
369{
370 table
371 .remove(name)
372 .map(|value| T::deserialize(value).map_err(E::custom))
373 .transpose()
374}
375
376impl RouteConfig {
377 fn capabilities(&self) -> ModelCapabilities {
378 ModelCapabilities {
379 context_window: self.context_window,
380 tool_calling: self.tool_calling,
381 reasoning: self.reasoning,
382 vision: self.vision,
383 }
384 }
385
386 fn routing_target_names(&self) -> Vec<&str> {
387 self.algorithm.routing_target_names()
388 }
389
390 fn callable_target_names(&self) -> Vec<&str> {
391 self.algorithm.callable_target_names()
392 }
393}
394
395impl DeploymentConfig {
396 pub(crate) fn requires_target_admission(&self) -> bool {
397 self.routes
398 .values()
399 .any(|route| route.eligibility.is_some())
400 }
401
402 fn target_labels(&self, name: &str) -> Option<&BTreeSet<String>> {
403 self.targets
404 .get(name)
405 .map(|target| &target.labels)
406 .or_else(|| self.decision_targets.get(name).map(|target| &target.labels))
407 }
408
409 fn check_schema_version(&self) -> RunnerResult<()> {
410 if self.schema_version == SUPPORTED_SCHEMA_VERSION {
411 return Ok(());
412 }
413 Err(RunnerError::configuration(format!(
414 "unsupported schema_version {}; expected {SUPPORTED_SCHEMA_VERSION}",
415 self.schema_version
416 )))
417 }
418
419 fn configured_targets(&self, names: Vec<&str>) -> Vec<ConfiguredTarget> {
422 names
423 .into_iter()
424 .map(|name| ConfiguredTarget {
425 name: name.to_string(),
426 model: self
427 .targets
428 .get(name)
429 .map(|target| (&target.llm_client, &target.id))
430 .or_else(|| {
431 self.decision_targets
432 .get(name)
433 .map(|target| (&target.decision_client, &target.id))
434 })
435 .map(|(client, id)| TargetModel {
436 client: client.clone(),
437 id: id.clone(),
438 }),
439 })
440 .collect()
441 }
442
443 fn decision_target(&self, name: &str) -> Option<DecisionTarget> {
444 let target = self.targets.get(name)?;
445 let client = self.llm_clients.get(&target.llm_client)?;
446 Some(DecisionTarget {
447 target: name.to_string(),
448 model: target.id.clone(),
449 format: client.format.wire_format(),
450 base_url: client.base_url.as_str().to_string(),
451 extra_body: target.extra_body.clone(),
452 reasoning_effort: target.reasoning_effort.clone(),
453 })
454 }
455
456 pub(crate) fn validate(&self) -> RunnerResult<()> {
458 self.check_schema_version()?;
459
460 let mut route_names_by_id = HashMap::new();
461 for (route_name, config) in &self.routes {
462 validate_value("route name", route_name)?;
463 validate_value(&format!("route {route_name} id"), &config.id)?;
464 if let Some(first_route_name) =
465 route_names_by_id.insert(config.id.as_str(), route_name.as_str())
466 {
467 return Err(RunnerError::configuration(format!(
468 "routes {first_route_name} and {route_name} both use id {}; route ids must be unique",
469 config.id
470 )));
471 }
472 }
473
474 for (name, target) in &self.targets {
475 validate_labels(&target.labels)
476 .map_err(|error| RunnerError::configuration(error.to_string()))?;
477 validate_value("target name", name)?;
478 validate_value(&format!("target {name} id"), &target.id)?;
479 if !self.llm_clients.contains_key(&target.llm_client) {
480 return Err(RunnerError::configuration(format!(
481 "target {name} references unknown llm client {}",
482 target.llm_client
483 )));
484 }
485 }
486 let mut decision_approvals = HashMap::new();
487 for (name, target) in &self.decision_targets {
488 validate_labels(&target.labels)
489 .map_err(|error| RunnerError::configuration(error.to_string()))?;
490 if decision_approvals
491 .insert((&target.decision_client, &target.id), &target.labels)
492 .is_some_and(|approved| approved != &target.labels)
493 {
494 return Err(RunnerError::configuration(format!(
495 "decision target {name} shares a model and client with an alias but disagrees on approval labels"
496 )));
497 }
498 validate_value("decision target name", name)?;
499 validate_value(&format!("decision target {name} id"), &target.id)?;
500 if self.targets.contains_key(name) {
501 return Err(RunnerError::configuration(format!(
502 "target {name} is defined as both an LLM and decision target"
503 )));
504 }
505 if self.targets.contains_key(target.id.as_str()) {
507 return Err(RunnerError::configuration(format!(
508 "decision target {name} id {} is also an LLM target name; rename one of them",
509 target.id
510 )));
511 }
512 if !self.decision_clients.contains_key(&target.decision_client) {
513 return Err(RunnerError::configuration(format!(
514 "decision target {name} references unknown decision client {}",
515 target.decision_client
516 )));
517 }
518 }
519 for (route_name, config) in &self.routes {
520 if let Some(eligibility) = &config.eligibility {
521 self.validate_eligibility(route_name, &config.algorithm, eligibility)?;
522 }
523 }
524 Ok(())
525 }
526
527 pub(crate) fn build_algorithms(
529 &self,
530 ) -> impl Iterator<Item = RunnerResult<(&str, &RouteConfig, AlgorithmAndModels)>> {
531 let targets = self.build_targets();
532 self.routes.iter().map(move |(route_name, config)| {
533 let decision_targets = config.algorithm.decision_target_names();
534 for name in config.callable_target_names() {
535 let (exists, kind) = if decision_targets.contains(&name) {
536 (self.decision_targets.contains_key(name), "decision")
537 } else {
538 (self.targets.contains_key(name), "LLM")
539 };
540 if !exists {
541 return Err(RunnerError::configuration(format!(
542 "route references unknown target {name}; route {route_name} requires target kind {kind}"
543 )));
544 }
545 }
546 for name in config.routing_target_names() {
547 if !self.targets.contains_key(name) {
548 return Err(RunnerError::configuration(format!(
549 "route {route_name} completion target {name} must be an LLM target"
550 )));
551 }
552 }
553 if config.context_window == Some(0) {
554 return Err(RunnerError::configuration(format!(
555 "route {route_name} context_window must be greater than zero"
556 )));
557 }
558 let (algorithm, models) = config
559 .algorithm
560 .build_with_runtime_models(route_name, &targets)
561 .map_err(|error| RunnerError::configuration_source(error.to_string(), error))?;
562 let models = models.with_provider_models(
563 config
564 .callable_target_names()
565 .into_iter()
566 .filter_map(|name| {
567 self.targets
568 .get(name)
569 .map(|target| (ModelId::from(name), target.id.clone()))
570 })
571 .collect(),
572 );
573 if let Some(infra) = &config.infra {
574 if !matches!(&config.algorithm, AlgorithmSpec::LlmClassifier { config }
575 if matches!(config.mode, None | Some(crate::ClassifierMode::Capability))
576 && config.escalation.is_none())
577 {
578 return Err(RunnerError::configuration(format!(
579 "route {route_name}: infra requires capability llm_classifier without escalation"
580 )));
581 }
582 infra.validate().map_err(|error| {
583 RunnerError::configuration_source(format!("route {route_name}: {error}"), error)
584 })?;
585 }
586 Ok((route_name.as_str(), config, (algorithm, models)))
587 })
588 }
589
590 fn build(self: Arc<Self>) -> RunnerResult<Runner> {
591 self.validate()?;
592
593 let mut approvals = HashMap::new();
594 for (target_name, target) in &self.targets {
595 if let Some((first_name, labels)) = approvals.insert(
596 (&target.llm_client, &target.id),
597 (target_name, &target.labels),
598 ) && labels != &target.labels
599 {
600 return Err(RunnerError::configuration(format!(
601 "targets {first_name} and {target_name} share a model and llm client but disagree on approval labels"
602 )));
603 }
604 }
605
606 let mut provider_api_keys = Vec::new();
607 let clients = Arc::new(self.build_clients(&mut provider_api_keys)?);
608 let decision_clients = Arc::new(self.build_decision_clients(&mut provider_api_keys)?);
609 let fallback_base_url = self.fallback_base_url()?;
610 let mut routes = Vec::with_capacity(self.routes.len());
611 let mut model_properties_probes = BTreeMap::new();
612 for entry in self.build_algorithms() {
613 let (route_name, config, (algorithm, models)) = entry?;
614 let capabilities = config.capabilities();
615 if let AlgorithmSpec::Vgr { config: vgr } = &config.algorithm
616 && let Some(probe) =
617 self.build_model_properties_probe(&vgr.efficient_target, &clients)
618 {
619 model_properties_probes.insert(config.id.clone(), probe);
620 }
621 let no_overrides = TargetOverrides::new();
622 let standard_targets = self.resolve_lane_targets(&config.algorithm, &no_overrides)?;
623 let decisions = self.resolve_decision_targets(
624 route_name,
625 &config.algorithm,
626 &no_overrides,
627 &decision_clients,
628 )?;
629 let (standard, caller_auth) = self.build_lane(
630 route_name,
631 &config.algorithm,
632 &standard_targets,
633 &decisions,
634 &clients,
635 Some((algorithm, models)),
636 )?;
637 let mut target_names = callable_targets(&config.algorithm);
639 if let Some(eligibility) = &config.eligibility {
640 target_names.extend(
641 eligibility
642 .target_alternatives
643 .values()
644 .flatten()
645 .map(String::as_str),
646 );
647 }
648 let route_targets = target_names
649 .into_iter()
650 .filter_map(|name| self.decision_target(name))
651 .collect();
652 let mut route = Route::from_lane(standard, caller_auth, capabilities, route_targets)
653 .with_infra(config.infra.clone());
654 if let Some(eligibility) = &config.eligibility {
655 let policy = match eligibility.scope {
656 RequirementScope::Request => {
657 EligibilityPolicy::new(eligibility.require_explicit_requirements)
658 }
659 };
660 route = route.with_eligibility(
661 policy,
662 EligibleLanes {
663 deployment: Arc::clone(&self),
664 route_name: route_name.to_owned(),
665 clients: Arc::clone(&clients),
666 decision_clients: Arc::clone(&decision_clients),
667 lanes: Mutex::new(BTreeMap::new()),
668 },
669 );
670 }
671 routes.push((config.id.clone(), route));
672 }
673 let runner = Runner::new(routes)
674 .with_fallback_url(fallback_base_url)
675 .with_provider_api_keys(provider_api_keys)
676 .with_model_properties_probes(model_properties_probes);
677 Ok(runner)
678 }
679
680 fn validate_eligibility(
682 &self,
683 route_name: &str,
684 algorithm: &AlgorithmSpec,
685 config: &TargetEligibilityConfig,
686 ) -> RunnerResult<()> {
687 if !algorithm.supports_target_restrictions() {
688 return Err(RunnerError::configuration(format!(
689 "route {route_name} cannot use eligibility with prefill_router"
690 )));
691 }
692 let callable = callable_targets(algorithm);
693 for (source, alternatives) in &config.target_alternatives {
694 if !callable.contains(source.as_str()) {
695 return Err(RunnerError::configuration(format!(
696 "route {route_name} target_alternatives source {source} is not used by the algorithm"
697 )));
698 }
699 if alternatives.is_empty() {
700 return Err(RunnerError::configuration(
701 "target alternatives must not be empty",
702 ));
703 }
704 for target in alternatives {
705 let same_kind = (self.targets.contains_key(source)
706 && self.targets.contains_key(target))
707 || (self.decision_targets.contains_key(source)
708 && self.decision_targets.contains_key(target));
709 if !same_kind {
710 return Err(RunnerError::configuration(format!(
711 "route {route_name} alternative {target} must exist and match the target kind of {source}"
712 )));
713 }
714 }
715 }
716 for name in callable.into_iter().chain(
717 config
718 .target_alternatives
719 .values()
720 .flatten()
721 .map(String::as_str),
722 ) {
723 if let Some(target) = self.targets.get(name)
724 && self
725 .llm_clients
726 .get(&target.llm_client)
727 .is_some_and(|client| client.forward_auth)
728 {
729 return Err(RunnerError::configuration(format!(
730 "route {route_name} cannot use eligibility with forwarded caller authentication"
731 )));
732 }
733 }
734 Ok(())
735 }
736
737 fn resolve_lane_targets<'a>(
738 &'a self,
739 algorithm: &'a AlgorithmSpec,
740 overrides: &TargetOverrides<'_>,
741 ) -> RunnerResult<LaneTargets<'a>> {
742 let decisions = algorithm.decision_target_names();
743 callable_targets(algorithm)
744 .into_iter()
745 .filter(|name| !decisions.contains(name))
746 .map(|name| {
747 let bound = bound_target(name, overrides);
748 let (resolved_name, config) =
749 self.targets.get_key_value(bound).ok_or_else(|| {
750 RunnerError::configuration(format!(
751 "route references unknown target {bound}"
752 ))
753 })?;
754 Ok((
755 name,
756 ResolvedLlmTarget {
757 name: resolved_name.as_str(),
758 config,
759 },
760 ))
761 })
762 .collect()
763 }
764
765 fn resolve_decision_targets<'a>(
766 &'a self,
767 route_name: &str,
768 algorithm: &'a AlgorithmSpec,
769 overrides: &TargetOverrides<'_>,
770 clients: &'a BTreeMap<String, Arc<dyn RoutedDecisionClient>>,
771 ) -> RunnerResult<LaneDecisions<'a>> {
772 algorithm
773 .decision_target_names()
774 .into_iter()
775 .map(|name| {
776 let bound = bound_target(name, overrides);
777 let target = self.decision_targets.get(bound).ok_or_else(|| {
778 RunnerError::configuration(format!(
779 "route {route_name} requires unknown decision target {bound}"
780 ))
781 })?;
782 let client = clients.get(&target.decision_client).ok_or_else(|| {
783 RunnerError::configuration(format!(
784 "decision target {bound} references unknown decision client {}",
785 target.decision_client
786 ))
787 })?;
788 Ok((
789 name,
790 ResolvedDecisionTarget {
791 model: &target.id,
792 client,
793 },
794 ))
795 })
796 .collect()
797 }
798
799 fn build_lane(
800 &self,
801 route_name: &str,
802 route: &AlgorithmSpec,
803 targets: &LaneTargets<'_>,
804 decisions: &LaneDecisions<'_>,
805 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
806 built: Option<AlgorithmAndModels>,
807 ) -> RunnerResult<(ExecutionLane, Option<CallerAuthKind>)> {
808 let (algorithm, mut models) = if let Some(built) = built {
809 built
810 } else {
811 let model_ids = targets
812 .iter()
813 .map(|(name, target)| ((*name).to_owned(), ModelId::from(target.name)))
814 .chain(
815 decisions
816 .iter()
817 .map(|(name, target)| ((*name).to_owned(), target.model.clone())),
818 )
819 .collect();
820 let (algorithm, models) = route
821 .build_with_runtime_models(route_name, &model_ids)
822 .map_err(|error| RunnerError::configuration_source(error.to_string(), error))?;
823 let models = models.with_provider_models(
824 targets
825 .values()
826 .map(|target| (ModelId::from(target.name), target.config.id.clone()))
827 .collect(),
828 );
829 (algorithm, models)
830 };
831 let (route_clients, caller_auth) =
832 self.build_route_clients(route_name, route, targets, decisions, clients)?;
833 let anthropic_auxiliary_target =
834 self.build_anthropic_auxiliary_target(route, targets, clients);
835 let responses_auxiliary_target =
836 self.build_responses_auxiliary_target(route, targets, clients);
837 if self
838 .routes
839 .get(route_name)
840 .is_some_and(|config| config.eligibility.is_some())
841 {
842 models = models.with_target_restriction();
843 }
844 Ok((
845 ExecutionLane::new(
846 algorithm,
847 route_clients,
848 anthropic_auxiliary_target,
849 responses_auxiliary_target,
850 models,
851 ),
852 caller_auth,
853 ))
854 }
855
856 fn build_clients(
857 &self,
858 provider_api_keys: &mut Vec<String>,
859 ) -> RunnerResult<BTreeMap<String, Arc<TranslatingLlmClient>>> {
860 let mut models_by_client = self
861 .llm_clients
862 .keys()
863 .map(|name| (name.clone(), Vec::new()))
864 .collect::<BTreeMap<String, Vec<ModelConfig>>>();
865
866 for (name, client_config) in &self.llm_clients {
867 validate_value("llm client name", name)?;
868 if client_config.responses_compatibility.is_some()
869 && !matches!(client_config.format, ClientFormat::OpenAiResponses)
870 {
871 return Err(RunnerError::configuration(format!(
872 "llm client {name} responses_compatibility is only valid for openai_responses"
873 )));
874 }
875 if client_config.responses_reasoning.is_some()
876 && !matches!(client_config.format, ClientFormat::OpenAiResponses)
877 {
878 return Err(RunnerError::configuration(format!(
879 "llm client {name} responses_reasoning is only valid for openai_responses"
880 )));
881 }
882 let backend = build_backend(
883 name,
884 client_config,
885 &BTreeMap::new(),
886 &BTreeSet::new(),
887 None,
888 )?;
889 let (Backend::OpenAiChat(config)
890 | Backend::OpenAiResponses(config)
891 | Backend::Anthropic(config)) = backend;
892 if let Some(key) = config.api_key {
893 provider_api_keys.push(key);
894 }
895 }
896 for (target_name, target) in &self.targets {
897 let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| {
898 RunnerError::configuration(format!(
899 "target {target_name} references unknown llm client {}",
900 target.llm_client
901 ))
902 })?;
903 let model_configs = models_by_client
904 .get_mut(&target.llm_client)
905 .ok_or_else(|| {
906 RunnerError::configuration("validated llm client was not initialized")
907 })?;
908 if let Some(effort) = &target.reasoning_effort {
909 if effort.trim().is_empty() {
910 return Err(RunnerError::configuration(format!(
911 "target {target_name} reasoning_effort must not be empty"
912 )));
913 }
914 if matches!(client_config.format, ClientFormat::AnthropicMessages)
916 && !ANTHROPIC_EFFORTS.contains(&effort.as_str())
917 {
918 return Err(RunnerError::configuration(format!(
919 "target {target_name} reasoning_effort {effort:?} is not an anthropic_messages effort; use one of {}",
920 ANTHROPIC_EFFORTS.join(", ")
921 )));
922 }
923 }
924 model_configs.push(
925 ModelConfig::new(
926 target_name.as_str(),
927 build_backend(
928 &target.llm_client,
929 client_config,
930 &target.extra_body,
931 &target.omit_body_fields,
932 target.reasoning_effort.clone(),
933 )?,
934 None,
935 )
936 .with_responses_reasoning(client_config.responses_reasoning.unwrap_or_default())
937 .with_responses_compatibility(
938 client_config.responses_compatibility.unwrap_or_default(),
939 )
940 .with_provider_model(target.id.clone()),
941 );
942 }
943
944 let mut clients = BTreeMap::new();
945 for (name, model_configs) in models_by_client {
946 let mut client = TranslatingLlmClient::new(&model_configs)
947 .map_err(|error| RunnerError::configuration(error.to_string()))?;
948 if !self.llm_clients[&name].fallback_on_rate_limit {
949 client = client.without_rate_limit_cooldown();
950 }
951 clients.insert(name, Arc::new(client));
952 }
953 Ok(clients)
954 }
955
956 fn build_decision_clients(
957 &self,
958 provider_api_keys: &mut Vec<String>,
959 ) -> RunnerResult<BTreeMap<String, Arc<dyn RoutedDecisionClient>>> {
960 self.decision_clients
961 .iter()
962 .map(|(name, config)| {
963 validate_value("decision client name", name)?;
964 let DecisionClientConfig {
965 format,
966 endpoint,
967 api_key_env,
968 timeout_ms,
969 } = config;
970 if *timeout_ms == 0 {
971 return Err(RunnerError::configuration(format!(
972 "decision client {name} timeout_ms must be at least 1"
973 )));
974 }
975 let api_key = read_api_key(&format!("decision client {name}"), api_key_env)?;
976 let timeout = Duration::from_millis(*timeout_ms);
977 let client = match format {
978 DecisionFormat::SystemOne => {
979 SystemOneClient::new(endpoint.0.clone(), api_key.clone(), timeout)
980 .map(|client| Arc::new(client) as Arc<dyn RoutedDecisionClient>)
981 }
982 DecisionFormat::OpenaiDecisions => {
983 OpenAiDecisionClient::new(endpoint.0.clone(), api_key.clone(), timeout)
984 .map(|client| Arc::new(client) as Arc<dyn RoutedDecisionClient>)
985 }
986 }
987 .map_err(|error| RunnerError::configuration(error.to_string()))?;
988 provider_api_keys.push(api_key);
989 Ok((name.clone(), client))
990 })
991 .collect()
992 }
993
994 fn build_targets(&self) -> BTreeMap<String, ModelId> {
998 self.targets
999 .keys()
1000 .map(|name| (name.clone(), ModelId::from(name.as_str())))
1001 .chain(
1002 self.decision_targets
1003 .iter()
1004 .map(|(name, config)| (name.clone(), config.id.clone())),
1005 )
1006 .collect()
1007 }
1008
1009 fn build_route_clients(
1013 &self,
1014 route_name: &str,
1015 route: &AlgorithmSpec,
1016 targets: &LaneTargets<'_>,
1017 decisions: &LaneDecisions<'_>,
1018 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1019 ) -> RunnerResult<(ClientRouter, Option<CallerAuthKind>)> {
1020 let TargetPromptPolicy {
1021 prompts,
1022 routing_answer_target,
1023 } = self.build_route_target_prompts(route_name, route, targets)?;
1024 let mut by_model = HashMap::new();
1025 let mut caller_auth = None;
1026 let mut has_mixed_families = false;
1027 let mut forwarding_origins = BTreeSet::new();
1028 let mut decisions_by_model = HashMap::new();
1029 for name in route.callable_target_names() {
1030 if let Some(target) = decisions.get(name) {
1031 if let Some(previous) =
1032 decisions_by_model.insert(target.model.clone(), Arc::clone(target.client))
1033 && !Arc::ptr_eq(&previous, target.client)
1034 {
1035 return Err(RunnerError::configuration(format!(
1036 "route {route_name} maps decision model {} to different clients",
1037 target.model
1038 )));
1039 }
1040 continue;
1041 }
1042 let target = targets.get(name).ok_or_else(|| {
1043 RunnerError::configuration(format!("route references unknown target {name}"))
1044 })?;
1045 let client = clients.get(&target.config.llm_client).ok_or_else(|| {
1046 RunnerError::configuration(format!("target {name} has no constructed llm client"))
1047 })?;
1048 let client_config =
1049 self.llm_clients
1050 .get(&target.config.llm_client)
1051 .ok_or_else(|| {
1052 RunnerError::configuration(format!(
1053 "target {name} references unknown llm client {}",
1054 target.config.llm_client
1055 ))
1056 })?;
1057 if client_config.forward_auth {
1058 let target_auth = client_config.format.caller_auth_kind();
1059 has_mixed_families |= caller_auth.is_some_and(|kind| kind != target_auth);
1060 caller_auth = Some(target_auth);
1061 forwarding_origins.insert(client_config.base_url.0.origin().ascii_serialization());
1062 }
1063 let client: Arc<dyn RoutedLlmClient> = client.clone();
1064 by_model.insert(ModelId::from(target.name), client);
1065 }
1066 if has_mixed_families {
1072 if forwarding_origins.len() > 1 {
1073 let origins = Vec::from_iter(forwarding_origins).join(", ");
1074 return Err(RunnerError::configuration(format!(
1075 "route {route_name} cannot forward both Anthropic and OpenAI caller credentials to different origins ({origins}); point all of its forwarding clients at one origin (same scheme, host, and port), such as an LLM gateway, or set api_key_env instead of forward_auth on one provider's clients"
1076 )));
1077 }
1078 caller_auth = Some(CallerAuthKind::OpenAi);
1079 }
1080 let completion_targets = route
1081 .routing_target_names()
1082 .into_iter()
1083 .map(|name| ModelId::from(targets[name].name))
1084 .collect::<Vec<_>>();
1085 let router = ClientRouter::new_with_decision_clients(
1086 by_model,
1087 decisions_by_model,
1088 prompts,
1089 routing_answer_target,
1090 &completion_targets,
1091 )
1092 .without_rate_limit_fallback(
1093 route
1094 .routing_target_names()
1095 .into_iter()
1096 .filter_map(|name| {
1097 let target = &targets[name];
1098 (!self.llm_clients[&target.config.llm_client].fallback_on_rate_limit)
1099 .then(|| ModelId::from(target.name))
1100 })
1101 .collect(),
1102 );
1103 Ok((router, caller_auth))
1104 }
1105
1106 fn build_route_target_prompts(
1108 &self,
1109 route_name: &str,
1110 route: &AlgorithmSpec,
1111 targets: &LaneTargets<'_>,
1112 ) -> RunnerResult<TargetPromptPolicy> {
1113 let mut prompts = HashMap::new();
1114 for name in route.routing_target_names() {
1115 let target = targets.get(name).ok_or_else(|| {
1116 RunnerError::configuration(format!("route references unknown target {name}"))
1117 })?;
1118 if let Some(prompt) = &target.config.system_prompt {
1119 prompts.insert(ModelId::from(target.name), prompt.clone());
1120 }
1121 }
1122 let mut policy = TargetPromptPolicy {
1123 prompts,
1124 routing_answer_target: None,
1125 };
1126 let Some((response_name, dependency_name)) = route.routing_response_and_dependency() else {
1127 return Ok(policy);
1128 };
1129 let response = &targets[response_name];
1130 if !policy.prompts.contains_key(response.name) {
1131 return Ok(policy);
1132 }
1133 if std::iter::once(dependency_name)
1134 .chain(route.judge_target_names())
1135 .any(|name| {
1136 targets
1137 .get(name)
1138 .is_some_and(|dependency| dependency.name == response.name)
1139 })
1140 {
1141 return Err(RunnerError::configuration(format!(
1142 "route {route_name} cannot apply system_prompt to target {response_name}: it is also the routing-only target"
1143 )));
1144 }
1145 policy.routing_answer_target = Some(ModelId::from(response.name));
1146 Ok(policy)
1147 }
1148
1149 fn fallback_base_url(&self) -> RunnerResult<Option<String>> {
1150 let Some(name) = &self.fallback_client else {
1151 return Ok(None);
1152 };
1153 let config = self.llm_clients.get(name).ok_or_else(|| {
1154 RunnerError::configuration(format!(
1155 "fallback_client references unknown llm client {name}"
1156 ))
1157 })?;
1158 Ok(Some(config.base_url.as_str().to_string()))
1159 }
1160
1161 fn build_model_properties_probe(
1162 &self,
1163 efficient_target_name: &str,
1164 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1165 ) -> Option<ModelPropertiesProbe> {
1166 let efficient_target = self.targets.get(efficient_target_name)?;
1167 let client_config = self.llm_clients.get(&efficient_target.llm_client)?;
1168 if !matches!(
1169 client_config.context_discovery,
1170 Some(ContextDiscovery::LlamaCpp)
1171 ) {
1172 return None;
1173 }
1174 let client = clients.get(&efficient_target.llm_client)?;
1175 Some(ModelPropertiesProbe {
1176 model: ModelId::from(efficient_target_name),
1177 format: client_config.format.wire_format(),
1178 client: Arc::clone(client),
1179 })
1180 }
1181
1182 fn build_anthropic_auxiliary_target(
1183 &self,
1184 route: &AlgorithmSpec,
1185 targets: &LaneTargets<'_>,
1186 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1187 ) -> Option<AuxiliaryTarget> {
1188 route
1189 .routing_target_names()
1190 .into_iter()
1191 .enumerate()
1192 .filter_map(|(index, name)| {
1193 let config = targets.get(name)?;
1194 let target = self.build_auxiliary_target(
1195 config.name,
1196 config.config,
1197 clients,
1198 AuxiliaryOperation::AnthropicCountTokens,
1199 )?;
1200 Some((
1201 count_tokens_priority(config.name, &config.config.id),
1202 index,
1203 target,
1204 ))
1205 })
1206 .min_by_key(|(priority, index, _)| (*priority, *index))
1207 .map(|(_, _, target)| target)
1208 }
1209
1210 fn build_responses_auxiliary_target(
1211 &self,
1212 route: &AlgorithmSpec,
1213 targets: &LaneTargets<'_>,
1214 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1215 ) -> Option<AuxiliaryTarget> {
1216 route.routing_target_names().into_iter().find_map(|name| {
1217 let target = targets.get(name)?;
1218 self.build_auxiliary_target(
1219 target.name,
1220 target.config,
1221 clients,
1222 AuxiliaryOperation::ResponsesInputTokens,
1223 )
1224 })
1225 }
1226
1227 fn build_auxiliary_target(
1228 &self,
1229 name: &str,
1230 target: &TargetConfig,
1231 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1232 operation: AuxiliaryOperation,
1233 ) -> Option<AuxiliaryTarget> {
1234 let client = clients.get(&target.llm_client)?;
1235 let model = ModelId::from(name);
1236 client
1237 .supports_auxiliary(&model, operation)
1238 .then(|| AuxiliaryTarget {
1239 model,
1240 client: client.clone(),
1241 })
1242 }
1243}
1244
1245const ANTHROPIC_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
1247
1248fn count_tokens_priority(target_name: &str, model_id: &ModelId) -> usize {
1249 let target_name = target_name.to_ascii_lowercase();
1250 let model_id = model_id.to_ascii_lowercase();
1251 ["opus", "sonnet", "haiku"]
1252 .iter()
1253 .position(|hint| target_name.contains(hint) || model_id.contains(hint))
1254 .unwrap_or(3)
1255}
1256
1257#[derive(Clone, Debug)]
1262struct HttpBaseUrl(reqwest::Url);
1263
1264impl HttpBaseUrl {
1265 fn as_str(&self) -> &str {
1266 self.0.as_str()
1267 }
1268}
1269
1270impl<'de> Deserialize<'de> for HttpBaseUrl {
1271 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1272 where
1273 D: serde::Deserializer<'de>,
1274 {
1275 let raw = String::deserialize(deserializer)?;
1276 let url = reqwest::Url::parse(raw.trim()).map_err(|error| {
1277 serde::de::Error::custom(format!("base_url must be an absolute HTTP(S) URL: {error}"))
1278 })?;
1279 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
1280 return Err(serde::de::Error::custom(
1281 "base_url must be an absolute HTTP(S) URL",
1282 ));
1283 }
1284 Ok(Self(url))
1285 }
1286}
1287
1288#[derive(Debug, Deserialize)]
1289#[serde(deny_unknown_fields)]
1290struct LlmClientConfig {
1291 format: ClientFormat,
1292 base_url: HttpBaseUrl,
1293 context_discovery: Option<ContextDiscovery>,
1295 api_key_env: Option<String>,
1296 #[serde(default)]
1297 forward_auth: bool,
1298 #[serde(default)]
1299 extra_headers: BTreeMap<String, String>,
1300 #[serde(default = "default_max_retries")]
1301 max_retries: u32,
1302 #[serde(default = "default_fallback_on_rate_limit")]
1303 fallback_on_rate_limit: bool,
1304 #[serde(default = "default_failure_cooldown_ms")]
1306 failure_cooldown_ms: u64,
1307 timeout_ms: Option<u64>,
1309 responses_reasoning: Option<ResponsesReasoningPolicy>,
1310 responses_compatibility: Option<ResponsesCompatibility>,
1311}
1312
1313#[derive(Debug, Deserialize)]
1314#[serde(rename_all = "snake_case")]
1315enum ContextDiscovery {
1316 LlamaCpp,
1317}
1318
1319#[derive(Debug, Deserialize)]
1320#[serde(deny_unknown_fields)]
1321struct DecisionClientConfig {
1322 format: DecisionFormat,
1323 endpoint: HttpBaseUrl,
1324 api_key_env: String,
1325 timeout_ms: u64,
1326}
1327
1328#[derive(Debug, Deserialize)]
1329#[serde(rename_all = "snake_case")]
1330enum DecisionFormat {
1331 SystemOne,
1332 OpenaiDecisions,
1333}
1334
1335#[derive(Debug, Deserialize)]
1336#[serde(deny_unknown_fields)]
1337struct DecisionTargetConfig {
1338 #[serde(default)]
1339 labels: BTreeSet<String>,
1340 id: ModelId,
1341 decision_client: String,
1342}
1343
1344#[derive(Debug, Deserialize)]
1345#[serde(deny_unknown_fields)]
1346struct TargetConfig {
1347 #[serde(default)]
1348 labels: BTreeSet<String>,
1349 id: ModelId,
1350 llm_client: String,
1351 #[serde(default)]
1352 extra_body: BTreeMap<String, Value>,
1353 #[serde(default)]
1356 omit_body_fields: BTreeSet<String>,
1357 system_prompt: Option<String>,
1358 reasoning_effort: Option<String>,
1360}
1361
1362#[derive(Clone, Copy, Debug, Deserialize)]
1363enum ClientFormat {
1364 #[serde(rename = "openai_chat")]
1365 OpenAiChat,
1366 #[serde(rename = "openai_responses")]
1367 OpenAiResponses,
1368 #[serde(rename = "anthropic_messages")]
1369 AnthropicMessages,
1370}
1371
1372impl ClientFormat {
1373 const fn wire_format(self) -> WireFormat {
1374 match self {
1375 Self::OpenAiChat => WireFormat::OpenAiChat,
1376 Self::OpenAiResponses => WireFormat::OpenAiResponses,
1377 Self::AnthropicMessages => WireFormat::AnthropicMessages,
1378 }
1379 }
1380
1381 const fn caller_auth_kind(self) -> CallerAuthKind {
1382 match self {
1383 Self::AnthropicMessages => CallerAuthKind::Anthropic,
1384 Self::OpenAiChat | Self::OpenAiResponses => CallerAuthKind::OpenAi,
1385 }
1386 }
1387}
1388
1389fn build_backend(
1390 client_name: &str,
1391 config: &LlmClientConfig,
1392 extra_body: &BTreeMap<String, Value>,
1393 omit_body_fields: &BTreeSet<String>,
1394 reasoning_effort: Option<String>,
1395) -> RunnerResult<Backend> {
1396 if config.max_retries > MAX_CONFIGURED_RETRIES {
1397 return Err(RunnerError::configuration(format!(
1398 "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}"
1399 )));
1400 }
1401 if config.timeout_ms == Some(0) {
1402 return Err(RunnerError::configuration(format!(
1403 "llm client {client_name} timeout_ms must be at least 1"
1404 )));
1405 }
1406 if config.forward_auth && config.api_key_env.is_some() {
1407 return Err(RunnerError::configuration(format!(
1408 "llm client {client_name} cannot set both forward_auth and api_key_env"
1409 )));
1410 }
1411 let api_key = config
1412 .api_key_env
1413 .as_deref()
1414 .map(|variable| read_api_key(&format!("llm client {client_name}"), variable))
1415 .transpose()?;
1416 let http = HttpBackendConfig {
1417 base_url: config.base_url.as_str().to_string(),
1418 api_key,
1419 forward_auth: config.forward_auth,
1420 extra_headers: config.extra_headers.clone(),
1421 extra_body: extra_body.clone(),
1422 omit_body_fields: omit_body_fields.clone(),
1423 reasoning_effort,
1424 max_retries: config.max_retries,
1425 failure_cooldown: Duration::from_millis(config.failure_cooldown_ms),
1426 timeout: config.timeout_ms.map(Duration::from_millis),
1427 };
1428 let backend = match config.format {
1429 ClientFormat::OpenAiChat => Backend::OpenAiChat(http),
1430 ClientFormat::OpenAiResponses => Backend::OpenAiResponses(http),
1431 ClientFormat::AnthropicMessages => Backend::Anthropic(http),
1432 };
1433 Ok(backend)
1434}
1435
1436fn read_api_key(client: &str, variable: &str) -> RunnerResult<String> {
1437 if variable.trim().is_empty() {
1438 return Err(RunnerError::configuration(format!(
1439 "{client} api_key_env must not be empty"
1440 )));
1441 }
1442 if variable.contains(['\0', '=']) {
1444 return Err(RunnerError::configuration(format!(
1445 "{client} api_key_env must not contain '=' or NUL"
1446 )));
1447 }
1448 let api_key = std::env::var(variable).map_err(|error| {
1449 let reason = match error {
1451 std::env::VarError::NotPresent => "environment variable not found",
1452 std::env::VarError::NotUnicode(_) => "environment variable is not valid Unicode",
1453 };
1454 RunnerError::configuration(format!(
1455 "{client} could not read api_key_env {variable}: {reason}"
1456 ))
1457 })?;
1458 if api_key.trim().is_empty() {
1459 return Err(RunnerError::configuration(format!(
1460 "{client} api_key_env {variable} is empty"
1461 )));
1462 }
1463 Ok(api_key)
1464}
1465
1466const fn default_max_retries() -> u32 {
1468 DEFAULT_MAX_RETRIES
1469}
1470
1471const fn default_fallback_on_rate_limit() -> bool {
1472 true
1473}
1474
1475const fn default_failure_cooldown_ms() -> u64 {
1476 5000
1477}
1478
1479fn validate_value(label: &str, value: &str) -> RunnerResult<()> {
1480 if value.trim().is_empty() || value.trim() != value {
1481 return Err(RunnerError::configuration(format!(
1482 "{label} must be non-empty and have no surrounding whitespace"
1483 )));
1484 }
1485 Ok(())
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490 use super::*;
1491
1492 #[test]
1493 fn route_presentation_fields_are_split_from_the_algorithm() {
1494 let route: RouteConfig = toml::from_str(
1495 r#"
1496type = "random"
1497id = "switchyard/random"
1498context_window = 128000
1499tool_calling = true
1500reasoning = false
1501targets = ["fast", "strong"]
1502weights = [1.0, 2.0]
1503seed = 7
1504"#,
1505 )
1506 .expect("route should deserialize");
1507
1508 assert_eq!(route.id, "switchyard/random");
1509 assert_eq!(route.context_window, Some(128_000));
1510 assert_eq!(route.tool_calling, Some(true));
1511 assert_eq!(route.reasoning, Some(false));
1512 assert_eq!(route.algorithm.routing_target_names(), ["fast", "strong"]);
1513 }
1514
1515 #[test]
1516 fn route_algorithm_unknown_fields_are_rejected() {
1517 let error = toml::from_str::<RouteConfig>(
1518 r#"
1519type = "passthrough"
1520id = "switchyard/fast"
1521target = "fast"
1522bogus = true
1523"#,
1524 )
1525 .expect_err("unknown route keys must be rejected");
1526
1527 assert!(error.to_string().contains("bogus"), "{error}");
1528 }
1529
1530 #[test]
1531 fn deployment_errors_keep_one_path_context() {
1532 let path = Path::new("/definitely/missing/switchyard-routes.toml");
1533 let error = match load_runner(path) {
1534 Ok(_) => panic!("missing config should fail"),
1535 Err(error) => error,
1536 };
1537 let message = error.to_string();
1538
1539 assert_eq!(message.matches(&path.display().to_string()).count(), 1);
1540 assert!(message.starts_with("failed to read server config"));
1541 }
1542}
1543#[cfg(test)]
1544mod deployment_tests {
1545 use super::*;
1546 use crate::require_target_labels;
1547 use serde_json::json;
1548 use switchyard_protocol::{Category, Request};
1549
1550 const VALID_CONFIG: &str = r#"
1551schema_version = 1
1552
1553[llm_clients.primary]
1554format = "openai_chat"
1555base_url = "https://example.test/v1"
1556
1557[llm_clients.responses]
1558format = "openai_responses"
1559base_url = "https://example.test/v1"
1560
1561[llm_clients.anthropic]
1562format = "anthropic_messages"
1563base_url = "https://example.test"
1564
1565[targets.classifier]
1566id = "classifier/model"
1567llm_client = "primary"
1568
1569[targets.strong]
1570id = "strong/model"
1571llm_client = "responses"
1572
1573[targets.weak]
1574id = "weak/model"
1575llm_client = "anthropic"
1576
1577[routes.noop]
1578id = "switchyard/noop"
1579type = "noop"
1580
1581[routes.random]
1582id = "switchyard/random"
1583type = "random"
1584targets = ["strong", "weak"]
1585
1586[routes.classifier]
1587id = "switchyard/classifier"
1588type = "llm_classifier"
1589classifier_target = "classifier"
1590strong_target = "strong"
1591weak_target = "weak"
1592base_threshold = 0.5
1593
1594[routes.passthrough]
1595id = "switchyard/passthrough"
1596type = "passthrough"
1597target = "weak"
1598
1599[routes.plan_execute]
1600id = "switchyard/plan-execute"
1601type = "stage_router"
1602capable_target = "strong"
1603efficient_target = "weak"
1604picker = "capable_first"
1605confidence_threshold = 0.0
1606efficient_hold_turns = -1
1607capable_system_prompt = "Inspect the task and plan before modifying files."
1608"#;
1609
1610 fn decision_eligibility_config() -> String {
1611 let config = VALID_CONFIG
1612 .replace(
1613 "id = \"strong/model\"",
1614 "id = \"strong/model\"\nlabels = ['private']",
1615 )
1616 .replace(
1617 "id = \"weak/model\"",
1618 "id = \"weak/model\"\nlabels = ['private']",
1619 );
1620 format!(
1621 r#"{config}
1622
1623[decision_clients.privacy]
1624format = "system_one"
1625endpoint = "https://example.test/v1/systemone"
1626api_key_env = "PATH"
1627timeout_ms = 5000
1628
1629[decision_targets.standard]
1630id = "decision/standard"
1631decision_client = "privacy"
1632labels = ["private"]
1633
1634[routes.typed]
1635id = "switchyard/typed"
1636type = "llm_classifier"
1637mode = "capability"
1638classifier_target = "standard"
1639strong_target = "strong"
1640weak_target = "weak"
1641
1642[routes.typed.decision]
1643cutoff = 0.4
1644
1645[routes.typed.eligibility]
1646"#
1647 )
1648 }
1649
1650 fn eligible_pool_config() -> String {
1651 let config = VALID_CONFIG
1652 .replace("id = \"strong/model\"", "id = \"strong/model\"\nlabels = ['private']")
1653 .replace(
1654 "type = \"passthrough\"\ntarget = \"weak\"",
1655 "type = \"random\"\ntargets = [\"weak\", \"strong\"]\nweights = [3.0, 1.0]\nseed = 7",
1656 );
1657 format!(
1658 r#"{config}
1659
1660[routes.passthrough.eligibility]
1661"#
1662 )
1663 }
1664
1665 #[tokio::test]
1666 async fn eligibility_requires_approved_roles_and_decision_targets() -> RunnerResult<()> {
1667 let configured = decision_eligibility_config();
1668 let runner = Runner::from_toml(&configured)?;
1669 let route = runner
1670 .route("switchyard/typed")
1671 .expect("typed eligible route should exist");
1672 assert_eq!(
1673 route.models().models_for(&Category::Judge),
1674 [ModelId::from("decision/standard")]
1675 );
1676
1677 for (config, expected) in [
1678 (
1679 configured.replace(
1680 "decision_client = \"privacy\"\nlabels = [\"private\"]",
1681 "decision_client = \"privacy\"",
1682 ),
1683 "required target standard",
1684 ),
1685 (
1686 configured.replace(
1687 "id = \"weak/model\"\nlabels = ['private']",
1688 "id = \"weak/model\"",
1689 ),
1690 "required target weak",
1691 ),
1692 ] {
1693 let runner = Runner::from_toml(&config)?;
1694 let mut request = Request::default();
1695 require_target_labels(&mut request, ["private"])?;
1696 let message = runner
1697 .route("switchyard/typed")
1698 .expect("typed route")
1699 .decide(request)
1700 .await
1701 .err()
1702 .expect("unapproved role must fail before model I/O")
1703 .to_string();
1704 assert!(
1705 message.contains(expected),
1706 "expected {expected:?}, got {message}"
1707 );
1708 }
1709 Ok(())
1710 }
1711
1712 #[tokio::test]
1713 async fn label_requirements_select_only_approved_targets() -> RunnerResult<()> {
1714 let configured = eligible_pool_config();
1715 let runner = Runner::from_toml(&configured)?;
1716 let route = runner
1717 .route("switchyard/passthrough")
1718 .expect("eligible route should exist");
1719
1720 let mut request = Request::default();
1721 require_target_labels(&mut request, ["private"])?;
1722 let outcome = route.decide(request).await?;
1723 assert_eq!(outcome.selected_model_id()?, "strong");
1724 assert_eq!(
1725 &runner
1726 .describe_decision(&ModelId::from("switchyard/passthrough"), &outcome)
1727 .expect("selected target metadata")
1728 .selected
1729 .target,
1730 outcome.selected_model_id()?.as_str()
1731 );
1732 for (config, route_id, expected) in [
1733 (
1734 configured.replace("labels = ['private']", "labels = []"),
1735 "switchyard/passthrough",
1736 "without an eligible target",
1737 ),
1738 (
1739 format!("{configured}\n[routes.plan_execute.eligibility]\n"),
1740 "switchyard/plan-execute",
1741 "required target weak",
1742 ),
1743 ] {
1744 let runner = Runner::from_toml(&config)?;
1745 let mut request = Request::default();
1746 require_target_labels(&mut request, ["private"])?;
1747 let error = runner
1748 .route(route_id)
1749 .expect("configured route")
1750 .decide(request)
1751 .await
1752 .err()
1753 .expect("no eligible setup");
1754 assert!(error.to_string().contains(expected), "{error}");
1755 }
1756 Ok(())
1757 }
1758
1759 #[tokio::test]
1760 async fn ordinary_routes_reject_label_requirements() -> RunnerResult<()> {
1761 let runner = Runner::from_toml(VALID_CONFIG)?;
1762 let ordinary = runner.route("switchyard/random").expect("ordinary route");
1763 for labels in [&[][..], &["private"][..]] {
1764 let mut request = Request::default();
1765 require_target_labels(&mut request, labels)?;
1766 assert!(ordinary.decide(request).await.is_err());
1767 }
1768 Ok(())
1769 }
1770
1771 fn alternative_config(algorithm: &str) -> String {
1772 let globals = VALID_CONFIG.split("[routes.noop]").next().expect("globals");
1773 let mut config = globals.to_string();
1774 for name in ["weak", "strong", "classifier"] {
1775 config.push_str(&format!(
1776 "\n[targets.approved_{name}]\nid = 'approved/{name}'\nllm_client = 'primary'\nlabels = ['private']\n"
1777 ));
1778 }
1779 config.push_str(&format!(
1780 "\n[routes.test]\nid = 'route'\n{algorithm}\n[routes.test.eligibility.target_alternatives]\n"
1781 ));
1782 let spec: AlgorithmSpec = toml::from_str(algorithm).expect("test algorithm");
1783 for name in spec
1784 .callable_target_names()
1785 .into_iter()
1786 .collect::<BTreeSet<_>>()
1787 {
1788 config.push_str(&format!("{name} = ['approved_{name}']\n"));
1789 }
1790 config
1791 }
1792
1793 #[tokio::test]
1795 async fn alternatives_preserve_router_roles() -> RunnerResult<()> {
1796 for (algorithm, initial) in [
1797 ("type = 'passthrough'\ntarget = 'weak'", "weak"),
1798 (
1799 "type = 'stage_router'\ncapable_target = 'strong'\nefficient_target = 'weak'\npicker = 'capable_first'\nconfidence_threshold = 0.0",
1800 "strong",
1801 ),
1802 ] {
1803 let runner = Runner::from_toml(&alternative_config(algorithm))?;
1804 let route = runner.route("route").expect("route");
1805 let standard = route.decide(Request::default()).await?;
1806 assert_eq!(standard.selected_model_id()?.as_str(), initial);
1807 let mut request = Request::default();
1808 require_target_labels(&mut request, ["private"])?;
1809 let outcome = route.decide(request).await?;
1810 assert_eq!(outcome.selected_model_id()?, &format!("approved_{initial}"));
1811 assert!(
1812 outcome
1813 .selected_model_ids
1814 .iter()
1815 .all(|model| model.starts_with("approved_"))
1816 );
1817 let description = runner
1818 .describe_decision(&ModelId::from("route"), &outcome)
1819 .expect("mapped metadata");
1820 assert_eq!(description.selected.target, format!("approved_{initial}"));
1821 }
1822 Ok(())
1823 }
1824
1825 #[tokio::test]
1827 async fn alternatives_reject_invalid_bindings() -> RunnerResult<()> {
1828 let config = alternative_config(
1829 "type = 'auto'\ncapable_target = 'strong'\nefficient_target = 'weak'",
1830 );
1831 for (invalid, expected) in [
1832 (
1833 format!("{config}typo = ['approved_weak']\n"),
1834 "not used by the algorithm",
1835 ),
1836 (
1837 config.replace("weak = ['approved_weak']", "weak = ['missing']"),
1838 "must exist and match the target kind",
1839 ),
1840 (
1841 config.replace("weak = ['approved_weak']", "weak = []"),
1842 "target alternatives must not be empty",
1843 ),
1844 (
1845 config.replace(
1846 "[llm_clients.primary]",
1847 "[llm_clients.primary]\nforward_auth = true",
1848 ),
1849 "forwarded caller authentication",
1850 ),
1851 ] {
1852 let message = error_message(&invalid);
1853 assert!(
1854 message.contains(expected),
1855 "expected {expected:?}, got {message}"
1856 );
1857 }
1858 let random =
1859 alternative_config("type = 'random'\ntargets = ['weak', 'strong']\nweights = [0, 1]")
1860 .replace("strong = ['approved_strong']", "strong = ['approved_weak']");
1861 for (source, expected) in [
1862 (
1863 config.replace("weak = ['approved_weak']", "weak = ['strong']"),
1864 "required target weak",
1865 ),
1866 (random, "multiple pool entries to target approved_weak"),
1867 ] {
1868 let runner = Runner::from_toml(&source)?;
1869 let mut request = Request::default();
1870 require_target_labels(&mut request, ["private"])?;
1871 let error = runner
1872 .route("route")
1873 .expect("configured route")
1874 .decide(request)
1875 .await
1876 .err()
1877 .expect("invalid eligible bindings");
1878 assert!(error.to_string().contains(expected), "{error}");
1879 }
1880 Runner::from_toml(&config.replace("id = 'approved/weak'", "id = 'weak/model'"))?;
1881 let shared_model =
1882 alternative_config("type = 'random'\ntargets = ['weak', 'strong']\nweights = [0, 1]")
1883 .replace("id = 'approved/strong'", "id = 'approved/weak'");
1884 let runner = Runner::from_toml(&shared_model)?;
1885 let mut request = Request::default();
1886 require_target_labels(&mut request, ["private"])?;
1887 assert_eq!(
1888 runner
1889 .route("route")
1890 .expect("route")
1891 .decide(request)
1892 .await?
1893 .selected_model_id()?,
1894 "approved_strong"
1895 );
1896 let wrong_kind = format!(
1897 "{}\n[routes.typed.eligibility.target_alternatives]\nstandard = ['weak']\n",
1898 decision_eligibility_config()
1899 );
1900 assert!(error_message(&wrong_kind).contains("must exist and match the target kind"));
1901 Ok(())
1902 }
1903
1904 #[tokio::test]
1906 async fn alternatives_reject_prompted_vgr_secondary_judge_alias() -> RunnerResult<()> {
1907 let source = alternative_config(
1908 "type = 'vgr'\nefficient_target = 'weak'\ncapable_target = 'strong'\njudge_target = 'strong'\ncapable_judge_target = 'classifier'",
1909 )
1910 .replace(
1911 "classifier = ['approved_classifier']",
1912 "classifier = ['approved_weak']",
1913 )
1914 .replace(
1915 "[targets.approved_weak]",
1916 "[targets.approved_weak]\nsystem_prompt = 'Answer the user.'",
1917 );
1918 let runner = Runner::from_toml(&source)?;
1919 let mut request = Request::default();
1920 require_target_labels(&mut request, ["private"])?;
1921 let error = runner
1922 .route("route")
1923 .expect("VGR route")
1924 .decide(request)
1925 .await
1926 .err()
1927 .expect("secondary judge alias must fail before model I/O");
1928 assert!(matches!(error, RunnerError::Configuration { .. }));
1929 assert!(error.to_string().contains("routing-only target"), "{error}");
1930 Ok(())
1931 }
1932
1933 #[tokio::test]
1935 async fn alternatives_reject_ambiguous_composite_decision_clients() -> RunnerResult<()> {
1936 let source = decision_eligibility_config().replace(
1937 "decision_client = \"privacy\"\nlabels = [\"private\"]",
1938 "decision_client = \"privacy\"",
1939 );
1940 let source = format!(
1941 r#"{source}
1942[decision_clients.other]
1943format = "system_one"
1944endpoint = "http://127.0.0.1:1/decisions"
1945api_key_env = "PATH"
1946timeout_ms = 1
1947
1948[decision_targets.secondary]
1949id = "decision/secondary"
1950decision_client = "privacy"
1951
1952[decision_targets.approved_primary]
1953id = "decision/shared"
1954decision_client = "privacy"
1955labels = ["private"]
1956
1957[decision_targets.approved_secondary]
1958id = "decision/shared"
1959decision_client = "other"
1960labels = ["private"]
1961
1962[routes.ambiguous]
1963id = "switchyard/ambiguous"
1964type = "composite"
1965classifier = {{ target = "standard", classify_trigger = "user_turn", decision = {{ cutoff = 0.4 }} }}
1966stage = {{ capable_target = "strong", efficient_target = "weak", confidence_threshold = 0.5 }}
1967efficient = {{ classifier = {{ target = "secondary", decision = {{ cutoff = 0.4 }} }} }}
1968
1969[routes.ambiguous.eligibility.target_alternatives]
1970standard = ["approved_primary"]
1971secondary = ["approved_secondary"]
1972"#
1973 );
1974 let runner = Runner::from_toml(&source)?;
1975 let mut request = Request::default();
1976 require_target_labels(&mut request, ["private"])?;
1977 let error = runner
1978 .route("switchyard/ambiguous")
1979 .expect("composite route")
1980 .decide(request)
1981 .await
1982 .err()
1983 .expect("ambiguous decision clients must fail before model I/O");
1984 assert!(matches!(error, RunnerError::Configuration { .. }));
1985 assert!(
1986 error
1987 .to_string()
1988 .contains("maps decision model decision/shared to different clients"),
1989 "{error}"
1990 );
1991 Ok(())
1992 }
1993
1994 #[test]
1995 fn eligibility_rejects_unknown_fields_and_invalid_scope() {
1996 let configured = eligible_pool_config();
1997 for (invalid, expected) in [
1998 (
1999 configured.replace("labels = ['private']", "label = ['private']"),
2000 "unknown field `label`",
2001 ),
2002 (
2003 configured.replace(
2004 "[routes.passthrough.eligibility]",
2005 "[routes.passthrough.eligible]",
2006 ),
2007 "unknown field `eligible`",
2008 ),
2009 (
2010 decision_eligibility_config().replace(
2011 "[decision_targets.standard]",
2012 "[decision_targets.standard]\nlabel = ['private']",
2013 ),
2014 "unknown field `label`",
2015 ),
2016 (
2017 format!("{configured}\nrequire_explicit_requirement = true\n"),
2018 "unknown field `require_explicit_requirement`",
2019 ),
2020 (
2021 format!("{configured}\nscope = 'task'\n"),
2022 "unknown variant `task`",
2023 ),
2024 ] {
2025 let message = error_message(&invalid);
2026 assert!(
2027 message.contains(expected),
2028 "expected {expected:?}, got {message}"
2029 );
2030 }
2031 }
2032
2033 #[test]
2034 fn eligibility_rejects_conflicting_approvals_and_forwarded_auth() {
2035 let configured = eligible_pool_config();
2036 for (invalid, expected) in [
2037 (
2038 format!(
2039 "{configured}\n[targets.alias]\nid = \"strong/model\"\nllm_client = \"responses\"\n"
2040 ),
2041 "disagree on approval labels",
2042 ),
2043 (
2044 format!(
2045 "{}\n[decision_targets.alias]\nid = \"decision/standard\"\ndecision_client = \"privacy\"\n",
2046 decision_eligibility_config()
2047 ),
2048 "disagrees on approval labels",
2049 ),
2050 (
2051 configured.replace(
2052 "[llm_clients.responses]\nformat = \"openai_responses\"",
2053 "[llm_clients.responses]\nformat = \"openai_responses\"\nforward_auth = true",
2054 ),
2055 "cannot use eligibility with forwarded caller authentication",
2056 ),
2057 (
2058 alternative_config(
2059 "type = 'prefill_router'\ntargets = ['weak']\ncheckpoint = 'unused'",
2060 ),
2061 "cannot use eligibility with prefill_router",
2062 ),
2063 ] {
2064 let message = error_message(&invalid);
2065 assert!(
2066 message.contains(expected),
2067 "expected {expected:?}, got {message}"
2068 );
2069 }
2070 }
2071
2072 #[test]
2073 fn public_runner_from_toml_builds_a_deployment() -> RunnerResult<()> {
2074 let config = format!(
2075 "{VALID_CONFIG}\n[routes.classifier.infra]\n\
2076 max_signal_age_ms = 1000\nmin_waiting_requests = 4\nmax_capable_inflight = 2"
2077 );
2078 let runner = Runner::from_toml(&config)?;
2079 assert!(
2080 Runner::from_toml(
2081 &config.replace("min_waiting_requests = 4", "min_waiting_requests = 0")
2082 )
2083 .is_err()
2084 );
2085
2086 let classifier = runner
2087 .route("switchyard/classifier")
2088 .expect("classifier route should exist");
2089 let models = classifier.models();
2090 assert_eq!(
2091 models.models_for(&Category::Judge),
2092 [ModelId::from("classifier")]
2093 );
2094 assert_eq!(
2095 models.models_for(&Category::Efficient),
2096 [ModelId::from("weak")]
2097 );
2098 assert_eq!(
2099 models.models_for(&Category::Capable),
2100 [ModelId::from("strong")]
2101 );
2102 assert_eq!(
2103 models.models_for(&Category::Any),
2104 [ModelId::from("weak"), ModelId::from("strong")]
2105 );
2106 let plan_execute = runner
2107 .route("switchyard/plan-execute")
2108 .expect("plan-execute route should exist");
2109 assert_eq!(
2110 plan_execute.models().models_for(&Category::Capable),
2111 [ModelId::from("strong")]
2112 );
2113 assert_eq!(
2114 plan_execute.models().models_for(&Category::Efficient),
2115 [ModelId::from("weak")]
2116 );
2117 assert!(runner.route("switchyard/passthrough").is_some());
2118 Ok(())
2119 }
2120
2121 #[test]
2123 fn removed_plan_execute_type_is_rejected() {
2124 let route = r#"
2125type = "plan_execute"
2126capable_target = "strong"
2127efficient_target = "weak"
2128"#;
2129 let config = format!("{VALID_CONFIG}\n[routes.obsolete]\nid = \"obsolete\"\n{route}");
2130 assert!(error_message(&config).contains("unknown variant `plan_execute`"));
2131
2132 let error = serde_json::from_str::<AlgorithmSpec>(
2133 r#"{"type":"plan_execute","capable_target":"strong","efficient_target":"weak"}"#,
2134 )
2135 .expect_err("the removed algorithm must be rejected");
2136 assert!(error.to_string().contains("unknown variant `plan_execute`"));
2137 }
2138
2139 #[test]
2142 fn stage_rejects_obsolete_plan_execute_settings() {
2143 let stage = json!({
2144 "type": "stage_router", "capable_target": "strong", "efficient_target": "weak",
2145 "picker": "capable_first", "confidence_threshold": 0.0,
2146 "efficient_hold_turns": -1, "capable_system_prompt": "Plan before editing.",
2147 });
2148 assert!(serde_json::from_str::<AlgorithmSpec>(&stage.to_string()).is_ok());
2149 for (field, value, toml_value) in [
2150 ("planning_prompt", json!("plan"), r#""plan""#),
2151 ("handoff_prompt", json!("execute"), r#""execute""#),
2152 ("planner_reasoning_as_text", json!(true), "true"),
2153 ("execution_trigger", json!("edit"), r#""edit""#),
2154 (
2155 "judge",
2156 json!({"target": "classifier"}),
2157 r#"{ target = "classifier" }"#,
2158 ),
2159 ] {
2160 let mut invalid = stage.clone();
2161 invalid[field] = value;
2162 let error = serde_json::from_str::<AlgorithmSpec>(&invalid.to_string())
2163 .expect_err("obsolete fields must be rejected");
2164 assert!(error.to_string().contains(field), "{error}");
2165 let error = error_message(&format!("{VALID_CONFIG}\n{field} = {toml_value}\n"));
2166 assert!(error.contains(field), "{error}");
2167 }
2168 }
2169
2170 #[test]
2171 fn duplicate_route_ids_are_rejected() {
2172 let config = format!(
2173 r#"{VALID_CONFIG}
2174
2175[routes.duplicate]
2176id = "switchyard/passthrough"
2177type = "passthrough"
2178target = "strong"
2179"#
2180 );
2181
2182 let error = error_message(&config);
2183
2184 assert!(
2185 error.contains(
2186 "routes duplicate and passthrough both use id switchyard/passthrough; route ids must be unique"
2187 ),
2188 "{error}"
2189 );
2190 }
2191
2192 fn error_message(toml: &str) -> String {
2193 match runner_from_toml(toml) {
2194 Ok(_) => "configuration unexpectedly succeeded".to_string(),
2195 Err(error) => error.to_string(),
2196 }
2197 }
2198
2199 fn vgr_config() -> String {
2200 format!(
2201 r#"{VALID_CONFIG}
2202
2203[routes.vgr]
2204id = "switchyard/vgr"
2205type = "vgr"
2206efficient_target = "weak"
2207capable_target = "strong"
2208judge_target = "classifier"
2209mode = "active"
2210active_approval = "prospective-validation-and-canary-approved"
2211task_typing = false
2212"#
2213 )
2214 }
2215
2216 #[test]
2217 fn vgr_compaction_is_opt_in_from_toml() -> RunnerResult<()> {
2218 for setting in ["", "compact_handoff = false", "compact_handoff = true"] {
2219 let source = format!("{}\nagentic_handoff = true\n{setting}\n", vgr_config());
2220 let parsed: DeploymentConfig = toml::from_str(&source).expect("valid VGR config");
2221 let AlgorithmSpec::Vgr { config } = &parsed.routes["vgr"].algorithm else {
2222 panic!("expected VGR route");
2223 };
2224 assert_eq!(config.compact_handoff, setting.ends_with("= true"));
2225 assert!(runner_from_toml(&source)?.route("switchyard/vgr").is_some());
2226 }
2227 Ok(())
2228 }
2229
2230 #[test]
2231 fn vgr_route_builds_and_rejects_unsafe_configuration() -> RunnerResult<()> {
2232 let runner = runner_from_toml(&vgr_config())?;
2233 assert!(runner.route("switchyard/vgr").is_some());
2234
2235 let compact = format!("{}\ncompact_handoff = true\n", vgr_config());
2236 assert!(error_message(&compact).contains("compact_handoff requires agentic_handoff"));
2237 let compact = format!("{compact}\nagentic_handoff = true\n");
2238 assert!(
2239 runner_from_toml(&compact)?
2240 .route("switchyard/vgr")
2241 .is_some()
2242 );
2243
2244 let identical_ids = vgr_config().replace(
2245 "efficient_target = \"weak\"",
2246 "efficient_target = \"strong\"",
2247 );
2248 assert!(error_message(&identical_ids).contains("distinct efficient and capable model IDs"));
2249
2250 let bad_approval =
2251 vgr_config().replace("prospective-validation-and-canary-approved", "approved");
2252 assert!(error_message(&bad_approval).contains("approval attestation"));
2253
2254 let missing_target = vgr_config().replace(
2255 "efficient_target = \"weak\"",
2256 "efficient_target = \"missing\"",
2257 );
2258 assert!(error_message(&missing_target).contains("unknown target missing"));
2259
2260 let zero_cooldown = format!("{}breaker_cooldown_seconds = 0\n", vgr_config());
2261 assert!(
2262 error_message(&zero_cooldown).contains("breaker_cooldown_seconds must be a positive")
2263 );
2264 Ok(())
2265 }
2266
2267 #[test]
2268 fn vgr_local_server_example_builds_and_rejects_unknown_discovery() -> RunnerResult<()> {
2269 let example = include_str!("../../../examples/vgr-local-servers.toml");
2270 let runner = runner_from_toml(example)?;
2271 assert!(runner.route("switchyard/vgr").is_some());
2272 assert!(runner.route("switchyard/only-efficient").is_some());
2273 let parsed: DeploymentConfig = toml::from_str(example).expect("valid VGR example");
2274 assert!(matches!(
2275 &parsed.routes["auxiliary"].algorithm,
2276 AlgorithmSpec::Passthrough { target, .. } if target == "efficient"
2277 ));
2278 let invalid = example.replace(
2279 "context_discovery = \"llama_cpp\"",
2280 "context_discovery = \"guess\"",
2281 );
2282 assert!(error_message(&invalid).contains("unknown variant `guess`"));
2283 Ok(())
2284 }
2285
2286 fn with_subagent_llm_classifier(config: &str, route: &str, extra: &str) -> String {
2287 let mut configured = config.to_string();
2288 configured.push_str(&format!("\n[routes.{route}.subagents]\n"));
2289 configured.push_str(
2290 r#"type = "llm_classifier"
2291mode = "custom"
2292models = { judge = ["classifier"], capable = ["strong"], efficient = ["weak"], any = ["strong", "weak"] }
2293default_target = "efficient"
2294prompt = "Select a target for this delegated task."
2295response_schema = '{"type":"object","properties":{"target":{"type":"string","enum":["capable","efficient"]}},"required":["target"],"additionalProperties":false}'
2296policy = { type = "target_selector", selector = "/target" }
2297classify_trigger = "new_session""#,
2298 );
2299 configured.push_str(extra);
2300 configured
2301 }
2302
2303 #[test]
2304 fn subagent_models_stay_separate_from_the_parent_tiers() -> RunnerResult<()> {
2305 let runner = runner_from_toml(&with_subagent_passthrough(&stage_config(), "stage"))?;
2309 let models = runner
2310 .route("switchyard/stage")
2311 .expect("stage route should exist")
2312 .models();
2313
2314 assert_eq!(
2315 models.subagent_models_for(&Category::Any),
2316 [ModelId::from("strong")]
2317 );
2318 assert_eq!(
2319 models.models_for(&Category::Any),
2320 [ModelId::from("strong"), ModelId::from("weak")]
2321 );
2322 Ok(())
2323 }
2324
2325 fn with_subagent_passthrough(config: &str, route: &str) -> String {
2326 format!("{config}\n[routes.{route}.subagents]\ntype = \"passthrough\"\ntarget = \"strong\"")
2327 }
2328
2329 fn stage_config() -> String {
2330 format!(
2331 r#"{VALID_CONFIG}
2332[targets.stage_judge]
2333id = "stage-judge/model"
2334llm_client = "primary"
2335
2336[routes.stage]
2337id = "switchyard/stage"
2338type = "stage_router"
2339capable_target = "strong"
2340efficient_target = "weak"
2341picker = "efficient_first"
2342confidence_threshold = 1.0
2343capable_hold_turns = 2
2344
2345[routes.stage.tool_semantics]
2346observe = ["lookup_customer"]
2347mutate = ["send_payment"]
2348plan = ["create_workflow"]
2349new = ["send_message"]
2350
2351[routes.stage.classifier]
2352target = "stage_judge"
2353base_threshold = 0.5
2354"#
2355 )
2356 }
2357
2358 fn composite_config() -> String {
2359 format!(
2360 r#"{VALID_CONFIG}
2361[targets.tier_judge]
2362id = "tier-judge/model"
2363llm_client = "primary"
2364
2365[routes.composed]
2366id = "switchyard/hier"
2367type = "composite"
2368
2369[routes.composed.classifier]
2370target = "tier_judge"
2371base_threshold = 0.5
2372classify_trigger = "user_turn"
2373
2374[routes.composed.stage]
2375capable_target = "strong"
2376efficient_target = "weak"
2377confidence_threshold = 0.5
2378capable_hold_turns = 2
2379
2380[routes.composed.stage.tool_semantics]
2381new = ["send_message"]
2382"#
2383 )
2384 }
2385
2386 #[test]
2387 fn stage_and_composite_validate_hold_turns() {
2388 let composite =
2389 composite_config().replace("capable_hold_turns = 2", "capable_hold_turns = 0");
2390 for source in [
2391 stage_config(),
2392 stage_config().replace("efficient_first", "capable_first"),
2393 composite_config(),
2394 format!("{composite}\n[routes.composed.efficient]\ncapable_hold_turns = 2"),
2395 format!("{composite}\n[routes.composed.capable]\ncapable_hold_turns = 2"),
2396 ] {
2397 for field in ["capable_hold_turns", "efficient_hold_turns"] {
2398 for (turns, valid) in [
2399 (-2_i64, false),
2400 (-1, true),
2401 (0, true),
2402 (3, true),
2403 (i64::from(u32::MAX), true),
2404 (i64::from(u32::MAX) + 1, false),
2405 ] {
2406 let configured =
2407 source.replace("capable_hold_turns = 2", &format!("{field} = {turns}"));
2408 let result = AlgorithmFactory::from_toml(&configured)
2409 .and_then(|factory| factory.build());
2410 assert_eq!(result.is_ok(), valid, "{field} = {turns}");
2411 if let Err(error) = result {
2412 assert!(error.to_string().contains(field), "{error}");
2413 }
2414 }
2415 }
2416 }
2417 }
2418
2419 #[test]
2420 fn composite_decision_judge_reuses_capability_validation() {
2421 let source = composite_config().replace(
2422 "[routes.composed.classifier]\ntarget = \"tier_judge\"\nbase_threshold = 0.5",
2423 "[routes.composed.classifier]\ntarget = \"tier_judge\"",
2424 ) + r#"
2425[routes.composed.classifier.decision]
2426cutoff = 0.4
2427instructions = "candidate comparison"
2428"#;
2429 for (source, valid) in [
2430 (source.clone(), true),
2431 (
2432 source.replace(
2433 "target = \"tier_judge\"",
2434 "target = \"tier_judge\"\nbase_threshold = 0.5",
2435 ),
2436 false,
2437 ),
2438 (source.replace("cutoff = 0.4", "cutoff = 1.1"), false),
2439 ] {
2440 let deployment: DeploymentConfig = toml::from_str(&source).unwrap();
2441 let spec = &deployment.routes["composed"].algorithm;
2442 assert_eq!(spec.classifier_targets().next().unwrap().0, "tier_judge");
2443 assert_eq!(
2444 spec.build_with_runtime_models("composed", &deployment.build_targets())
2445 .is_ok(),
2446 valid
2447 );
2448 }
2449 }
2450
2451 #[test]
2452 fn composite_custom_judge_builds_and_rejects_capability_settings() -> RunnerResult<()> {
2453 let source = composite_config().replace(
2454 "base_threshold = 0.5\nclassify_trigger",
2455 r#"mode = "custom"
2456prompt = 'Select efficient or capable.'
2457response_schema = '{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["efficient","capable"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}'
2458policy = { type = "target_selector", selector = "/decision/target" }
2459classify_trigger"#,
2460 );
2461 runner_from_toml(&source)?;
2462 runner_from_toml(
2463 &source.replace("mode = \"custom\"", "mode = \"custom\"\nfail_open = true"),
2464 )?;
2465 for (setting, expected) in [
2466 (
2467 "base_threshold = 0.5",
2468 "composite custom classifier cannot use capability thresholds",
2469 ),
2470 (
2471 "fail_open = false",
2472 "composite custom classifiers always fail open",
2473 ),
2474 ] {
2475 let invalid = source.replace(
2476 "mode = \"custom\"",
2477 &format!("mode = \"custom\"\n{setting}"),
2478 );
2479 assert!(error_message(&invalid).contains(expected));
2480 }
2481 Ok(())
2482 }
2483
2484 #[test]
2485 fn composite_route_builds_and_claims_both_tiers_and_its_judge() -> RunnerResult<()> {
2486 let runner = runner_from_toml(&composite_config())?;
2487 assert!(
2488 runner
2489 .models()
2490 .any(|model| model.id.as_str() == "switchyard/hier")
2491 );
2492 Ok(())
2493 }
2494
2495 #[test]
2496 fn builds_all_supported_algorithm_types() -> RunnerResult<()> {
2497 let state = runner_from_toml(VALID_CONFIG)?;
2498 assert_eq!(
2500 state
2501 .models()
2502 .map(|model| model.id.as_str())
2503 .collect::<Vec<_>>(),
2504 [
2505 "switchyard/classifier",
2506 "switchyard/noop",
2507 "switchyard/passthrough",
2508 "switchyard/plan-execute",
2509 "switchyard/random",
2510 ]
2511 );
2512 Ok(())
2513 }
2514
2515 #[test]
2516 fn stage_rejects_ambiguous_or_non_additive_tool_semantics() {
2517 for (configured, expected) in [
2518 (
2519 stage_config().replace(
2520 "mutate = [\"send_payment\"]",
2521 "mutate = [\"LOOKUP_CUSTOMER\"]",
2522 ),
2523 "appears in both tool_semantics.observe and tool_semantics.mutate",
2524 ),
2525 (
2526 stage_config().replace("new = [\"send_message\"]", "new = [\"Read\"]"),
2527 "already has built-in semantics and cannot be reclassified",
2528 ),
2529 (
2530 stage_config().replace("observe = [\"lookup_customer\"]", "observe = [\" \"]"),
2531 "contains an empty tool name",
2532 ),
2533 ] {
2534 let message = error_message(&configured);
2535 assert!(
2536 message.contains(expected),
2537 "expected {expected:?} in error, got: {message}"
2538 );
2539 }
2540 }
2541
2542 #[test]
2543 fn passthrough_and_stage_accept_subagent_routing() -> RunnerResult<()> {
2544 let stage = stage_config();
2545 let stage_with_classifier = with_subagent_llm_classifier(&stage, "stage", "");
2546 let parsed: DeploymentConfig = toml::from_str(&stage_with_classifier).map_err(|error| {
2547 RunnerError::configuration(format!("failed to parse stage config: {error}"))
2548 })?;
2549 let Some(stage_route) = parsed.routes.get("stage") else {
2550 return Err(RunnerError::configuration("stage route is missing"));
2551 };
2552 let callable_targets = stage_route.callable_target_names();
2553 for expected in ["strong", "weak", "stage_judge", "classifier"] {
2554 assert!(callable_targets.contains(&expected));
2555 }
2556
2557 for configured in [
2558 with_subagent_llm_classifier(VALID_CONFIG, "passthrough", ""),
2559 with_subagent_passthrough(VALID_CONFIG, "passthrough"),
2560 stage_with_classifier,
2561 with_subagent_passthrough(&stage, "stage"),
2562 ] {
2563 runner_from_toml(&configured)?;
2564 }
2565 Ok(())
2566 }
2567
2568 #[test]
2570 fn rejects_prompted_answer_target_used_as_routing_only_target() {
2571 let configured = VALID_CONFIG
2572 .replace(
2573 "classifier_target = \"classifier\"",
2574 "classifier_target = \"weak\"",
2575 )
2576 .replace(
2577 "id = \"weak/model\"\nllm_client = \"anthropic\"",
2578 "id = \"weak/model\"\nllm_client = \"anthropic\"\nsystem_prompt = \"answer prompt\"",
2579 )
2580 .replace(
2581 "base_threshold = 0.5",
2582 "base_threshold = 0.5\nescalation = { confirmations = 1 }",
2583 );
2584
2585 assert_eq!(
2586 error_message(&configured),
2587 "route classifier cannot apply system_prompt to target weak: it is also the routing-only target"
2588 );
2589 }
2590
2591 #[test]
2592 fn rejects_invalid_unreferenced_llm_client() {
2593 for (settings, expected) in [
2594 (
2595 "base_url = \"not a url\"",
2596 "base_url must be an absolute HTTP(S) URL",
2597 ),
2598 (
2599 "base_url = \"https://example.test\"\ntimeout_ms = 0",
2600 "timeout_ms must be at least 1",
2601 ),
2602 ] {
2603 let invalid = format!(
2604 "{VALID_CONFIG}\n[llm_clients.unused]\nformat = \"openai_chat\"\n{settings}"
2605 );
2606 let message = error_message(&invalid);
2607 assert!(message.contains(expected), "unexpected error: {message}");
2608 }
2609 }
2610
2611 #[test]
2612 fn an_escalation_table_switches_the_classifier_route_to_escalation() -> RunnerResult<()> {
2613 let escalating = VALID_CONFIG.replace(
2617 "base_threshold = 0.5",
2618 "base_threshold = 0.5\nescalation = { confirmations = 2 }",
2619 );
2620 runner_from_toml(&escalating)?;
2621
2622 let reversible = VALID_CONFIG.replace(
2623 "base_threshold = 0.5",
2624 "base_threshold = 0.5\nescalation = { confirmations = 2, deescalation = { strong_min_calls = 3, confirmations = 2, strong_max_calls = 6, weak_cooldown_calls = 8 } }",
2625 );
2626 runner_from_toml(&reversible)?;
2627
2628 let starved = VALID_CONFIG.replace(
2631 "base_threshold = 0.5",
2632 "base_threshold = 0.5\nescalation = { confirmations = 0 }",
2633 );
2634 assert!(error_message(&starved).contains("confirmations must be at least 1"));
2635
2636 Ok(())
2637 }
2638
2639 #[test]
2640 fn unmatched_steps_is_a_capability_setting() -> RunnerResult<()> {
2641 runner_from_toml(&VALID_CONFIG.replace(
2643 "base_threshold = 0.5",
2644 "base_threshold = 0.5\nunmatched_steps = 2",
2645 ))?;
2646 assert!(
2647 error_message(&VALID_CONFIG.replace(
2648 "base_threshold = 0.5",
2649 "base_threshold = 0.5\nunmatched_steps = 3",
2650 ))
2651 .contains("unmatched_steps must be 0, 1, or 2")
2652 );
2653
2654 assert!(
2658 error_message(&VALID_CONFIG.replace(
2659 "base_threshold = 0.5",
2660 "base_threshold = 0.5\nunmatched_steps = 2\nescalation = { confirmations = 2 }",
2661 ))
2662 .contains("mode escalation cannot use capability routing settings")
2663 );
2664 Ok(())
2665 }
2666
2667 #[test]
2668 fn a_target_reasoning_effort_parses_on_every_client_format() -> RunnerResult<()> {
2669 let strong = "[targets.strong]\nid = \"strong/model\"\nllm_client = \"responses\"";
2670 let weak = "[targets.weak]\nid = \"weak/model\"\nllm_client = \"anthropic\"";
2671 assert!(VALID_CONFIG.contains(strong) && VALID_CONFIG.contains(weak));
2672
2673 let forced = VALID_CONFIG.replace(strong, &format!("{strong}\nreasoning_effort = \"max\""));
2674 runner_from_toml(&forced)?;
2675
2676 let blank = VALID_CONFIG.replace(strong, &format!("{strong}\nreasoning_effort = \" \""));
2677 assert!(error_message(&blank).contains("reasoning_effort must not be empty"));
2678
2679 let anthropic = VALID_CONFIG.replace(weak, &format!("{weak}\nreasoning_effort = \"high\""));
2680 runner_from_toml(&anthropic)?;
2681
2682 let minimal =
2683 VALID_CONFIG.replace(weak, &format!("{weak}\nreasoning_effort = \"minimal\""));
2684 assert!(
2685 error_message(&minimal).contains("is not an anthropic_messages effort"),
2686 "{}",
2687 error_message(&minimal)
2688 );
2689 Ok(())
2690 }
2691
2692 #[test]
2693 fn decision_target_ids_must_not_match_llm_target_names() {
2694 let configured = format!(
2695 "{VALID_CONFIG}\n[decision_clients.decisions]\nformat = \"system_one\"\nendpoint = \"https://example.test/decisions\"\napi_key_env = \"PATH\"\ntimeout_ms = 5000\n\n[decision_targets.judge]\nid = \"strong\"\ndecision_client = \"decisions\"\n"
2696 );
2697 let message = error_message(&configured);
2698 assert!(
2699 message.contains("decision target judge id strong is also an LLM target name"),
2700 "{message}"
2701 );
2702 }
2703
2704 #[test]
2705 fn classifier_judge_completion_caps_are_configurable() -> RunnerResult<()> {
2706 let capability = VALID_CONFIG.replace(
2707 "base_threshold = 0.5",
2708 "base_threshold = 0.5\nmax_output_tokens = 512",
2709 );
2710 runner_from_toml(&capability)?;
2711
2712 let escalation = VALID_CONFIG.replace(
2713 "base_threshold = 0.5",
2714 "base_threshold = 0.5\nmax_output_tokens = 256\nescalation = { confirmations = 2 }",
2715 );
2716 runner_from_toml(&escalation)?;
2717 Ok(())
2718 }
2719
2720 #[test]
2721 fn classifier_prompts_are_configurable_in_both_modes() -> RunnerResult<()> {
2722 let capability = VALID_CONFIG.replace(
2723 "base_threshold = 0.5",
2724 "base_threshold = 0.5\nprompt = \"custom capability rubric\"",
2725 );
2726 runner_from_toml(&capability)?;
2727
2728 let escalation = VALID_CONFIG.replace(
2729 "base_threshold = 0.5",
2730 "base_threshold = 0.5\nprompt = \"custom trajectory rubric\"\nescalation = { confirmations = 2 }",
2731 );
2732 runner_from_toml(&escalation)?;
2733
2734 let empty = VALID_CONFIG.replace(
2735 "base_threshold = 0.5",
2736 "base_threshold = 0.5\nprompt = \" \"",
2737 );
2738 assert!(error_message(&empty).contains("classifier prompt must not be empty"));
2739
2740 let schema_placeholder = VALID_CONFIG.replace(
2741 "base_threshold = 0.5",
2742 "base_threshold = 0.5\nprompt = \"{{RESPONSE_SCHEMA}}\"",
2743 );
2744 assert!(
2745 error_message(&schema_placeholder)
2746 .contains("Switchyard supplies the schema automatically")
2747 );
2748 Ok(())
2749 }
2750
2751 #[test]
2752 fn mode_custom_rejects_capability_fields() {
2753 let mixed = VALID_CONFIG.replace(
2754 "base_threshold = 0.5",
2755 "mode = \"custom\"\nbase_threshold = 0.5",
2756 );
2757
2758 assert!(
2759 error_message(&mixed)
2760 .contains("mode custom cannot use capability or escalation fields")
2761 );
2762 }
2763
2764 #[test]
2765 fn stage_router_rejects_an_unknown_field() {
2766 let config = stage_config().replace(
2767 "picker = \"efficient_first\"",
2768 "picker = \"efficient_first\"\nmagic = true",
2769 );
2770 assert!(error_message(&config).contains("unknown field"));
2771 }
2772
2773 #[test]
2774 fn auto_route_builds_a_stage_router_with_no_extra_fields() -> RunnerResult<()> {
2775 let config = format!(
2776 r#"{VALID_CONFIG}
2777[routes.auto]
2778id = "switchyard/auto"
2779type = "auto"
2780capable_target = "strong"
2781efficient_target = "weak"
2782"#
2783 );
2784 let runner = runner_from_toml(&config)?;
2785 assert!(runner.route("switchyard/auto").is_some());
2786 Ok(())
2787 }
2788
2789 #[test]
2790 fn composite_stage_block_rejects_an_unknown_field() {
2791 let config = composite_config().replace(
2792 "confidence_threshold = 0.5",
2793 "confidence_threshold = 0.5\nmagic = true",
2794 );
2795 assert!(error_message(&config).contains("unknown field"));
2796 }
2797
2798 #[test]
2799 fn rejects_unknown_fields_and_algorithm_types() {
2800 let unknown_field =
2801 VALID_CONFIG.replace("schema_version = 1", "schema_version = 1\nmagic = true");
2802 assert!(error_message(&unknown_field).contains("unknown field"));
2803
2804 let nested_completion_cap = VALID_CONFIG.replace(
2805 "base_threshold = 0.5",
2806 "base_threshold = 0.5\nescalation = { max_output_tokens = 256 }",
2807 );
2808 assert!(error_message(&nested_completion_cap).contains("unknown field"));
2809
2810 let unknown_classifier_field = VALID_CONFIG.replace(
2811 "base_threshold = 0.5",
2812 "base_threshold = 0.5\nclassifier_magic = true",
2813 );
2814 assert!(error_message(&unknown_classifier_field).contains("unknown field"));
2815
2816 let target_capability = VALID_CONFIG.replace(
2817 "llm_client = \"responses\"",
2818 "llm_client = \"responses\"\ncontext_window = 1000000",
2819 );
2820 assert!(error_message(&target_capability).contains("unknown field `context_window`"));
2821
2822 let unknown_algorithm = VALID_CONFIG.replace("type = \"noop\"", "type = \"imaginary\"");
2823 assert!(error_message(&unknown_algorithm).contains("unknown variant"));
2824 }
2825
2826 #[test]
2827 fn rejects_unknown_stage_classifier_fields() {
2828 let config = format!(
2830 r#"{VALID_CONFIG}
2831
2832[routes.stage]
2833id = "switchyard/stage"
2834type = "stage_router"
2835capable_target = "strong"
2836efficient_target = "weak"
2837picker = "efficient_first"
2838confidence_threshold = 1.0
2839
2840[routes.stage.classifier]
2841target = "classifier"
2842base_threshold = 0.5
2843classifier_magic = true
2844"#
2845 );
2846
2847 let error = error_message(&config);
2848 assert!(
2849 error.contains("unknown field `classifier_magic`"),
2850 "{error}"
2851 );
2852 }
2853
2854 #[test]
2855 fn rejects_invalid_references_and_parameters() {
2856 let cases = [
2857 (
2858 VALID_CONFIG.replace("llm_client = \"primary\"", "llm_client = \"missing\""),
2859 "unknown llm client missing",
2860 ),
2861 (
2862 VALID_CONFIG.replace(
2863 "targets = [\"strong\", \"weak\"]",
2864 "targets = [\"strong\", \"missing\"]",
2865 ),
2866 "unknown target missing",
2867 ),
2868 (
2869 VALID_CONFIG.replace(
2870 "targets = [\"strong\", \"weak\"]",
2871 "targets = [\"strong\", \"weak\"]\nweights = [0, 0]",
2872 ),
2873 "at least one weight must be positive",
2874 ),
2875 (
2876 VALID_CONFIG.replace(
2877 "targets = [\"strong\", \"weak\"]",
2878 "targets = [\"strong\", \"strong\"]",
2879 ),
2880 "targets must be unique, strong is repeated",
2881 ),
2882 (
2883 VALID_CONFIG.replace(
2884 "targets = [\"strong\", \"weak\"]",
2885 "targets = [\"strong\", \"weak\"]\nweights = [1]",
2886 ),
2887 "expected 2 weights, got 1",
2888 ),
2889 (
2890 VALID_CONFIG.replace("base_threshold = 0.5", "base_threshold = 1.5"),
2891 "base_threshold must be between 0 and 1",
2892 ),
2893 (
2894 VALID_CONFIG.replace("classifier_target = \"classifier\"\n", ""),
2895 "route references unknown target",
2896 ),
2897 (
2898 VALID_CONFIG.replace(
2899 "classifier_target = \"classifier\"",
2900 "classifier_target = \"\"",
2901 ),
2902 "route references unknown target",
2903 ),
2904 (
2905 VALID_CONFIG.replace(
2906 "classifier_target = \"classifier\"",
2907 "classifier_target = \" \"",
2908 ),
2909 "route references unknown target",
2910 ),
2911 (
2912 VALID_CONFIG.replace(
2913 "base_threshold = 0.5",
2914 "base_threshold = 0.5\nthreshold_step = -0.1",
2915 ),
2916 "threshold_step must be finite and greater than or equal to 0",
2917 ),
2918 (
2919 VALID_CONFIG.replace(
2920 "base_threshold = 0.5",
2921 "base_threshold = 0.8\nthreshold_step = 0.11",
2922 ),
2923 "base_threshold + 2 * threshold_step must be at most 1",
2924 ),
2925 (
2926 VALID_CONFIG.replace(
2927 "base_threshold = 0.5",
2928 "base_threshold = 0.5\nmax_output_tokens = 0\nescalation = { confirmations = 2 }",
2929 ),
2930 "max_output_tokens must be at least 1",
2931 ),
2932 (
2933 VALID_CONFIG.replace(
2934 "base_threshold = 0.5",
2935 "base_threshold = 0.5\nmessage_hash_fallback = true",
2936 ),
2937 "message_hash_fallback requires classify_trigger = new_session",
2938 ),
2939 (
2940 with_subagent_llm_classifier(
2941 VALID_CONFIG,
2942 "passthrough",
2943 "\nmessage_hash_fallback = true",
2944 ),
2945 "cannot use message_hash_fallback",
2946 ),
2947 (
2948 with_subagent_llm_classifier(VALID_CONFIG, "passthrough", "")
2949 .replace("mode = \"custom\"", "mode = \"capability\""),
2950 "mode capability cannot use custom classifier fields",
2951 ),
2952 (
2953 VALID_CONFIG.replace(
2954 "base_threshold = 0.5",
2955 "escalation = { confirmations = 2 }\nclassify_trigger = \"user_turn\"",
2956 ),
2957 "mode escalation cannot use classify_trigger",
2958 ),
2959 (
2960 VALID_CONFIG.replace("schema_version = 1", "schema_version = 2"),
2961 "unsupported schema_version 2",
2962 ),
2963 (
2964 VALID_CONFIG.replace("[targets.strong]", "[targets.\" strong \"]"),
2965 "target name must be non-empty and have no surrounding whitespace",
2966 ),
2967 (
2968 VALID_CONFIG.replace(
2969 "targets = [\"strong\", \"weak\"]",
2970 "targets = [\"strong\", \"weak\"]\ncontext_window = 0",
2971 ),
2972 "route random context_window must be greater than zero",
2973 ),
2974 ];
2975
2976 for (toml, expected) in cases {
2977 let error = AlgorithmFactory::from_toml(&toml)
2978 .and_then(|factory| factory.build())
2979 .err()
2980 .expect("invalid configuration should fail")
2981 .to_string();
2982 assert!(
2983 error.contains(expected),
2984 "expected error containing {expected}, got {error}"
2985 );
2986 }
2987 }
2988
2989 #[test]
2990 fn accepts_duplicate_target_model_ids_on_one_client() -> RunnerResult<()> {
2991 const SAME_MODEL_TWO_ROUTES: &str = r#"
2996schema_version = 1
2997
2998[llm_clients.primary]
2999format = "openai_chat"
3000base_url = "https://example.test/v1"
3001
3002[targets.fast]
3003id = "gpt-4o"
3004llm_client = "primary"
3005
3006[targets.smart]
3007id = "gpt-4o"
3008llm_client = "primary"
3009
3010[routes.fast]
3011id = "switchyard/fast"
3012type = "passthrough"
3013target = "fast"
3014
3015[routes.smart]
3016id = "switchyard/smart"
3017type = "passthrough"
3018target = "smart"
3019"#;
3020 let state = runner_from_toml(SAME_MODEL_TWO_ROUTES)?;
3021 assert_eq!(
3022 state
3023 .models()
3024 .map(|model| model.id.as_str())
3025 .collect::<Vec<_>>(),
3026 ["switchyard/fast", "switchyard/smart"]
3027 );
3028 Ok(())
3029 }
3030
3031 #[test]
3032 fn accepts_same_model_id_on_different_llm_clients() -> RunnerResult<()> {
3033 const CROSS_PROVIDER: &str = r#"
3035schema_version = 1
3036
3037[llm_clients.openai]
3038format = "openai_chat"
3039base_url = "https://example.test/v1"
3040
3041[llm_clients.azure]
3042format = "openai_chat"
3043base_url = "https://azure.test/v1"
3044
3045[targets.openai]
3046id = "gpt-4o"
3047llm_client = "openai"
3048
3049[targets.azure]
3050id = "gpt-4o"
3051llm_client = "azure"
3052
3053[routes.openai]
3054id = "switchyard/openai-gpt4o"
3055type = "passthrough"
3056target = "openai"
3057
3058[routes.azure]
3059id = "switchyard/azure-gpt4o"
3060type = "passthrough"
3061target = "azure"
3062"#;
3063 runner_from_toml(CROSS_PROVIDER)?;
3064 Ok(())
3065 }
3066
3067 #[test]
3068 fn accepts_relative_weights_and_seed() -> RunnerResult<()> {
3069 let weighted = VALID_CONFIG.replace(
3070 "targets = [\"strong\", \"weak\"]",
3071 "targets = [\"strong\", \"weak\"]\nweights = [1, 3]\nseed = 42",
3072 );
3073 runner_from_toml(&weighted)?;
3074 Ok(())
3075 }
3076
3077 #[test]
3078 fn accepts_new_session_trigger_with_message_hash_fallback() -> RunnerResult<()> {
3079 let configured = VALID_CONFIG.replace(
3080 "base_threshold = 0.5",
3081 "base_threshold = 0.25\nthreshold_step = 0.1\nclassify_trigger = \"new_session\"\nmessage_hash_fallback = true",
3082 );
3083 runner_from_toml(&configured)?;
3084 Ok(())
3085 }
3086
3087 #[test]
3088 fn target_extra_body_is_parsed_and_applied_to_its_backend() -> RunnerResult<()> {
3089 let configured = VALID_CONFIG.replacen(
3090 "llm_client = \"primary\"",
3091 "llm_client = \"primary\"\n\
3092 extra_body = { service_tier = \"priority\", \
3093 chat_template_kwargs = { enable_thinking = false } }",
3094 1,
3095 );
3096 let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
3097 RunnerError::configuration(format!("failed to parse config: {error}"))
3098 })?;
3099 let Some(target) = config.targets.get("classifier") else {
3100 return Err(RunnerError::configuration("classifier target is missing"));
3101 };
3102 let Some(client) = config.llm_clients.get("primary") else {
3103 return Err(RunnerError::configuration("primary llm client is missing"));
3104 };
3105 let backend = build_backend(
3106 "primary",
3107 client,
3108 &target.extra_body,
3109 &target.omit_body_fields,
3110 None,
3111 )?;
3112
3113 assert_eq!(
3114 backend.extra_body().get("service_tier"),
3115 Some(&json!("priority"))
3116 );
3117 assert_eq!(
3118 backend
3119 .extra_body()
3120 .get("chat_template_kwargs")
3121 .and_then(|value| value.get("enable_thinking")),
3122 Some(&json!(false))
3123 );
3124 Ok(())
3125 }
3126
3127 #[test]
3128 fn target_omit_body_fields_is_parsed_and_applied_to_its_backend() -> RunnerResult<()> {
3129 let configured = VALID_CONFIG.replacen(
3130 "llm_client = \"primary\"",
3131 "llm_client = \"primary\"\n\
3132 omit_body_fields = [\"max_output_tokens\"]",
3133 1,
3134 );
3135 let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
3136 RunnerError::configuration(format!("failed to parse config: {error}"))
3137 })?;
3138 let Some(target) = config.targets.get("classifier") else {
3139 return Err(RunnerError::configuration("classifier target is missing"));
3140 };
3141 let Some(client) = config.llm_clients.get("primary") else {
3142 return Err(RunnerError::configuration("primary llm client is missing"));
3143 };
3144 let backend = build_backend(
3145 "primary",
3146 client,
3147 &target.extra_body,
3148 &target.omit_body_fields,
3149 None,
3150 )?;
3151
3152 assert!(backend.omit_body_fields().contains("max_output_tokens"));
3153 Ok(())
3154 }
3155
3156 #[test]
3157 fn client_deadline_defaults_and_rejects_zero() -> RunnerResult<()> {
3158 for (setting, expected) in [
3159 ("", None),
3160 ("timeout_ms = 1500", Some(1500)),
3161 ("timeout_ms = 0", Some(0)),
3162 ] {
3163 let source = format!(
3164 "format = \"openai_chat\"\nbase_url = \"https://example.test/v1\"\n{setting}"
3165 );
3166 let config: LlmClientConfig = toml::from_str(&source).expect("valid deadline config");
3167 let backend = build_backend("test", &config, &BTreeMap::new(), &BTreeSet::new(), None);
3168 if expected == Some(0) {
3169 assert!(backend.is_err());
3170 } else {
3171 assert_eq!(backend?.timeout(), expected.map(Duration::from_millis));
3172 }
3173 }
3174 Ok(())
3175 }
3176
3177 #[test]
3178 fn retry_budget_defaults_and_accepts_an_override() -> RunnerResult<()> {
3179 let default: DeploymentConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
3180 RunnerError::configuration(format!("failed to parse default config: {error}"))
3181 })?;
3182 let Some(primary) = default.llm_clients.get("primary") else {
3183 return Err(RunnerError::configuration("primary llm client is missing"));
3184 };
3185 assert_eq!(primary.max_retries, DEFAULT_MAX_RETRIES);
3186
3187 let explicit = VALID_CONFIG.replacen(
3188 "base_url = \"https://example.test/v1\"",
3189 "base_url = \"https://example.test/v1\"\nmax_retries = 0",
3190 1,
3191 );
3192 let config: DeploymentConfig = toml::from_str(&explicit).map_err(|error| {
3193 RunnerError::configuration(format!("failed to parse explicit retry config: {error}"))
3194 })?;
3195 let Some(primary) = config.llm_clients.get("primary") else {
3196 return Err(RunnerError::configuration("primary llm client is missing"));
3197 };
3198 assert_eq!(primary.max_retries, 0);
3199
3200 let maximum = VALID_CONFIG.replacen(
3201 "base_url = \"https://example.test/v1\"",
3202 &format!(
3203 "base_url = \"https://example.test/v1\"\nmax_retries = {MAX_CONFIGURED_RETRIES}"
3204 ),
3205 1,
3206 );
3207 let config: DeploymentConfig = toml::from_str(&maximum).map_err(|error| {
3208 RunnerError::configuration(format!("failed to parse maximum retry config: {error}"))
3209 })?;
3210 let Some(primary) = config.llm_clients.get("primary") else {
3211 return Err(RunnerError::configuration("primary llm client is missing"));
3212 };
3213 assert_eq!(primary.max_retries, MAX_CONFIGURED_RETRIES);
3214 Ok(())
3215 }
3216
3217 #[test]
3218 fn responses_reasoning_defaults_and_accepts_drop() -> RunnerResult<()> {
3219 let default: DeploymentConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
3220 RunnerError::configuration(format!("failed to parse config: {error}"))
3221 })?;
3222 let responses = default
3223 .llm_clients
3224 .get("responses")
3225 .ok_or_else(|| RunnerError::configuration("responses llm client is missing"))?;
3226 assert_eq!(responses.responses_reasoning, None);
3227
3228 let configured = VALID_CONFIG.replacen(
3229 "[llm_clients.responses]\nformat = \"openai_responses\"\nbase_url = \"https://example.test/v1\"",
3230 "[llm_clients.responses]\nformat = \"openai_responses\"\nbase_url = \"https://example.test/v1\"\nresponses_reasoning = \"drop\"",
3231 1,
3232 );
3233 let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
3234 RunnerError::configuration(format!("failed to parse config: {error}"))
3235 })?;
3236 let responses = config
3237 .llm_clients
3238 .get("responses")
3239 .ok_or_else(|| RunnerError::configuration("responses llm client is missing"))?;
3240 assert_eq!(
3241 responses.responses_reasoning,
3242 Some(ResponsesReasoningPolicy::Drop)
3243 );
3244 runner_from_toml(&configured)?;
3245 Ok(())
3246 }
3247
3248 #[test]
3249 fn responses_reasoning_rejects_other_formats() {
3250 let invalid = VALID_CONFIG.replacen(
3251 "format = \"openai_chat\"",
3252 "format = \"openai_chat\"\nresponses_reasoning = \"drop\"",
3253 1,
3254 );
3255 assert!(
3256 error_message(&invalid)
3257 .contains("responses_reasoning is only valid for openai_responses")
3258 );
3259 }
3260
3261 #[test]
3262 fn responses_reasoning_rejects_unreferenced_other_format() {
3263 let invalid = format!(
3264 "{VALID_CONFIG}\n[llm_clients.unused]\nformat = \"openai_chat\"\nbase_url = \"https://example.test/v1\"\nresponses_reasoning = \"drop\"\n"
3265 );
3266 assert!(
3267 error_message(&invalid)
3268 .contains("responses_reasoning is only valid for openai_responses")
3269 );
3270 }
3271
3272 #[test]
3273 fn rejects_headers_that_switchyard_sets() {
3274 let cases = [
3275 (
3276 "base_url = \"https://example.test/v1\"",
3277 "base_url = \"https://example.test/v1\"\n\
3278 extra_headers = { AUTHORIZATION = \"Bearer custom-key\" }",
3279 "AUTHORIZATION",
3280 ),
3281 (
3282 "base_url = \"https://example.test\"",
3283 "base_url = \"https://example.test\"\n\
3284 extra_headers = { \"X-Api-Key\" = \"custom-key\" }",
3285 "X-Api-Key",
3286 ),
3287 (
3288 "base_url = \"https://example.test\"",
3289 "base_url = \"https://example.test\"\n\
3290 extra_headers = { \"ANTHROPIC-VERSION\" = \"custom-version\" }",
3291 "ANTHROPIC-VERSION",
3292 ),
3293 ];
3294
3295 for (original, replacement, header) in cases {
3296 let configured = VALID_CONFIG.replacen(original, replacement, 1);
3297 let error = error_message(&configured);
3298 assert!(
3299 error.contains(&format!("extra_headers cannot set {header:?}")),
3300 "expected {header} to be rejected, got: {error}"
3301 );
3302 }
3303 }
3304
3305 #[test]
3306 fn accepts_additional_headers() -> RunnerResult<()> {
3307 let configured = VALID_CONFIG.replacen(
3308 "base_url = \"https://example.test/v1\"",
3309 "base_url = \"https://example.test/v1\"\n\
3310 extra_headers = { X-Inference-Priority = \"batch\" }",
3311 1,
3312 );
3313
3314 runner_from_toml(&configured)?;
3315 Ok(())
3316 }
3317
3318 #[test]
3319 fn retry_budget_rejects_negative_values() {
3320 let invalid = VALID_CONFIG.replacen(
3321 "base_url = \"https://example.test/v1\"",
3322 "base_url = \"https://example.test/v1\"\nmax_retries = -1",
3323 1,
3324 );
3325 assert!(error_message(&invalid).contains("max_retries"));
3326 }
3327
3328 #[test]
3329 fn retry_budget_rejects_excessive_values() {
3330 let invalid = VALID_CONFIG.replacen(
3331 "base_url = \"https://example.test/v1\"",
3332 "base_url = \"https://example.test/v1\"\nmax_retries = 11",
3333 1,
3334 );
3335 assert!(
3336 error_message(&invalid).contains("llm client primary max_retries must be at most 10")
3337 );
3338 }
3339
3340 #[test]
3341 fn api_key_environment_reference_is_validated() {
3342 let missing = VALID_CONFIG.replacen(
3343 "base_url = \"https://example.test/v1\"",
3344 "base_url = \"https://example.test/v1\"\napi_key_env = \"SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET\"",
3345 1,
3346 );
3347 assert!(error_message(&missing).contains("SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET"));
3348
3349 const EMPTY_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_EMPTY_KEY";
3350 unsafe {
3351 std::env::set_var(EMPTY_KEY_ENV, "");
3353 }
3354 let empty = VALID_CONFIG.replacen(
3355 "base_url = \"https://example.test/v1\"",
3356 &format!("base_url = \"https://example.test/v1\"\napi_key_env = \"{EMPTY_KEY_ENV}\""),
3357 1,
3358 );
3359 let message = error_message(&empty);
3360 unsafe {
3361 std::env::remove_var(EMPTY_KEY_ENV);
3362 }
3363 assert!(message.contains("is empty"));
3364
3365 let with_equals = VALID_CONFIG.replacen(
3368 "base_url = \"https://example.test/v1\"",
3369 "base_url = \"https://example.test/v1\"\napi_key_env = \"NVIDIA_API_KEY=DUMMY_SECRET_DO_NOT_LOG\"",
3370 1,
3371 );
3372 let message = error_message(&with_equals);
3373 assert!(message.contains("llm client primary api_key_env must not contain '=' or NUL"));
3374 assert!(!message.contains("DUMMY_SECRET_DO_NOT_LOG"), "{message}");
3375 }
3376
3377 #[cfg(any(unix, windows))]
3379 #[test]
3380 fn invalid_unicode_api_key_is_not_printed() {
3381 const RAW_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_RAW_KEY";
3382 #[cfg(unix)]
3383 let raw = {
3384 use std::os::unix::ffi::OsStringExt;
3385 let mut bytes = b"DUMMY_SECRET_DO_NOT_LOG".to_vec();
3386 bytes.push(0xff);
3387 std::ffi::OsString::from_vec(bytes)
3388 };
3389 #[cfg(windows)]
3390 let raw = {
3391 use std::os::windows::ffi::OsStringExt;
3392 let mut words: Vec<_> = "DUMMY_SECRET_DO_NOT_LOG".encode_utf16().collect();
3393 words.push(0xd800);
3394 std::ffi::OsString::from_wide(&words)
3395 };
3396 unsafe {
3397 std::env::set_var(RAW_KEY_ENV, raw);
3399 }
3400 let result = read_api_key("llm client fixture", RAW_KEY_ENV);
3401 unsafe {
3402 std::env::remove_var(RAW_KEY_ENV);
3403 }
3404 let error = result.expect_err("invalid Unicode credential").to_string();
3405 assert!(error.contains("not valid Unicode"), "{error}");
3406 assert!(!error.contains("DUMMY_SECRET_DO_NOT_LOG"), "{error}");
3407 }
3408
3409 #[test]
3410 fn forward_auth_rejects_conflicting_credentials() {
3411 let competing_auth = VALID_CONFIG.replacen(
3412 "base_url = \"https://example.test/v1\"",
3413 "base_url = \"https://example.test/v1\"\n\
3414 forward_auth = true\n\
3415 api_key_env = \"UNUSED_TEST_KEY\"",
3416 1,
3417 );
3418 assert!(
3419 error_message(&competing_auth).contains("cannot set both forward_auth and api_key_env")
3420 );
3421
3422 let static_auth = VALID_CONFIG.replacen(
3423 "base_url = \"https://example.test\"",
3424 "base_url = \"https://example.test\"\n\
3425 forward_auth = true\n\
3426 extra_headers = { Authorization = \"static-value\" }",
3427 1,
3428 );
3429 assert!(error_message(&static_auth).contains("extra_headers cannot set \"Authorization\""));
3430
3431 let static_beta = static_auth.replace("Authorization", "anthropic-beta");
3432 assert!(
3433 error_message(&static_beta).contains("extra_headers cannot set \"anthropic-beta\"")
3434 );
3435
3436 for header in ["chatgpt-account-id", "x-openai-fedramp"] {
3437 let static_context = VALID_CONFIG.replacen(
3438 "base_url = \"https://example.test/v1\"",
3439 &format!(
3440 "base_url = \"https://example.test/v1\"\n\
3441 forward_auth = true\n\
3442 extra_headers = {{ \"{header}\" = \"static-value\" }}"
3443 ),
3444 1,
3445 );
3446 assert!(
3447 error_message(&static_context)
3448 .contains(&format!("extra_headers cannot set \"{header}\""))
3449 );
3450 }
3451 }
3452
3453 fn mixed_forwarding_config(messages_url: &str) -> String {
3456 format!(
3457 r#"
3458schema_version = 1
3459
3460[llm_clients.responses]
3461format = "openai_responses"
3462base_url = "https://gateway.example.test/v1"
3463forward_auth = true
3464
3465[llm_clients.messages]
3466format = "anthropic_messages"
3467base_url = "{messages_url}"
3468forward_auth = true
3469
3470[targets]
3471gpt = {{ id = "gpt/model", llm_client = "responses" }}
3472claude = {{ id = "claude/model", llm_client = "messages" }}
3473
3474[routes.mixed]
3475id = "switchyard/mixed"
3476type = "random"
3477targets = ["gpt", "claude"]
3478
3479[routes.claude]
3480id = "switchyard/claude"
3481type = "passthrough"
3482target = "claude"
3483"#
3484 )
3485 }
3486
3487 #[test]
3490 fn forwarding_route_mixes_formats_only_on_one_host() -> RunnerResult<()> {
3491 let runner = runner_from_toml(&mixed_forwarding_config("https://gateway.example.test"))?;
3494 let caller_auth = |id| runner.route(id).and_then(Route::caller_auth);
3495 assert_eq!(
3496 caller_auth("switchyard/mixed"),
3497 Some(CallerAuthKind::OpenAi)
3498 );
3499 assert_eq!(
3500 caller_auth("switchyard/claude"),
3501 Some(CallerAuthKind::Anthropic)
3502 );
3503
3504 for other in [
3506 "https://api.anthropic.test",
3507 "https://gateway.example.test:8443",
3508 "http://gateway.example.test",
3509 ] {
3510 let error = error_message(&mixed_forwarding_config(other));
3511 assert!(
3512 error.contains("different origins") && error.contains(other),
3513 "{error}"
3514 );
3515 }
3516 Ok(())
3517 }
3518
3519 const ADVISOR_CONFIG: &str = r#"
3520schema_version = 1
3521
3522[llm_clients.anthropic]
3523format = "anthropic_messages"
3524base_url = "https://example.test"
3525
3526[targets.executor]
3527id = "executor/model"
3528llm_client = "anthropic"
3529
3530[targets.advisor]
3531id = "advisor/model"
3532llm_client = "anthropic"
3533
3534[routes.gated]
3535id = "switchyard/advisor"
3536type = "advisor"
3537executor_target = "executor"
3538advisor_target = "advisor"
3539"#;
3540
3541 #[test]
3542 fn advisor_route_parses_with_defaults_and_builds() -> RunnerResult<()> {
3543 let state = runner_from_toml(ADVISOR_CONFIG)?;
3544 let route = state
3545 .route("switchyard/advisor")
3546 .expect("advisor route should exist");
3547 let models = route.models();
3548 assert_eq!(
3551 models.models_for(&Category::Efficient),
3552 [ModelId::from("executor")]
3553 );
3554 assert_eq!(
3555 models.models_for(&Category::Any),
3556 [ModelId::from("executor")]
3557 );
3558 assert_eq!(
3559 models.models_for(&Category::Judge),
3560 [ModelId::from("advisor")]
3561 );
3562 assert_eq!(
3563 state
3564 .models()
3565 .map(|model| model.id.as_str())
3566 .collect::<Vec<_>>(),
3567 ["switchyard/advisor"]
3568 );
3569 Ok(())
3570 }
3571
3572 #[test]
3573 fn advisor_route_accepts_every_gate_knob() -> RunnerResult<()> {
3574 let tuned = ADVISOR_CONFIG.replace(
3575 "advisor_target = \"advisor\"",
3576 concat!(
3577 "advisor_target = \"advisor\"\n",
3578 "reviewer_system_prompt = \"review it\"\n",
3579 "redo_feedback_prefix = \"REVIEWER SAYS: \"\n",
3580 "gate_trigger = \"pattern\"\n",
3581 "gate_trigger_pattern = 'task_complete[\"\\s>:]*true'\n",
3582 "max_reviews = 2\n",
3583 "gate_stall_turns = 40\n",
3584 "gate_min_tool_results = 1\n",
3585 "advisor_max_tokens = 1024\n",
3586 "advisor_temperature = 0.0\n",
3587 "transcript_max_chars = 100000\n",
3588 "fail_open = false\n",
3589 "context_window = 200000\n",
3590 "tool_calling = true\n",
3591 "reasoning = true",
3592 ),
3593 );
3594 runner_from_toml(&tuned)?;
3595 Ok(())
3596 }
3597
3598 #[test]
3599 fn advisor_route_rejects_unknown_keys() {
3600 let invalid = ADVISOR_CONFIG.replace(
3601 "advisor_target = \"advisor\"",
3602 "advisor_target = \"advisor\"\nbogus_field = 1",
3603 );
3604 assert!(error_message(&invalid).contains("bogus_field"));
3605 }
3606
3607 #[test]
3608 fn advisor_route_requires_both_targets() {
3609 let missing = ADVISOR_CONFIG.replace("advisor_target = \"advisor\"\n", "");
3610 assert!(error_message(&missing).contains("advisor_target"));
3611 }
3612
3613 #[test]
3614 fn advisor_route_rejects_unknown_target() {
3615 let invalid = ADVISOR_CONFIG.replace(
3616 "advisor_target = \"advisor\"",
3617 "advisor_target = \"missing\"",
3618 );
3619 assert!(error_message(&invalid).contains("missing"));
3620 }
3621
3622 #[test]
3623 fn advisor_route_rejects_invalid_pattern() {
3624 let invalid = ADVISOR_CONFIG.replace(
3625 "advisor_target = \"advisor\"",
3626 "advisor_target = \"advisor\"\ngate_trigger = \"pattern\"\ngate_trigger_pattern = \"(unclosed\"",
3627 );
3628 assert!(error_message(&invalid).contains("not a valid regex"));
3629 }
3630
3631 #[test]
3632 fn advisor_route_rejects_pattern_without_pattern_trigger() {
3633 let invalid = ADVISOR_CONFIG.replace(
3634 "advisor_target = \"advisor\"",
3635 "advisor_target = \"advisor\"\ngate_trigger_pattern = \"done\"",
3636 );
3637 assert!(
3638 error_message(&invalid)
3639 .contains("gate_trigger_pattern requires gate_trigger = \"pattern\"")
3640 );
3641 }
3642
3643 #[test]
3644 fn advisor_route_pattern_trigger_requires_pattern() {
3645 let invalid = ADVISOR_CONFIG.replace(
3646 "advisor_target = \"advisor\"",
3647 "advisor_target = \"advisor\"\ngate_trigger = \"pattern\"",
3648 );
3649 assert!(error_message(&invalid).contains("non-empty gate_trigger_pattern"));
3650 }
3651
3652 #[test]
3653 fn advisor_route_rejects_zero_max_reviews() {
3654 let invalid = ADVISOR_CONFIG.replace(
3655 "advisor_target = \"advisor\"",
3656 "advisor_target = \"advisor\"\nmax_reviews = 0",
3657 );
3658 assert!(error_message(&invalid).contains("max_reviews must be at least 1"));
3659 }
3660}