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