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