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