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