1use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10use std::time::Duration;
11
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Deserializer};
14use serde_json::Value;
15use switchyard_llm_client::{
16 AuxiliaryOperation, Backend, ClientRouter, DEFAULT_MAX_RETRIES, HttpBackendConfig, ModelConfig,
17 OpenAiDecisionClient, ResponsesReasoningPolicy, SystemOneClient, TranslatingLlmClient,
18};
19use switchyard_protocol::{ModelId, RoutedDecisionClient, RoutedLlmClient, WireFormat};
20
21use crate::algorithm_factory::AlgorithmAndModels;
22use crate::runner::ModelPropertiesProbe;
23use crate::{
24 AlgorithmFactory, AlgorithmSpec, AuxiliaryTarget, CallerAuthKind, DecisionTarget,
25 ModelCapabilities, Route, Runner, RunnerError,
26};
27
28const SUPPORTED_SCHEMA_VERSION: u32 = 1;
29const MAX_CONFIGURED_RETRIES: u32 = 10;
30
31pub fn api_key_variables(source: &str) -> Result<BTreeSet<String>, RunnerError> {
34 let config: DeploymentConfig = toml::from_str(source).map_err(|_| {
35 RunnerError::configuration(
36 "Could not parse the server config to read credential references.",
37 )
38 })?;
39 if config.schema_version != SUPPORTED_SCHEMA_VERSION {
40 return Err(RunnerError::configuration(
41 "Unsupported server config schema version.",
42 ));
43 }
44 let mut variables: BTreeSet<_> = config
45 .llm_clients
46 .values()
47 .filter_map(|client| client.api_key_env.clone())
48 .collect();
49 for client in config.decision_clients.values() {
50 variables.insert(client.api_key_env.clone());
51 }
52 if variables
53 .iter()
54 .any(|name| name.trim().is_empty() || name.contains(['\0', '=']))
55 {
56 return Err(RunnerError::configuration(
57 "Invalid credential environment variable name.",
58 ));
59 }
60 Ok(variables)
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct RouteTargets {
66 pub name: String,
68 pub id: ModelId,
70 pub kind: &'static str,
72 pub answer_targets: Vec<ConfiguredTarget>,
75 pub judge_targets: Vec<ConfiguredTarget>,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ConfiguredTarget {
82 pub name: String,
83 pub model: Option<TargetModel>,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct TargetModel {
92 pub client: String,
93 pub id: ModelId,
94}
95
96pub fn route_targets(source: &str) -> Result<Vec<RouteTargets>, RunnerError> {
99 let config: DeploymentConfig = toml::from_str(source).map_err(|error| {
100 RunnerError::configuration_source(format!("failed to parse TOML: {error}"), error)
101 })?;
102 config.check_schema_version()?;
103 Ok(config
104 .routes
105 .iter()
106 .map(|(name, route)| RouteTargets {
107 name: name.clone(),
108 id: route.id.clone(),
109 kind: (&route.algorithm).into(),
110 answer_targets: config.configured_targets(route.routing_target_names()),
111 judge_targets: config.configured_targets(route.algorithm.judge_target_names()),
112 })
113 .collect())
114}
115
116type RunnerResult<T> = Result<T, RunnerError>;
117
118pub(crate) fn load_runner(path: impl AsRef<Path>) -> RunnerResult<Runner> {
119 let path = path.as_ref();
120 let source = fs::read_to_string(path).map_err(|error| {
121 RunnerError::configuration_source(
122 format!("failed to read server config {}: {error}", path.display()),
123 error,
124 )
125 })?;
126 runner_from_toml(&source).map_err(|error| {
127 RunnerError::configuration_source(
128 format!("invalid server config {}: {error}", path.display()),
129 error,
130 )
131 })
132}
133
134pub(crate) fn runner_from_toml(source: &str) -> RunnerResult<Runner> {
135 AlgorithmFactory::from_toml(source)?.config.build()
136}
137
138#[derive(Debug, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub(crate) struct DeploymentConfig {
141 schema_version: u32,
142 fallback_client: Option<String>,
143 #[serde(default)]
144 llm_clients: BTreeMap<String, LlmClientConfig>,
145 targets: BTreeMap<String, TargetConfig>,
146 #[serde(default)]
147 decision_clients: BTreeMap<String, DecisionClientConfig>,
148 #[serde(default)]
149 decision_targets: BTreeMap<String, DecisionTargetConfig>,
150 routes: BTreeMap<String, RouteConfig>,
151}
152
153#[derive(Debug)]
154pub(crate) struct RouteConfig {
155 pub(crate) id: ModelId,
156 context_window: Option<u32>,
157 tool_calling: Option<bool>,
158 reasoning: Option<bool>,
159 vision: Option<bool>,
160 algorithm: AlgorithmSpec,
161 infra: Option<libsy::InfraPolicy>,
162}
163
164struct TargetPromptPolicy {
165 prompts: HashMap<ModelId, String>,
166 routing_answer_target: Option<ModelId>,
167}
168
169impl<'de> Deserialize<'de> for RouteConfig {
170 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171 where
172 D: Deserializer<'de>,
173 {
174 let mut table = toml::Table::deserialize(deserializer)?;
175 let id = take_required(&mut table, "id")?;
176 let context_window = take_optional(&mut table, "context_window")?;
177 let tool_calling = take_optional(&mut table, "tool_calling")?;
178 let reasoning = take_optional(&mut table, "reasoning")?;
179 let vision = take_optional(&mut table, "vision")?;
180 let infra = take_optional(&mut table, "infra")?;
181 let algorithm = AlgorithmSpec::deserialize(toml::Value::Table(table))
182 .map_err(serde::de::Error::custom)?;
183 Ok(Self {
184 id,
185 context_window,
186 tool_calling,
187 reasoning,
188 vision,
189 algorithm,
190 infra,
191 })
192 }
193}
194
195fn take_required<T, E>(table: &mut toml::Table, name: &'static str) -> Result<T, E>
196where
197 T: DeserializeOwned,
198 E: serde::de::Error,
199{
200 let value = table.remove(name).ok_or_else(|| E::missing_field(name))?;
201 T::deserialize(value).map_err(E::custom)
202}
203
204fn take_optional<T, E>(table: &mut toml::Table, name: &'static str) -> Result<Option<T>, E>
205where
206 T: DeserializeOwned,
207 E: serde::de::Error,
208{
209 table
210 .remove(name)
211 .map(|value| T::deserialize(value).map_err(E::custom))
212 .transpose()
213}
214
215impl RouteConfig {
216 fn capabilities(&self) -> ModelCapabilities {
217 ModelCapabilities {
218 context_window: self.context_window,
219 tool_calling: self.tool_calling,
220 reasoning: self.reasoning,
221 vision: self.vision,
222 }
223 }
224
225 fn routing_target_names(&self) -> Vec<&str> {
226 self.algorithm.routing_target_names()
227 }
228
229 fn callable_target_names(&self) -> Vec<&str> {
230 self.algorithm.callable_target_names()
231 }
232}
233
234impl DeploymentConfig {
235 fn check_schema_version(&self) -> RunnerResult<()> {
236 if self.schema_version == SUPPORTED_SCHEMA_VERSION {
237 return Ok(());
238 }
239 Err(RunnerError::configuration(format!(
240 "unsupported schema_version {}; expected {SUPPORTED_SCHEMA_VERSION}",
241 self.schema_version
242 )))
243 }
244
245 fn configured_targets(&self, names: Vec<&str>) -> Vec<ConfiguredTarget> {
248 names
249 .into_iter()
250 .map(|name| ConfiguredTarget {
251 name: name.to_string(),
252 model: self
253 .targets
254 .get(name)
255 .map(|target| (&target.llm_client, &target.id))
256 .or_else(|| {
257 self.decision_targets
258 .get(name)
259 .map(|target| (&target.decision_client, &target.id))
260 })
261 .map(|(client, id)| TargetModel {
262 client: client.clone(),
263 id: id.clone(),
264 }),
265 })
266 .collect()
267 }
268
269 fn decision_target(&self, name: &str) -> Option<DecisionTarget> {
270 let target = self.targets.get(name)?;
271 let client = self.llm_clients.get(&target.llm_client)?;
272 Some(DecisionTarget {
273 target: name.to_string(),
274 model: target.id.clone(),
275 format: client.format.wire_format(),
276 base_url: client.base_url.as_str().to_string(),
277 extra_body: target.extra_body.clone(),
278 })
279 }
280
281 pub(crate) fn validate(&self) -> RunnerResult<()> {
283 self.check_schema_version()?;
284
285 let mut route_names_by_id = HashMap::new();
286 for (route_name, config) in &self.routes {
287 validate_value("route name", route_name)?;
288 validate_value(&format!("route {route_name} id"), &config.id)?;
289 if let Some(first_route_name) =
290 route_names_by_id.insert(config.id.as_str(), route_name.as_str())
291 {
292 return Err(RunnerError::configuration(format!(
293 "routes {first_route_name} and {route_name} both use id {}; route ids must be unique",
294 config.id
295 )));
296 }
297 }
298
299 for (name, target) in &self.targets {
300 validate_value("target name", name)?;
301 validate_value(&format!("target {name} id"), &target.id)?;
302 if !self.llm_clients.contains_key(&target.llm_client) {
303 return Err(RunnerError::configuration(format!(
304 "target {name} references unknown llm client {}",
305 target.llm_client
306 )));
307 }
308 }
309 for (name, target) in &self.decision_targets {
310 validate_value("decision target name", name)?;
311 validate_value(&format!("decision target {name} id"), &target.id)?;
312 if self.targets.contains_key(name) {
313 return Err(RunnerError::configuration(format!(
314 "target {name} is defined as both an LLM and decision target"
315 )));
316 }
317 if !self.decision_clients.contains_key(&target.decision_client) {
318 return Err(RunnerError::configuration(format!(
319 "decision target {name} references unknown decision client {}",
320 target.decision_client
321 )));
322 }
323 }
324 Ok(())
325 }
326
327 pub(crate) fn build_algorithms(
329 &self,
330 ) -> impl Iterator<Item = RunnerResult<(&str, &RouteConfig, AlgorithmAndModels)>> {
331 let targets = self.build_targets();
332 self.routes.iter().map(move |(route_name, config)| {
333 let classifiers: Vec<_> = config.algorithm.classifier_targets().collect();
334 for (name, decision) in classifiers.iter().copied().chain(
335 config.callable_target_names().into_iter()
336 .filter(|name| !classifiers.iter().any(|(judge, _)| judge == name))
337 .map(|name| (name, None)),
338 ) {
339 let (exists, kind) = if decision.is_some() {
340 (self.decision_targets.contains_key(name), "decision")
341 } else {
342 (self.targets.contains_key(name), "LLM")
343 };
344 if !exists {
345 return Err(RunnerError::configuration(format!(
346 "route references unknown target {name}; route {route_name} requires target kind {kind}"
347 )));
348 }
349 }
350 for name in config.routing_target_names().into_iter().chain(
351 classifiers
352 .into_iter()
353 .filter_map(|(_, decision)| decision)
354 .flat_map(|judge| judge.candidates.values().map(String::as_str)),
355 ) {
356 if !self.targets.contains_key(name) {
357 return Err(RunnerError::configuration(format!(
358 "route {route_name} completion and candidate target {name} must be an LLM target"
359 )));
360 }
361 }
362 if config.context_window == Some(0) {
363 return Err(RunnerError::configuration(format!(
364 "route {route_name} context_window must be greater than zero"
365 )));
366 }
367 let (algorithm, models) = config
368 .algorithm
369 .build_with_runtime_models(route_name, &targets)
370 .map_err(|error| RunnerError::configuration_source(error.to_string(), error))?;
371 if let Some(infra) = &config.infra {
372 if !matches!(&config.algorithm, AlgorithmSpec::LlmClassifier { config }
373 if matches!(config.mode, None | Some(crate::ClassifierMode::Capability))
374 && config.escalation.is_none())
375 {
376 return Err(RunnerError::configuration(format!(
377 "route {route_name}: infra requires capability llm_classifier without escalation"
378 )));
379 }
380 infra.validate().map_err(|error| {
381 RunnerError::configuration_source(format!("route {route_name}: {error}"), error)
382 })?;
383 }
384 Ok((route_name.as_str(), config, (algorithm, models)))
385 })
386 }
387
388 fn build(self) -> RunnerResult<Runner> {
389 self.validate()?;
390
391 let mut seen_client_model_ids: HashMap<(&str, &str), (&String, &TargetConfig)> =
396 HashMap::new();
397 for (target_name, target) in &self.targets {
398 match seen_client_model_ids.entry((target.llm_client.as_str(), target.id.as_str())) {
399 std::collections::hash_map::Entry::Vacant(slot) => {
400 slot.insert((target_name, target));
401 }
402 std::collections::hash_map::Entry::Occupied(slot) => {
403 let (first_name, first) = slot.get();
404 if first.reasoning_effort != target.reasoning_effort
405 || first.extra_body != target.extra_body
406 || first.omit_body_fields != target.omit_body_fields
407 {
408 return Err(RunnerError::configuration(format!(
409 "targets {first_name} and {target_name} both name model {} on llm client {} but with different reasoning_effort, extra_body, or omit_body_fields; one target per model id is kept, so give each its own model id or llm client",
410 target.id, target.llm_client
411 )));
412 }
413 tracing::warn!(
414 "target {target_name} reuses model id {} on llm client {}; only one target per id is kept and the other is dropped. Give each target a unique model id, or point both routes at one target.",
415 target.id,
416 target.llm_client
417 );
418 }
419 }
420 }
421
422 let mut provider_api_keys = Vec::new();
423 let clients = self.build_clients(&mut provider_api_keys)?;
424 let decision_clients = self.build_decision_clients(&mut provider_api_keys)?;
425 let fallback_base_url = self.fallback_base_url()?;
426 let mut routes = Vec::with_capacity(self.routes.len());
427 let mut model_properties_probes = BTreeMap::new();
428 for entry in self.build_algorithms() {
429 let (route_name, config, (algorithm, models)) = entry?;
430 let capabilities = config.capabilities();
431 if let AlgorithmSpec::Vgr { config: vgr } = &config.algorithm
432 && let Some(probe) =
433 self.build_model_properties_probe(&vgr.efficient_target, &clients)
434 {
435 model_properties_probes.insert(config.id.clone(), probe);
436 }
437 let (route_clients, caller_auth) =
438 self.build_route_clients(route_name, config, &clients, &decision_clients)?;
439 let anthropic_auxiliary_target =
440 self.build_anthropic_auxiliary_target(config, &clients);
441 let responses_auxiliary_target =
442 self.build_responses_auxiliary_target(config, &clients);
443 let decision_targets = config
444 .routing_target_names()
445 .into_iter()
446 .filter_map(|name| self.decision_target(name))
447 .collect();
448 let route = Route::new(
449 algorithm,
450 route_clients,
451 caller_auth,
452 capabilities,
453 anthropic_auxiliary_target,
454 responses_auxiliary_target,
455 decision_targets,
456 models,
457 )
458 .with_infra(config.infra.clone());
459 routes.push((config.id.clone(), route));
460 }
461 let runner = Runner::new(routes)
462 .with_fallback_url(fallback_base_url)
463 .with_provider_api_keys(provider_api_keys)
464 .with_model_properties_probes(model_properties_probes);
465 Ok(runner)
466 }
467
468 fn build_clients(
469 &self,
470 provider_api_keys: &mut Vec<String>,
471 ) -> RunnerResult<BTreeMap<String, Arc<TranslatingLlmClient>>> {
472 let mut models_by_client = self
473 .llm_clients
474 .keys()
475 .map(|name| (name.clone(), Vec::new()))
476 .collect::<BTreeMap<String, Vec<ModelConfig>>>();
477
478 for (name, client_config) in &self.llm_clients {
479 validate_value("llm client name", name)?;
480 if client_config.responses_reasoning.is_some()
481 && !matches!(client_config.format, ClientFormat::OpenAiResponses)
482 {
483 return Err(RunnerError::configuration(format!(
484 "llm client {name} responses_reasoning is only valid for openai_responses"
485 )));
486 }
487 let backend = build_backend(
488 name,
489 client_config,
490 &BTreeMap::new(),
491 &BTreeSet::new(),
492 None,
493 )?;
494 let (Backend::OpenAiChat(config)
495 | Backend::OpenAiResponses(config)
496 | Backend::Anthropic(config)) = backend;
497 if let Some(key) = config.api_key {
498 provider_api_keys.push(key);
499 }
500 }
501 for (target_name, target) in &self.targets {
502 let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| {
503 RunnerError::configuration(format!(
504 "target {target_name} references unknown llm client {}",
505 target.llm_client
506 ))
507 })?;
508 let model_configs = models_by_client
509 .get_mut(&target.llm_client)
510 .ok_or_else(|| {
511 RunnerError::configuration("validated llm client was not initialized")
512 })?;
513 if let Some(effort) = &target.reasoning_effort {
514 if effort.trim().is_empty() {
515 return Err(RunnerError::configuration(format!(
516 "target {target_name} reasoning_effort must not be empty"
517 )));
518 }
519 if matches!(client_config.format, ClientFormat::AnthropicMessages) {
520 return Err(RunnerError::configuration(format!(
521 "target {target_name} reasoning_effort is only supported on openai_chat and openai_responses clients"
522 )));
523 }
524 }
525 model_configs.push(
526 ModelConfig::new(
527 target.id.clone(),
528 build_backend(
529 &target.llm_client,
530 client_config,
531 &target.extra_body,
532 &target.omit_body_fields,
533 target.reasoning_effort.clone(),
534 )?,
535 None,
536 )
537 .with_responses_reasoning(client_config.responses_reasoning.unwrap_or_default()),
538 );
539 }
540
541 let mut clients = BTreeMap::new();
542 for (name, model_configs) in models_by_client {
543 let mut client = TranslatingLlmClient::new(&model_configs)
544 .map_err(|error| RunnerError::configuration(error.to_string()))?;
545 if !self.llm_clients[&name].fallback_on_rate_limit {
546 client = client.without_rate_limit_cooldown();
547 }
548 clients.insert(name, Arc::new(client));
549 }
550 Ok(clients)
551 }
552
553 fn build_decision_clients(
554 &self,
555 provider_api_keys: &mut Vec<String>,
556 ) -> RunnerResult<BTreeMap<String, Arc<dyn RoutedDecisionClient>>> {
557 self.decision_clients
558 .iter()
559 .map(|(name, config)| {
560 validate_value("decision client name", name)?;
561 let DecisionClientConfig {
562 format,
563 endpoint,
564 api_key_env,
565 timeout_ms,
566 } = config;
567 if *timeout_ms == 0 {
568 return Err(RunnerError::configuration(format!(
569 "decision client {name} timeout_ms must be at least 1"
570 )));
571 }
572 let api_key = read_api_key(&format!("decision client {name}"), api_key_env)?;
573 let timeout = Duration::from_millis(*timeout_ms);
574 let client = match format {
575 DecisionFormat::SystemOne => {
576 SystemOneClient::new(endpoint.0.clone(), api_key.clone(), timeout)
577 .map(|client| Arc::new(client) as Arc<dyn RoutedDecisionClient>)
578 }
579 DecisionFormat::OpenaiDecisions => {
580 OpenAiDecisionClient::new(endpoint.0.clone(), api_key.clone(), timeout)
581 .map(|client| Arc::new(client) as Arc<dyn RoutedDecisionClient>)
582 }
583 }
584 .map_err(|error| RunnerError::configuration(error.to_string()))?;
585 provider_api_keys.push(api_key);
586 Ok((name.clone(), client))
587 })
588 .collect()
589 }
590
591 fn build_targets(&self) -> BTreeMap<String, ModelId> {
592 self.targets
593 .iter()
594 .map(|(name, config)| (name.clone(), config.id.clone()))
595 .chain(
596 self.decision_targets
597 .iter()
598 .map(|(name, config)| (name.clone(), config.id.clone())),
599 )
600 .collect()
601 }
602
603 fn build_route_clients(
607 &self,
608 route_name: &str,
609 route: &RouteConfig,
610 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
611 decision_clients: &BTreeMap<String, Arc<dyn RoutedDecisionClient>>,
612 ) -> RunnerResult<(ClientRouter, Option<CallerAuthKind>)> {
613 let TargetPromptPolicy {
614 prompts,
615 routing_answer_target,
616 } = self.build_route_target_prompts(route_name, route)?;
617 let mut by_model = HashMap::new();
618 let mut targets_by_model: HashMap<&str, (&str, &TargetConfig)> = HashMap::new();
619 let mut caller_auth = None;
620 let mut has_mixed_families = false;
621 let mut forwarding_origins = BTreeSet::new();
622 let mut decisions_by_model = HashMap::new();
623 for name in route.callable_target_names() {
624 if route
625 .algorithm
626 .classifier_targets()
627 .any(|(judge, decision)| judge == name && decision.is_some())
628 {
629 let target = &self.decision_targets[name];
630 decisions_by_model.insert(
631 target.id.clone(),
632 decision_clients[&target.decision_client].clone(),
633 );
634 continue;
635 }
636 let target = self.targets.get(name).ok_or_else(|| {
637 RunnerError::configuration(format!("route references unknown target {name}"))
638 })?;
639 if let Some((first_name, first)) = targets_by_model.insert(&target.id, (name, target))
640 && first.llm_client != target.llm_client
641 {
642 return Err(RunnerError::configuration(format!(
643 "route {route_name} targets {first_name} and {name} use model {} on different llm clients; execution is keyed by model id, so use distinct model ids within this route or put these targets in separate routes",
644 target.id
645 )));
646 }
647 let client = clients.get(&target.llm_client).ok_or_else(|| {
648 RunnerError::configuration(format!("target {name} has no constructed llm client"))
649 })?;
650 let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| {
651 RunnerError::configuration(format!(
652 "target {name} references unknown llm client {}",
653 target.llm_client
654 ))
655 })?;
656 if client_config.forward_auth {
657 let target_auth = client_config.format.caller_auth_kind();
658 has_mixed_families |= caller_auth.is_some_and(|kind| kind != target_auth);
659 caller_auth = Some(target_auth);
660 forwarding_origins.insert(client_config.base_url.0.origin().ascii_serialization());
661 }
662 let client: Arc<dyn RoutedLlmClient> = client.clone();
663 by_model.insert(target.id.clone(), client);
664 }
665 if has_mixed_families {
671 if forwarding_origins.len() > 1 {
672 let origins = Vec::from_iter(forwarding_origins).join(", ");
673 return Err(RunnerError::configuration(format!(
674 "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"
675 )));
676 }
677 caller_auth = Some(CallerAuthKind::OpenAi);
678 }
679 let completion_targets = route
680 .routing_target_names()
681 .into_iter()
682 .map(|name| self.targets[name].id.clone())
683 .collect::<Vec<_>>();
684 let router = ClientRouter::new_with_decision_clients(
685 by_model,
686 decisions_by_model,
687 prompts,
688 routing_answer_target,
689 &completion_targets,
690 )
691 .without_rate_limit_fallback(
692 route
693 .routing_target_names()
694 .into_iter()
695 .filter_map(|name| {
696 let target = &self.targets[name];
697 (!self.llm_clients[&target.llm_client].fallback_on_rate_limit)
698 .then(|| target.id.clone())
699 })
700 .collect(),
701 );
702 Ok((router, caller_auth))
703 }
704
705 fn build_route_target_prompts(
707 &self,
708 route_name: &str,
709 route: &RouteConfig,
710 ) -> RunnerResult<TargetPromptPolicy> {
711 let mut prompts = HashMap::new();
712 let mut aliases = HashMap::<&ModelId, Option<&str>>::new();
713 for name in route.algorithm.routing_target_names() {
714 let target = self.targets.get(name).ok_or_else(|| {
715 RunnerError::configuration(format!("route references unknown target {name}"))
716 })?;
717 let prompt = target.system_prompt.as_deref();
718 if aliases
719 .insert(&target.id, prompt)
720 .is_some_and(|configured| configured != prompt)
721 {
722 return Err(RunnerError::configuration(format!(
723 "route {route_name} maps completion target aliases to model {} with different system_prompt values",
724 target.id
725 )));
726 }
727 if let Some(prompt) = prompt {
728 prompts.insert(target.id.clone(), prompt.to_string());
729 }
730 }
731 let mut policy = TargetPromptPolicy {
732 prompts,
733 routing_answer_target: None,
734 };
735 let Some((response_name, dependency_name)) =
736 route.algorithm.routing_response_and_dependency()
737 else {
738 return Ok(policy);
739 };
740 let response = self.targets.get(response_name).ok_or_else(|| {
741 RunnerError::configuration(format!("route references unknown target {response_name}"))
742 })?;
743 if !policy.prompts.contains_key(&response.id) {
744 return Ok(policy);
745 }
746 let dependency = self.targets.get(dependency_name).ok_or_else(|| {
747 RunnerError::configuration(format!("route references unknown target {dependency_name}"))
748 })?;
749 if response.id == dependency.id {
750 return Err(RunnerError::configuration(format!(
751 "route {route_name} cannot apply system_prompt to target {response_name}: model {} is also used by routing-only target {dependency_name}",
752 response.id,
753 )));
754 }
755 policy.routing_answer_target = Some(response.id.clone());
756 Ok(policy)
757 }
758
759 fn fallback_base_url(&self) -> RunnerResult<Option<String>> {
760 let Some(name) = &self.fallback_client else {
761 return Ok(None);
762 };
763 let config = self.llm_clients.get(name).ok_or_else(|| {
764 RunnerError::configuration(format!(
765 "fallback_client references unknown llm client {name}"
766 ))
767 })?;
768 Ok(Some(config.base_url.as_str().to_string()))
769 }
770
771 fn build_model_properties_probe(
772 &self,
773 efficient_target_name: &str,
774 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
775 ) -> Option<ModelPropertiesProbe> {
776 let efficient_target = self.targets.get(efficient_target_name)?;
777 let client_config = self.llm_clients.get(&efficient_target.llm_client)?;
778 if !matches!(
779 client_config.context_discovery,
780 Some(ContextDiscovery::LlamaCpp)
781 ) {
782 return None;
783 }
784 let client = clients.get(&efficient_target.llm_client)?;
785 Some(ModelPropertiesProbe {
786 model: efficient_target.id.clone(),
787 format: client_config.format.wire_format(),
788 client: Arc::clone(client),
789 })
790 }
791
792 fn build_anthropic_auxiliary_target(
793 &self,
794 route: &RouteConfig,
795 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
796 ) -> Option<AuxiliaryTarget> {
797 route
798 .routing_target_names()
799 .into_iter()
800 .enumerate()
801 .filter_map(|(index, name)| {
802 let target = self.build_auxiliary_target(
803 name,
804 clients,
805 AuxiliaryOperation::AnthropicCountTokens,
806 )?;
807 Some((count_tokens_priority(name, &target.model), index, target))
808 })
809 .min_by_key(|(priority, index, _)| (*priority, *index))
810 .map(|(_, _, target)| target)
811 }
812
813 fn build_responses_auxiliary_target(
814 &self,
815 route: &RouteConfig,
816 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
817 ) -> Option<AuxiliaryTarget> {
818 route.routing_target_names().into_iter().find_map(|name| {
819 self.build_auxiliary_target(name, clients, AuxiliaryOperation::ResponsesInputTokens)
820 })
821 }
822
823 fn build_auxiliary_target(
824 &self,
825 name: &str,
826 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
827 operation: AuxiliaryOperation,
828 ) -> Option<AuxiliaryTarget> {
829 let target = self.targets.get(name)?;
830 let client = clients.get(&target.llm_client)?;
831 client
832 .supports_auxiliary(&target.id, operation)
833 .then(|| AuxiliaryTarget {
834 model: target.id.clone(),
835 client: client.clone(),
836 })
837 }
838}
839
840fn count_tokens_priority(target_name: &str, model_id: &ModelId) -> usize {
841 let target_name = target_name.to_ascii_lowercase();
842 let model_id = model_id.to_ascii_lowercase();
843 ["opus", "sonnet", "haiku"]
844 .iter()
845 .position(|hint| target_name.contains(hint) || model_id.contains(hint))
846 .unwrap_or(3)
847}
848
849#[derive(Clone, Debug)]
854struct HttpBaseUrl(reqwest::Url);
855
856impl HttpBaseUrl {
857 fn as_str(&self) -> &str {
858 self.0.as_str()
859 }
860}
861
862impl<'de> Deserialize<'de> for HttpBaseUrl {
863 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
864 where
865 D: serde::Deserializer<'de>,
866 {
867 let raw = String::deserialize(deserializer)?;
868 let url = reqwest::Url::parse(raw.trim()).map_err(|error| {
869 serde::de::Error::custom(format!("base_url must be an absolute HTTP(S) URL: {error}"))
870 })?;
871 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
872 return Err(serde::de::Error::custom(
873 "base_url must be an absolute HTTP(S) URL",
874 ));
875 }
876 Ok(Self(url))
877 }
878}
879
880#[derive(Debug, Deserialize)]
881#[serde(deny_unknown_fields)]
882struct LlmClientConfig {
883 format: ClientFormat,
884 base_url: HttpBaseUrl,
885 context_discovery: Option<ContextDiscovery>,
887 api_key_env: Option<String>,
888 #[serde(default)]
889 forward_auth: bool,
890 #[serde(default)]
891 extra_headers: BTreeMap<String, String>,
892 #[serde(default = "default_max_retries")]
893 max_retries: u32,
894 #[serde(default = "default_fallback_on_rate_limit")]
895 fallback_on_rate_limit: bool,
896 #[serde(default = "default_failure_cooldown_ms")]
898 failure_cooldown_ms: u64,
899 timeout_ms: Option<u64>,
901 responses_reasoning: Option<ResponsesReasoningPolicy>,
902}
903
904#[derive(Debug, Deserialize)]
905#[serde(rename_all = "snake_case")]
906enum ContextDiscovery {
907 LlamaCpp,
908}
909
910#[derive(Debug, Deserialize)]
911#[serde(deny_unknown_fields)]
912struct DecisionClientConfig {
913 format: DecisionFormat,
914 endpoint: HttpBaseUrl,
915 api_key_env: String,
916 timeout_ms: u64,
917}
918
919#[derive(Debug, Deserialize)]
920#[serde(rename_all = "snake_case")]
921enum DecisionFormat {
922 SystemOne,
923 OpenaiDecisions,
924}
925
926#[derive(Debug, Deserialize)]
927#[serde(deny_unknown_fields)]
928struct DecisionTargetConfig {
929 id: ModelId,
930 decision_client: String,
931}
932
933#[derive(Debug, Deserialize)]
934#[serde(deny_unknown_fields)]
935struct TargetConfig {
936 id: ModelId,
937 llm_client: String,
938 #[serde(default)]
939 extra_body: BTreeMap<String, Value>,
940 #[serde(default)]
943 omit_body_fields: BTreeSet<String>,
944 system_prompt: Option<String>,
945 reasoning_effort: Option<String>,
948}
949
950#[derive(Clone, Copy, Debug, Deserialize)]
951enum ClientFormat {
952 #[serde(rename = "openai_chat")]
953 OpenAiChat,
954 #[serde(rename = "openai_responses")]
955 OpenAiResponses,
956 #[serde(rename = "anthropic_messages")]
957 AnthropicMessages,
958}
959
960impl ClientFormat {
961 const fn wire_format(self) -> WireFormat {
962 match self {
963 Self::OpenAiChat => WireFormat::OpenAiChat,
964 Self::OpenAiResponses => WireFormat::OpenAiResponses,
965 Self::AnthropicMessages => WireFormat::AnthropicMessages,
966 }
967 }
968
969 const fn caller_auth_kind(self) -> CallerAuthKind {
970 match self {
971 Self::AnthropicMessages => CallerAuthKind::Anthropic,
972 Self::OpenAiChat | Self::OpenAiResponses => CallerAuthKind::OpenAi,
973 }
974 }
975}
976
977fn build_backend(
978 client_name: &str,
979 config: &LlmClientConfig,
980 extra_body: &BTreeMap<String, Value>,
981 omit_body_fields: &BTreeSet<String>,
982 reasoning_effort: Option<String>,
983) -> RunnerResult<Backend> {
984 if config.max_retries > MAX_CONFIGURED_RETRIES {
985 return Err(RunnerError::configuration(format!(
986 "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}"
987 )));
988 }
989 if config.timeout_ms == Some(0) {
990 return Err(RunnerError::configuration(format!(
991 "llm client {client_name} timeout_ms must be at least 1"
992 )));
993 }
994 if config.forward_auth && config.api_key_env.is_some() {
995 return Err(RunnerError::configuration(format!(
996 "llm client {client_name} cannot set both forward_auth and api_key_env"
997 )));
998 }
999 let api_key = config
1000 .api_key_env
1001 .as_deref()
1002 .map(|variable| read_api_key(&format!("llm client {client_name}"), variable))
1003 .transpose()?;
1004 let http = HttpBackendConfig {
1005 base_url: config.base_url.as_str().to_string(),
1006 api_key,
1007 forward_auth: config.forward_auth,
1008 extra_headers: config.extra_headers.clone(),
1009 extra_body: extra_body.clone(),
1010 omit_body_fields: omit_body_fields.clone(),
1011 reasoning_effort,
1012 max_retries: config.max_retries,
1013 failure_cooldown: Duration::from_millis(config.failure_cooldown_ms),
1014 timeout: config.timeout_ms.map(Duration::from_millis),
1015 };
1016 let backend = match config.format {
1017 ClientFormat::OpenAiChat => Backend::OpenAiChat(http),
1018 ClientFormat::OpenAiResponses => Backend::OpenAiResponses(http),
1019 ClientFormat::AnthropicMessages => Backend::Anthropic(http),
1020 };
1021 Ok(backend)
1022}
1023
1024fn read_api_key(client: &str, variable: &str) -> RunnerResult<String> {
1025 if variable.trim().is_empty() {
1026 return Err(RunnerError::configuration(format!(
1027 "{client} api_key_env must not be empty"
1028 )));
1029 }
1030 if variable.contains(['\0', '=']) {
1032 return Err(RunnerError::configuration(format!(
1033 "{client} api_key_env must not contain '=' or NUL"
1034 )));
1035 }
1036 let api_key = std::env::var(variable).map_err(|error| {
1037 let reason = match error {
1039 std::env::VarError::NotPresent => "environment variable not found",
1040 std::env::VarError::NotUnicode(_) => "environment variable is not valid Unicode",
1041 };
1042 RunnerError::configuration(format!(
1043 "{client} could not read api_key_env {variable}: {reason}"
1044 ))
1045 })?;
1046 if api_key.trim().is_empty() {
1047 return Err(RunnerError::configuration(format!(
1048 "{client} api_key_env {variable} is empty"
1049 )));
1050 }
1051 Ok(api_key)
1052}
1053
1054const fn default_max_retries() -> u32 {
1056 DEFAULT_MAX_RETRIES
1057}
1058
1059const fn default_fallback_on_rate_limit() -> bool {
1060 true
1061}
1062
1063const fn default_failure_cooldown_ms() -> u64 {
1064 5000
1065}
1066
1067fn validate_value(label: &str, value: &str) -> RunnerResult<()> {
1068 if value.trim().is_empty() || value.trim() != value {
1069 return Err(RunnerError::configuration(format!(
1070 "{label} must be non-empty and have no surrounding whitespace"
1071 )));
1072 }
1073 Ok(())
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079
1080 #[test]
1081 fn route_presentation_fields_are_split_from_the_algorithm() {
1082 let route: RouteConfig = toml::from_str(
1083 r#"
1084type = "random"
1085id = "switchyard/random"
1086context_window = 128000
1087tool_calling = true
1088reasoning = false
1089targets = ["fast", "strong"]
1090weights = [1.0, 2.0]
1091seed = 7
1092"#,
1093 )
1094 .expect("route should deserialize");
1095
1096 assert_eq!(route.id, "switchyard/random");
1097 assert_eq!(route.context_window, Some(128_000));
1098 assert_eq!(route.tool_calling, Some(true));
1099 assert_eq!(route.reasoning, Some(false));
1100 assert_eq!(route.algorithm.routing_target_names(), ["fast", "strong"]);
1101 }
1102
1103 #[test]
1104 fn route_algorithm_unknown_fields_are_rejected() {
1105 let error = toml::from_str::<RouteConfig>(
1106 r#"
1107type = "passthrough"
1108id = "switchyard/fast"
1109target = "fast"
1110bogus = true
1111"#,
1112 )
1113 .expect_err("unknown route keys must be rejected");
1114
1115 assert!(error.to_string().contains("bogus"), "{error}");
1116 }
1117
1118 #[test]
1119 fn deployment_errors_keep_one_path_context() {
1120 let path = Path::new("/definitely/missing/switchyard-routes.toml");
1121 let error = match load_runner(path) {
1122 Ok(_) => panic!("missing config should fail"),
1123 Err(error) => error,
1124 };
1125 let message = error.to_string();
1126
1127 assert_eq!(message.matches(&path.display().to_string()).count(), 1);
1128 assert!(message.starts_with("failed to read server config"));
1129 }
1130}
1131#[cfg(test)]
1132mod deployment_tests {
1133 use super::*;
1134 use serde_json::json;
1135 use switchyard_protocol::Category;
1136
1137 const VALID_CONFIG: &str = r#"
1138schema_version = 1
1139
1140[llm_clients.primary]
1141format = "openai_chat"
1142base_url = "https://example.test/v1"
1143
1144[llm_clients.responses]
1145format = "openai_responses"
1146base_url = "https://example.test/v1"
1147
1148[llm_clients.anthropic]
1149format = "anthropic_messages"
1150base_url = "https://example.test"
1151
1152[targets.classifier]
1153id = "classifier/model"
1154llm_client = "primary"
1155
1156[targets.strong]
1157id = "strong/model"
1158llm_client = "responses"
1159
1160[targets.weak]
1161id = "weak/model"
1162llm_client = "anthropic"
1163
1164[routes.noop]
1165id = "switchyard/noop"
1166type = "noop"
1167
1168[routes.random]
1169id = "switchyard/random"
1170type = "random"
1171targets = ["strong", "weak"]
1172
1173[routes.classifier]
1174id = "switchyard/classifier"
1175type = "llm_classifier"
1176classifier_target = "classifier"
1177strong_target = "strong"
1178weak_target = "weak"
1179base_threshold = 0.5
1180
1181[routes.passthrough]
1182id = "switchyard/passthrough"
1183type = "passthrough"
1184target = "weak"
1185
1186[routes.plan_execute]
1187id = "switchyard/plan-execute"
1188type = "plan_execute"
1189capable_target = "strong"
1190efficient_target = "weak"
1191"#;
1192
1193 #[test]
1194 fn public_runner_from_toml_builds_a_deployment() -> RunnerResult<()> {
1195 let config = format!(
1196 "{VALID_CONFIG}\n[routes.classifier.infra]\n\
1197 max_signal_age_ms = 1000\nmin_waiting_requests = 4\nmax_capable_inflight = 2"
1198 );
1199 let runner = Runner::from_toml(&config)?;
1200 assert!(
1201 Runner::from_toml(
1202 &config.replace("min_waiting_requests = 4", "min_waiting_requests = 0")
1203 )
1204 .is_err()
1205 );
1206
1207 let classifier = runner
1208 .route("switchyard/classifier")
1209 .expect("classifier route should exist");
1210 let models = classifier.models();
1211 assert_eq!(
1212 models.models_for(&Category::Judge),
1213 [ModelId::from("classifier/model")]
1214 );
1215 assert_eq!(
1216 models.models_for(&Category::Efficient),
1217 [ModelId::from("weak/model")]
1218 );
1219 assert_eq!(
1220 models.models_for(&Category::Capable),
1221 [ModelId::from("strong/model")]
1222 );
1223 assert_eq!(
1224 models.models_for(&Category::Any),
1225 [ModelId::from("weak/model"), ModelId::from("strong/model")]
1226 );
1227 let plan_execute = runner
1228 .route("switchyard/plan-execute")
1229 .expect("plan-execute route should exist");
1230 assert_eq!(
1231 plan_execute.models().models_for(&Category::Capable),
1232 [ModelId::from("strong/model")]
1233 );
1234 assert_eq!(
1235 plan_execute.models().models_for(&Category::Efficient),
1236 [ModelId::from("weak/model")]
1237 );
1238 assert!(runner.route("switchyard/passthrough").is_some());
1239 Ok(())
1240 }
1241
1242 #[test]
1243 fn duplicate_route_ids_are_rejected() {
1244 let config = format!(
1245 r#"{VALID_CONFIG}
1246
1247[routes.duplicate]
1248id = "switchyard/passthrough"
1249type = "passthrough"
1250target = "strong"
1251"#
1252 );
1253
1254 let error = error_message(&config);
1255
1256 assert!(
1257 error.contains(
1258 "routes duplicate and passthrough both use id switchyard/passthrough; route ids must be unique"
1259 ),
1260 "{error}"
1261 );
1262 }
1263
1264 #[tokio::test]
1265 async fn plan_execute_applies_custom_tool_semantics() -> RunnerResult<()> {
1266 use switchyard_protocol::{ContentBlock, Message, Request, Role, ToolCall};
1267
1268 let configured = format!(
1269 "{VALID_CONFIG}\n[routes.plan_execute.tool_semantics]\nmutate = [\"persist_source_file\"]"
1270 );
1271 let mut request = Request::default();
1272 request.llm_request.messages.push(Message {
1273 role: Role::Assistant,
1274 content: vec![ContentBlock::ToolCall(ToolCall {
1275 id: "write-1".to_string(),
1276 name: "mcp__files__Persist_Source_File".to_string(),
1277 arguments: json!({"path": "task.py", "content": "print('hello')"}),
1278 })],
1279 });
1280
1281 for (config, expected) in [
1282 (VALID_CONFIG, "strong/model"),
1283 (configured.as_str(), "weak/model"),
1284 ] {
1285 let runner = Runner::from_toml(config)?;
1286 let route = runner.route("switchyard/plan-execute").unwrap();
1287 let outcome = route.decide(request.clone()).await?;
1288 assert_eq!(outcome.selected_model_ids[0], expected);
1289 }
1290 Ok(())
1291 }
1292
1293 fn error_message(toml: &str) -> String {
1294 match runner_from_toml(toml) {
1295 Ok(_) => "configuration unexpectedly succeeded".to_string(),
1296 Err(error) => error.to_string(),
1297 }
1298 }
1299
1300 fn vgr_config() -> String {
1301 format!(
1302 r#"{VALID_CONFIG}
1303
1304[routes.vgr]
1305id = "switchyard/vgr"
1306type = "vgr"
1307efficient_target = "weak"
1308capable_target = "strong"
1309judge_target = "classifier"
1310mode = "active"
1311active_approval = "prospective-validation-and-canary-approved"
1312task_typing = false
1313"#
1314 )
1315 }
1316
1317 #[test]
1318 fn vgr_compaction_is_opt_in_from_toml() -> RunnerResult<()> {
1319 for setting in ["", "compact_handoff = false", "compact_handoff = true"] {
1320 let source = format!("{}\nagentic_handoff = true\n{setting}\n", vgr_config());
1321 let parsed: DeploymentConfig = toml::from_str(&source).expect("valid VGR config");
1322 let AlgorithmSpec::Vgr { config } = &parsed.routes["vgr"].algorithm else {
1323 panic!("expected VGR route");
1324 };
1325 assert_eq!(config.compact_handoff, setting.ends_with("= true"));
1326 assert!(runner_from_toml(&source)?.route("switchyard/vgr").is_some());
1327 }
1328 Ok(())
1329 }
1330
1331 #[test]
1332 fn vgr_route_builds_and_rejects_unsafe_configuration() -> RunnerResult<()> {
1333 let runner = runner_from_toml(&vgr_config())?;
1334 assert!(runner.route("switchyard/vgr").is_some());
1335
1336 let compact = format!("{}\ncompact_handoff = true\n", vgr_config());
1337 assert!(error_message(&compact).contains("compact_handoff requires agentic_handoff"));
1338 let compact = format!("{compact}\nagentic_handoff = true\n");
1339 assert!(
1340 runner_from_toml(&compact)?
1341 .route("switchyard/vgr")
1342 .is_some()
1343 );
1344
1345 let identical_ids = vgr_config().replace(
1346 "efficient_target = \"weak\"",
1347 "efficient_target = \"strong\"",
1348 );
1349 assert!(error_message(&identical_ids).contains("distinct efficient and capable model IDs"));
1350
1351 let bad_approval =
1352 vgr_config().replace("prospective-validation-and-canary-approved", "approved");
1353 assert!(error_message(&bad_approval).contains("approval attestation"));
1354
1355 let missing_target = vgr_config().replace(
1356 "efficient_target = \"weak\"",
1357 "efficient_target = \"missing\"",
1358 );
1359 assert!(error_message(&missing_target).contains("unknown target missing"));
1360
1361 let zero_cooldown = format!("{}breaker_cooldown_seconds = 0\n", vgr_config());
1362 assert!(
1363 error_message(&zero_cooldown).contains("breaker_cooldown_seconds must be a positive")
1364 );
1365 Ok(())
1366 }
1367
1368 #[test]
1369 fn vgr_local_server_example_builds_and_rejects_unknown_discovery() -> RunnerResult<()> {
1370 let example = include_str!("../../../examples/vgr-local-servers.toml");
1371 let runner = runner_from_toml(example)?;
1372 assert!(runner.route("switchyard/vgr").is_some());
1373 assert!(runner.route("switchyard/only-efficient").is_some());
1374 let parsed: DeploymentConfig = toml::from_str(example).expect("valid VGR example");
1375 assert!(matches!(
1376 &parsed.routes["auxiliary"].algorithm,
1377 AlgorithmSpec::Passthrough { target, .. } if target == "efficient"
1378 ));
1379 let invalid = example.replace(
1380 "context_discovery = \"llama_cpp\"",
1381 "context_discovery = \"guess\"",
1382 );
1383 assert!(error_message(&invalid).contains("unknown variant `guess`"));
1384 Ok(())
1385 }
1386
1387 fn with_subagent_llm_classifier(config: &str, route: &str, extra: &str) -> String {
1388 let mut configured = config.to_string();
1389 configured.push_str(&format!("\n[routes.{route}.subagents]\n"));
1390 configured.push_str(
1391 r#"type = "llm_classifier"
1392mode = "custom"
1393models = { judge = ["classifier"], capable = ["strong"], efficient = ["weak"], any = ["strong", "weak"] }
1394default_target = "efficient"
1395prompt = "Select a target for this delegated task."
1396response_schema = '{"type":"object","properties":{"target":{"type":"string","enum":["capable","efficient"]}},"required":["target"],"additionalProperties":false}'
1397policy = { type = "target_selector", selector = "/target" }
1398classify_trigger = "new_session""#,
1399 );
1400 configured.push_str(extra);
1401 configured
1402 }
1403
1404 #[test]
1405 fn subagent_models_stay_separate_from_the_parent_tiers() -> RunnerResult<()> {
1406 let runner = runner_from_toml(&with_subagent_passthrough(&stage_config(), "stage"))?;
1410 let models = runner
1411 .route("switchyard/stage")
1412 .expect("stage route should exist")
1413 .models();
1414
1415 assert_eq!(
1416 models.subagent_models_for(&Category::Any),
1417 [ModelId::from("strong/model")]
1418 );
1419 assert_eq!(
1420 models.models_for(&Category::Any),
1421 [ModelId::from("strong/model"), ModelId::from("weak/model")]
1422 );
1423 Ok(())
1424 }
1425
1426 fn with_subagent_passthrough(config: &str, route: &str) -> String {
1427 format!("{config}\n[routes.{route}.subagents]\ntype = \"passthrough\"\ntarget = \"strong\"")
1428 }
1429
1430 fn stage_config() -> String {
1431 format!(
1432 r#"{VALID_CONFIG}
1433[targets.stage_judge]
1434id = "stage-judge/model"
1435llm_client = "primary"
1436
1437[routes.stage]
1438id = "switchyard/stage"
1439type = "stage_router"
1440capable_target = "strong"
1441efficient_target = "weak"
1442picker = "efficient_first"
1443confidence_threshold = 1.0
1444capable_hold_turns = 2
1445
1446[routes.stage.tool_semantics]
1447observe = ["lookup_customer"]
1448mutate = ["send_payment"]
1449plan = ["create_workflow"]
1450new = ["send_message"]
1451
1452[routes.stage.classifier]
1453target = "stage_judge"
1454base_threshold = 0.5
1455"#
1456 )
1457 }
1458
1459 fn composite_config() -> String {
1460 format!(
1461 r#"{VALID_CONFIG}
1462[targets.tier_judge]
1463id = "tier-judge/model"
1464llm_client = "primary"
1465
1466[routes.composed]
1467id = "switchyard/hier"
1468type = "composite"
1469
1470[routes.composed.classifier]
1471target = "tier_judge"
1472base_threshold = 0.5
1473classify_trigger = "user_turn"
1474
1475[routes.composed.stage]
1476capable_target = "strong"
1477efficient_target = "weak"
1478confidence_threshold = 0.5
1479capable_hold_turns = 2
1480
1481[routes.composed.stage.tool_semantics]
1482new = ["send_message"]
1483"#
1484 )
1485 }
1486
1487 #[test]
1488 fn stage_and_composite_validate_hold_turns() {
1489 let composite =
1490 composite_config().replace("capable_hold_turns = 2", "capable_hold_turns = 0");
1491 for source in [
1492 stage_config(),
1493 stage_config().replace("efficient_first", "capable_first"),
1494 composite_config(),
1495 format!("{composite}\n[routes.composed.efficient]\ncapable_hold_turns = 2"),
1496 format!("{composite}\n[routes.composed.capable]\ncapable_hold_turns = 2"),
1497 ] {
1498 for field in ["capable_hold_turns", "efficient_hold_turns"] {
1499 for (turns, valid) in [
1500 (-2_i64, false),
1501 (-1, true),
1502 (0, true),
1503 (3, true),
1504 (i64::from(u32::MAX), true),
1505 (i64::from(u32::MAX) + 1, false),
1506 ] {
1507 let configured =
1508 source.replace("capable_hold_turns = 2", &format!("{field} = {turns}"));
1509 let result = runner_from_toml(&configured);
1510 assert_eq!(result.is_ok(), valid, "{field} = {turns}");
1511 if let Err(error) = result {
1512 assert!(error.to_string().contains(field), "{error}");
1513 }
1514 }
1515 }
1516 }
1517 }
1518
1519 #[test]
1520 fn composite_decision_judge_reuses_capability_validation() {
1521 let source = composite_config().replace(
1522 "[routes.composed.classifier]\ntarget = \"tier_judge\"\nbase_threshold = 0.5",
1523 "[routes.composed.classifier]\ntarget = \"tier_judge\"",
1524 ) + r#"
1525[routes.composed.classifier.decision]
1526cutoff = 0.4
1527candidates = { a = "strong", b = "weak" }
1528evidence = { description = "candidate comparison" }
1529"#;
1530 for (source, valid) in [
1531 (source.clone(), true),
1532 (
1533 source.replace(
1534 "target = \"tier_judge\"",
1535 "target = \"tier_judge\"\nbase_threshold = 0.5",
1536 ),
1537 false,
1538 ),
1539 (source.replace("b = \"weak\"", "b = \"strong\""), false),
1540 ] {
1541 let deployment: DeploymentConfig = toml::from_str(&source).unwrap();
1542 let spec = &deployment.routes["composed"].algorithm;
1543 assert_eq!(spec.classifier_targets().next().unwrap().0, "tier_judge");
1544 assert_eq!(
1545 spec.build_with_runtime_models("composed", &deployment.build_targets())
1546 .is_ok(),
1547 valid
1548 );
1549 }
1550 }
1551
1552 #[test]
1553 fn composite_custom_judge_builds_and_rejects_capability_settings() -> RunnerResult<()> {
1554 let source = composite_config().replace(
1555 "base_threshold = 0.5\nclassify_trigger",
1556 r#"mode = "custom"
1557prompt = 'Select efficient or capable.'
1558response_schema = '{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["efficient","capable"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}'
1559policy = { type = "target_selector", selector = "/decision/target" }
1560classify_trigger"#,
1561 );
1562 runner_from_toml(&source)?;
1563 runner_from_toml(
1564 &source.replace("mode = \"custom\"", "mode = \"custom\"\nfail_open = true"),
1565 )?;
1566 for (setting, expected) in [
1567 (
1568 "base_threshold = 0.5",
1569 "composite custom classifier cannot use capability thresholds",
1570 ),
1571 (
1572 "fail_open = false",
1573 "composite custom classifiers always fail open",
1574 ),
1575 ] {
1576 let invalid = source.replace(
1577 "mode = \"custom\"",
1578 &format!("mode = \"custom\"\n{setting}"),
1579 );
1580 assert!(error_message(&invalid).contains(expected));
1581 }
1582 Ok(())
1583 }
1584
1585 #[test]
1586 fn composite_route_builds_and_claims_both_tiers_and_its_judge() -> RunnerResult<()> {
1587 let runner = runner_from_toml(&composite_config())?;
1588 assert!(
1589 runner
1590 .models()
1591 .any(|model| model.id.as_str() == "switchyard/hier")
1592 );
1593 Ok(())
1594 }
1595
1596 #[test]
1597 fn builds_all_supported_algorithm_types() -> RunnerResult<()> {
1598 let state = runner_from_toml(VALID_CONFIG)?;
1599 assert_eq!(
1601 state
1602 .models()
1603 .map(|model| model.id.as_str())
1604 .collect::<Vec<_>>(),
1605 [
1606 "switchyard/classifier",
1607 "switchyard/noop",
1608 "switchyard/passthrough",
1609 "switchyard/plan-execute",
1610 "switchyard/random",
1611 ]
1612 );
1613 Ok(())
1614 }
1615
1616 #[test]
1617 fn stage_rejects_ambiguous_or_non_additive_tool_semantics() {
1618 for (configured, expected) in [
1619 (
1620 stage_config().replace(
1621 "mutate = [\"send_payment\"]",
1622 "mutate = [\"LOOKUP_CUSTOMER\"]",
1623 ),
1624 "appears in both tool_semantics.observe and tool_semantics.mutate",
1625 ),
1626 (
1627 stage_config().replace("new = [\"send_message\"]", "new = [\"Read\"]"),
1628 "already has built-in semantics and cannot be reclassified",
1629 ),
1630 (
1631 stage_config().replace("observe = [\"lookup_customer\"]", "observe = [\" \"]"),
1632 "contains an empty tool name",
1633 ),
1634 ] {
1635 let message = error_message(&configured);
1636 assert!(
1637 message.contains(expected),
1638 "expected {expected:?} in error, got: {message}"
1639 );
1640 }
1641 }
1642
1643 #[test]
1644 fn passthrough_and_stage_accept_subagent_routing() -> RunnerResult<()> {
1645 let stage = stage_config();
1646 let stage_with_classifier = with_subagent_llm_classifier(&stage, "stage", "");
1647 let parsed: DeploymentConfig = toml::from_str(&stage_with_classifier).map_err(|error| {
1648 RunnerError::configuration(format!("failed to parse stage config: {error}"))
1649 })?;
1650 let Some(stage_route) = parsed.routes.get("stage") else {
1651 return Err(RunnerError::configuration("stage route is missing"));
1652 };
1653 let callable_targets = stage_route.callable_target_names();
1654 for expected in ["strong", "weak", "stage_judge", "classifier"] {
1655 assert!(callable_targets.contains(&expected));
1656 }
1657
1658 for configured in [
1659 with_subagent_llm_classifier(VALID_CONFIG, "passthrough", ""),
1660 with_subagent_passthrough(VALID_CONFIG, "passthrough"),
1661 stage_with_classifier,
1662 with_subagent_passthrough(&stage, "stage"),
1663 ] {
1664 runner_from_toml(&configured)?;
1665 }
1666 Ok(())
1667 }
1668
1669 #[test]
1670 fn aliased_completion_targets_reject_prompt_conflicts() {
1671 let configured = stage_config()
1672 .replace(
1673 "id = \"strong/model\"\nllm_client = \"responses\"",
1674 "id = \"strong/model\"\nllm_client = \"responses\"\nsystem_prompt = \"capable\"",
1675 )
1676 .replace(
1677 "[routes.stage]",
1678 "[targets.strong_alias]\nid = \"strong/model\"\nllm_client = \"responses\"\n\n[routes.stage]",
1679 )
1680 .replace("efficient_target = \"weak\"", "efficient_target = \"strong_alias\"");
1681 let message = error_message(&configured);
1682 assert!(
1683 message.contains("completion target aliases to model strong/model with different system_prompt values"),
1684 "unexpected error: {message}"
1685 );
1686 }
1687
1688 #[test]
1689 fn prompted_routing_response_cannot_share_a_model_with_a_dependency() {
1690 let configured = VALID_CONFIG
1691 .replace(
1692 "id = \"classifier/model\"\nllm_client = \"primary\"",
1693 "id = \"weak/model\"\nllm_client = \"primary\"",
1694 )
1695 .replace(
1696 "id = \"weak/model\"\nllm_client = \"anthropic\"",
1697 "id = \"weak/model\"\nllm_client = \"anthropic\"\nsystem_prompt = \"answer prompt\"",
1698 )
1699 .replace(
1700 "base_threshold = 0.5",
1701 "base_threshold = 0.5\nescalation = { confirmations = 1 }",
1702 );
1703
1704 let message = error_message(&configured);
1705
1706 assert!(
1707 message.contains("cannot apply system_prompt to target weak: model weak/model is also used by routing-only target classifier"),
1708 "unexpected error: {message}"
1709 );
1710 }
1711
1712 #[test]
1713 fn rejects_invalid_unreferenced_llm_client() {
1714 for (settings, expected) in [
1715 (
1716 "base_url = \"not a url\"",
1717 "base_url must be an absolute HTTP(S) URL",
1718 ),
1719 (
1720 "base_url = \"https://example.test\"\ntimeout_ms = 0",
1721 "timeout_ms must be at least 1",
1722 ),
1723 ] {
1724 let invalid = format!(
1725 "{VALID_CONFIG}\n[llm_clients.unused]\nformat = \"openai_chat\"\n{settings}"
1726 );
1727 let message = error_message(&invalid);
1728 assert!(message.contains(expected), "unexpected error: {message}");
1729 }
1730 }
1731
1732 #[test]
1733 fn an_escalation_table_switches_the_classifier_route_to_escalation() -> RunnerResult<()> {
1734 let escalating = VALID_CONFIG.replace(
1738 "base_threshold = 0.5",
1739 "base_threshold = 0.5\nescalation = { confirmations = 2 }",
1740 );
1741 runner_from_toml(&escalating)?;
1742
1743 let reversible = VALID_CONFIG.replace(
1744 "base_threshold = 0.5",
1745 "base_threshold = 0.5\nescalation = { confirmations = 2, deescalation = { strong_min_calls = 3, confirmations = 2, strong_max_calls = 6, weak_cooldown_calls = 8 } }",
1746 );
1747 runner_from_toml(&reversible)?;
1748
1749 let starved = VALID_CONFIG.replace(
1752 "base_threshold = 0.5",
1753 "base_threshold = 0.5\nescalation = { confirmations = 0 }",
1754 );
1755 assert!(error_message(&starved).contains("confirmations must be at least 1"));
1756
1757 Ok(())
1758 }
1759
1760 #[test]
1761 fn unmatched_steps_is_a_capability_setting() -> RunnerResult<()> {
1762 runner_from_toml(&VALID_CONFIG.replace(
1764 "base_threshold = 0.5",
1765 "base_threshold = 0.5\nunmatched_steps = 2",
1766 ))?;
1767 assert!(
1768 error_message(&VALID_CONFIG.replace(
1769 "base_threshold = 0.5",
1770 "base_threshold = 0.5\nunmatched_steps = 3",
1771 ))
1772 .contains("unmatched_steps must be 0, 1, or 2")
1773 );
1774
1775 assert!(
1779 error_message(&VALID_CONFIG.replace(
1780 "base_threshold = 0.5",
1781 "base_threshold = 0.5\nunmatched_steps = 2\nescalation = { confirmations = 2 }",
1782 ))
1783 .contains("mode escalation cannot use capability routing settings")
1784 );
1785 Ok(())
1786 }
1787
1788 #[test]
1789 fn a_target_reasoning_effort_parses_and_is_rejected_where_unsupported() -> RunnerResult<()> {
1790 let strong = "[targets.strong]\nid = \"strong/model\"\nllm_client = \"responses\"";
1791 let weak = "[targets.weak]\nid = \"weak/model\"\nllm_client = \"anthropic\"";
1792 assert!(VALID_CONFIG.contains(strong) && VALID_CONFIG.contains(weak));
1793
1794 let forced = VALID_CONFIG.replace(strong, &format!("{strong}\nreasoning_effort = \"max\""));
1795 runner_from_toml(&forced)?;
1796
1797 let blank = VALID_CONFIG.replace(strong, &format!("{strong}\nreasoning_effort = \" \""));
1798 assert!(error_message(&blank).contains("reasoning_effort must not be empty"));
1799
1800 let anthropic = VALID_CONFIG.replace(weak, &format!("{weak}\nreasoning_effort = \"high\""));
1801 assert!(
1802 error_message(&anthropic)
1803 .contains("only supported on openai_chat and openai_responses")
1804 );
1805 Ok(())
1806 }
1807
1808 #[test]
1809 fn duplicate_targets_with_conflicting_settings_are_rejected() -> RunnerResult<()> {
1810 let strong = "[targets.strong]\nid = \"strong/model\"\nllm_client = \"responses\"";
1811 assert!(VALID_CONFIG.contains(strong));
1812 let conflicting = VALID_CONFIG.replace(
1814 strong,
1815 &format!(
1816 "{strong}\n\n[targets.strong_max]\nid = \"strong/model\"\nllm_client = \"responses\"\nreasoning_effort = \"max\""
1817 ),
1818 );
1819 assert!(
1820 error_message(&conflicting)
1821 .contains("different reasoning_effort, extra_body, or omit_body_fields"),
1822 "{}",
1823 error_message(&conflicting)
1824 );
1825 let alias = VALID_CONFIG.replace(
1827 strong,
1828 &format!("{strong}\n\n[targets.strong_alias]\nid = \"strong/model\"\nllm_client = \"responses\""),
1829 );
1830 runner_from_toml(&alias)?;
1831 Ok(())
1832 }
1833
1834 #[test]
1835 fn classifier_judge_completion_caps_are_configurable() -> RunnerResult<()> {
1836 let capability = VALID_CONFIG.replace(
1837 "base_threshold = 0.5",
1838 "base_threshold = 0.5\nmax_output_tokens = 512",
1839 );
1840 runner_from_toml(&capability)?;
1841
1842 let escalation = VALID_CONFIG.replace(
1843 "base_threshold = 0.5",
1844 "base_threshold = 0.5\nmax_output_tokens = 256\nescalation = { confirmations = 2 }",
1845 );
1846 runner_from_toml(&escalation)?;
1847 Ok(())
1848 }
1849
1850 #[test]
1851 fn classifier_prompts_are_configurable_in_both_modes() -> RunnerResult<()> {
1852 let capability = VALID_CONFIG.replace(
1853 "base_threshold = 0.5",
1854 "base_threshold = 0.5\nprompt = \"custom capability rubric\"",
1855 );
1856 runner_from_toml(&capability)?;
1857
1858 let escalation = VALID_CONFIG.replace(
1859 "base_threshold = 0.5",
1860 "base_threshold = 0.5\nprompt = \"custom trajectory rubric\"\nescalation = { confirmations = 2 }",
1861 );
1862 runner_from_toml(&escalation)?;
1863
1864 let empty = VALID_CONFIG.replace(
1865 "base_threshold = 0.5",
1866 "base_threshold = 0.5\nprompt = \" \"",
1867 );
1868 assert!(error_message(&empty).contains("classifier prompt must not be empty"));
1869
1870 let schema_placeholder = VALID_CONFIG.replace(
1871 "base_threshold = 0.5",
1872 "base_threshold = 0.5\nprompt = \"{{RESPONSE_SCHEMA}}\"",
1873 );
1874 assert!(
1875 error_message(&schema_placeholder)
1876 .contains("Switchyard supplies the schema automatically")
1877 );
1878 Ok(())
1879 }
1880
1881 #[test]
1882 fn mode_custom_rejects_capability_fields() {
1883 let mixed = VALID_CONFIG.replace(
1884 "base_threshold = 0.5",
1885 "mode = \"custom\"\nbase_threshold = 0.5",
1886 );
1887
1888 assert!(
1889 error_message(&mixed)
1890 .contains("mode custom cannot use capability or escalation fields")
1891 );
1892 }
1893
1894 #[test]
1895 fn stage_router_rejects_an_unknown_field() {
1896 let config = stage_config().replace(
1897 "picker = \"efficient_first\"",
1898 "picker = \"efficient_first\"\nmagic = true",
1899 );
1900 assert!(error_message(&config).contains("unknown field"));
1901 }
1902
1903 #[test]
1904 fn auto_route_builds_a_stage_router_with_no_extra_fields() -> RunnerResult<()> {
1905 let config = format!(
1906 r#"{VALID_CONFIG}
1907[routes.auto]
1908id = "switchyard/auto"
1909type = "auto"
1910capable_target = "strong"
1911efficient_target = "weak"
1912"#
1913 );
1914 let runner = runner_from_toml(&config)?;
1915 assert!(runner.route("switchyard/auto").is_some());
1916 Ok(())
1917 }
1918
1919 #[test]
1920 fn composite_stage_block_rejects_an_unknown_field() {
1921 let config = composite_config().replace(
1922 "confidence_threshold = 0.5",
1923 "confidence_threshold = 0.5\nmagic = true",
1924 );
1925 assert!(error_message(&config).contains("unknown field"));
1926 }
1927
1928 #[test]
1929 fn rejects_unknown_fields_and_algorithm_types() {
1930 let unknown_field =
1931 VALID_CONFIG.replace("schema_version = 1", "schema_version = 1\nmagic = true");
1932 assert!(error_message(&unknown_field).contains("unknown field"));
1933
1934 let nested_completion_cap = VALID_CONFIG.replace(
1935 "base_threshold = 0.5",
1936 "base_threshold = 0.5\nescalation = { max_output_tokens = 256 }",
1937 );
1938 assert!(error_message(&nested_completion_cap).contains("unknown field"));
1939
1940 let unknown_classifier_field = VALID_CONFIG.replace(
1941 "base_threshold = 0.5",
1942 "base_threshold = 0.5\nclassifier_magic = true",
1943 );
1944 assert!(error_message(&unknown_classifier_field).contains("unknown field"));
1945
1946 let target_capability = VALID_CONFIG.replace(
1947 "llm_client = \"responses\"",
1948 "llm_client = \"responses\"\ncontext_window = 1000000",
1949 );
1950 assert!(error_message(&target_capability).contains("unknown field `context_window`"));
1951
1952 let unknown_algorithm = VALID_CONFIG.replace("type = \"noop\"", "type = \"imaginary\"");
1953 assert!(error_message(&unknown_algorithm).contains("unknown variant"));
1954 }
1955
1956 #[test]
1957 fn rejects_unknown_stage_classifier_fields() {
1958 let config = format!(
1960 r#"{VALID_CONFIG}
1961
1962[routes.stage]
1963id = "switchyard/stage"
1964type = "stage_router"
1965capable_target = "strong"
1966efficient_target = "weak"
1967picker = "efficient_first"
1968confidence_threshold = 1.0
1969
1970[routes.stage.classifier]
1971target = "classifier"
1972base_threshold = 0.5
1973classifier_magic = true
1974"#
1975 );
1976
1977 let error = error_message(&config);
1978 assert!(
1979 error.contains("unknown field `classifier_magic`"),
1980 "{error}"
1981 );
1982 }
1983
1984 #[test]
1985 fn rejects_invalid_references_and_parameters() {
1986 let cases = [
1987 (
1988 VALID_CONFIG.replace("llm_client = \"primary\"", "llm_client = \"missing\""),
1989 "unknown llm client missing",
1990 ),
1991 (
1992 VALID_CONFIG.replace(
1993 "targets = [\"strong\", \"weak\"]",
1994 "targets = [\"strong\", \"missing\"]",
1995 ),
1996 "unknown target missing",
1997 ),
1998 (
1999 VALID_CONFIG.replace(
2000 "targets = [\"strong\", \"weak\"]",
2001 "targets = [\"strong\", \"weak\"]\nweights = [0, 0]",
2002 ),
2003 "at least one weight must be positive",
2004 ),
2005 (
2006 VALID_CONFIG.replace(
2007 "targets = [\"strong\", \"weak\"]",
2008 "targets = [\"strong\", \"strong\"]",
2009 ),
2010 "targets must be unique, strong is repeated",
2011 ),
2012 (
2013 VALID_CONFIG.replace(
2014 "targets = [\"strong\", \"weak\"]",
2015 "targets = [\"strong\", \"weak\"]\nweights = [1]",
2016 ),
2017 "expected 2 weights, got 1",
2018 ),
2019 (
2020 VALID_CONFIG.replace("base_threshold = 0.5", "base_threshold = 1.5"),
2021 "base_threshold must be between 0 and 1",
2022 ),
2023 (
2024 VALID_CONFIG.replace("classifier_target = \"classifier\"\n", ""),
2025 "route references unknown target",
2026 ),
2027 (
2028 VALID_CONFIG.replace(
2029 "classifier_target = \"classifier\"",
2030 "classifier_target = \"\"",
2031 ),
2032 "route references unknown target",
2033 ),
2034 (
2035 VALID_CONFIG.replace(
2036 "classifier_target = \"classifier\"",
2037 "classifier_target = \" \"",
2038 ),
2039 "route references unknown target",
2040 ),
2041 (
2042 VALID_CONFIG.replace(
2043 "base_threshold = 0.5",
2044 "base_threshold = 0.5\nthreshold_step = -0.1",
2045 ),
2046 "threshold_step must be finite and greater than or equal to 0",
2047 ),
2048 (
2049 VALID_CONFIG.replace(
2050 "base_threshold = 0.5",
2051 "base_threshold = 0.8\nthreshold_step = 0.11",
2052 ),
2053 "base_threshold + 2 * threshold_step must be at most 1",
2054 ),
2055 (
2056 VALID_CONFIG.replace(
2057 "base_threshold = 0.5",
2058 "base_threshold = 0.5\nmax_output_tokens = 0\nescalation = { confirmations = 2 }",
2059 ),
2060 "max_output_tokens must be at least 1",
2061 ),
2062 (
2063 VALID_CONFIG.replace(
2064 "base_threshold = 0.5",
2065 "base_threshold = 0.5\nmessage_hash_fallback = true",
2066 ),
2067 "message_hash_fallback requires classify_trigger = new_session",
2068 ),
2069 (
2070 with_subagent_llm_classifier(
2071 VALID_CONFIG,
2072 "passthrough",
2073 "\nmessage_hash_fallback = true",
2074 ),
2075 "cannot use message_hash_fallback",
2076 ),
2077 (
2078 with_subagent_llm_classifier(VALID_CONFIG, "passthrough", "")
2079 .replace("mode = \"custom\"", "mode = \"capability\""),
2080 "mode capability cannot use custom classifier fields",
2081 ),
2082 (
2083 VALID_CONFIG.replace(
2084 "base_threshold = 0.5",
2085 "escalation = { confirmations = 2 }\nclassify_trigger = \"user_turn\"",
2086 ),
2087 "mode escalation cannot use classify_trigger",
2088 ),
2089 (
2090 VALID_CONFIG.replace("schema_version = 1", "schema_version = 2"),
2091 "unsupported schema_version 2",
2092 ),
2093 (
2094 VALID_CONFIG.replace("[targets.strong]", "[targets.\" strong \"]"),
2095 "target name must be non-empty and have no surrounding whitespace",
2096 ),
2097 (
2098 VALID_CONFIG.replace(
2099 "targets = [\"strong\", \"weak\"]",
2100 "targets = [\"strong\", \"weak\"]\ncontext_window = 0",
2101 ),
2102 "route random context_window must be greater than zero",
2103 ),
2104 ];
2105
2106 for (toml, expected) in cases {
2107 let error = error_message(&toml);
2108 assert!(
2109 error.contains(expected),
2110 "expected error containing {expected}, got {error}"
2111 );
2112 }
2113 }
2114
2115 #[test]
2116 fn accepts_duplicate_target_model_ids_on_one_client() -> RunnerResult<()> {
2117 const SAME_MODEL_TWO_ROUTES: &str = r#"
2122schema_version = 1
2123
2124[llm_clients.primary]
2125format = "openai_chat"
2126base_url = "https://example.test/v1"
2127
2128[targets.fast]
2129id = "gpt-4o"
2130llm_client = "primary"
2131
2132[targets.smart]
2133id = "gpt-4o"
2134llm_client = "primary"
2135
2136[routes.fast]
2137id = "switchyard/fast"
2138type = "passthrough"
2139target = "fast"
2140
2141[routes.smart]
2142id = "switchyard/smart"
2143type = "passthrough"
2144target = "smart"
2145"#;
2146 let state = runner_from_toml(SAME_MODEL_TWO_ROUTES)?;
2147 assert_eq!(
2148 state
2149 .models()
2150 .map(|model| model.id.as_str())
2151 .collect::<Vec<_>>(),
2152 ["switchyard/fast", "switchyard/smart"]
2153 );
2154 Ok(())
2155 }
2156
2157 #[test]
2158 fn accepts_same_model_id_on_different_llm_clients() -> RunnerResult<()> {
2159 const CROSS_PROVIDER: &str = r#"
2161schema_version = 1
2162
2163[llm_clients.openai]
2164format = "openai_chat"
2165base_url = "https://example.test/v1"
2166
2167[llm_clients.azure]
2168format = "openai_chat"
2169base_url = "https://azure.test/v1"
2170
2171[targets.openai]
2172id = "gpt-4o"
2173llm_client = "openai"
2174
2175[targets.azure]
2176id = "gpt-4o"
2177llm_client = "azure"
2178
2179[routes.openai]
2180id = "switchyard/openai-gpt4o"
2181type = "passthrough"
2182target = "openai"
2183
2184[routes.azure]
2185id = "switchyard/azure-gpt4o"
2186type = "passthrough"
2187target = "azure"
2188"#;
2189 runner_from_toml(CROSS_PROVIDER)?;
2190 let shared_route = format!(
2191 r#"{CROSS_PROVIDER}
2192
2193[routes.shared]
2194id = "switchyard/shared"
2195type = "stage_router"
2196capable_target = "openai"
2197efficient_target = "azure"
2198picker = "efficient_first"
2199confidence_threshold = 0.5
2200"#
2201 );
2202 let message = error_message(&shared_route);
2203 assert!(
2204 message.contains("route shared")
2205 && message.contains("openai")
2206 && message.contains("azure")
2207 && message.contains("gpt-4o")
2208 && message.contains("different llm clients"),
2209 "{message}"
2210 );
2211 Ok(())
2212 }
2213
2214 #[test]
2215 fn accepts_relative_weights_and_seed() -> RunnerResult<()> {
2216 let weighted = VALID_CONFIG.replace(
2217 "targets = [\"strong\", \"weak\"]",
2218 "targets = [\"strong\", \"weak\"]\nweights = [1, 3]\nseed = 42",
2219 );
2220 runner_from_toml(&weighted)?;
2221 Ok(())
2222 }
2223
2224 #[test]
2225 fn accepts_new_session_trigger_with_message_hash_fallback() -> RunnerResult<()> {
2226 let configured = VALID_CONFIG.replace(
2227 "base_threshold = 0.5",
2228 "base_threshold = 0.25\nthreshold_step = 0.1\nclassify_trigger = \"new_session\"\nmessage_hash_fallback = true",
2229 );
2230 runner_from_toml(&configured)?;
2231 Ok(())
2232 }
2233
2234 #[test]
2235 fn target_extra_body_is_parsed_and_applied_to_its_backend() -> RunnerResult<()> {
2236 let configured = VALID_CONFIG.replacen(
2237 "llm_client = \"primary\"",
2238 "llm_client = \"primary\"\n\
2239 extra_body = { service_tier = \"priority\", \
2240 chat_template_kwargs = { enable_thinking = false } }",
2241 1,
2242 );
2243 let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
2244 RunnerError::configuration(format!("failed to parse config: {error}"))
2245 })?;
2246 let Some(target) = config.targets.get("classifier") else {
2247 return Err(RunnerError::configuration("classifier target is missing"));
2248 };
2249 let Some(client) = config.llm_clients.get("primary") else {
2250 return Err(RunnerError::configuration("primary llm client is missing"));
2251 };
2252 let backend = build_backend(
2253 "primary",
2254 client,
2255 &target.extra_body,
2256 &target.omit_body_fields,
2257 None,
2258 )?;
2259
2260 assert_eq!(
2261 backend.extra_body().get("service_tier"),
2262 Some(&json!("priority"))
2263 );
2264 assert_eq!(
2265 backend
2266 .extra_body()
2267 .get("chat_template_kwargs")
2268 .and_then(|value| value.get("enable_thinking")),
2269 Some(&json!(false))
2270 );
2271 Ok(())
2272 }
2273
2274 #[test]
2275 fn target_omit_body_fields_is_parsed_and_applied_to_its_backend() -> RunnerResult<()> {
2276 let configured = VALID_CONFIG.replacen(
2277 "llm_client = \"primary\"",
2278 "llm_client = \"primary\"\n\
2279 omit_body_fields = [\"max_output_tokens\"]",
2280 1,
2281 );
2282 let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
2283 RunnerError::configuration(format!("failed to parse config: {error}"))
2284 })?;
2285 let Some(target) = config.targets.get("classifier") else {
2286 return Err(RunnerError::configuration("classifier target is missing"));
2287 };
2288 let Some(client) = config.llm_clients.get("primary") else {
2289 return Err(RunnerError::configuration("primary llm client is missing"));
2290 };
2291 let backend = build_backend(
2292 "primary",
2293 client,
2294 &target.extra_body,
2295 &target.omit_body_fields,
2296 None,
2297 )?;
2298
2299 assert!(backend.omit_body_fields().contains("max_output_tokens"));
2300 Ok(())
2301 }
2302
2303 #[test]
2304 fn client_deadline_defaults_and_rejects_zero() -> RunnerResult<()> {
2305 for (setting, expected) in [
2306 ("", None),
2307 ("timeout_ms = 1500", Some(1500)),
2308 ("timeout_ms = 0", Some(0)),
2309 ] {
2310 let source = format!(
2311 "format = \"openai_chat\"\nbase_url = \"https://example.test/v1\"\n{setting}"
2312 );
2313 let config: LlmClientConfig = toml::from_str(&source).expect("valid deadline config");
2314 let backend = build_backend("test", &config, &BTreeMap::new(), &BTreeSet::new(), None);
2315 if expected == Some(0) {
2316 assert!(backend.is_err());
2317 } else {
2318 assert_eq!(backend?.timeout(), expected.map(Duration::from_millis));
2319 }
2320 }
2321 Ok(())
2322 }
2323
2324 #[test]
2325 fn retry_budget_defaults_and_accepts_an_override() -> RunnerResult<()> {
2326 let default: DeploymentConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
2327 RunnerError::configuration(format!("failed to parse default config: {error}"))
2328 })?;
2329 let Some(primary) = default.llm_clients.get("primary") else {
2330 return Err(RunnerError::configuration("primary llm client is missing"));
2331 };
2332 assert_eq!(primary.max_retries, DEFAULT_MAX_RETRIES);
2333
2334 let explicit = VALID_CONFIG.replacen(
2335 "base_url = \"https://example.test/v1\"",
2336 "base_url = \"https://example.test/v1\"\nmax_retries = 0",
2337 1,
2338 );
2339 let config: DeploymentConfig = toml::from_str(&explicit).map_err(|error| {
2340 RunnerError::configuration(format!("failed to parse explicit retry config: {error}"))
2341 })?;
2342 let Some(primary) = config.llm_clients.get("primary") else {
2343 return Err(RunnerError::configuration("primary llm client is missing"));
2344 };
2345 assert_eq!(primary.max_retries, 0);
2346
2347 let maximum = VALID_CONFIG.replacen(
2348 "base_url = \"https://example.test/v1\"",
2349 &format!(
2350 "base_url = \"https://example.test/v1\"\nmax_retries = {MAX_CONFIGURED_RETRIES}"
2351 ),
2352 1,
2353 );
2354 let config: DeploymentConfig = toml::from_str(&maximum).map_err(|error| {
2355 RunnerError::configuration(format!("failed to parse maximum retry config: {error}"))
2356 })?;
2357 let Some(primary) = config.llm_clients.get("primary") else {
2358 return Err(RunnerError::configuration("primary llm client is missing"));
2359 };
2360 assert_eq!(primary.max_retries, MAX_CONFIGURED_RETRIES);
2361 Ok(())
2362 }
2363
2364 #[test]
2365 fn responses_reasoning_defaults_and_accepts_drop() -> RunnerResult<()> {
2366 let default: DeploymentConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
2367 RunnerError::configuration(format!("failed to parse config: {error}"))
2368 })?;
2369 let responses = default
2370 .llm_clients
2371 .get("responses")
2372 .ok_or_else(|| RunnerError::configuration("responses llm client is missing"))?;
2373 assert_eq!(responses.responses_reasoning, None);
2374
2375 let configured = VALID_CONFIG.replacen(
2376 "[llm_clients.responses]\nformat = \"openai_responses\"\nbase_url = \"https://example.test/v1\"",
2377 "[llm_clients.responses]\nformat = \"openai_responses\"\nbase_url = \"https://example.test/v1\"\nresponses_reasoning = \"drop\"",
2378 1,
2379 );
2380 let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
2381 RunnerError::configuration(format!("failed to parse config: {error}"))
2382 })?;
2383 let responses = config
2384 .llm_clients
2385 .get("responses")
2386 .ok_or_else(|| RunnerError::configuration("responses llm client is missing"))?;
2387 assert_eq!(
2388 responses.responses_reasoning,
2389 Some(ResponsesReasoningPolicy::Drop)
2390 );
2391 runner_from_toml(&configured)?;
2392 Ok(())
2393 }
2394
2395 #[test]
2396 fn responses_reasoning_rejects_other_formats() {
2397 let invalid = VALID_CONFIG.replacen(
2398 "format = \"openai_chat\"",
2399 "format = \"openai_chat\"\nresponses_reasoning = \"drop\"",
2400 1,
2401 );
2402 assert!(
2403 error_message(&invalid)
2404 .contains("responses_reasoning is only valid for openai_responses")
2405 );
2406 }
2407
2408 #[test]
2409 fn responses_reasoning_rejects_unreferenced_other_format() {
2410 let invalid = format!(
2411 "{VALID_CONFIG}\n[llm_clients.unused]\nformat = \"openai_chat\"\nbase_url = \"https://example.test/v1\"\nresponses_reasoning = \"drop\"\n"
2412 );
2413 assert!(
2414 error_message(&invalid)
2415 .contains("responses_reasoning is only valid for openai_responses")
2416 );
2417 }
2418
2419 #[test]
2420 fn rejects_headers_that_switchyard_sets() {
2421 let cases = [
2422 (
2423 "base_url = \"https://example.test/v1\"",
2424 "base_url = \"https://example.test/v1\"\n\
2425 extra_headers = { AUTHORIZATION = \"Bearer custom-key\" }",
2426 "AUTHORIZATION",
2427 ),
2428 (
2429 "base_url = \"https://example.test\"",
2430 "base_url = \"https://example.test\"\n\
2431 extra_headers = { \"X-Api-Key\" = \"custom-key\" }",
2432 "X-Api-Key",
2433 ),
2434 (
2435 "base_url = \"https://example.test\"",
2436 "base_url = \"https://example.test\"\n\
2437 extra_headers = { \"ANTHROPIC-VERSION\" = \"custom-version\" }",
2438 "ANTHROPIC-VERSION",
2439 ),
2440 ];
2441
2442 for (original, replacement, header) in cases {
2443 let configured = VALID_CONFIG.replacen(original, replacement, 1);
2444 let error = error_message(&configured);
2445 assert!(
2446 error.contains(&format!("extra_headers cannot set {header:?}")),
2447 "expected {header} to be rejected, got: {error}"
2448 );
2449 }
2450 }
2451
2452 #[test]
2453 fn accepts_additional_headers() -> RunnerResult<()> {
2454 let configured = VALID_CONFIG.replacen(
2455 "base_url = \"https://example.test/v1\"",
2456 "base_url = \"https://example.test/v1\"\n\
2457 extra_headers = { X-Inference-Priority = \"batch\" }",
2458 1,
2459 );
2460
2461 runner_from_toml(&configured)?;
2462 Ok(())
2463 }
2464
2465 #[test]
2466 fn retry_budget_rejects_negative_values() {
2467 let invalid = VALID_CONFIG.replacen(
2468 "base_url = \"https://example.test/v1\"",
2469 "base_url = \"https://example.test/v1\"\nmax_retries = -1",
2470 1,
2471 );
2472 assert!(error_message(&invalid).contains("max_retries"));
2473 }
2474
2475 #[test]
2476 fn retry_budget_rejects_excessive_values() {
2477 let invalid = VALID_CONFIG.replacen(
2478 "base_url = \"https://example.test/v1\"",
2479 "base_url = \"https://example.test/v1\"\nmax_retries = 11",
2480 1,
2481 );
2482 assert!(
2483 error_message(&invalid).contains("llm client primary max_retries must be at most 10")
2484 );
2485 }
2486
2487 #[test]
2488 fn api_key_environment_reference_is_validated() {
2489 let missing = VALID_CONFIG.replacen(
2490 "base_url = \"https://example.test/v1\"",
2491 "base_url = \"https://example.test/v1\"\napi_key_env = \"SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET\"",
2492 1,
2493 );
2494 assert!(error_message(&missing).contains("SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET"));
2495
2496 const EMPTY_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_EMPTY_KEY";
2497 unsafe {
2498 std::env::set_var(EMPTY_KEY_ENV, "");
2500 }
2501 let empty = VALID_CONFIG.replacen(
2502 "base_url = \"https://example.test/v1\"",
2503 &format!("base_url = \"https://example.test/v1\"\napi_key_env = \"{EMPTY_KEY_ENV}\""),
2504 1,
2505 );
2506 let message = error_message(&empty);
2507 unsafe {
2508 std::env::remove_var(EMPTY_KEY_ENV);
2509 }
2510 assert!(message.contains("is empty"));
2511
2512 let with_equals = VALID_CONFIG.replacen(
2515 "base_url = \"https://example.test/v1\"",
2516 "base_url = \"https://example.test/v1\"\napi_key_env = \"NVIDIA_API_KEY=DUMMY_SECRET_DO_NOT_LOG\"",
2517 1,
2518 );
2519 let message = error_message(&with_equals);
2520 assert!(message.contains("llm client primary api_key_env must not contain '=' or NUL"));
2521 assert!(!message.contains("DUMMY_SECRET_DO_NOT_LOG"), "{message}");
2522 }
2523
2524 #[cfg(any(unix, windows))]
2526 #[test]
2527 fn invalid_unicode_api_key_is_not_printed() {
2528 const RAW_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_RAW_KEY";
2529 #[cfg(unix)]
2530 let raw = {
2531 use std::os::unix::ffi::OsStringExt;
2532 let mut bytes = b"DUMMY_SECRET_DO_NOT_LOG".to_vec();
2533 bytes.push(0xff);
2534 std::ffi::OsString::from_vec(bytes)
2535 };
2536 #[cfg(windows)]
2537 let raw = {
2538 use std::os::windows::ffi::OsStringExt;
2539 let mut words: Vec<_> = "DUMMY_SECRET_DO_NOT_LOG".encode_utf16().collect();
2540 words.push(0xd800);
2541 std::ffi::OsString::from_wide(&words)
2542 };
2543 unsafe {
2544 std::env::set_var(RAW_KEY_ENV, raw);
2546 }
2547 let result = read_api_key("llm client fixture", RAW_KEY_ENV);
2548 unsafe {
2549 std::env::remove_var(RAW_KEY_ENV);
2550 }
2551 let error = result.expect_err("invalid Unicode credential").to_string();
2552 assert!(error.contains("not valid Unicode"), "{error}");
2553 assert!(!error.contains("DUMMY_SECRET_DO_NOT_LOG"), "{error}");
2554 }
2555
2556 #[test]
2557 fn forward_auth_rejects_conflicting_credentials() {
2558 let competing_auth = VALID_CONFIG.replacen(
2559 "base_url = \"https://example.test/v1\"",
2560 "base_url = \"https://example.test/v1\"\n\
2561 forward_auth = true\n\
2562 api_key_env = \"UNUSED_TEST_KEY\"",
2563 1,
2564 );
2565 assert!(
2566 error_message(&competing_auth).contains("cannot set both forward_auth and api_key_env")
2567 );
2568
2569 let static_auth = VALID_CONFIG.replacen(
2570 "base_url = \"https://example.test\"",
2571 "base_url = \"https://example.test\"\n\
2572 forward_auth = true\n\
2573 extra_headers = { Authorization = \"static-value\" }",
2574 1,
2575 );
2576 assert!(error_message(&static_auth).contains("extra_headers cannot set \"Authorization\""));
2577
2578 let static_beta = static_auth.replace("Authorization", "anthropic-beta");
2579 assert!(
2580 error_message(&static_beta).contains("extra_headers cannot set \"anthropic-beta\"")
2581 );
2582
2583 for header in ["chatgpt-account-id", "x-openai-fedramp"] {
2584 let static_context = VALID_CONFIG.replacen(
2585 "base_url = \"https://example.test/v1\"",
2586 &format!(
2587 "base_url = \"https://example.test/v1\"\n\
2588 forward_auth = true\n\
2589 extra_headers = {{ \"{header}\" = \"static-value\" }}"
2590 ),
2591 1,
2592 );
2593 assert!(
2594 error_message(&static_context)
2595 .contains(&format!("extra_headers cannot set \"{header}\""))
2596 );
2597 }
2598 }
2599
2600 fn mixed_forwarding_config(messages_url: &str) -> String {
2603 format!(
2604 r#"
2605schema_version = 1
2606
2607[llm_clients.responses]
2608format = "openai_responses"
2609base_url = "https://gateway.example.test/v1"
2610forward_auth = true
2611
2612[llm_clients.messages]
2613format = "anthropic_messages"
2614base_url = "{messages_url}"
2615forward_auth = true
2616
2617[targets]
2618gpt = {{ id = "gpt/model", llm_client = "responses" }}
2619claude = {{ id = "claude/model", llm_client = "messages" }}
2620
2621[routes.mixed]
2622id = "switchyard/mixed"
2623type = "random"
2624targets = ["gpt", "claude"]
2625
2626[routes.claude]
2627id = "switchyard/claude"
2628type = "passthrough"
2629target = "claude"
2630"#
2631 )
2632 }
2633
2634 #[test]
2637 fn forwarding_route_mixes_formats_only_on_one_host() -> RunnerResult<()> {
2638 let runner = runner_from_toml(&mixed_forwarding_config("https://gateway.example.test"))?;
2641 let caller_auth = |id| runner.route(id).and_then(Route::caller_auth);
2642 assert_eq!(
2643 caller_auth("switchyard/mixed"),
2644 Some(CallerAuthKind::OpenAi)
2645 );
2646 assert_eq!(
2647 caller_auth("switchyard/claude"),
2648 Some(CallerAuthKind::Anthropic)
2649 );
2650
2651 for other in [
2653 "https://api.anthropic.test",
2654 "https://gateway.example.test:8443",
2655 "http://gateway.example.test",
2656 ] {
2657 let error = error_message(&mixed_forwarding_config(other));
2658 assert!(
2659 error.contains("different origins") && error.contains(other),
2660 "{error}"
2661 );
2662 }
2663 Ok(())
2664 }
2665
2666 const ADVISOR_CONFIG: &str = r#"
2667schema_version = 1
2668
2669[llm_clients.anthropic]
2670format = "anthropic_messages"
2671base_url = "https://example.test"
2672
2673[targets.executor]
2674id = "executor/model"
2675llm_client = "anthropic"
2676
2677[targets.advisor]
2678id = "advisor/model"
2679llm_client = "anthropic"
2680
2681[routes.gated]
2682id = "switchyard/advisor"
2683type = "advisor"
2684executor_target = "executor"
2685advisor_target = "advisor"
2686"#;
2687
2688 #[test]
2689 fn advisor_route_parses_with_defaults_and_builds() -> RunnerResult<()> {
2690 let state = runner_from_toml(ADVISOR_CONFIG)?;
2691 let route = state
2692 .route("switchyard/advisor")
2693 .expect("advisor route should exist");
2694 let models = route.models();
2695 assert_eq!(
2698 models.models_for(&Category::Efficient),
2699 [ModelId::from("executor/model")]
2700 );
2701 assert_eq!(
2702 models.models_for(&Category::Any),
2703 [ModelId::from("executor/model")]
2704 );
2705 assert_eq!(
2706 models.models_for(&Category::Judge),
2707 [ModelId::from("advisor/model")]
2708 );
2709 assert_eq!(
2710 state
2711 .models()
2712 .map(|model| model.id.as_str())
2713 .collect::<Vec<_>>(),
2714 ["switchyard/advisor"]
2715 );
2716 Ok(())
2717 }
2718
2719 #[test]
2720 fn advisor_route_accepts_every_gate_knob() -> RunnerResult<()> {
2721 let tuned = ADVISOR_CONFIG.replace(
2722 "advisor_target = \"advisor\"",
2723 concat!(
2724 "advisor_target = \"advisor\"\n",
2725 "reviewer_system_prompt = \"review it\"\n",
2726 "redo_feedback_prefix = \"REVIEWER SAYS: \"\n",
2727 "gate_trigger = \"pattern\"\n",
2728 "gate_trigger_pattern = 'task_complete[\"\\s>:]*true'\n",
2729 "max_reviews = 2\n",
2730 "gate_stall_turns = 40\n",
2731 "gate_min_tool_results = 1\n",
2732 "advisor_max_tokens = 1024\n",
2733 "advisor_temperature = 0.0\n",
2734 "transcript_max_chars = 100000\n",
2735 "fail_open = false\n",
2736 "context_window = 200000\n",
2737 "tool_calling = true\n",
2738 "reasoning = true",
2739 ),
2740 );
2741 runner_from_toml(&tuned)?;
2742 Ok(())
2743 }
2744
2745 #[test]
2746 fn advisor_route_rejects_unknown_keys() {
2747 let invalid = ADVISOR_CONFIG.replace(
2748 "advisor_target = \"advisor\"",
2749 "advisor_target = \"advisor\"\nbogus_field = 1",
2750 );
2751 assert!(error_message(&invalid).contains("bogus_field"));
2752 }
2753
2754 #[test]
2755 fn advisor_route_requires_both_targets() {
2756 let missing = ADVISOR_CONFIG.replace("advisor_target = \"advisor\"\n", "");
2757 assert!(error_message(&missing).contains("advisor_target"));
2758 }
2759
2760 #[test]
2761 fn advisor_route_rejects_unknown_target() {
2762 let invalid = ADVISOR_CONFIG.replace(
2763 "advisor_target = \"advisor\"",
2764 "advisor_target = \"missing\"",
2765 );
2766 assert!(error_message(&invalid).contains("missing"));
2767 }
2768
2769 #[test]
2770 fn advisor_route_rejects_invalid_pattern() {
2771 let invalid = ADVISOR_CONFIG.replace(
2772 "advisor_target = \"advisor\"",
2773 "advisor_target = \"advisor\"\ngate_trigger = \"pattern\"\ngate_trigger_pattern = \"(unclosed\"",
2774 );
2775 assert!(error_message(&invalid).contains("not a valid regex"));
2776 }
2777
2778 #[test]
2779 fn advisor_route_rejects_pattern_without_pattern_trigger() {
2780 let invalid = ADVISOR_CONFIG.replace(
2781 "advisor_target = \"advisor\"",
2782 "advisor_target = \"advisor\"\ngate_trigger_pattern = \"done\"",
2783 );
2784 assert!(
2785 error_message(&invalid)
2786 .contains("gate_trigger_pattern requires gate_trigger = \"pattern\"")
2787 );
2788 }
2789
2790 #[test]
2791 fn advisor_route_pattern_trigger_requires_pattern() {
2792 let invalid = ADVISOR_CONFIG.replace(
2793 "advisor_target = \"advisor\"",
2794 "advisor_target = \"advisor\"\ngate_trigger = \"pattern\"",
2795 );
2796 assert!(error_message(&invalid).contains("non-empty gate_trigger_pattern"));
2797 }
2798
2799 #[test]
2800 fn advisor_route_rejects_zero_max_reviews() {
2801 let invalid = ADVISOR_CONFIG.replace(
2802 "advisor_target = \"advisor\"",
2803 "advisor_target = \"advisor\"\nmax_reviews = 0",
2804 );
2805 assert!(error_message(&invalid).contains("max_reviews must be at least 1"));
2806 }
2807}