1use std::collections::{BTreeMap, BTreeSet};
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{Message, Role, SimpleDecision};
13
14use super::fall_through::{DefaultTarget, FallThrough};
15use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
16use super::util::affinity::AffinityRouter;
17use super::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
18use super::util::escalation::{self, EscalationJudge, EscalationJudgeConfig, EscalationPolicy};
19use super::util::llm_judge::{
20 ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
21 SerdeDecoder, StructuredJudge,
22};
23use super::util::target_selector::TargetSelectorPolicy;
24use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet};
25use crate::core::classifier::{Classification, Classifier, Score};
26use crate::core::state::{State, StateValue};
27use crate::{LibsyError, Result};
28use switchyard_protocol::{
29 AggLlmResponse, Context, LlmClientError, LlmResponse, Request, Response, RoutedLlmClient,
30};
31
32const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
33const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
34const ALGORITHM_NAME: &str = "llm_task_classifier";
36
37#[derive(Deserialize)]
38#[serde(deny_unknown_fields)]
39struct TaskClassifierVerdict {
40 crux: String,
41 primary_rule: String,
42 capability_boundary: String,
43 p_solve: f64,
44}
45
46impl TaskClassifierVerdict {
47 fn is_valid(&self) -> bool {
49 (0.0..=1.0).contains(&self.p_solve)
50 && !self.crux.trim().is_empty()
51 && matches!(
52 (
53 self.primary_rule.as_str(),
54 self.capability_boundary.as_str()
55 ),
56 ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
57 | ("UNC-1" | "UNC-2", "uncertain")
58 | ("LIM-1" | "LIM-2", "unsupported")
59 | ("none", "unmatched")
60 )
61 }
62
63 fn boundary_steps(&self) -> Option<u8> {
65 match self.capability_boundary.as_str() {
66 "supported" => Some(0),
67 "uncertain" | "unmatched" => Some(1),
68 "unsupported" => Some(2),
69 _ => None,
70 }
71 }
72}
73
74fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
81 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
82 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
83 let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
84 return kept.into_iter().cloned().collect();
85 };
86 kept.push(&messages[task]);
87
88 let tail: Vec<&Message> = messages[task + 1..]
89 .iter()
90 .filter(|m| !is_instruction(m))
91 .collect();
92 kept.extend(&tail[tail.len().saturating_sub(recent_turn_window)..]);
93 kept.into_iter().cloned().collect()
94}
95
96fn task_messages(messages: &[Message]) -> Vec<Message> {
98 let mut user_messages = messages.iter().filter(|message| message.role == Role::User);
99 let Some(opening_task) = user_messages.next() else {
100 return Vec::new();
101 };
102 match user_messages.next_back() {
103 Some(latest_follow_up) => vec![opening_task.clone(), latest_follow_up.clone()],
104 None => vec![opening_task.clone()],
105 }
106}
107
108struct TaskInput {
110 recent_turn_window: Option<usize>,
111}
112
113impl ClassifierInput for TaskInput {
114 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
115 match self.recent_turn_window {
118 Some(window) => trim_messages(&request.llm_request.messages, window),
119 None => task_messages(&request.llm_request.messages),
120 }
121 }
122}
123
124type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
125
126struct TaskClassifierPolicy {
127 efficient_target: String,
128 capable_target: String,
129 base_threshold: f64,
130 threshold_step: f64,
131}
132
133impl TaskClassifierPolicy {
134 fn new(
135 efficient_target: impl Into<String>,
136 capable_target: impl Into<String>,
137 config: &TaskClassifierConfig,
138 ) -> Self {
139 Self {
140 efficient_target: efficient_target.into(),
141 capable_target: capable_target.into(),
142 base_threshold: config.base_threshold,
143 threshold_step: config.threshold_step,
144 }
145 }
146
147 fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
149 Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
150 }
151}
152
153impl JudgePolicy for TaskClassifierPolicy {
154 type Verdict = TaskClassifierVerdict;
155
156 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
157 let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
160 return Classification::Ambiguous(vec![]);
161 };
162 let Some(threshold) = self.threshold(verdict) else {
165 return Classification::Ambiguous(vec![]);
166 };
167 let target = if verdict.p_solve >= threshold
168 || (threshold - verdict.p_solve).abs() <= f64::EPSILON
169 {
170 &self.efficient_target
171 } else {
172 &self.capable_target
173 };
174 Classification::Scores(vec![Score {
175 target: target.clone(),
176 confidence: 1.0,
177 }])
178 }
179}
180
181#[derive(Clone, Debug)]
182pub struct TaskClassifierConfig {
184 pub base_threshold: f64,
186 pub threshold_step: f64,
191 pub session_affinity: bool,
193 pub message_hash_fallback: bool,
195 pub recent_turn_window: Option<usize>,
202 pub contract: ClassifierContractConfig,
204 pub max_output_tokens: u64,
206}
207
208#[derive(Deserialize)]
210#[serde(deny_unknown_fields)]
211struct TaskClassifierConfigWire {
212 base_threshold: f64,
213 #[serde(default)]
214 threshold_step: f64,
215 #[serde(default)]
216 session_affinity: bool,
217 #[serde(default)]
218 message_hash_fallback: bool,
219 #[serde(default)]
220 recent_turn_window: Option<usize>,
221 #[serde(default)]
222 prompt: Option<String>,
223 #[serde(default = "default_judge_max_output_tokens")]
224 max_output_tokens: u64,
225}
226
227impl<'de> Deserialize<'de> for TaskClassifierConfig {
228 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
229 where
230 D: Deserializer<'de>,
231 {
232 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
233 let mut contract = ClassifierContractConfig::default();
234 if let Some(prompt) = wire.prompt {
235 contract = contract.with_prompt(prompt);
236 }
237 Ok(Self {
238 base_threshold: wire.base_threshold,
239 threshold_step: wire.threshold_step,
240 session_affinity: wire.session_affinity,
241 message_hash_fallback: wire.message_hash_fallback,
242 recent_turn_window: wire.recent_turn_window,
243 contract,
244 max_output_tokens: wire.max_output_tokens,
245 })
246 }
247}
248
249const fn default_judge_max_output_tokens() -> u64 {
250 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
251}
252
253impl Default for TaskClassifierConfig {
254 fn default() -> Self {
255 Self {
256 base_threshold: 0.0,
257 threshold_step: 0.0,
258 session_affinity: false,
259 message_hash_fallback: false,
260 recent_turn_window: None,
261 contract: ClassifierContractConfig::default(),
262 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
263 }
264 }
265}
266
267impl TaskClassifierConfig {
268 fn validate(&self) -> Result<()> {
270 if !(0.0..=1.0).contains(&self.base_threshold) {
271 return Err(LibsyError::AlgorithmError {
272 message: format!(
273 "base_threshold must be between 0 and 1, got {}",
274 self.base_threshold
275 ),
276 });
277 }
278 if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
279 return Err(LibsyError::AlgorithmError {
280 message: format!(
281 "threshold_step must be finite and greater than or equal to 0, got {}",
282 self.threshold_step
283 ),
284 });
285 }
286 let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
287 if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
288 return Err(LibsyError::AlgorithmError {
289 message: format!(
290 "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
291 ),
292 });
293 }
294 if self.max_output_tokens == 0 {
295 return Err(LibsyError::AlgorithmError {
296 message: "max_output_tokens must be at least 1".to_string(),
297 });
298 }
299 if self.message_hash_fallback && !self.session_affinity {
300 return Err(LibsyError::AlgorithmError {
301 message: "message_hash_fallback requires session_affinity".to_string(),
302 });
303 }
304 Ok(())
305 }
306}
307
308#[derive(Clone, Debug)]
310pub enum CustomClassifierPolicy {
311 TargetSelector {
313 selector: String,
315 },
316}
317
318impl CustomClassifierPolicy {
319 pub fn target_selector(selector: impl Into<String>) -> Self {
321 Self::TargetSelector {
322 selector: selector.into(),
323 }
324 }
325}
326
327#[derive(Clone, Debug)]
329pub struct CustomClassifierConfig {
330 pub prompt: String,
332 pub response_schema: Value,
334 pub policy: CustomClassifierPolicy,
336 pub session_affinity: bool,
338 pub message_hash_fallback: bool,
340 pub recent_turn_window: Option<usize>,
342 pub max_output_tokens: u64,
344}
345
346impl CustomClassifierConfig {
347 pub fn new(
349 prompt: impl Into<String>,
350 response_schema: Value,
351 policy: CustomClassifierPolicy,
352 ) -> Self {
353 Self {
354 prompt: prompt.into(),
355 response_schema,
356 policy,
357 session_affinity: false,
358 message_hash_fallback: false,
359 recent_turn_window: None,
360 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
361 }
362 }
363
364 fn validate(&self) -> Result<()> {
365 if self.max_output_tokens == 0 {
366 return Err(LibsyError::AlgorithmError {
367 message: "max_output_tokens must be at least 1".to_string(),
368 });
369 }
370 if self.message_hash_fallback && !self.session_affinity {
371 return Err(LibsyError::AlgorithmError {
372 message: "message_hash_fallback requires session_affinity".to_string(),
373 });
374 }
375 Ok(())
376 }
377}
378
379enum CustomPolicyRuntime {
380 TargetSelector(TargetSelectorPolicy),
381}
382
383impl JudgePolicy for CustomPolicyRuntime {
384 type Verdict = Value;
385
386 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
387 match self {
388 Self::TargetSelector(policy) => policy.to_classification(verdict),
389 }
390 }
391}
392
393struct TaskClassifier {
394 classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
395 efficient_target: String,
396 capable_target: String,
397}
398
399const STREAK_KEY: &str = "escalation_streak";
403
404fn streak(state: &State) -> u32 {
405 match state.extra.get(STREAK_KEY) {
406 Some(StateValue::Count(n)) => *n,
407 _ => 0,
408 }
409}
410
411fn decisive(target: &str) -> Classification {
412 Classification::Scores(vec![Score {
413 target: target.to_string(),
414 confidence: 1.0,
415 }])
416}
417
418fn assistant_message(response: &AggLlmResponse) -> Message {
419 Message {
420 role: Role::Assistant,
421 content: response
422 .first_output()
423 .map(|output| output.content.clone())
424 .unwrap_or_default(),
425 }
426}
427
428struct EscalationClassifier {
432 judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
433 capable: LlmTarget,
434 efficient: LlmTarget,
435 confirmations: u32,
437}
438
439#[async_trait]
440impl Classifier<State> for EscalationClassifier {
441 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
442 if self.capable.semantic_name == self.efficient.semantic_name {
443 None
444 } else if selected_model == self.capable.semantic_name {
445 Some("strong")
446 } else if selected_model == self.efficient.semantic_name {
447 Some("weak")
448 } else {
449 None
450 }
451 }
452
453 async fn score(
454 &self,
455 state: &mut State,
456 request: &mut Request,
457 driver: Option<&Driver>,
458 ) -> Result<(Classification, Option<Response>)> {
459 let Some(driver) = driver else {
460 return Err(LibsyError::AlgorithmError {
461 message: "escalation classifier requires a driver".into(),
462 });
463 };
464
465 if streak(state) >= self.confirmations {
467 return Ok((decisive(&self.capable.semantic_name), None));
468 }
469
470 let efficient_response = match driver
479 .call_llm_target(
480 Context::default(),
481 &self.efficient,
482 request.clone(),
483 Arc::new(SimpleDecision {
484 selected_model: self.efficient.semantic_name.clone(),
485 reasoning: Some("escalation classifier: efficient tier".into()),
486 }),
487 )
488 .await
489 {
490 Ok(r) => r,
491 Err(LibsyError::ClientCall {
492 source: LlmClientError::ContextWindowExceeded { .. },
493 ..
494 }) => return Ok((decisive(&self.capable.semantic_name), None)),
495 Err(e) => return Err(e),
496 };
497 let agg = efficient_response
498 .llm_response
499 .into_agg()
500 .await
501 .map_err(|e| LibsyError::AlgorithmError {
502 message: format!("failed to aggregate efficient response: {e}"),
503 })?;
504 let mut judge_request = request.clone();
506 judge_request
507 .llm_request
508 .messages
509 .push(assistant_message(&agg));
510 let efficient_response = Response {
511 llm_response: if request.llm_request.stream {
512 LlmResponse::Stream(agg.into_stream())
513 } else {
514 LlmResponse::Agg(agg)
515 },
516 metadata: efficient_response.metadata,
517 };
518
519 let (classification, _) = self
520 .judge
521 .score(state, &mut judge_request, Some(driver))
522 .await?;
523
524 let held = streak(state);
525 let best = classification.argmax(false)?;
526 let (escalate, pending) = match &best {
527 Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
528 Some(_) => (false, 0),
529 None => (false, held),
530 };
531 state
532 .extra
533 .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
534
535 if escalate && pending >= self.confirmations {
536 return Ok((decisive(&self.capable.semantic_name), None));
538 }
539
540 Ok((
541 decisive(&self.efficient.semantic_name),
542 Some(efficient_response),
543 ))
544 }
545}
546
547pub struct LlmTaskClassifier {
549 route: FallThrough<State>,
550 inner: Arc<dyn Classifier<State>>,
552}
553
554struct ClassifierRouteConfig {
555 default_target: String,
556 session_affinity: bool,
557 message_hash_fallback: bool,
558}
559
560#[non_exhaustive]
562pub enum LlmClassifierConfig {
563 Capability {
565 judge_target: LlmTarget,
567 efficient_target: LlmTarget,
569 capable_target: LlmTarget,
571 config: TaskClassifierConfig,
573 },
574 Escalation {
576 judge_target: LlmTarget,
578 efficient_target: LlmTarget,
580 capable_target: LlmTarget,
582 contract: ClassifierContractConfig,
584 config: EscalationJudgeConfig,
586 max_output_tokens: u64,
588 },
589 Custom {
591 judge_target: LlmTarget,
593 targets: Vec<(String, LlmTarget)>,
595 default_target: String,
597 config: CustomClassifierConfig,
599 },
600}
601
602impl LlmTaskClassifier {
603 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
610 match config {
611 LlmClassifierConfig::Capability {
612 judge_target,
613 efficient_target,
614 capable_target,
615 config,
616 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
617 LlmClassifierConfig::Escalation {
618 judge_target,
619 efficient_target,
620 capable_target,
621 contract,
622 config,
623 max_output_tokens,
624 } => Self::build_escalation(
625 judge_target,
626 efficient_target,
627 capable_target,
628 contract,
629 config,
630 max_output_tokens,
631 ),
632 LlmClassifierConfig::Custom {
633 judge_target,
634 targets,
635 default_target,
636 config,
637 } => Self::build_custom(judge_target, targets, default_target, config),
638 }
639 }
640
641 fn build_capability(
642 judge_target: LlmTarget,
643 efficient_target: LlmTarget,
644 capable_target: LlmTarget,
645 config: TaskClassifierConfig,
646 ) -> Result<Self> {
647 config.validate()?;
648 let contract = Self::load_capability_contract(&config.contract)?;
649 let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
650 let session_affinity = config.session_affinity;
651 let message_hash_fallback = config.message_hash_fallback;
652 let classifier = Arc::new(TaskClassifier {
653 classifier: JudgeClassifier::new(
654 StructuredJudge::new(
655 TaskInput {
656 recent_turn_window: config.recent_turn_window,
657 },
658 contract,
659 SerdeDecoder::new(),
660 JudgeRuntimeConfig::new(config.max_output_tokens)?,
661 ),
662 judge_target.clone(),
663 TaskClassifierPolicy::new(
664 efficient_target.semantic_name.clone(),
665 capable_target.semantic_name.clone(),
666 &config,
667 ),
668 ),
669 efficient_target: efficient_target.semantic_name.clone(),
670 capable_target: capable_target.semantic_name.clone(),
671 });
672 let inner: Arc<dyn Classifier<State>> = classifier.clone();
673 Self::from_classifier(
674 targets,
675 inner,
676 ClassifierRouteConfig {
677 default_target: classifier.capable_target.clone(),
678 session_affinity,
679 message_hash_fallback,
680 },
681 )
682 }
683
684 fn build_custom(
685 judge_target: LlmTarget,
686 targets: Vec<(String, LlmTarget)>,
687 default_target: String,
688 config: CustomClassifierConfig,
689 ) -> Result<Self> {
690 config.validate()?;
691 if targets.len() < 2 {
692 return Err(LibsyError::AlgorithmError {
693 message: "custom classifier requires at least two targets".to_string(),
694 });
695 }
696
697 let mut labels = BTreeSet::new();
698 let mut semantic_names = BTreeSet::new();
699 let mut target_map = BTreeMap::new();
700 let mut resolved_targets = Vec::with_capacity(targets.len());
701 for (label, target) in targets {
702 if label.trim().is_empty() || label.trim() != label {
703 return Err(LibsyError::AlgorithmError {
704 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
705 .to_string(),
706 });
707 }
708 if !labels.insert(label.clone()) {
709 return Err(LibsyError::AlgorithmError {
710 message: format!("custom classifier target label {label:?} is duplicated"),
711 });
712 }
713 if !semantic_names.insert(target.semantic_name.clone()) {
714 return Err(LibsyError::AlgorithmError {
715 message: format!(
716 "custom classifier resolved target {:?} is duplicated",
717 target.semantic_name
718 ),
719 });
720 }
721 target_map.insert(label, target.semantic_name.clone());
722 resolved_targets.push(target);
723 }
724 let default_semantic_name =
725 target_map
726 .get(&default_target)
727 .cloned()
728 .ok_or_else(|| LibsyError::AlgorithmError {
729 message: format!(
730 "default_target {default_target:?} must be one of the configured targets"
731 ),
732 })?;
733
734 let CustomClassifierConfig {
735 prompt,
736 response_schema,
737 policy,
738 session_affinity,
739 message_hash_fallback,
740 recent_turn_window,
741 max_output_tokens,
742 } = config;
743 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
744 let policy = match policy {
745 CustomClassifierPolicy::TargetSelector { selector } => {
746 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
747 selector, target_map,
748 )?)
749 }
750 };
751 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
752 StructuredJudge::new(
753 TaskInput { recent_turn_window },
754 contract,
755 JsonSchemaDecoder::new(),
756 JudgeRuntimeConfig::new(max_output_tokens)?,
757 ),
758 judge_target,
759 policy,
760 ));
761
762 Self::from_classifier(
763 LlmTargetSet::new(resolved_targets),
764 classifier,
765 ClassifierRouteConfig {
766 default_target: default_semantic_name,
767 session_affinity,
768 message_hash_fallback,
769 },
770 )
771 }
772
773 fn build_escalation(
774 judge_target: LlmTarget,
775 efficient_target: LlmTarget,
776 capable_target: LlmTarget,
777 contract_config: ClassifierContractConfig,
778 config: EscalationJudgeConfig,
779 max_output_tokens: u64,
780 ) -> Result<Self> {
781 let capable_name = capable_target.semantic_name.clone();
782 let efficient_name = efficient_target.semantic_name.clone();
783 let confirmations = config.confirmations;
784 let esc = Arc::new(EscalationClassifier {
785 judge: escalation::build_judge(
786 judge_target,
787 capable_name,
788 efficient_name,
789 &contract_config,
790 config,
791 max_output_tokens,
792 )?,
793 capable: capable_target.clone(),
794 efficient: efficient_target.clone(),
795 confirmations,
796 });
797 let inner: Arc<dyn Classifier<State>> = esc.clone();
798 let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
799 Ok(Self {
800 route: FallThrough::<State>::new_with_state(targets)
801 .with_name(ALGORITHM_NAME)
802 .with_classifier(esc),
803 inner,
804 })
805 }
806
807 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
809 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
810 }
811
812 fn from_classifier(
814 targets: LlmTargetSet,
815 inner: Arc<dyn Classifier<State>>,
816 config: ClassifierRouteConfig,
817 ) -> Result<Self> {
818 targets.get_target(&config.default_target)?;
819 if config.message_hash_fallback && !config.session_affinity {
820 return Err(LibsyError::AlgorithmError {
821 message: "message_hash_fallback requires session_affinity".to_string(),
822 });
823 }
824 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
828 if config.session_affinity {
829 let affinity = if config.message_hash_fallback {
830 AffinityRouter::new().with_message_hash_fallback()
831 } else {
832 AffinityRouter::new()
833 };
834 let affinity = Arc::new(affinity);
836 route = route
837 .with_processor(affinity.clone())
838 .with_classifier(affinity);
839 }
840 let fallback = DefaultTarget::new(config.default_target);
841 Ok(Self {
842 route: route
843 .with_classifier(inner.clone())
844 .with_classifier(Arc::new(fallback)),
845 inner,
846 })
847 }
848}
849
850#[async_trait]
851impl Classifier<State> for TaskClassifier {
852 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
853 if self.efficient_target == self.capable_target {
854 None
855 } else if selected_model == self.efficient_target {
856 Some("weak")
857 } else if selected_model == self.capable_target {
858 Some("strong")
859 } else {
860 None
861 }
862 }
863
864 async fn score(
865 &self,
866 state: &mut State,
867 request: &mut Request,
868 driver: Option<&Driver>,
869 ) -> Result<(Classification, Option<Response>)> {
870 self.classifier.score(state, request, driver).await
871 }
872}
873
874#[async_trait]
875impl Classifier<State> for LlmTaskClassifier {
876 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
877 self.inner.routing_tier(selected_model)
878 }
879
880 async fn score(
881 &self,
882 state: &mut State,
883 request: &mut Request,
884 driver: Option<&Driver>,
885 ) -> Result<(Classification, Option<Response>)> {
886 self.inner.score(state, request, driver).await
887 }
888}
889
890#[async_trait]
891impl Algorithm for LlmTaskClassifier {
892 fn name(&self) -> &str {
893 "llm_task_classifier"
894 }
895
896 fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
897 self.route.count_tokens_client()
898 }
899
900 async fn create_run_task(
901 self: Arc<Self>,
902 ctx: Context,
903 driver: Driver,
904 request: Request,
905 ) -> Result<Response> {
906 self.route.execute(ctx, driver, request).await
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use std::sync::Arc;
913
914 use parking_lot::Mutex;
915 use serde_json::Value;
916
917 use super::*;
918 use switchyard_protocol::{
919 LlmClientError, LlmRequest, Metadata, completion_text, text_request, text_response,
920 };
921
922 use crate::algorithms::util::llm_judge::Judge;
923 use crate::core::algorithm::Algorithm;
924 use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
925
926 const TEST_THRESHOLD: f64 = 0.5;
927
928 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
929 TaskClassifierConfig {
930 base_threshold,
931 ..TaskClassifierConfig::default()
932 }
933 }
934
935 fn policy() -> TaskClassifierPolicy {
936 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
937 }
938
939 fn verdict(
940 p_solve: f64,
941 capability_boundary: &str,
942 primary_rule: &str,
943 ) -> TaskClassifierVerdict {
944 TaskClassifierVerdict {
945 crux: "test crux".to_string(),
946 primary_rule: primary_rule.to_string(),
947 capability_boundary: capability_boundary.to_string(),
948 p_solve,
949 }
950 }
951
952 fn selected(
953 policy: &TaskClassifierPolicy,
954 verdict: Option<&TaskClassifierVerdict>,
955 ) -> Result<String> {
956 policy
957 .to_classification(verdict)
958 .argmax(false)?
959 .map(|score| score.target)
960 .ok_or_else(|| LibsyError::AlgorithmError {
961 message: "policy abstained".to_string(),
962 })
963 }
964
965 #[derive(Default)]
966 struct PerRequestClient {
967 calls: Mutex<Vec<String>>,
968 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
969 judge_system_prompts: Mutex<Vec<String>>,
970 }
971
972 impl PerRequestClient {
973 fn calls(&self) -> Vec<String> {
974 self.calls.lock().clone()
975 }
976
977 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
978 self.judge_max_output_tokens.lock().clone()
979 }
980
981 fn judge_system_prompts(&self) -> Vec<String> {
982 self.judge_system_prompts.lock().clone()
983 }
984 }
985
986 #[async_trait]
987 impl RoutedLlmClient for PerRequestClient {
988 async fn call(
989 &self,
990 _ctx: Context,
991 request: Request,
992 decision: Arc<dyn Decision>,
993 ) -> std::result::Result<Response, LlmClientError> {
994 let model = decision.selected_model().to_string();
995 self.calls.lock().push(model.clone());
996 let completion = if model == "judge" {
997 self.judge_max_output_tokens
998 .lock()
999 .push(request.llm_request.output.max_output_tokens);
1000 self.judge_system_prompts.lock().extend(
1001 request
1002 .llm_request
1003 .messages
1004 .first()
1005 .and_then(|message| message.text_content("\n")),
1006 );
1007 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1008 } else {
1009 format!("answer from {model}")
1010 };
1011 Ok(Response {
1012 llm_response: LlmResponse::Agg(text_response(None, completion)),
1013 metadata: request.metadata,
1014 })
1015 }
1016 }
1017
1018 struct UnreachableJudgeClient;
1019
1020 #[async_trait]
1021 impl RoutedLlmClient for UnreachableJudgeClient {
1022 async fn call(
1023 &self,
1024 _ctx: Context,
1025 request: Request,
1026 decision: Arc<dyn Decision>,
1027 ) -> std::result::Result<Response, LlmClientError> {
1028 let model = decision.selected_model().to_string();
1029 if model == "judge" {
1030 return Err(LlmClientError::Timeout {
1031 source: Box::new(std::io::Error::other("judge unreachable")),
1032 });
1033 }
1034 Ok(Response {
1035 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1036 metadata: request.metadata,
1037 })
1038 }
1039 }
1040
1041 fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
1042 let target = |name: &str| LlmTarget {
1043 semantic_name: name.to_string(),
1044 llm_client: Some(client.clone()),
1045 };
1046 Ok(Arc::new(LlmTaskClassifier::new(
1047 LlmClassifierConfig::Capability {
1048 judge_target: target("judge"),
1049 efficient_target: target("efficient"),
1050 capable_target: target("capable"),
1051 config: test_config(TEST_THRESHOLD),
1052 },
1053 )?))
1054 }
1055
1056 fn classify_request() -> Request {
1057 Request {
1058 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1059 raw_request: None,
1060 metadata: None,
1061 }
1062 }
1063
1064 fn classify_session_request() -> Request {
1065 Request {
1066 metadata: Some(Metadata {
1067 session_id: Some("session-1".to_string()),
1068 ..Metadata::default()
1069 }),
1070 ..classify_request()
1071 }
1072 }
1073
1074 fn classify_follow_up_request() -> Request {
1075 let mut request = classify_request();
1076 request
1077 .llm_request
1078 .messages
1079 .push(Message::text(Role::Assistant, "I will add the test."));
1080 request.llm_request.messages.push(Message::text(
1081 Role::User,
1082 "Now run the test suite and report the result.",
1083 ));
1084 request
1085 }
1086
1087 #[tokio::test]
1088 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1089 let router = router(Arc::new(UnreachableJudgeClient))?;
1090
1091 let (trace, response) = router.run(Context::default(), classify_request()).await?;
1092
1093 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1094 assert_eq!(
1095 response.llm_response.as_agg().map(completion_text),
1096 Some("answer from capable".to_string())
1097 );
1098 Ok(())
1099 }
1100
1101 #[tokio::test]
1102 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1103 let client = Arc::new(PerRequestClient::default());
1104 let router = router(client.clone())?;
1105 let request = classify_request;
1106
1107 router.clone().run(Context::default(), request()).await?;
1108 router.clone().run(Context::default(), request()).await?;
1109
1110 assert_eq!(
1111 client.calls(),
1112 vec!["judge", "efficient", "judge", "efficient"]
1113 );
1114 Ok(())
1115 }
1116
1117 #[tokio::test]
1118 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1119 let client = Arc::new(PerRequestClient::default());
1120 let target = |name: &str| LlmTarget {
1121 semantic_name: name.to_string(),
1122 llm_client: Some(client.clone()),
1123 };
1124 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1125 judge_target: target("judge"),
1126 efficient_target: target("efficient"),
1127 capable_target: target("capable"),
1128 config: TaskClassifierConfig {
1129 max_output_tokens: 512,
1130 ..test_config(TEST_THRESHOLD)
1131 },
1132 })?);
1133
1134 router.run(Context::default(), classify_request()).await?;
1135
1136 assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
1137 Ok(())
1138 }
1139
1140 #[tokio::test]
1141 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1142 let client = Arc::new(PerRequestClient::default());
1143 let target = |name: &str| LlmTarget {
1144 semantic_name: name.to_string(),
1145 llm_client: Some(client.clone()),
1146 };
1147 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1148 judge_target: target("judge"),
1149 efficient_target: target("efficient"),
1150 capable_target: target("capable"),
1151 config: TaskClassifierConfig {
1152 contract: ClassifierContractConfig::default()
1153 .with_prompt("Custom capability rubric."),
1154 ..test_config(TEST_THRESHOLD)
1155 },
1156 })?);
1157
1158 router.run(Context::default(), classify_request()).await?;
1159
1160 let prompts = client.judge_system_prompts();
1161 assert_eq!(prompts.len(), 1);
1162 assert_eq!(prompts[0], "Custom capability rubric.");
1163 Ok(())
1164 }
1165
1166 #[tokio::test]
1167 async fn classifier_config_enables_session_affinity() -> Result<()> {
1168 let client = Arc::new(PerRequestClient::default());
1169 let target = |name: &str| LlmTarget {
1170 semantic_name: name.to_string(),
1171 llm_client: Some(client.clone()),
1172 };
1173 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1174 judge_target: target("judge"),
1175 efficient_target: target("efficient"),
1176 capable_target: target("capable"),
1177 config: TaskClassifierConfig {
1178 session_affinity: true,
1179 ..test_config(TEST_THRESHOLD)
1180 },
1181 })?);
1182
1183 router
1184 .clone()
1185 .run(Context::default(), classify_session_request())
1186 .await?;
1187 router
1188 .clone()
1189 .run(Context::default(), classify_session_request())
1190 .await?;
1191
1192 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1193 Ok(())
1194 }
1195
1196 #[tokio::test]
1197 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1198 let client = Arc::new(PerRequestClient::default());
1199 let target = |name: &str| LlmTarget {
1200 semantic_name: name.to_string(),
1201 llm_client: Some(client.clone()),
1202 };
1203 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1204 judge_target: target("judge"),
1205 efficient_target: target("efficient"),
1206 capable_target: target("capable"),
1207 config: TaskClassifierConfig {
1208 session_affinity: true,
1209 message_hash_fallback: true,
1210 recent_turn_window: None,
1211 ..test_config(TEST_THRESHOLD)
1212 },
1213 })?);
1214
1215 router
1216 .clone()
1217 .run(Context::default(), classify_request())
1218 .await?;
1219 router
1220 .clone()
1221 .run(Context::default(), classify_follow_up_request())
1222 .await?;
1223
1224 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1225 Ok(())
1226 }
1227
1228 #[test]
1229 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1230 let policy = policy();
1231 let at_threshold = verdict(0.5, "supported", "SUP-1");
1232 let below_threshold = verdict(0.49, "supported", "SUP-1");
1233 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1234 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1235 Ok(())
1236 }
1237
1238 #[test]
1239 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1240 let borderline = verdict(0.5, "supported", "SUP-1");
1241 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1242 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1243 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1244 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1245 Ok(())
1246 }
1247
1248 #[test]
1249 fn classifier_config_rejects_unknown_fields() {
1250 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1251 "base_threshold": 0.5,
1252 "classifier_magic": true,
1253 }))
1254 .expect_err("unknown classifier fields must be rejected");
1255
1256 assert!(
1257 error
1258 .to_string()
1259 .contains("unknown field `classifier_magic`"),
1260 "{error}"
1261 );
1262 }
1263
1264 #[test]
1265 fn invalid_classifier_config_is_rejected() -> Result<()> {
1266 let target = |name: &str| LlmTarget {
1267 semantic_name: name.to_string(),
1268 llm_client: None,
1269 };
1270 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1271 assert!(
1272 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1273 judge_target: target("judge"),
1274 efficient_target: target("e"),
1275 capable_target: target("c"),
1276 config: test_config(bad),
1277 })
1278 .is_err(),
1279 "base threshold {bad} should be rejected"
1280 );
1281 }
1282 for config in [
1283 TaskClassifierConfig {
1284 base_threshold: 0.5,
1285 threshold_step: -0.1,
1286 ..TaskClassifierConfig::default()
1287 },
1288 TaskClassifierConfig {
1289 base_threshold: 0.8,
1290 threshold_step: 0.11,
1291 ..TaskClassifierConfig::default()
1292 },
1293 TaskClassifierConfig {
1294 base_threshold: 0.5,
1295 message_hash_fallback: true,
1296 ..TaskClassifierConfig::default()
1297 },
1298 TaskClassifierConfig {
1299 base_threshold: 0.5,
1300 max_output_tokens: 0,
1301 ..TaskClassifierConfig::default()
1302 },
1303 ] {
1304 assert!(
1305 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1306 judge_target: target("judge"),
1307 efficient_target: target("e"),
1308 capable_target: target("c"),
1309 config,
1310 })
1311 .is_err()
1312 );
1313 }
1314 for base_threshold in [0.0, 1.0] {
1315 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1316 judge_target: target("judge"),
1317 efficient_target: target("e"),
1318 capable_target: target("c"),
1319 config: test_config(base_threshold),
1320 })?;
1321 }
1322 Ok(())
1323 }
1324
1325 #[test]
1326 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1327 let policy = policy();
1328 let inconsistent_rule = TaskClassifierVerdict {
1329 capability_boundary: "uncertain".to_string(),
1330 ..verdict(1.0, "supported", "SUP-1")
1331 };
1332 let empty_crux = TaskClassifierVerdict {
1333 crux: " ".to_string(),
1334 ..verdict(1.0, "supported", "SUP-1")
1335 };
1336 let unusable = [
1337 Some(verdict(1.1, "supported", "SUP-1")),
1338 Some(inconsistent_rule),
1339 Some(empty_crux),
1340 None,
1341 ];
1342 for verdict in unusable {
1343 let classification = policy.to_classification(verdict.as_ref());
1344 assert!(matches!(classification, Classification::Ambiguous(_)));
1345 assert!(classification.argmax(false)?.is_none());
1346 assert!(classification.argmax(true)?.is_none());
1347 }
1348 Ok(())
1349 }
1350
1351 #[test]
1352 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1353 let policy = TaskClassifierPolicy::new(
1354 "efficient",
1355 "capable",
1356 &TaskClassifierConfig {
1357 threshold_step: 0.1,
1358 ..test_config(0.4)
1359 },
1360 );
1361
1362 assert_eq!(
1363 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1364 "efficient"
1365 );
1366 assert_eq!(
1367 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1368 "capable"
1369 );
1370 assert_eq!(
1371 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1372 "efficient"
1373 );
1374 assert_eq!(
1375 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1376 "efficient"
1377 );
1378 assert_eq!(
1379 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1380 "capable"
1381 );
1382 assert_eq!(
1383 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1384 "efficient"
1385 );
1386 Ok(())
1387 }
1388
1389 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1392 Ok(StructuredJudge::new(
1393 TaskInput { recent_turn_window },
1394 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1395 SerdeDecoder::new(),
1396 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1397 ))
1398 }
1399
1400 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1401 let judge = capability_judge(Some(recent_turn_window))?;
1402 let request = Request {
1403 llm_request: LlmRequest {
1404 messages: vec![
1405 Message::text(Role::System, "client instructions"),
1406 Message::text(Role::User, "initial task"),
1407 Message::text(Role::Assistant, "old response"),
1408 Message::text(Role::User, "old follow-up"),
1409 Message::text(Role::Assistant, "recent 1"),
1410 Message::text(Role::User, "recent 2"),
1411 ],
1412 ..LlmRequest::default()
1413 },
1414 raw_request: None,
1415 metadata: None,
1416 };
1417 Ok(judge
1418 .build_request(&State::default(), &request)
1419 .llm_request
1420 .messages
1421 .iter()
1422 .filter_map(|message| message.text_content("\n"))
1423 .collect())
1424 }
1425
1426 #[test]
1427 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1428 let contents = judged_contents(2)?;
1430 assert!(contents.contains(&"client instructions".to_string()));
1431 assert!(contents.contains(&"initial task".to_string()));
1432 assert!(contents.contains(&"recent 1".to_string()));
1433 assert!(contents.contains(&"recent 2".to_string()));
1434 assert!(!contents.contains(&"old response".to_string()));
1435 Ok(())
1436 }
1437
1438 #[test]
1439 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1440 let contents = judged_contents(0)?;
1441 assert!(contents.contains(&"client instructions".to_string()));
1442 assert!(contents.contains(&"initial task".to_string()));
1443 assert!(!contents.contains(&"recent 2".to_string()));
1444 Ok(())
1445 }
1446
1447 #[test]
1448 fn capability_judge_builds_a_structured_request() -> Result<()> {
1449 let judge = capability_judge(None)?;
1450 let request = Request {
1451 llm_request: LlmRequest {
1452 model: Some("inbound".to_string()),
1453 messages: vec![
1454 Message::text(Role::System, "client instructions"),
1455 Message::text(Role::Developer, "client developer instructions"),
1456 Message::text(Role::User, "initial task"),
1457 Message::text(Role::Assistant, "old response"),
1458 Message::text(Role::User, "old follow-up"),
1459 Message::text(Role::Assistant, "recent 1"),
1460 Message::text(Role::User, "recent 2"),
1461 Message::text(Role::Assistant, "recent 3"),
1462 Message::text(Role::User, "recent 4"),
1463 Message::text(Role::Assistant, "recent 5"),
1464 ],
1465 ..LlmRequest::default()
1466 },
1467 raw_request: None,
1468 metadata: None,
1469 };
1470 let judge_request = judge.build_request(&State::default(), &request);
1471
1472 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1473 assert_eq!(judge_request.llm_request.messages.len(), 3);
1474 let contents = judge_request
1475 .llm_request
1476 .messages
1477 .iter()
1478 .filter_map(|message| message.text_content("\n"))
1479 .collect::<Vec<_>>();
1480 assert!(contents.contains(&"recent 4".to_string()));
1481 assert!(contents.contains(&"initial task".to_string()));
1482 assert!(!contents.contains(&"recent 5".to_string()));
1483 assert!(!contents.contains(&"client instructions".to_string()));
1484 assert_eq!(
1485 judge_request.llm_request.output.response_format,
1486 Some(judge.contract().response_format().clone())
1487 );
1488 assert_eq!(
1489 judge_request.llm_request.output.max_output_tokens,
1490 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1491 );
1492 Ok(())
1493 }
1494
1495 fn sample_value(spec: &Value) -> Value {
1496 if let Some(first) = spec
1497 .get("enum")
1498 .and_then(Value::as_array)
1499 .and_then(|values| values.first())
1500 {
1501 return first.clone();
1502 }
1503 match spec.get("type").and_then(Value::as_str) {
1504 Some("number") => serde_json::json!(0.5),
1505 Some("boolean") => serde_json::json!(false),
1506 _ => serde_json::json!("sample"),
1507 }
1508 }
1509
1510 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1511 let properties = schema
1512 .pointer("/json_schema/schema/properties")
1513 .and_then(Value::as_object)
1514 .ok_or_else(|| LibsyError::AlgorithmError {
1515 message: "packaged schema declares no properties".to_string(),
1516 })?;
1517 Ok(Value::Object(
1518 properties
1519 .iter()
1520 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1521 .collect(),
1522 )
1523 .to_string())
1524 }
1525
1526 #[test]
1529 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1530 let contract =
1531 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1532 let schema = contract.response_format();
1533 let reply = schema_shaped_verdict(schema)?;
1534 let judge: CapabilityJudge = StructuredJudge::new(
1535 TaskInput {
1536 recent_turn_window: None,
1537 },
1538 contract,
1539 SerdeDecoder::new(),
1540 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1541 );
1542
1543 let verdict = judge.parse(&text_response(None, reply))?;
1544
1545 assert!(verdict.is_valid());
1546 assert!((0.0..=1.0).contains(&verdict.p_solve));
1547 Ok(())
1548 }
1549
1550 #[test]
1551 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1552 let contract =
1553 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1554 let prompt = contract.system_prompt();
1555 let schema_name = contract
1556 .response_format()
1557 .pointer("/json_schema/name")
1558 .and_then(Value::as_str)
1559 .ok_or_else(|| LibsyError::AlgorithmError {
1560 message: "packaged response schema has no name".to_string(),
1561 })?;
1562 assert_eq!(schema_name, "CapabilityClassifierDecision");
1563 assert!(prompt.contains("SUP-1 [supported]"));
1564 assert!(prompt.contains("SUP-5 [supported]"));
1565 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1566 assert!(!prompt.contains("\"type\": \"object\""));
1567 assert!(!prompt.contains("\"json_schema\""));
1568 assert!(!prompt.contains(schema_name));
1569 let rule_values = contract
1570 .response_format()
1571 .pointer("/json_schema/schema/properties/primary_rule/enum")
1572 .and_then(Value::as_array)
1573 .ok_or_else(|| LibsyError::AlgorithmError {
1574 message: "rendered response schema has no primary rule enum".to_string(),
1575 })?;
1576 assert!(
1577 rule_values
1578 .iter()
1579 .any(|value| value.as_str() == Some("SUP-1"))
1580 );
1581 assert!(
1582 rule_values
1583 .iter()
1584 .any(|value| value.as_str() == Some("none"))
1585 );
1586 Ok(())
1587 }
1588
1589 use std::collections::VecDeque;
1592
1593 use switchyard_protocol::LlmClientError as ClientError;
1594
1595 use switchyard_protocol::Decision;
1596
1597 struct QueuedClient {
1599 replies: Mutex<VecDeque<String>>,
1600 }
1601
1602 impl QueuedClient {
1603 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1604 Arc::new(Self {
1605 replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1606 })
1607 }
1608 }
1609
1610 #[async_trait]
1611 impl RoutedLlmClient for QueuedClient {
1612 async fn call(
1613 &self,
1614 _ctx: Context,
1615 request: Request,
1616 _decision: Arc<dyn Decision>,
1617 ) -> std::result::Result<Response, ClientError> {
1618 let reply = self
1619 .replies
1620 .lock()
1621 .pop_front()
1622 .unwrap_or_else(|| "unexpected call".to_string());
1623 Ok(Response {
1624 llm_response: LlmResponse::Agg(text_response(None, reply)),
1625 metadata: request.metadata,
1626 })
1627 }
1628 }
1629
1630 fn escalation_router(
1632 client: Arc<dyn RoutedLlmClient>,
1633 judge_client: Arc<dyn RoutedLlmClient>,
1634 ) -> Result<Arc<LlmTaskClassifier>> {
1635 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1636 semantic_name: name.to_string(),
1637 llm_client: Some(c),
1638 };
1639 Ok(Arc::new(LlmTaskClassifier::new(
1640 LlmClassifierConfig::Escalation {
1641 judge_target: target("judge", judge_client),
1642 efficient_target: target("efficient", client.clone()),
1643 capable_target: target("capable", client),
1644 contract: ClassifierContractConfig::default(),
1645 config: EscalationJudgeConfig {
1646 confirmations: 1,
1647 ..EscalationJudgeConfig::default()
1648 },
1649 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1650 },
1651 )?))
1652 }
1653
1654 #[tokio::test]
1655 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1656 let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1658 let model_client = QueuedClient::new(["efficient answer"]);
1659 let router = escalation_router(model_client, judge_client)?;
1660 let request = classify_request();
1661
1662 let (trace, response) = router.run(Context::default(), request).await?;
1663
1664 assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1666 assert_eq!(
1667 response.llm_response.as_agg().map(completion_text),
1668 Some("efficient answer".to_string())
1669 );
1670 Ok(())
1671 }
1672
1673 #[tokio::test]
1674 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1675 let client = Arc::new(PerRequestClient::default());
1676 let target = |name: &str| LlmTarget {
1677 semantic_name: name.to_string(),
1678 llm_client: Some(client.clone()),
1679 };
1680 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1681 judge_target: target("judge"),
1682 efficient_target: target("efficient"),
1683 capable_target: target("capable"),
1684 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1685 config: EscalationJudgeConfig {
1686 confirmations: 1,
1687 ..EscalationJudgeConfig::default()
1688 },
1689 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1690 })?);
1691
1692 router.run(Context::default(), classify_request()).await?;
1693
1694 let prompts = client.judge_system_prompts();
1695 assert_eq!(prompts.len(), 1);
1696 assert_eq!(prompts[0], "Custom trajectory rubric.");
1697 Ok(())
1698 }
1699
1700 #[tokio::test]
1701 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1702 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1704 let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1706 let router = escalation_router(model_client, judge_client)?;
1707 let request = classify_request();
1708
1709 let (trace, response) = router.run(Context::default(), request).await?;
1710
1711 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1712 assert_eq!(
1713 response.llm_response.as_agg().map(completion_text),
1714 Some("capable answer".to_string())
1715 );
1716 Ok(())
1717 }
1718
1719 #[tokio::test]
1720 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1721 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1724 let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
1725 let router = escalation_router(model_client, judge_client)?;
1726
1727 let session_request = classify_session_request();
1728 router
1729 .clone()
1730 .run(Context::default(), session_request.clone())
1731 .await?;
1732 let (trace, _) = router
1733 .clone()
1734 .run(Context::default(), session_request)
1735 .await?;
1736
1737 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1738 Ok(())
1739 }
1740
1741 #[tokio::test]
1742 async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
1743 struct OverflowClient;
1747 #[async_trait]
1748 impl RoutedLlmClient for OverflowClient {
1749 async fn call(
1750 &self,
1751 _ctx: Context,
1752 _request: Request,
1753 decision: Arc<dyn Decision>,
1754 ) -> std::result::Result<Response, LlmClientError> {
1755 Err(LlmClientError::ContextWindowExceeded {
1756 model: decision.selected_model().to_string(),
1757 message: "prompt is too long".to_string(),
1758 })
1759 }
1760 }
1761
1762 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1763 semantic_name: name.to_string(),
1764 llm_client: Some(c),
1765 };
1766 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1767 judge_target: target("judge", QueuedClient::new([])), efficient_target: target("efficient", Arc::new(OverflowClient)),
1769 capable_target: target("capable", QueuedClient::new(["capable answer"])),
1770 contract: ClassifierContractConfig::default(),
1771 config: EscalationJudgeConfig {
1772 confirmations: 1,
1773 ..EscalationJudgeConfig::default()
1774 },
1775 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1776 })?);
1777
1778 let (trace, response) = router.run(Context::default(), classify_request()).await?;
1779
1780 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1781 assert_eq!(
1782 response.llm_response.as_agg().map(completion_text),
1783 Some("capable answer".to_string())
1784 );
1785 Ok(())
1786 }
1787}