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