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