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, 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 = driver
475 .call_llm_target(
476 Context::default(),
477 &self.efficient,
478 request.clone(),
479 Arc::new(SimpleDecision {
480 selected_model: self.efficient.semantic_name.clone(),
481 reasoning: Some("escalation classifier: efficient tier".into()),
482 }),
483 )
484 .await?;
485 let agg = efficient_response
486 .llm_response
487 .into_agg()
488 .await
489 .map_err(|e| LibsyError::AlgorithmError {
490 message: format!("failed to aggregate efficient response: {e}"),
491 })?;
492 let mut judge_request = request.clone();
494 judge_request
495 .llm_request
496 .messages
497 .push(assistant_message(&agg));
498 let efficient_response = Response {
499 llm_response: if request.llm_request.stream {
500 LlmResponse::Stream(agg.into_stream())
501 } else {
502 LlmResponse::Agg(agg)
503 },
504 metadata: efficient_response.metadata,
505 };
506
507 let (classification, _) = self
508 .judge
509 .score(state, &mut judge_request, Some(driver))
510 .await?;
511
512 let held = streak(state);
513 let best = classification.argmax(false)?;
514 let (escalate, pending) = match &best {
515 Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
516 Some(_) => (false, 0),
517 None => (false, held),
518 };
519 state
520 .extra
521 .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
522
523 if escalate && pending >= self.confirmations {
524 return Ok((decisive(&self.capable.semantic_name), None));
526 }
527
528 Ok((
529 decisive(&self.efficient.semantic_name),
530 Some(efficient_response),
531 ))
532 }
533}
534
535pub struct LlmTaskClassifier {
537 route: FallThrough<State>,
538 inner: Arc<dyn Classifier<State>>,
540}
541
542struct ClassifierRouteConfig {
543 default_target: String,
544 session_affinity: bool,
545 message_hash_fallback: bool,
546}
547
548#[non_exhaustive]
550pub enum LlmClassifierConfig {
551 Capability {
553 judge_target: LlmTarget,
555 efficient_target: LlmTarget,
557 capable_target: LlmTarget,
559 config: TaskClassifierConfig,
561 },
562 Escalation {
564 judge_target: LlmTarget,
566 efficient_target: LlmTarget,
568 capable_target: LlmTarget,
570 contract: ClassifierContractConfig,
572 config: EscalationJudgeConfig,
574 max_output_tokens: u64,
576 },
577 Custom {
579 judge_target: LlmTarget,
581 targets: Vec<(String, LlmTarget)>,
583 default_target: String,
585 config: CustomClassifierConfig,
587 },
588}
589
590impl LlmTaskClassifier {
591 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
598 match config {
599 LlmClassifierConfig::Capability {
600 judge_target,
601 efficient_target,
602 capable_target,
603 config,
604 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
605 LlmClassifierConfig::Escalation {
606 judge_target,
607 efficient_target,
608 capable_target,
609 contract,
610 config,
611 max_output_tokens,
612 } => Self::build_escalation(
613 judge_target,
614 efficient_target,
615 capable_target,
616 contract,
617 config,
618 max_output_tokens,
619 ),
620 LlmClassifierConfig::Custom {
621 judge_target,
622 targets,
623 default_target,
624 config,
625 } => Self::build_custom(judge_target, targets, default_target, config),
626 }
627 }
628
629 fn build_capability(
630 judge_target: LlmTarget,
631 efficient_target: LlmTarget,
632 capable_target: LlmTarget,
633 config: TaskClassifierConfig,
634 ) -> Result<Self> {
635 config.validate()?;
636 let contract = Self::load_capability_contract(&config.contract)?;
637 let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
638 let session_affinity = config.session_affinity;
639 let message_hash_fallback = config.message_hash_fallback;
640 let classifier = Arc::new(TaskClassifier {
641 classifier: JudgeClassifier::new(
642 StructuredJudge::new(
643 TaskInput {
644 recent_turn_window: config.recent_turn_window,
645 },
646 contract,
647 SerdeDecoder::new(),
648 JudgeRuntimeConfig::new(config.max_output_tokens)?,
649 ),
650 judge_target.clone(),
651 TaskClassifierPolicy::new(
652 efficient_target.semantic_name.clone(),
653 capable_target.semantic_name.clone(),
654 &config,
655 ),
656 ),
657 efficient_target: efficient_target.semantic_name.clone(),
658 capable_target: capable_target.semantic_name.clone(),
659 });
660 let inner: Arc<dyn Classifier<State>> = classifier.clone();
661 Self::from_classifier(
662 targets,
663 inner,
664 ClassifierRouteConfig {
665 default_target: classifier.capable_target.clone(),
666 session_affinity,
667 message_hash_fallback,
668 },
669 )
670 }
671
672 fn build_custom(
673 judge_target: LlmTarget,
674 targets: Vec<(String, LlmTarget)>,
675 default_target: String,
676 config: CustomClassifierConfig,
677 ) -> Result<Self> {
678 config.validate()?;
679 if targets.len() < 2 {
680 return Err(LibsyError::AlgorithmError {
681 message: "custom classifier requires at least two targets".to_string(),
682 });
683 }
684
685 let mut labels = BTreeSet::new();
686 let mut semantic_names = BTreeSet::new();
687 let mut target_map = BTreeMap::new();
688 let mut resolved_targets = Vec::with_capacity(targets.len());
689 for (label, target) in targets {
690 if label.trim().is_empty() || label.trim() != label {
691 return Err(LibsyError::AlgorithmError {
692 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
693 .to_string(),
694 });
695 }
696 if !labels.insert(label.clone()) {
697 return Err(LibsyError::AlgorithmError {
698 message: format!("custom classifier target label {label:?} is duplicated"),
699 });
700 }
701 if !semantic_names.insert(target.semantic_name.clone()) {
702 return Err(LibsyError::AlgorithmError {
703 message: format!(
704 "custom classifier resolved target {:?} is duplicated",
705 target.semantic_name
706 ),
707 });
708 }
709 target_map.insert(label, target.semantic_name.clone());
710 resolved_targets.push(target);
711 }
712 let default_semantic_name =
713 target_map
714 .get(&default_target)
715 .cloned()
716 .ok_or_else(|| LibsyError::AlgorithmError {
717 message: format!(
718 "default_target {default_target:?} must be one of the configured targets"
719 ),
720 })?;
721
722 let CustomClassifierConfig {
723 prompt,
724 response_schema,
725 policy,
726 session_affinity,
727 message_hash_fallback,
728 recent_turn_window,
729 max_output_tokens,
730 } = config;
731 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
732 let policy = match policy {
733 CustomClassifierPolicy::TargetSelector { selector } => {
734 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
735 selector, target_map,
736 )?)
737 }
738 };
739 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
740 StructuredJudge::new(
741 TaskInput { recent_turn_window },
742 contract,
743 JsonSchemaDecoder::new(),
744 JudgeRuntimeConfig::new(max_output_tokens)?,
745 ),
746 judge_target,
747 policy,
748 ));
749
750 Self::from_classifier(
751 LlmTargetSet::new(resolved_targets),
752 classifier,
753 ClassifierRouteConfig {
754 default_target: default_semantic_name,
755 session_affinity,
756 message_hash_fallback,
757 },
758 )
759 }
760
761 fn build_escalation(
762 judge_target: LlmTarget,
763 efficient_target: LlmTarget,
764 capable_target: LlmTarget,
765 contract_config: ClassifierContractConfig,
766 config: EscalationJudgeConfig,
767 max_output_tokens: u64,
768 ) -> Result<Self> {
769 let capable_name = capable_target.semantic_name.clone();
770 let efficient_name = efficient_target.semantic_name.clone();
771 let confirmations = config.confirmations;
772 let esc = Arc::new(EscalationClassifier {
773 judge: escalation::build_judge(
774 judge_target,
775 capable_name,
776 efficient_name,
777 &contract_config,
778 config,
779 max_output_tokens,
780 )?,
781 capable: capable_target.clone(),
782 efficient: efficient_target.clone(),
783 confirmations,
784 });
785 let inner: Arc<dyn Classifier<State>> = esc.clone();
786 let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
787 Ok(Self {
788 route: FallThrough::<State>::new_with_state(targets)
789 .with_name(ALGORITHM_NAME)
790 .with_classifier(esc),
791 inner,
792 })
793 }
794
795 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
797 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
798 }
799
800 fn from_classifier(
802 targets: LlmTargetSet,
803 inner: Arc<dyn Classifier<State>>,
804 config: ClassifierRouteConfig,
805 ) -> Result<Self> {
806 targets.get_target(&config.default_target)?;
807 if config.message_hash_fallback && !config.session_affinity {
808 return Err(LibsyError::AlgorithmError {
809 message: "message_hash_fallback requires session_affinity".to_string(),
810 });
811 }
812 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
816 if config.session_affinity {
817 let affinity = if config.message_hash_fallback {
818 AffinityRouter::new().with_message_hash_fallback()
819 } else {
820 AffinityRouter::new()
821 };
822 let affinity = Arc::new(affinity);
824 route = route
825 .with_processor(affinity.clone())
826 .with_classifier(affinity);
827 }
828 let fallback = DefaultTarget::new(config.default_target);
829 Ok(Self {
830 route: route
831 .with_classifier(inner.clone())
832 .with_classifier(Arc::new(fallback)),
833 inner,
834 })
835 }
836}
837
838#[async_trait]
839impl Classifier<State> for TaskClassifier {
840 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
841 if self.efficient_target == self.capable_target {
842 None
843 } else if selected_model == self.efficient_target {
844 Some("weak")
845 } else if selected_model == self.capable_target {
846 Some("strong")
847 } else {
848 None
849 }
850 }
851
852 async fn score(
853 &self,
854 state: &mut State,
855 request: &mut Request,
856 driver: Option<&Driver>,
857 ) -> Result<(Classification, Option<Response>)> {
858 self.classifier.score(state, request, driver).await
859 }
860}
861
862#[async_trait]
863impl Classifier<State> for LlmTaskClassifier {
864 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
865 self.inner.routing_tier(selected_model)
866 }
867
868 async fn score(
869 &self,
870 state: &mut State,
871 request: &mut Request,
872 driver: Option<&Driver>,
873 ) -> Result<(Classification, Option<Response>)> {
874 self.inner.score(state, request, driver).await
875 }
876}
877
878#[async_trait]
879impl Algorithm for LlmTaskClassifier {
880 fn name(&self) -> &str {
881 "llm_task_classifier"
882 }
883
884 fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
885 self.route.count_tokens_client()
886 }
887
888 async fn create_run_task(
889 self: Arc<Self>,
890 ctx: Context,
891 driver: Driver,
892 request: Request,
893 ) -> Result<Response> {
894 self.route.execute(ctx, driver, request).await
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use std::sync::Arc;
901
902 use parking_lot::Mutex;
903 use serde_json::Value;
904
905 use super::*;
906 use switchyard_protocol::{
907 LlmClientError, LlmRequest, Metadata, completion_text, text_request, text_response,
908 };
909
910 use crate::algorithms::util::llm_judge::Judge;
911 use crate::core::algorithm::Algorithm;
912 use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
913
914 const TEST_THRESHOLD: f64 = 0.5;
915
916 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
917 TaskClassifierConfig {
918 base_threshold,
919 ..TaskClassifierConfig::default()
920 }
921 }
922
923 fn policy() -> TaskClassifierPolicy {
924 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
925 }
926
927 fn verdict(
928 p_solve: f64,
929 capability_boundary: &str,
930 primary_rule: &str,
931 ) -> TaskClassifierVerdict {
932 TaskClassifierVerdict {
933 crux: "test crux".to_string(),
934 primary_rule: primary_rule.to_string(),
935 capability_boundary: capability_boundary.to_string(),
936 p_solve,
937 }
938 }
939
940 fn selected(
941 policy: &TaskClassifierPolicy,
942 verdict: Option<&TaskClassifierVerdict>,
943 ) -> Result<String> {
944 policy
945 .to_classification(verdict)
946 .argmax(false)?
947 .map(|score| score.target)
948 .ok_or_else(|| LibsyError::AlgorithmError {
949 message: "policy abstained".to_string(),
950 })
951 }
952
953 #[derive(Default)]
954 struct PerRequestClient {
955 calls: Mutex<Vec<String>>,
956 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
957 judge_system_prompts: Mutex<Vec<String>>,
958 }
959
960 impl PerRequestClient {
961 fn calls(&self) -> Vec<String> {
962 self.calls.lock().clone()
963 }
964
965 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
966 self.judge_max_output_tokens.lock().clone()
967 }
968
969 fn judge_system_prompts(&self) -> Vec<String> {
970 self.judge_system_prompts.lock().clone()
971 }
972 }
973
974 #[async_trait]
975 impl RoutedLlmClient for PerRequestClient {
976 async fn call(
977 &self,
978 _ctx: Context,
979 request: Request,
980 decision: Arc<dyn Decision>,
981 ) -> std::result::Result<Response, LlmClientError> {
982 let model = decision.selected_model().to_string();
983 self.calls.lock().push(model.clone());
984 let completion = if model == "judge" {
985 self.judge_max_output_tokens
986 .lock()
987 .push(request.llm_request.output.max_output_tokens);
988 self.judge_system_prompts.lock().extend(
989 request
990 .llm_request
991 .messages
992 .first()
993 .and_then(|message| message.text_content("\n")),
994 );
995 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
996 } else {
997 format!("answer from {model}")
998 };
999 Ok(Response {
1000 llm_response: LlmResponse::Agg(text_response(None, completion)),
1001 metadata: request.metadata,
1002 })
1003 }
1004 }
1005
1006 struct UnreachableJudgeClient;
1007
1008 #[async_trait]
1009 impl RoutedLlmClient for UnreachableJudgeClient {
1010 async fn call(
1011 &self,
1012 _ctx: Context,
1013 request: Request,
1014 decision: Arc<dyn Decision>,
1015 ) -> std::result::Result<Response, LlmClientError> {
1016 let model = decision.selected_model().to_string();
1017 if model == "judge" {
1018 return Err(LlmClientError::Timeout {
1019 source: Box::new(std::io::Error::other("judge unreachable")),
1020 });
1021 }
1022 Ok(Response {
1023 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1024 metadata: request.metadata,
1025 })
1026 }
1027 }
1028
1029 fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
1030 let target = |name: &str| LlmTarget {
1031 semantic_name: name.to_string(),
1032 llm_client: Some(client.clone()),
1033 };
1034 Ok(Arc::new(LlmTaskClassifier::new(
1035 LlmClassifierConfig::Capability {
1036 judge_target: target("judge"),
1037 efficient_target: target("efficient"),
1038 capable_target: target("capable"),
1039 config: test_config(TEST_THRESHOLD),
1040 },
1041 )?))
1042 }
1043
1044 fn classify_request() -> Request {
1045 Request {
1046 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1047 raw_request: None,
1048 metadata: None,
1049 }
1050 }
1051
1052 fn classify_session_request() -> Request {
1053 Request {
1054 metadata: Some(Metadata {
1055 session_id: Some("session-1".to_string()),
1056 ..Metadata::default()
1057 }),
1058 ..classify_request()
1059 }
1060 }
1061
1062 fn classify_follow_up_request() -> Request {
1063 let mut request = classify_request();
1064 request
1065 .llm_request
1066 .messages
1067 .push(Message::text(Role::Assistant, "I will add the test."));
1068 request.llm_request.messages.push(Message::text(
1069 Role::User,
1070 "Now run the test suite and report the result.",
1071 ));
1072 request
1073 }
1074
1075 #[tokio::test]
1076 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1077 let router = router(Arc::new(UnreachableJudgeClient))?;
1078
1079 let (trace, response) = router.run(Context::default(), classify_request()).await?;
1080
1081 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1082 assert_eq!(
1083 response.llm_response.as_agg().map(completion_text),
1084 Some("answer from capable".to_string())
1085 );
1086 Ok(())
1087 }
1088
1089 #[tokio::test]
1090 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1091 let client = Arc::new(PerRequestClient::default());
1092 let router = router(client.clone())?;
1093 let request = classify_request;
1094
1095 router.clone().run(Context::default(), request()).await?;
1096 router.clone().run(Context::default(), request()).await?;
1097
1098 assert_eq!(
1099 client.calls(),
1100 vec!["judge", "efficient", "judge", "efficient"]
1101 );
1102 Ok(())
1103 }
1104
1105 #[tokio::test]
1106 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1107 let client = Arc::new(PerRequestClient::default());
1108 let target = |name: &str| LlmTarget {
1109 semantic_name: name.to_string(),
1110 llm_client: Some(client.clone()),
1111 };
1112 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1113 judge_target: target("judge"),
1114 efficient_target: target("efficient"),
1115 capable_target: target("capable"),
1116 config: TaskClassifierConfig {
1117 max_output_tokens: 512,
1118 ..test_config(TEST_THRESHOLD)
1119 },
1120 })?);
1121
1122 router.run(Context::default(), classify_request()).await?;
1123
1124 assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
1125 Ok(())
1126 }
1127
1128 #[tokio::test]
1129 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1130 let client = Arc::new(PerRequestClient::default());
1131 let target = |name: &str| LlmTarget {
1132 semantic_name: name.to_string(),
1133 llm_client: Some(client.clone()),
1134 };
1135 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1136 judge_target: target("judge"),
1137 efficient_target: target("efficient"),
1138 capable_target: target("capable"),
1139 config: TaskClassifierConfig {
1140 contract: ClassifierContractConfig::default()
1141 .with_prompt("Custom capability rubric."),
1142 ..test_config(TEST_THRESHOLD)
1143 },
1144 })?);
1145
1146 router.run(Context::default(), classify_request()).await?;
1147
1148 let prompts = client.judge_system_prompts();
1149 assert_eq!(prompts.len(), 1);
1150 assert_eq!(prompts[0], "Custom capability rubric.");
1151 Ok(())
1152 }
1153
1154 #[tokio::test]
1155 async fn classifier_config_enables_session_affinity() -> Result<()> {
1156 let client = Arc::new(PerRequestClient::default());
1157 let target = |name: &str| LlmTarget {
1158 semantic_name: name.to_string(),
1159 llm_client: Some(client.clone()),
1160 };
1161 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1162 judge_target: target("judge"),
1163 efficient_target: target("efficient"),
1164 capable_target: target("capable"),
1165 config: TaskClassifierConfig {
1166 session_affinity: true,
1167 ..test_config(TEST_THRESHOLD)
1168 },
1169 })?);
1170
1171 router
1172 .clone()
1173 .run(Context::default(), classify_session_request())
1174 .await?;
1175 router
1176 .clone()
1177 .run(Context::default(), classify_session_request())
1178 .await?;
1179
1180 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1181 Ok(())
1182 }
1183
1184 #[tokio::test]
1185 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1186 let client = Arc::new(PerRequestClient::default());
1187 let target = |name: &str| LlmTarget {
1188 semantic_name: name.to_string(),
1189 llm_client: Some(client.clone()),
1190 };
1191 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1192 judge_target: target("judge"),
1193 efficient_target: target("efficient"),
1194 capable_target: target("capable"),
1195 config: TaskClassifierConfig {
1196 session_affinity: true,
1197 message_hash_fallback: true,
1198 recent_turn_window: None,
1199 ..test_config(TEST_THRESHOLD)
1200 },
1201 })?);
1202
1203 router
1204 .clone()
1205 .run(Context::default(), classify_request())
1206 .await?;
1207 router
1208 .clone()
1209 .run(Context::default(), classify_follow_up_request())
1210 .await?;
1211
1212 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1213 Ok(())
1214 }
1215
1216 #[test]
1217 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1218 let policy = policy();
1219 let at_threshold = verdict(0.5, "supported", "SUP-1");
1220 let below_threshold = verdict(0.49, "supported", "SUP-1");
1221 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1222 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1223 Ok(())
1224 }
1225
1226 #[test]
1227 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1228 let borderline = verdict(0.5, "supported", "SUP-1");
1229 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1230 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1231 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1232 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1233 Ok(())
1234 }
1235
1236 #[test]
1237 fn classifier_config_rejects_unknown_fields() {
1238 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1239 "base_threshold": 0.5,
1240 "classifier_magic": true,
1241 }))
1242 .expect_err("unknown classifier fields must be rejected");
1243
1244 assert!(
1245 error
1246 .to_string()
1247 .contains("unknown field `classifier_magic`"),
1248 "{error}"
1249 );
1250 }
1251
1252 #[test]
1253 fn invalid_classifier_config_is_rejected() -> Result<()> {
1254 let target = |name: &str| LlmTarget {
1255 semantic_name: name.to_string(),
1256 llm_client: None,
1257 };
1258 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1259 assert!(
1260 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1261 judge_target: target("judge"),
1262 efficient_target: target("e"),
1263 capable_target: target("c"),
1264 config: test_config(bad),
1265 })
1266 .is_err(),
1267 "base threshold {bad} should be rejected"
1268 );
1269 }
1270 for config in [
1271 TaskClassifierConfig {
1272 base_threshold: 0.5,
1273 threshold_step: -0.1,
1274 ..TaskClassifierConfig::default()
1275 },
1276 TaskClassifierConfig {
1277 base_threshold: 0.8,
1278 threshold_step: 0.11,
1279 ..TaskClassifierConfig::default()
1280 },
1281 TaskClassifierConfig {
1282 base_threshold: 0.5,
1283 message_hash_fallback: true,
1284 ..TaskClassifierConfig::default()
1285 },
1286 TaskClassifierConfig {
1287 base_threshold: 0.5,
1288 max_output_tokens: 0,
1289 ..TaskClassifierConfig::default()
1290 },
1291 ] {
1292 assert!(
1293 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1294 judge_target: target("judge"),
1295 efficient_target: target("e"),
1296 capable_target: target("c"),
1297 config,
1298 })
1299 .is_err()
1300 );
1301 }
1302 for base_threshold in [0.0, 1.0] {
1303 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1304 judge_target: target("judge"),
1305 efficient_target: target("e"),
1306 capable_target: target("c"),
1307 config: test_config(base_threshold),
1308 })?;
1309 }
1310 Ok(())
1311 }
1312
1313 #[test]
1314 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1315 let policy = policy();
1316 let inconsistent_rule = TaskClassifierVerdict {
1317 capability_boundary: "uncertain".to_string(),
1318 ..verdict(1.0, "supported", "SUP-1")
1319 };
1320 let empty_crux = TaskClassifierVerdict {
1321 crux: " ".to_string(),
1322 ..verdict(1.0, "supported", "SUP-1")
1323 };
1324 let unusable = [
1325 Some(verdict(1.1, "supported", "SUP-1")),
1326 Some(inconsistent_rule),
1327 Some(empty_crux),
1328 None,
1329 ];
1330 for verdict in unusable {
1331 let classification = policy.to_classification(verdict.as_ref());
1332 assert!(matches!(classification, Classification::Ambiguous(_)));
1333 assert!(classification.argmax(false)?.is_none());
1334 assert!(classification.argmax(true)?.is_none());
1335 }
1336 Ok(())
1337 }
1338
1339 #[test]
1340 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1341 let policy = TaskClassifierPolicy::new(
1342 "efficient",
1343 "capable",
1344 &TaskClassifierConfig {
1345 threshold_step: 0.1,
1346 ..test_config(0.4)
1347 },
1348 );
1349
1350 assert_eq!(
1351 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1352 "efficient"
1353 );
1354 assert_eq!(
1355 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1356 "capable"
1357 );
1358 assert_eq!(
1359 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1360 "efficient"
1361 );
1362 assert_eq!(
1363 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1364 "efficient"
1365 );
1366 assert_eq!(
1367 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1368 "capable"
1369 );
1370 assert_eq!(
1371 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1372 "efficient"
1373 );
1374 Ok(())
1375 }
1376
1377 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1380 Ok(StructuredJudge::new(
1381 TaskInput { recent_turn_window },
1382 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1383 SerdeDecoder::new(),
1384 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1385 ))
1386 }
1387
1388 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1389 let judge = capability_judge(Some(recent_turn_window))?;
1390 let request = Request {
1391 llm_request: LlmRequest {
1392 messages: vec![
1393 Message::text(Role::System, "client instructions"),
1394 Message::text(Role::User, "initial task"),
1395 Message::text(Role::Assistant, "old response"),
1396 Message::text(Role::User, "old follow-up"),
1397 Message::text(Role::Assistant, "recent 1"),
1398 Message::text(Role::User, "recent 2"),
1399 ],
1400 ..LlmRequest::default()
1401 },
1402 raw_request: None,
1403 metadata: None,
1404 };
1405 Ok(judge
1406 .build_request(&State::default(), &request)
1407 .llm_request
1408 .messages
1409 .iter()
1410 .filter_map(|message| message.text_content("\n"))
1411 .collect())
1412 }
1413
1414 #[test]
1415 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1416 let contents = judged_contents(2)?;
1418 assert!(contents.contains(&"client instructions".to_string()));
1419 assert!(contents.contains(&"initial task".to_string()));
1420 assert!(contents.contains(&"recent 1".to_string()));
1421 assert!(contents.contains(&"recent 2".to_string()));
1422 assert!(!contents.contains(&"old response".to_string()));
1423 Ok(())
1424 }
1425
1426 #[test]
1427 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1428 let contents = judged_contents(0)?;
1429 assert!(contents.contains(&"client instructions".to_string()));
1430 assert!(contents.contains(&"initial task".to_string()));
1431 assert!(!contents.contains(&"recent 2".to_string()));
1432 Ok(())
1433 }
1434
1435 #[test]
1436 fn capability_judge_builds_a_structured_request() -> Result<()> {
1437 let judge = capability_judge(None)?;
1438 let request = Request {
1439 llm_request: LlmRequest {
1440 model: Some("inbound".to_string()),
1441 messages: vec![
1442 Message::text(Role::System, "client instructions"),
1443 Message::text(Role::Developer, "client developer instructions"),
1444 Message::text(Role::User, "initial task"),
1445 Message::text(Role::Assistant, "old response"),
1446 Message::text(Role::User, "old follow-up"),
1447 Message::text(Role::Assistant, "recent 1"),
1448 Message::text(Role::User, "recent 2"),
1449 Message::text(Role::Assistant, "recent 3"),
1450 Message::text(Role::User, "recent 4"),
1451 Message::text(Role::Assistant, "recent 5"),
1452 ],
1453 ..LlmRequest::default()
1454 },
1455 raw_request: None,
1456 metadata: None,
1457 };
1458 let judge_request = judge.build_request(&State::default(), &request);
1459
1460 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1461 assert_eq!(judge_request.llm_request.messages.len(), 3);
1462 let contents = judge_request
1463 .llm_request
1464 .messages
1465 .iter()
1466 .filter_map(|message| message.text_content("\n"))
1467 .collect::<Vec<_>>();
1468 assert!(contents.contains(&"recent 4".to_string()));
1469 assert!(contents.contains(&"initial task".to_string()));
1470 assert!(!contents.contains(&"recent 5".to_string()));
1471 assert!(!contents.contains(&"client instructions".to_string()));
1472 assert_eq!(
1473 judge_request.llm_request.output.response_format,
1474 Some(judge.contract().response_format().clone())
1475 );
1476 assert_eq!(
1477 judge_request.llm_request.output.max_output_tokens,
1478 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1479 );
1480 Ok(())
1481 }
1482
1483 fn sample_value(spec: &Value) -> Value {
1484 if let Some(first) = spec
1485 .get("enum")
1486 .and_then(Value::as_array)
1487 .and_then(|values| values.first())
1488 {
1489 return first.clone();
1490 }
1491 match spec.get("type").and_then(Value::as_str) {
1492 Some("number") => serde_json::json!(0.5),
1493 Some("boolean") => serde_json::json!(false),
1494 _ => serde_json::json!("sample"),
1495 }
1496 }
1497
1498 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1499 let properties = schema
1500 .pointer("/json_schema/schema/properties")
1501 .and_then(Value::as_object)
1502 .ok_or_else(|| LibsyError::AlgorithmError {
1503 message: "packaged schema declares no properties".to_string(),
1504 })?;
1505 Ok(Value::Object(
1506 properties
1507 .iter()
1508 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1509 .collect(),
1510 )
1511 .to_string())
1512 }
1513
1514 #[test]
1517 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1518 let contract =
1519 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1520 let schema = contract.response_format();
1521 let reply = schema_shaped_verdict(schema)?;
1522 let judge: CapabilityJudge = StructuredJudge::new(
1523 TaskInput {
1524 recent_turn_window: None,
1525 },
1526 contract,
1527 SerdeDecoder::new(),
1528 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1529 );
1530
1531 let verdict = judge.parse(&text_response(None, reply))?;
1532
1533 assert!(verdict.is_valid());
1534 assert!((0.0..=1.0).contains(&verdict.p_solve));
1535 Ok(())
1536 }
1537
1538 #[test]
1539 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1540 let contract =
1541 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1542 let prompt = contract.system_prompt();
1543 let schema_name = contract
1544 .response_format()
1545 .pointer("/json_schema/name")
1546 .and_then(Value::as_str)
1547 .ok_or_else(|| LibsyError::AlgorithmError {
1548 message: "packaged response schema has no name".to_string(),
1549 })?;
1550 assert_eq!(schema_name, "CapabilityClassifierDecision");
1551 assert!(prompt.contains("SUP-1 [supported]"));
1552 assert!(prompt.contains("SUP-5 [supported]"));
1553 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1554 assert!(!prompt.contains("\"type\": \"object\""));
1555 assert!(!prompt.contains("\"json_schema\""));
1556 assert!(!prompt.contains(schema_name));
1557 let rule_values = contract
1558 .response_format()
1559 .pointer("/json_schema/schema/properties/primary_rule/enum")
1560 .and_then(Value::as_array)
1561 .ok_or_else(|| LibsyError::AlgorithmError {
1562 message: "rendered response schema has no primary rule enum".to_string(),
1563 })?;
1564 assert!(
1565 rule_values
1566 .iter()
1567 .any(|value| value.as_str() == Some("SUP-1"))
1568 );
1569 assert!(
1570 rule_values
1571 .iter()
1572 .any(|value| value.as_str() == Some("none"))
1573 );
1574 Ok(())
1575 }
1576
1577 use std::collections::VecDeque;
1580
1581 use switchyard_protocol::LlmClientError as ClientError;
1582
1583 use switchyard_protocol::Decision;
1584
1585 struct QueuedClient {
1587 replies: Mutex<VecDeque<String>>,
1588 }
1589
1590 impl QueuedClient {
1591 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1592 Arc::new(Self {
1593 replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1594 })
1595 }
1596 }
1597
1598 #[async_trait]
1599 impl RoutedLlmClient for QueuedClient {
1600 async fn call(
1601 &self,
1602 _ctx: Context,
1603 request: Request,
1604 _decision: Arc<dyn Decision>,
1605 ) -> std::result::Result<Response, ClientError> {
1606 let reply = self
1607 .replies
1608 .lock()
1609 .pop_front()
1610 .unwrap_or_else(|| "unexpected call".to_string());
1611 Ok(Response {
1612 llm_response: LlmResponse::Agg(text_response(None, reply)),
1613 metadata: request.metadata,
1614 })
1615 }
1616 }
1617
1618 fn escalation_router(
1620 client: Arc<dyn RoutedLlmClient>,
1621 judge_client: Arc<dyn RoutedLlmClient>,
1622 ) -> Result<Arc<LlmTaskClassifier>> {
1623 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1624 semantic_name: name.to_string(),
1625 llm_client: Some(c),
1626 };
1627 Ok(Arc::new(LlmTaskClassifier::new(
1628 LlmClassifierConfig::Escalation {
1629 judge_target: target("judge", judge_client),
1630 efficient_target: target("efficient", client.clone()),
1631 capable_target: target("capable", client),
1632 contract: ClassifierContractConfig::default(),
1633 config: EscalationJudgeConfig {
1634 confirmations: 1,
1635 ..EscalationJudgeConfig::default()
1636 },
1637 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1638 },
1639 )?))
1640 }
1641
1642 #[tokio::test]
1643 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1644 let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1646 let model_client = QueuedClient::new(["efficient answer"]);
1647 let router = escalation_router(model_client, judge_client)?;
1648 let request = classify_request();
1649
1650 let (trace, response) = router.run(Context::default(), request).await?;
1651
1652 assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1654 assert_eq!(
1655 response.llm_response.as_agg().map(completion_text),
1656 Some("efficient answer".to_string())
1657 );
1658 Ok(())
1659 }
1660
1661 #[tokio::test]
1662 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1663 let client = Arc::new(PerRequestClient::default());
1664 let target = |name: &str| LlmTarget {
1665 semantic_name: name.to_string(),
1666 llm_client: Some(client.clone()),
1667 };
1668 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1669 judge_target: target("judge"),
1670 efficient_target: target("efficient"),
1671 capable_target: target("capable"),
1672 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1673 config: EscalationJudgeConfig {
1674 confirmations: 1,
1675 ..EscalationJudgeConfig::default()
1676 },
1677 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1678 })?);
1679
1680 router.run(Context::default(), classify_request()).await?;
1681
1682 let prompts = client.judge_system_prompts();
1683 assert_eq!(prompts.len(), 1);
1684 assert_eq!(prompts[0], "Custom trajectory rubric.");
1685 Ok(())
1686 }
1687
1688 #[tokio::test]
1689 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1690 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1692 let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1694 let router = escalation_router(model_client, judge_client)?;
1695 let request = classify_request();
1696
1697 let (trace, response) = router.run(Context::default(), request).await?;
1698
1699 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1700 assert_eq!(
1701 response.llm_response.as_agg().map(completion_text),
1702 Some("capable answer".to_string())
1703 );
1704 Ok(())
1705 }
1706
1707 #[tokio::test]
1708 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1709 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1712 let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
1713 let router = escalation_router(model_client, judge_client)?;
1714
1715 let session_request = classify_session_request();
1716 router
1717 .clone()
1718 .run(Context::default(), session_request.clone())
1719 .await?;
1720 let (trace, _) = router
1721 .clone()
1722 .run(Context::default(), session_request)
1723 .await?;
1724
1725 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1726 Ok(())
1727 }
1728}