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