1use std::collections::{BTreeMap, HashSet};
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10
11use libsy::{
12 Algorithm, ClassifierContractConfig, CustomClassifierConfig, CustomClassifierPolicy,
13 EscalationJudgeConfig, HandoffNoteConfig, LlmClassifierConfig, LlmFallback, LlmTarget,
14 LlmTargetSet, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random, StageRouter,
15 StageRouterConfig, TargetPrompts, TaskClassifierConfig,
16};
17use serde::Deserialize;
18use serde_json::Value;
19use switchyard_llm_client::{
20 Backend, DEFAULT_MAX_RETRIES, HttpBackendConfig, ModelConfig, TranslatingLlmClient,
21};
22use switchyard_protocol::RoutedLlmClient;
23
24use crate::{CountTokensTarget, ModelCapabilities, ServerError, ServerResult, ServerState};
25
26const SUPPORTED_SCHEMA_VERSION: u32 = 1;
27const MAX_CONFIGURED_RETRIES: u32 = 10;
28
29pub fn load_server_state(path: impl AsRef<Path>) -> ServerResult<ServerState> {
31 let path = path.as_ref();
32 let toml = fs::read_to_string(path).map_err(|error| {
33 ServerError::new(format!(
34 "failed to read server config {}: {error}",
35 path.display()
36 ))
37 })?;
38 server_state_from_toml(&toml).map_err(|error| {
39 ServerError::new(format!("invalid server config {}: {error}", path.display()))
40 })
41}
42
43fn server_state_from_toml(toml: &str) -> ServerResult<ServerState> {
44 let config: ServerConfig = toml::from_str(toml)
45 .map_err(|error| ServerError::new(format!("failed to parse TOML: {error}")))?;
46 config.build()
47}
48
49#[derive(Debug, Deserialize)]
50#[serde(deny_unknown_fields)]
51struct ServerConfig {
52 schema_version: u32,
53 #[serde(default)]
54 llm_clients: BTreeMap<String, LlmClientConfig>,
55 targets: BTreeMap<String, TargetConfig>,
56 routes: BTreeMap<String, RouteConfig>,
57}
58
59impl ServerConfig {
60 fn build(&self) -> ServerResult<ServerState> {
61 if self.schema_version != SUPPORTED_SCHEMA_VERSION {
62 return Err(ServerError::new(format!(
63 "unsupported schema_version {}; expected {SUPPORTED_SCHEMA_VERSION}",
64 self.schema_version
65 )));
66 }
67
68 let mut seen_client_model_ids = HashSet::new();
76 for (target_name, target) in &self.targets {
77 validate_value("target name", target_name)?;
78 validate_value(&format!("target {target_name} id"), &target.id)?;
79 if !seen_client_model_ids.insert((target.llm_client.as_str(), target.id.as_str())) {
80 tracing::warn!(
81 "target {target_name} reuses model id {} on llm client {}; only one target per id is kept and the other is dropped. Give each target a unique model id, or point both routes at one target.",
82 target.id,
83 target.llm_client
84 );
85 }
86 }
87
88 let clients = self.build_clients()?;
89 let targets = self.build_targets(&clients)?;
90 let mut routes = Vec::with_capacity(self.routes.len());
91 for (route_name, config) in &self.routes {
92 validate_value("route name", route_name)?;
93 validate_value(&format!("route {route_name} id"), config.id())?;
94 let capabilities = config.capabilities();
95 if capabilities.context_window == Some(0) {
96 return Err(ServerError::new(format!(
97 "route {route_name} context_window must be greater than zero"
98 )));
99 }
100 let algorithm = build_algorithm(route_name, config, &targets)?;
101 let count_tokens_target = self.build_count_tokens_target(config, &clients);
102 routes.push((
103 config.id().to_string(),
104 algorithm,
105 capabilities,
106 count_tokens_target,
107 ));
108 }
109 ServerState::new_with_capabilities(routes)
110 }
111
112 fn build_clients(&self) -> ServerResult<BTreeMap<String, Arc<TranslatingLlmClient>>> {
113 let mut models_by_client = self
114 .llm_clients
115 .keys()
116 .map(|name| (name.clone(), Vec::new()))
117 .collect::<BTreeMap<String, Vec<ModelConfig>>>();
118
119 for name in self.llm_clients.keys() {
120 validate_value("llm client name", name)?;
121 }
122 for (target_name, target) in &self.targets {
123 let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| {
124 ServerError::new(format!(
125 "target {target_name} references unknown llm client {}",
126 target.llm_client
127 ))
128 })?;
129 let model_configs = models_by_client
130 .get_mut(&target.llm_client)
131 .ok_or_else(|| ServerError::new("validated llm client was not initialized"))?;
132 model_configs.push(ModelConfig::new(
133 &target.id,
134 build_backend(&target.llm_client, client_config, &target.extra_body)?,
135 None,
136 ));
137 }
138
139 let mut clients = BTreeMap::new();
140 for (name, model_configs) in models_by_client {
141 let client = Arc::new(
142 TranslatingLlmClient::new(&model_configs)
143 .map_err(|error| ServerError::new(error.to_string()))?,
144 );
145 clients.insert(name, client);
146 }
147 Ok(clients)
148 }
149
150 fn build_targets(
151 &self,
152 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
153 ) -> ServerResult<BTreeMap<String, LlmTarget>> {
154 self.targets
155 .iter()
156 .map(|(name, config)| {
157 let client = clients.get(&config.llm_client).ok_or_else(|| {
158 ServerError::new(format!("target {name} has no constructed llm client"))
159 })?;
160 let client: Arc<dyn RoutedLlmClient> = client.clone();
161 Ok((
162 name.clone(),
163 LlmTarget {
164 semantic_name: config.id.clone(),
165 llm_client: Some(client),
166 },
167 ))
168 })
169 .collect()
170 }
171
172 fn build_count_tokens_target(
173 &self,
174 route_config: &RouteConfig,
175 clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
176 ) -> Option<CountTokensTarget> {
177 route_config
178 .routing_target_names()
179 .into_iter()
180 .enumerate()
181 .filter_map(|(index, name)| {
182 let target = self.targets.get(name)?;
183 let client = clients.get(&target.llm_client)?;
184 client.supports_count_tokens(&target.id).then_some((
185 count_tokens_priority(name, &target.id),
186 index,
187 target,
188 client,
189 ))
190 })
191 .min_by_key(|(priority, index, _, _)| (*priority, *index))
192 .map(|(_, _, target, client)| CountTokensTarget {
193 model: target.id.clone(),
194 client: client.clone(),
195 })
196 }
197}
198
199fn count_tokens_priority(target_name: &str, model_id: &str) -> usize {
201 let target_name = target_name.to_ascii_lowercase();
202 let model_id = model_id.to_ascii_lowercase();
203 ["opus", "sonnet", "haiku"]
204 .iter()
205 .position(|hint| target_name.contains(hint) || model_id.contains(hint))
206 .unwrap_or(3)
207}
208
209#[derive(Debug, Deserialize)]
210#[serde(deny_unknown_fields)]
211struct LlmClientConfig {
212 format: ClientFormat,
213 base_url: String,
214 api_key_env: Option<String>,
215 #[serde(default)]
216 extra_headers: BTreeMap<String, String>,
217 #[serde(default = "default_max_retries")]
218 max_retries: u32,
219}
220
221#[derive(Debug, Deserialize)]
222#[serde(deny_unknown_fields)]
223struct TargetConfig {
224 id: String,
225 llm_client: String,
226 #[serde(default)]
227 extra_body: BTreeMap<String, Value>,
228}
229
230#[derive(Clone, Copy, Debug, Deserialize)]
231enum ClientFormat {
232 #[serde(rename = "openai_chat")]
233 OpenAiChat,
234 #[serde(rename = "openai_responses")]
235 OpenAiResponses,
236 #[serde(rename = "anthropic_messages")]
237 AnthropicMessages,
238}
239
240#[derive(Clone, Debug, Deserialize)]
241#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
242enum ClassifierPolicyConfig {
243 TargetSelector { selector: String },
244}
245
246#[derive(Clone, Copy, Debug, Deserialize)]
247#[serde(rename_all = "snake_case")]
248enum ClassifierMode {
249 Capability,
250 Escalation,
251 Custom,
252}
253
254impl ClassifierPolicyConfig {
255 fn into_libsy(self) -> CustomClassifierPolicy {
256 match self {
257 Self::TargetSelector { selector } => CustomClassifierPolicy::target_selector(selector),
258 }
259 }
260}
261
262#[derive(Debug)]
263enum LlmClassifierModeConfig {
264 Capability(CapabilityClassifierRouteConfig),
265 Escalation(EscalationClassifierRouteConfig),
266 Custom(CustomClassifierRouteConfig),
267}
268
269#[derive(Debug)]
270struct CapabilityClassifierRouteConfig {
271 strong_target: String,
272 weak_target: String,
273 base_threshold: f64,
274 threshold_step: f64,
275 session_affinity: bool,
276 message_hash_fallback: bool,
277 recent_turn_window: Option<usize>,
278 prompt: Option<String>,
279 max_output_tokens: u64,
280}
281
282#[derive(Debug)]
283struct EscalationClassifierRouteConfig {
284 strong_target: String,
285 weak_target: String,
286 prompt: Option<String>,
287 max_output_tokens: u64,
288 judge: EscalationJudgeConfig,
289}
290
291#[derive(Debug)]
292struct CustomClassifierRouteConfig {
293 targets: Vec<String>,
294 default_target: String,
295 prompt: String,
296 response_schema: String,
297 policy: ClassifierPolicyConfig,
298 session_affinity: bool,
299 message_hash_fallback: bool,
300 recent_turn_window: Option<usize>,
301 max_output_tokens: u64,
302}
303
304#[derive(Debug, Deserialize)]
305#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
306enum RouteConfig {
307 Noop {
308 id: String,
309 #[serde(default)]
310 context_window: Option<u32>,
311 #[serde(default)]
312 tool_calling: Option<bool>,
313 #[serde(default)]
314 reasoning: Option<bool>,
315 },
316 Random {
317 id: String,
318 #[serde(default)]
319 context_window: Option<u32>,
320 #[serde(default)]
321 tool_calling: Option<bool>,
322 #[serde(default)]
323 reasoning: Option<bool>,
324 targets: Vec<String>,
325 weights: Option<Vec<f64>>,
326 seed: Option<u64>,
327 },
328 Passthrough {
329 id: String,
330 #[serde(default)]
331 context_window: Option<u32>,
332 #[serde(default)]
333 tool_calling: Option<bool>,
334 #[serde(default)]
335 reasoning: Option<bool>,
336 target: String,
337 },
338 LlmClassifier {
339 id: String,
340 #[serde(default)]
341 context_window: Option<u32>,
342 #[serde(default)]
343 tool_calling: Option<bool>,
344 #[serde(default)]
345 reasoning: Option<bool>,
346 classifier_target: String,
347 #[serde(default)]
348 mode: Option<ClassifierMode>,
349 #[serde(default)]
350 strong_target: Option<String>,
351 #[serde(default)]
352 weak_target: Option<String>,
353 #[serde(default)]
354 base_threshold: Option<f64>,
355 #[serde(default)]
356 threshold_step: Option<f64>,
357 #[serde(default)]
358 session_affinity: bool,
359 #[serde(default)]
360 message_hash_fallback: bool,
361 #[serde(default)]
362 recent_turn_window: Option<usize>,
363 #[serde(default)]
364 prompt: Option<String>,
365 #[serde(default = "default_classifier_max_output_tokens")]
366 max_output_tokens: u64,
367 #[serde(default)]
368 escalation: Option<EscalationJudgeConfig>,
369 #[serde(default)]
370 targets: Option<Vec<String>>,
371 #[serde(default)]
372 default_target: Option<String>,
373 #[serde(default)]
374 response_schema: Option<String>,
375 #[serde(default)]
376 policy: Option<ClassifierPolicyConfig>,
377 },
378 StageRouter {
379 id: String,
380 #[serde(default)]
381 context_window: Option<u32>,
382 #[serde(default)]
383 tool_calling: Option<bool>,
384 #[serde(default)]
385 reasoning: Option<bool>,
386 capable_target: String,
387 efficient_target: String,
388 picker: PickerMode,
390 confidence_threshold: f64,
391 #[serde(default)]
393 recent_turn_window: Option<usize>,
394 #[serde(default)]
396 handoff_notes: Option<HandoffNoteConfig>,
397 #[serde(default)]
399 capable_system_prompt: Option<String>,
400 #[serde(default)]
401 efficient_system_prompt: Option<String>,
402 #[serde(default)]
404 classifier: Option<StageClassifierConfig>,
405 },
406}
407
408#[derive(Debug, Deserialize)]
410#[serde(deny_unknown_fields)]
411struct StageClassifierConfig {
412 target: String,
414 base_threshold: f64,
415 #[serde(default)]
416 threshold_step: f64,
417 #[serde(default)]
418 session_affinity: bool,
419 #[serde(default)]
420 message_hash_fallback: bool,
421 #[serde(default)]
422 recent_turn_window: Option<usize>,
423 #[serde(default)]
424 prompt: Option<String>,
425 #[serde(default = "default_classifier_max_output_tokens")]
426 max_output_tokens: u64,
427}
428
429impl StageClassifierConfig {
430 fn task_classifier_config(&self) -> TaskClassifierConfig {
431 TaskClassifierConfig {
432 base_threshold: self.base_threshold,
433 threshold_step: self.threshold_step,
434 session_affinity: self.session_affinity,
435 message_hash_fallback: self.message_hash_fallback,
436 recent_turn_window: self.recent_turn_window,
437 contract: classifier_contract(self.prompt.as_deref()),
438 max_output_tokens: self.max_output_tokens,
439 }
440 }
441}
442
443impl RouteConfig {
444 fn id(&self) -> &str {
445 use RouteConfig::*;
446 match self {
447 Noop { id, .. }
448 | Random { id, .. }
449 | LlmClassifier { id, .. }
450 | Passthrough { id, .. }
451 | StageRouter { id, .. } => id,
452 }
453 }
454
455 fn routing_target_names(&self) -> Vec<&str> {
457 match self {
458 Self::Noop { .. } => Vec::new(),
459 Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
460 Self::Passthrough { target, .. } => vec![target],
461 Self::LlmClassifier {
462 mode,
463 strong_target,
464 weak_target,
465 escalation,
466 targets,
467 ..
468 } => match mode.unwrap_or(if escalation.is_some() {
469 ClassifierMode::Escalation
470 } else {
471 ClassifierMode::Capability
472 }) {
473 ClassifierMode::Capability => weak_target
474 .iter()
475 .chain(strong_target)
476 .map(String::as_str)
477 .collect(),
478 ClassifierMode::Escalation => strong_target
479 .iter()
480 .chain(weak_target)
481 .map(String::as_str)
482 .collect(),
483 ClassifierMode::Custom => targets.iter().flatten().map(String::as_str).collect(),
484 },
485 Self::StageRouter {
486 capable_target,
487 efficient_target,
488 ..
489 } => vec![capable_target, efficient_target],
490 }
491 }
492
493 fn capabilities(&self) -> ModelCapabilities {
494 use RouteConfig::*;
495 match self {
496 Noop {
497 context_window,
498 tool_calling,
499 reasoning,
500 ..
501 }
502 | Random {
503 context_window,
504 tool_calling,
505 reasoning,
506 ..
507 }
508 | Passthrough {
509 context_window,
510 tool_calling,
511 reasoning,
512 ..
513 }
514 | LlmClassifier {
515 context_window,
516 tool_calling,
517 reasoning,
518 ..
519 }
520 | StageRouter {
521 context_window,
522 tool_calling,
523 reasoning,
524 ..
525 } => ModelCapabilities {
526 context_window: *context_window,
527 tool_calling: *tool_calling,
528 reasoning: *reasoning,
529 },
530 }
531 }
532
533 fn classifier_mode(&self, route_name: &str) -> ServerResult<LlmClassifierModeConfig> {
534 let Self::LlmClassifier {
535 mode,
536 strong_target,
537 weak_target,
538 base_threshold,
539 threshold_step,
540 session_affinity,
541 message_hash_fallback,
542 recent_turn_window,
543 prompt,
544 max_output_tokens,
545 escalation,
546 targets,
547 default_target,
548 response_schema,
549 policy,
550 ..
551 } = self
552 else {
553 return Err(ServerError::new("route is not an llm_classifier"));
554 };
555
556 let selected_mode = match (mode, escalation.is_some()) {
557 (Some(mode), _) => *mode,
558 (None, true) => ClassifierMode::Escalation,
559 (None, false) => ClassifierMode::Capability,
560 };
561
562 match selected_mode {
563 ClassifierMode::Capability => {
564 if escalation.is_some() {
565 return Err(classifier_field_error(
566 route_name,
567 "escalation",
568 "capability",
569 ));
570 }
571 reject_custom_fields(
572 route_name,
573 "capability",
574 targets,
575 default_target,
576 response_schema,
577 policy,
578 )?;
579 Ok(LlmClassifierModeConfig::Capability(
580 CapabilityClassifierRouteConfig {
581 strong_target: required_classifier_field(
582 route_name,
583 "strong_target",
584 strong_target,
585 )?,
586 weak_target: required_classifier_field(
587 route_name,
588 "weak_target",
589 weak_target,
590 )?,
591 base_threshold: required_classifier_field(
592 route_name,
593 "base_threshold",
594 base_threshold,
595 )?,
596 threshold_step: threshold_step.unwrap_or_default(),
597 session_affinity: *session_affinity,
598 message_hash_fallback: *message_hash_fallback,
599 recent_turn_window: *recent_turn_window,
600 prompt: prompt.clone(),
601 max_output_tokens: *max_output_tokens,
602 },
603 ))
604 }
605 ClassifierMode::Escalation => {
606 reject_custom_fields(
607 route_name,
608 "escalation",
609 targets,
610 default_target,
611 response_schema,
612 policy,
613 )?;
614 if mode.is_some()
615 && (base_threshold.is_some()
616 || threshold_step.is_some()
617 || *session_affinity
618 || *message_hash_fallback
619 || recent_turn_window.is_some())
620 {
621 return Err(ServerError::new(format!(
622 "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
623 )));
624 }
625 Ok(LlmClassifierModeConfig::Escalation(
626 EscalationClassifierRouteConfig {
627 strong_target: required_classifier_field(
628 route_name,
629 "strong_target",
630 strong_target,
631 )?,
632 weak_target: required_classifier_field(
633 route_name,
634 "weak_target",
635 weak_target,
636 )?,
637 prompt: prompt.clone(),
638 max_output_tokens: *max_output_tokens,
639 judge: required_classifier_field(route_name, "escalation", escalation)?,
640 },
641 ))
642 }
643 ClassifierMode::Custom => {
644 if strong_target.is_some()
645 || weak_target.is_some()
646 || base_threshold.is_some()
647 || threshold_step.is_some()
648 || escalation.is_some()
649 {
650 return Err(ServerError::new(format!(
651 "llm_classifier route {route_name} mode custom cannot use capability or escalation fields"
652 )));
653 }
654 Ok(LlmClassifierModeConfig::Custom(
655 CustomClassifierRouteConfig {
656 targets: required_classifier_field(route_name, "targets", targets)?,
657 default_target: required_classifier_field(
658 route_name,
659 "default_target",
660 default_target,
661 )?,
662 prompt: required_classifier_field(route_name, "prompt", prompt)?,
663 response_schema: required_classifier_field(
664 route_name,
665 "response_schema",
666 response_schema,
667 )?,
668 policy: required_classifier_field(route_name, "policy", policy)?,
669 session_affinity: *session_affinity,
670 message_hash_fallback: *message_hash_fallback,
671 recent_turn_window: *recent_turn_window,
672 max_output_tokens: *max_output_tokens,
673 },
674 ))
675 }
676 }
677 }
678}
679
680fn reject_custom_fields(
681 route_name: &str,
682 mode: &str,
683 targets: &Option<Vec<String>>,
684 default_target: &Option<String>,
685 response_schema: &Option<String>,
686 policy: &Option<ClassifierPolicyConfig>,
687) -> ServerResult<()> {
688 if targets.is_some()
689 || default_target.is_some()
690 || response_schema.is_some()
691 || policy.is_some()
692 {
693 return Err(ServerError::new(format!(
694 "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
695 )));
696 }
697 Ok(())
698}
699
700fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> ServerError {
701 ServerError::new(format!(
702 "llm_classifier route {route_name} mode {mode} cannot use {field}"
703 ))
704}
705
706fn required_classifier_field<T: Clone>(
707 route_name: &str,
708 field: &str,
709 value: &Option<T>,
710) -> ServerResult<T> {
711 value.clone().ok_or_else(|| {
712 ServerError::new(format!(
713 "llm_classifier route {route_name} requires {field}"
714 ))
715 })
716}
717
718fn build_backend(
719 client_name: &str,
720 config: &LlmClientConfig,
721 extra_body: &BTreeMap<String, Value>,
722) -> ServerResult<Backend> {
723 let base_url = config.base_url.trim();
724 if base_url.is_empty() {
725 return Err(ServerError::new(format!(
726 "llm client {client_name} base_url must not be empty"
727 )));
728 }
729 if config.max_retries > MAX_CONFIGURED_RETRIES {
730 return Err(ServerError::new(format!(
731 "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}"
732 )));
733 }
734 let api_key = config
735 .api_key_env
736 .as_deref()
737 .map(|variable| {
738 if variable.trim().is_empty() {
739 return Err(ServerError::new(format!(
740 "llm client {client_name} api_key_env must not be empty"
741 )));
742 }
743 let api_key = std::env::var(variable).map_err(|error| {
744 ServerError::new(format!(
745 "llm client {client_name} could not read api_key_env {variable}: {error}"
746 ))
747 })?;
748 if api_key.trim().is_empty() {
749 return Err(ServerError::new(format!(
750 "llm client {client_name} api_key_env {variable} is empty"
751 )));
752 }
753 Ok(api_key)
754 })
755 .transpose()?;
756 let http = HttpBackendConfig {
757 base_url: base_url.to_string(),
758 api_key,
759 extra_headers: config.extra_headers.clone(),
760 extra_body: extra_body.clone(),
761 max_retries: config.max_retries,
762 };
763 Ok(match config.format {
764 ClientFormat::OpenAiChat => Backend::OpenAiChat(http),
765 ClientFormat::OpenAiResponses => Backend::OpenAiResponses(http),
766 ClientFormat::AnthropicMessages => Backend::Anthropic(http),
767 })
768}
769
770const fn default_max_retries() -> u32 {
771 DEFAULT_MAX_RETRIES
772}
773
774fn build_algorithm(
775 route_name: &str,
776 config: &RouteConfig,
777 targets: &BTreeMap<String, LlmTarget>,
778) -> ServerResult<Arc<dyn Algorithm>> {
779 match config {
780 RouteConfig::Noop { .. } => Ok(Arc::new(Noop {})),
781 RouteConfig::Random {
782 targets: names,
783 weights,
784 seed,
785 ..
786 } => {
787 let target_set =
788 resolve_targets(route_name, names.iter().map(String::as_str), targets)?;
789 let algorithm = Random::new(target_set, weights.clone(), *seed)
790 .map_err(|error| ServerError::new(format!("random route {route_name}: {error}")))?;
791 Ok(Arc::new(algorithm))
792 }
793 RouteConfig::Passthrough { target, .. } => {
794 let target = resolve_target(route_name, target, targets)?;
795 Ok(Arc::new(Passthrough::new(target)))
796 }
797 RouteConfig::LlmClassifier {
798 classifier_target, ..
799 } => {
800 let classifier = resolve_target(route_name, classifier_target, targets)?;
801 let mode = config.classifier_mode(route_name)?;
802 let algorithm = match mode {
803 LlmClassifierModeConfig::Capability(config) => {
804 let strong = resolve_target(route_name, &config.strong_target, targets)?;
805 let weak = resolve_target(route_name, &config.weak_target, targets)?;
806 let classifier_config = TaskClassifierConfig {
807 base_threshold: config.base_threshold,
808 threshold_step: config.threshold_step,
809 session_affinity: config.session_affinity,
810 message_hash_fallback: config.message_hash_fallback,
811 recent_turn_window: config.recent_turn_window,
812 contract: classifier_contract(config.prompt.as_deref()),
813 max_output_tokens: config.max_output_tokens,
814 };
815 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
816 judge_target: classifier,
817 efficient_target: weak,
818 capable_target: strong,
819 config: classifier_config,
820 })
821 }
822 LlmClassifierModeConfig::Escalation(config) => {
823 let strong = resolve_target(route_name, &config.strong_target, targets)?;
824 let weak = resolve_target(route_name, &config.weak_target, targets)?;
825 LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
826 judge_target: classifier,
827 efficient_target: weak,
828 capable_target: strong,
829 contract: classifier_contract(config.prompt.as_deref()),
830 config: config.judge,
831 max_output_tokens: config.max_output_tokens,
832 })
833 }
834 LlmClassifierModeConfig::Custom(config) => {
835 let resolved_targets = config
836 .targets
837 .iter()
838 .map(|name| {
839 resolve_target(route_name, name, targets)
840 .map(|target| (name.clone(), target))
841 })
842 .collect::<ServerResult<Vec<_>>>()?;
843 let response_schema = serde_json::from_str(&config.response_schema).map_err(
844 |error| {
845 ServerError::new(format!(
846 "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
847 ))
848 },
849 )?;
850 let mut classifier_config = CustomClassifierConfig::new(
851 config.prompt,
852 response_schema,
853 config.policy.into_libsy(),
854 );
855 classifier_config.session_affinity = config.session_affinity;
856 classifier_config.message_hash_fallback = config.message_hash_fallback;
857 classifier_config.recent_turn_window = config.recent_turn_window;
858 classifier_config.max_output_tokens = config.max_output_tokens;
859 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
860 judge_target: classifier,
861 targets: resolved_targets,
862 default_target: config.default_target,
863 config: classifier_config,
864 })
865 }
866 }
867 .map_err(|error| {
868 ServerError::new(format!("llm_classifier route {route_name}: {error}"))
869 })?;
870 Ok(Arc::new(algorithm))
871 }
872 RouteConfig::StageRouter {
873 capable_target,
874 efficient_target,
875 picker,
876 confidence_threshold,
877 recent_turn_window,
878 handoff_notes,
879 capable_system_prompt,
880 efficient_system_prompt,
881 classifier,
882 ..
883 } => {
884 let capable = resolve_target(route_name, capable_target, targets)?;
885 let efficient = resolve_target(route_name, efficient_target, targets)?;
886 let mut config = StageRouterConfig::new(*picker, *confidence_threshold);
887 config.recent_window = *recent_turn_window;
888 config.handoff_notes = handoff_notes.clone();
889 config.tier_prompts = tier_prompts(
890 &capable.semantic_name,
891 capable_system_prompt.as_deref(),
892 &efficient.semantic_name,
893 efficient_system_prompt.as_deref(),
894 );
895 config.llm_fallback = classifier
898 .as_ref()
899 .map(|classifier| {
900 resolve_target(route_name, &classifier.target, targets).map(|judge_target| {
901 LlmFallback {
902 judge_target,
903 config: classifier.task_classifier_config(),
904 }
905 })
906 })
907 .transpose()?;
908 let algorithm = StageRouter::new(capable, efficient, config).map_err(|error| {
909 ServerError::new(format!("stage_router route {route_name}: {error}"))
910 })?;
911 Ok(Arc::new(algorithm))
912 }
913 }
914}
915
916fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
917 prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
918 ClassifierContractConfig::default().with_prompt(prompt)
919 })
920}
921
922fn default_classifier_max_output_tokens() -> u64 {
923 TaskClassifierConfig::default().max_output_tokens
924}
925
926fn tier_prompts(
928 capable: &str,
929 capable_prompt: Option<&str>,
930 efficient: &str,
931 efficient_prompt: Option<&str>,
932) -> TargetPrompts {
933 let mut prompts = TargetPrompts::default();
934 if let Some(prompt) = capable_prompt {
935 prompts = prompts.with(capable, prompt);
936 }
937 if let Some(prompt) = efficient_prompt {
938 prompts = prompts.with(efficient, prompt);
939 }
940 prompts
941}
942
943fn resolve_targets<'a>(
944 route_name: &str,
945 names: impl IntoIterator<Item = &'a str>,
946 targets: &BTreeMap<String, LlmTarget>,
947) -> ServerResult<LlmTargetSet> {
948 let resolved = names
949 .into_iter()
950 .map(|name| resolve_target(route_name, name, targets))
951 .collect::<ServerResult<Vec<_>>>()?;
952 Ok(LlmTargetSet::new(resolved))
953}
954
955fn resolve_target(
956 route_name: &str,
957 name: &str,
958 targets: &BTreeMap<String, LlmTarget>,
959) -> ServerResult<LlmTarget> {
960 targets.get(name).cloned().ok_or_else(|| {
961 ServerError::new(format!(
962 "route {route_name} references unknown target {name}"
963 ))
964 })
965}
966
967fn validate_value(label: &str, value: &str) -> ServerResult<()> {
968 if value.trim().is_empty() || value.trim() != value {
969 return Err(ServerError::new(format!(
970 "{label} must be non-empty and have no surrounding whitespace"
971 )));
972 }
973 Ok(())
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979 use serde_json::json;
980
981 const VALID_CONFIG: &str = r#"
982schema_version = 1
983
984[llm_clients.primary]
985format = "openai_chat"
986base_url = "https://example.test/v1"
987
988[llm_clients.responses]
989format = "openai_responses"
990base_url = "https://example.test/v1"
991
992[llm_clients.anthropic]
993format = "anthropic_messages"
994base_url = "https://example.test"
995
996[targets.classifier]
997id = "classifier/model"
998llm_client = "primary"
999
1000[targets.strong]
1001id = "strong/model"
1002llm_client = "responses"
1003
1004[targets.weak]
1005id = "weak/model"
1006llm_client = "anthropic"
1007
1008[routes.noop]
1009id = "switchyard/noop"
1010type = "noop"
1011
1012[routes.random]
1013id = "switchyard/random"
1014type = "random"
1015targets = ["strong", "weak"]
1016
1017[routes.classifier]
1018id = "switchyard/classifier"
1019type = "llm_classifier"
1020classifier_target = "classifier"
1021strong_target = "strong"
1022weak_target = "weak"
1023base_threshold = 0.5
1024
1025[routes.passthrough]
1026id = "switchyard/passthrough"
1027type = "passthrough"
1028target = "weak"
1029"#;
1030
1031 fn error_message(toml: &str) -> String {
1032 match server_state_from_toml(toml) {
1033 Ok(_) => "configuration unexpectedly succeeded".to_string(),
1034 Err(error) => error.to_string(),
1035 }
1036 }
1037
1038 #[test]
1039 fn builds_all_supported_algorithm_types() -> ServerResult<()> {
1040 let state = server_state_from_toml(VALID_CONFIG)?;
1041 assert_eq!(
1043 state.models().collect::<Vec<_>>(),
1044 [
1045 "switchyard/classifier",
1046 "switchyard/noop",
1047 "switchyard/passthrough",
1048 "switchyard/random",
1049 ]
1050 );
1051 Ok(())
1052 }
1053
1054 #[test]
1055 fn an_escalation_table_switches_the_classifier_route_to_escalation() -> ServerResult<()> {
1056 let escalating = VALID_CONFIG.replace(
1060 "base_threshold = 0.5",
1061 "base_threshold = 0.5\nescalation = { confirmations = 2 }",
1062 );
1063 server_state_from_toml(&escalating)?;
1064
1065 let starved = VALID_CONFIG.replace(
1068 "base_threshold = 0.5",
1069 "base_threshold = 0.5\nescalation = { confirmations = 0 }",
1070 );
1071 assert!(error_message(&starved).contains("confirmations must be at least 1"));
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn classifier_judge_completion_caps_are_configurable() -> ServerResult<()> {
1077 let capability = VALID_CONFIG.replace(
1078 "base_threshold = 0.5",
1079 "base_threshold = 0.5\nmax_output_tokens = 512",
1080 );
1081 server_state_from_toml(&capability)?;
1082
1083 let escalation = VALID_CONFIG.replace(
1084 "base_threshold = 0.5",
1085 "base_threshold = 0.5\nmax_output_tokens = 256\nescalation = { confirmations = 2 }",
1086 );
1087 server_state_from_toml(&escalation)?;
1088 Ok(())
1089 }
1090
1091 #[test]
1092 fn classifier_prompts_are_configurable_in_both_modes() -> ServerResult<()> {
1093 let capability = VALID_CONFIG.replace(
1094 "base_threshold = 0.5",
1095 "base_threshold = 0.5\nprompt = \"custom capability rubric\"",
1096 );
1097 server_state_from_toml(&capability)?;
1098
1099 let escalation = VALID_CONFIG.replace(
1100 "base_threshold = 0.5",
1101 "base_threshold = 0.5\nprompt = \"custom trajectory rubric\"\nescalation = { confirmations = 2 }",
1102 );
1103 server_state_from_toml(&escalation)?;
1104
1105 let empty = VALID_CONFIG.replace(
1106 "base_threshold = 0.5",
1107 "base_threshold = 0.5\nprompt = \" \"",
1108 );
1109 assert!(error_message(&empty).contains("classifier prompt must not be empty"));
1110
1111 let schema_placeholder = VALID_CONFIG.replace(
1112 "base_threshold = 0.5",
1113 "base_threshold = 0.5\nprompt = \"{{RESPONSE_SCHEMA}}\"",
1114 );
1115 assert!(error_message(&schema_placeholder).contains("schema is sent separately"));
1116 Ok(())
1117 }
1118
1119 #[test]
1120 fn mode_custom_rejects_capability_fields() {
1121 let mixed = VALID_CONFIG.replace(
1122 "base_threshold = 0.5",
1123 "mode = \"custom\"\nbase_threshold = 0.5",
1124 );
1125
1126 assert!(
1127 error_message(&mixed)
1128 .contains("mode custom cannot use capability or escalation fields")
1129 );
1130 }
1131
1132 #[test]
1133 fn rejects_unknown_fields_and_algorithm_types() {
1134 let unknown_field =
1135 VALID_CONFIG.replace("schema_version = 1", "schema_version = 1\nmagic = true");
1136 assert!(error_message(&unknown_field).contains("unknown field"));
1137
1138 let nested_completion_cap = VALID_CONFIG.replace(
1139 "base_threshold = 0.5",
1140 "base_threshold = 0.5\nescalation = { max_output_tokens = 256 }",
1141 );
1142 assert!(error_message(&nested_completion_cap).contains("unknown field"));
1143
1144 let unknown_classifier_field = VALID_CONFIG.replace(
1145 "base_threshold = 0.5",
1146 "base_threshold = 0.5\nclassifier_magic = true",
1147 );
1148 assert!(error_message(&unknown_classifier_field).contains("unknown field"));
1149
1150 let target_capability = VALID_CONFIG.replace(
1151 "llm_client = \"responses\"",
1152 "llm_client = \"responses\"\ncontext_window = 1000000",
1153 );
1154 assert!(error_message(&target_capability).contains("unknown field `context_window`"));
1155
1156 let unknown_algorithm = VALID_CONFIG.replace("type = \"noop\"", "type = \"imaginary\"");
1157 assert!(error_message(&unknown_algorithm).contains("unknown variant"));
1158 }
1159
1160 #[test]
1161 fn rejects_unknown_stage_classifier_fields() {
1162 let config = format!(
1164 r#"{VALID_CONFIG}
1165
1166[routes.stage]
1167id = "switchyard/stage"
1168type = "stage_router"
1169capable_target = "strong"
1170efficient_target = "weak"
1171picker = "efficient_first"
1172confidence_threshold = 1.0
1173
1174[routes.stage.classifier]
1175target = "classifier"
1176base_threshold = 0.5
1177classifier_magic = true
1178"#
1179 );
1180
1181 let error = error_message(&config);
1182 assert!(
1183 error.contains("unknown field `classifier_magic`"),
1184 "{error}"
1185 );
1186 }
1187
1188 #[test]
1189 fn rejects_invalid_references_and_parameters() {
1190 let cases = [
1191 (
1192 VALID_CONFIG.replace("llm_client = \"primary\"", "llm_client = \"missing\""),
1193 "unknown llm client missing",
1194 ),
1195 (
1196 VALID_CONFIG.replace(
1197 "targets = [\"strong\", \"weak\"]",
1198 "targets = [\"strong\", \"missing\"]",
1199 ),
1200 "unknown target missing",
1201 ),
1202 (
1203 VALID_CONFIG.replace(
1204 "targets = [\"strong\", \"weak\"]",
1205 "targets = [\"strong\", \"strong\"]",
1206 ),
1207 "random targets must be unique",
1208 ),
1209 (
1210 VALID_CONFIG.replace(
1211 "targets = [\"strong\", \"weak\"]",
1212 "targets = [\"strong\", \"weak\"]\nweights = [1]",
1213 ),
1214 "expected 2 weights, got 1",
1215 ),
1216 (
1217 VALID_CONFIG.replace(
1218 "targets = [\"strong\", \"weak\"]",
1219 "targets = [\"strong\", \"weak\"]\nweights = [0, 0]",
1220 ),
1221 "at least one weight must be positive",
1222 ),
1223 (
1224 VALID_CONFIG.replace("base_threshold = 0.5", "base_threshold = 1.5"),
1225 "base_threshold must be between 0 and 1",
1226 ),
1227 (
1228 VALID_CONFIG.replace(
1229 "base_threshold = 0.5",
1230 "base_threshold = 0.5\nthreshold_step = -0.1",
1231 ),
1232 "threshold_step must be finite and greater than or equal to 0",
1233 ),
1234 (
1235 VALID_CONFIG.replace(
1236 "base_threshold = 0.5",
1237 "base_threshold = 0.8\nthreshold_step = 0.11",
1238 ),
1239 "base_threshold + 2 * threshold_step must be at most 1",
1240 ),
1241 (
1242 VALID_CONFIG.replace(
1243 "base_threshold = 0.5",
1244 "base_threshold = 0.5\nmax_output_tokens = 0\nescalation = { confirmations = 2 }",
1245 ),
1246 "max_output_tokens must be at least 1",
1247 ),
1248 (
1249 VALID_CONFIG.replace(
1250 "base_threshold = 0.5",
1251 "base_threshold = 0.5\nmessage_hash_fallback = true",
1252 ),
1253 "message_hash_fallback requires session_affinity",
1254 ),
1255 (
1256 VALID_CONFIG.replace("schema_version = 1", "schema_version = 2"),
1257 "unsupported schema_version 2",
1258 ),
1259 (
1260 VALID_CONFIG.replace("[targets.strong]", "[targets.\" strong \"]"),
1261 "target name must be non-empty and have no surrounding whitespace",
1262 ),
1263 (
1264 VALID_CONFIG.replace(
1265 "targets = [\"strong\", \"weak\"]",
1266 "targets = [\"strong\", \"weak\"]\ncontext_window = 0",
1267 ),
1268 "route random context_window must be greater than zero",
1269 ),
1270 ];
1271
1272 for (toml, expected) in cases {
1273 assert!(
1274 error_message(&toml).contains(expected),
1275 "expected error containing {expected}"
1276 );
1277 }
1278 }
1279
1280 #[test]
1281 fn accepts_duplicate_target_model_ids_on_one_client() -> ServerResult<()> {
1282 const SAME_MODEL_TWO_ROUTES: &str = r#"
1287schema_version = 1
1288
1289[llm_clients.primary]
1290format = "openai_chat"
1291base_url = "https://example.test/v1"
1292
1293[targets.fast]
1294id = "gpt-4o"
1295llm_client = "primary"
1296
1297[targets.smart]
1298id = "gpt-4o"
1299llm_client = "primary"
1300
1301[routes.fast]
1302id = "switchyard/fast"
1303type = "passthrough"
1304target = "fast"
1305
1306[routes.smart]
1307id = "switchyard/smart"
1308type = "passthrough"
1309target = "smart"
1310"#;
1311 let state = server_state_from_toml(SAME_MODEL_TWO_ROUTES)?;
1312 assert_eq!(
1313 state.models().collect::<Vec<_>>(),
1314 ["switchyard/fast", "switchyard/smart"]
1315 );
1316 Ok(())
1317 }
1318
1319 #[test]
1320 fn accepts_same_model_id_on_different_llm_clients() -> ServerResult<()> {
1321 const CROSS_PROVIDER: &str = r#"
1325schema_version = 1
1326
1327[llm_clients.openai]
1328format = "openai_chat"
1329base_url = "https://example.test/v1"
1330
1331[llm_clients.azure]
1332format = "openai_chat"
1333base_url = "https://azure.test/v1"
1334
1335[targets.openai]
1336id = "gpt-4o"
1337llm_client = "openai"
1338
1339[targets.azure]
1340id = "gpt-4o"
1341llm_client = "azure"
1342
1343[routes.openai]
1344id = "switchyard/openai-gpt4o"
1345type = "passthrough"
1346target = "openai"
1347
1348[routes.azure]
1349id = "switchyard/azure-gpt4o"
1350type = "passthrough"
1351target = "azure"
1352"#;
1353 server_state_from_toml(CROSS_PROVIDER)?;
1354 Ok(())
1355 }
1356
1357 #[test]
1358 fn accepts_relative_weights_and_seed() -> ServerResult<()> {
1359 let weighted = VALID_CONFIG.replace(
1360 "targets = [\"strong\", \"weak\"]",
1361 "targets = [\"strong\", \"weak\"]\nweights = [1, 3]\nseed = 42",
1362 );
1363 server_state_from_toml(&weighted)?;
1364 Ok(())
1365 }
1366
1367 #[test]
1368 fn accepts_session_affinity_with_message_hash_fallback() -> ServerResult<()> {
1369 let configured = VALID_CONFIG.replace(
1370 "base_threshold = 0.5",
1371 "base_threshold = 0.25\nthreshold_step = 0.1\nsession_affinity = true\nmessage_hash_fallback = true",
1372 );
1373 server_state_from_toml(&configured)?;
1374 Ok(())
1375 }
1376
1377 #[test]
1378 fn target_extra_body_is_parsed_and_applied_to_its_backend() -> ServerResult<()> {
1379 let configured = VALID_CONFIG.replacen(
1380 "llm_client = \"primary\"",
1381 "llm_client = \"primary\"\n\
1382 extra_body = { service_tier = \"priority\", \
1383 chat_template_kwargs = { enable_thinking = false } }",
1384 1,
1385 );
1386 let config: ServerConfig = toml::from_str(&configured)
1387 .map_err(|error| ServerError::new(format!("failed to parse config: {error}")))?;
1388 let Some(target) = config.targets.get("classifier") else {
1389 return Err(ServerError::new("classifier target is missing"));
1390 };
1391 let Some(client) = config.llm_clients.get("primary") else {
1392 return Err(ServerError::new("primary llm client is missing"));
1393 };
1394 let backend = build_backend("primary", client, &target.extra_body)?;
1395
1396 assert_eq!(
1397 backend.extra_body().get("service_tier"),
1398 Some(&json!("priority"))
1399 );
1400 assert_eq!(
1401 backend
1402 .extra_body()
1403 .get("chat_template_kwargs")
1404 .and_then(|value| value.get("enable_thinking")),
1405 Some(&json!(false))
1406 );
1407 Ok(())
1408 }
1409
1410 #[test]
1411 fn retry_budget_defaults_and_accepts_an_override() -> ServerResult<()> {
1412 let default: ServerConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
1413 ServerError::new(format!("failed to parse default config: {error}"))
1414 })?;
1415 let Some(primary) = default.llm_clients.get("primary") else {
1416 return Err(ServerError::new("primary llm client is missing"));
1417 };
1418 assert_eq!(primary.max_retries, DEFAULT_MAX_RETRIES);
1419
1420 let explicit = VALID_CONFIG.replacen(
1421 "base_url = \"https://example.test/v1\"",
1422 "base_url = \"https://example.test/v1\"\nmax_retries = 0",
1423 1,
1424 );
1425 let config: ServerConfig = toml::from_str(&explicit).map_err(|error| {
1426 ServerError::new(format!("failed to parse explicit retry config: {error}"))
1427 })?;
1428 let Some(primary) = config.llm_clients.get("primary") else {
1429 return Err(ServerError::new("primary llm client is missing"));
1430 };
1431 assert_eq!(primary.max_retries, 0);
1432 Ok(())
1433 }
1434
1435 #[test]
1436 fn retry_budget_rejects_negative_values() {
1437 let invalid = VALID_CONFIG.replacen(
1438 "base_url = \"https://example.test/v1\"",
1439 "base_url = \"https://example.test/v1\"\nmax_retries = -1",
1440 1,
1441 );
1442 assert!(error_message(&invalid).contains("max_retries"));
1443 }
1444
1445 #[test]
1446 fn retry_budget_rejects_excessive_values() {
1447 let invalid = VALID_CONFIG.replacen(
1448 "base_url = \"https://example.test/v1\"",
1449 "base_url = \"https://example.test/v1\"\nmax_retries = 11",
1450 1,
1451 );
1452 assert!(
1453 error_message(&invalid).contains("llm client primary max_retries must be at most 10")
1454 );
1455 }
1456
1457 #[test]
1458 fn api_key_environment_reference_is_validated() {
1459 let missing = VALID_CONFIG.replacen(
1460 "base_url = \"https://example.test/v1\"",
1461 "base_url = \"https://example.test/v1\"\napi_key_env = \"SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET\"",
1462 1,
1463 );
1464 assert!(error_message(&missing).contains("SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET"));
1465
1466 const EMPTY_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_EMPTY_KEY";
1467 unsafe {
1468 std::env::set_var(EMPTY_KEY_ENV, "");
1470 }
1471 let empty = VALID_CONFIG.replacen(
1472 "base_url = \"https://example.test/v1\"",
1473 &format!("base_url = \"https://example.test/v1\"\napi_key_env = \"{EMPTY_KEY_ENV}\""),
1474 1,
1475 );
1476 let message = error_message(&empty);
1477 unsafe {
1478 std::env::remove_var(EMPTY_KEY_ENV);
1479 }
1480 assert!(message.contains("is empty"));
1481 }
1482}