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