1use anyhow::Result;
15
16#[derive(Debug, Clone)]
20pub struct ClassifierInput {
21 pub message: String,
23 pub context_tokens: usize,
25 pub turn_count: usize,
27 pub available_tools: Vec<String>,
29}
30
31impl ClassifierInput {
32 pub fn contains_code_blocks(&self) -> bool {
34 self.message.contains("```")
35 }
36
37 pub fn contains_file_paths(&self) -> bool {
42 let msg = self.message.as_bytes();
43 let mut i = 0;
44 while i < msg.len() {
45 if msg[i] == b'/' || msg[i] == b'\\' {
46 for j in (i + 1)..std::cmp::min(i + 20, msg.len()) {
47 if msg[j] == b'.' && j + 1 < msg.len() && msg[j + 1].is_ascii_alphabetic() {
48 return true;
49 }
50 }
51 }
52 i += 1;
53 }
54 false
55 }
56
57 pub fn line_count(&self) -> usize {
59 self.message.lines().count().max(1)
60 }
61
62 pub fn symbol_density(&self) -> f64 {
68 if self.message.is_empty() {
69 return 0.0;
70 }
71 let code_symbols: &[u8] = b"{}()[]<>=;|&!@#$%^*+-/:\\";
72 let count = self
73 .message
74 .bytes()
75 .filter(|b| code_symbols.contains(b))
76 .count();
77 count as f64 / self.message.len() as f64
78 }
79
80 pub fn is_question(&self) -> bool {
82 self.message.trim().ends_with('?')
83 }
84
85 pub fn is_single_sentence(&self) -> bool {
89 let trimmed = self.message.trim();
90 !trimmed.contains('\n') && trimmed.split_whitespace().count() <= 3
91 }
92
93 pub fn file_path_count(&self) -> usize {
95 let msg = self.message.as_bytes();
96 let mut count = 0;
97 let mut i = 0;
98 while i < msg.len() {
99 if msg[i] == b'/' || msg[i] == b'\\' {
100 for j in (i + 1)..std::cmp::min(i + 20, msg.len()) {
101 if msg[j] == b'.' && j + 1 < msg.len() && msg[j + 1].is_ascii_alphabetic() {
102 count += 1;
103 i = j + 1;
105 break;
106 }
107 }
108 }
109 i += 1;
110 }
111 count
112 }
113}
114
115#[derive(Debug, Clone)]
139pub struct HeuristicClassifier {
140 context_threshold_high: usize,
142 context_threshold_low: usize,
144}
145
146impl Default for HeuristicClassifier {
147 fn default() -> Self {
148 Self::new()
149 }
150}
151
152impl HeuristicClassifier {
153 pub fn new() -> Self {
155 Self {
156 context_threshold_high: 20_000,
157 context_threshold_low: 2_000,
158 }
159 }
160
161 pub fn classify(&self, input: &ClassifierInput) -> f64 {
163 let mut score = 0.0;
164
165 score += self.length_weight(input.message.len());
167
168 score += self.line_weight(input.line_count());
170
171 if input.contains_code_blocks() {
173 score += 0.12;
174 }
175
176 let path_count = input.file_path_count();
178 if path_count > 0 {
179 score += (0.08 + 0.06 * (path_count - 1).min(2) as f64).min(0.20);
181 }
182
183 score += self.symbol_density_weight(input.symbol_density());
185
186 score += self.context_weight(input.context_tokens);
188
189 score += self.turn_weight(input.turn_count);
191
192 if input.is_single_sentence() {
194 score -= 0.08;
195 }
196 if input.is_question() && input.message.len() < 80 {
197 score -= 0.06;
199 }
200
201 score.clamp(0.0, 1.0)
202 }
203
204 fn length_weight(&self, len: usize) -> f64 {
208 if len < 20 {
209 0.0
210 } else if len < 60 {
211 0.05
212 } else if len < 200 {
213 0.12
214 } else if len < 600 {
215 0.22
216 } else if len < 2000 {
217 0.32
218 } else {
219 0.38
220 }
221 }
222
223 fn line_weight(&self, lines: usize) -> f64 {
225 if lines <= 1 {
226 0.0
227 } else if lines <= 3 {
228 0.03
229 } else if lines <= 10 {
230 0.08
231 } else {
232 0.12
233 }
234 }
235
236 fn symbol_density_weight(&self, density: f64) -> f64 {
240 if density < 0.03 {
241 0.0
242 } else if density < 0.08 {
243 0.02
244 } else if density < 0.15 {
245 0.06
246 } else {
247 0.10
249 }
250 }
251
252 fn context_weight(&self, tokens: usize) -> f64 {
254 if tokens < self.context_threshold_low {
255 0.0
256 } else if tokens < self.context_threshold_high {
257 let ratio = (tokens - self.context_threshold_low) as f64
258 / (self.context_threshold_high - self.context_threshold_low) as f64;
259 0.12 * ratio
260 } else {
261 0.12
262 }
263 }
264
265 fn turn_weight(&self, turns: usize) -> f64 {
267 if turns < 2 {
268 0.0
269 } else if turns < 5 {
270 0.02
271 } else if turns < 10 {
272 0.04
273 } else {
274 0.06
275 }
276 }
277}
278
279#[derive(Debug, Clone, Default)]
289pub struct LlmClassifier {
290 pub model: Option<String>,
292}
293
294impl LlmClassifier {
295 pub fn new(model: Option<String>) -> Self {
297 Self { model }
298 }
299
300 pub async fn classify(&self, input: &ClassifierInput, heuristic_score: f64) -> Result<f64> {
312 let model_str = self
313 .model
314 .as_deref()
315 .ok_or_else(|| anyhow::anyhow!("no classifier model configured"))?;
316
317 let (provider_name, model_id) = model_str
318 .split_once('/')
319 .ok_or_else(|| anyhow::anyhow!("invalid classifier model format: {model_str}"))?;
320
321 let provider = crate::providers::get_provider_arc(provider_name)
323 .ok_or_else(|| anyhow::anyhow!("unknown provider: {provider_name}"))?;
324
325 let model = crate::types::Model::new(
327 model_id,
328 model_id,
329 crate::Api::AnthropicMessages,
330 provider_name,
331 "",
332 );
333
334 let prompt = build_classifier_prompt(input, heuristic_score);
336
337 let context = crate::context::Context {
339 system_prompt: Some(
340 "You are a model router classifier. Reply with exactly one word: high, medium, or low."
341 .to_string(),
342 ),
343 messages: vec![crate::messages::Message::User(
344 crate::messages::UserMessage {
345 role: crate::messages::UserRole::User,
346 content: crate::messages::MessageContent::Text(prompt),
347 timestamp: 0,
348 visible: true,
349 },
350 )],
351 tools: vec![],
352 };
353
354 let stream = provider
356 .stream(&model, &context, None)
357 .await
358 .map_err(|e| anyhow::anyhow!("classifier stream error: {e}"))?;
359
360 let text = collect_stream_text(stream).await?;
361 parse_tier_from_response(&text, heuristic_score)
362 }
363}
364
365fn build_classifier_prompt(input: &ClassifierInput, heuristic_score: f64) -> String {
367 let msg_preview = if input.message.len() > 500 {
368 format!("{}...", &input.message[..500])
369 } else {
370 input.message.clone()
371 };
372
373 format!(
374 "Categorize this request into one tier:\n\
375 - high: architecture, design, planning, complex debugging, large refactors\n\
376 - medium: implementation, normal coding, multi-file edits\n\
377 - low: summaries, formatting, quick questions, simple lookups\n\
378 \n\
379 Context tokens: {}\n\
380 Turn count: {}\n\
381 Heuristic score: {heuristic_score:.2}\n\
382 \n\
383 User request:\n\
384 {msg_preview}\n\
385 \n\
386 Reply with exactly one word: high, medium, or low",
387 input.context_tokens, input.turn_count
388 )
389}
390
391async fn collect_stream_text(
393 stream: std::pin::Pin<Box<dyn futures::Stream<Item = crate::ProviderEvent> + Send>>,
394) -> Result<String> {
395 use futures::StreamExt;
396
397 let mut text = String::new();
398 let mut stream = stream;
399
400 while let Some(event) = stream.next().await {
401 match event {
402 crate::ProviderEvent::TextDelta { delta, .. } => {
403 text.push_str(&delta);
404 }
405 crate::ProviderEvent::Done { .. } => break,
406 crate::ProviderEvent::Error { reason, .. } => {
407 anyhow::bail!("classifier stream error: {reason:?}");
408 }
409 _ => {}
410 }
411 }
412
413 Ok(text)
414}
415
416fn parse_tier_from_response(text: &str, fallback: f64) -> Result<f64> {
421 let lower = text.to_lowercase();
422
423 if lower.contains("high") {
425 return Ok(0.9);
426 }
427 if lower.contains("medium") {
428 return Ok(0.5);
429 }
430 if lower.contains("low") {
431 return Ok(0.1);
432 }
433
434 tracing::warn!(
436 "LLM classifier returned unparseable response: '{text}', falling back to heuristic score {fallback:.2}"
437 );
438 Ok(fallback)
439}
440
441#[cfg(test)]
444mod tests {
445 use super::*;
446
447 fn make_input(message: &str) -> ClassifierInput {
448 ClassifierInput {
449 message: message.to_string(),
450 context_tokens: 0,
451 turn_count: 0,
452 available_tools: vec![],
453 }
454 }
455
456 fn make_input_with_context(message: &str, tokens: usize, turns: usize) -> ClassifierInput {
457 ClassifierInput {
458 message: message.to_string(),
459 context_tokens: tokens,
460 turn_count: turns,
461 available_tools: vec![],
462 }
463 }
464
465 #[test]
468 fn simple_greeting() {
469 let classifier = HeuristicClassifier::new();
470 let score = classifier.classify(&make_input("hello"));
471 assert!(score < 0.1, "greeting should score very low, got {score}");
472 }
473
474 #[test]
475 fn simple_thanks() {
476 let classifier = HeuristicClassifier::new();
477 let score = classifier.classify(&make_input("thank you!"));
478 assert!(score < 0.1, "thanks should score very low, got {score}");
479 }
480
481 #[test]
482 fn korean_greeting() {
483 let classifier = HeuristicClassifier::new();
484 let score = classifier.classify(&make_input("안녕하세요"));
485 assert!(
486 score < 0.1,
487 "korean greeting should score very low, got {score}"
488 );
489 }
490
491 #[test]
492 fn short_question() {
493 let classifier = HeuristicClassifier::new();
494 let score = classifier.classify(&make_input("what is rust?"));
495 assert!(score < 0.1, "short question should score low, got {score}");
496 }
497
498 #[test]
499 fn japanese_short() {
500 let classifier = HeuristicClassifier::new();
501 let score = classifier.classify(&make_input("これ何?"));
502 assert!(score < 0.1, "japanese short should score low, got {score}");
503 }
504
505 #[test]
508 fn medium_request() {
509 let classifier = HeuristicClassifier::new();
510 let score = classifier.classify(&make_input(
511 "Modify the config file to add the new endpoint for the auth service",
512 ));
513 assert!(
514 (0.05..0.35).contains(&score),
515 "medium request should score modest, got {score}"
516 );
517 }
518
519 #[test]
520 fn multi_line_request() {
521 let classifier = HeuristicClassifier::new();
522 let score = classifier.classify(&make_input(
523 "I need to update the following:\n- config file\n- router\n- middleware",
524 ));
525 assert!(
526 score > 0.1,
527 "multi-line should score higher than single line, got {score}"
528 );
529 }
530
531 #[test]
534 fn long_request_with_code_blocks() {
535 let classifier = HeuristicClassifier::new();
536 let score = classifier.classify(&make_input(
537 "Debug this error:\n```rust\nfn main() { panic!() }\n```\nThe stack trace shows a null pointer.",
538 ));
539 assert!(
540 score >= 0.25,
541 "code block request should score medium+, got {score}"
542 );
543 }
544
545 #[test]
546 fn multi_file_request() {
547 let classifier = HeuristicClassifier::new();
548 let score = classifier.classify(&make_input(
549 "Update src/main.rs and lib/config.rs to implement the new API",
550 ));
551 assert!(
552 score >= 0.15,
553 "multi-file should score medium+, got {score}"
554 );
555 }
556
557 #[test]
558 fn long_technical_request() {
559 let classifier = HeuristicClassifier::new();
560 let score = classifier.classify(&make_input(&format!(
561 "I need to implement a distributed event sourcing system with CQRS. \
562 The system should support: (1) event store with append-only log, \
563 (2) command bus with validation, (3) query side with materialized views, \
564 (4) saga orchestration for distributed transactions. \
565 Here's my current architecture:\n{}\nPlease review and suggest improvements.",
566 "x".repeat(200)
567 )));
568 assert!(
569 score >= 0.2,
570 "long technical request should score medium+, got {score}"
571 );
572 }
573
574 #[test]
575 fn high_symbol_density() {
576 let classifier = HeuristicClassifier::new();
577 let score = classifier.classify(&make_input(
578 r#"{"type": "router", "config": {"high": {"model": "opus"}, "low": {"model": "haiku"}}}"#,
579 ));
580 assert!(
581 score >= 0.15,
582 "json/config should score higher due to symbol density, got {score}"
583 );
584 }
585
586 #[test]
589 fn large_context_boosts_score() {
590 let classifier = HeuristicClassifier::new();
591 let low = classifier.classify(&make_input_with_context("hello", 0, 0));
592 let high = classifier.classify(&make_input_with_context("hello", 30_000, 15));
593 assert!(
594 high > low,
595 "large context should boost score: {low} vs {high}"
596 );
597 }
598
599 #[test]
600 fn turn_count_increases_score() {
601 let classifier = HeuristicClassifier::new();
602 let low = classifier.classify(&make_input_with_context("update this", 0, 0));
603 let high = classifier.classify(&make_input_with_context("update this", 10_000, 12));
604 assert!(
605 high >= low,
606 "more turns+context should boost score: {low} vs {high}"
607 );
608 }
609
610 #[test]
613 fn detect_file_paths() {
614 let input = make_input("Look at src/main.rs for the bug");
615 assert!(input.contains_file_paths());
616 assert_eq!(input.file_path_count(), 1);
617 }
618
619 #[test]
620 fn detect_multiple_file_paths() {
621 let input = make_input("Update src/main.rs and lib/config.rs");
622 assert_eq!(input.file_path_count(), 2);
623 }
624
625 #[test]
626 fn no_file_paths() {
627 let input = make_input("What is a closure?");
628 assert!(!input.contains_file_paths());
629 }
630
631 #[test]
632 fn detect_code_blocks() {
633 let input = make_input("Here is the code:\n```rust\nfn main() {}\n```");
634 assert!(input.contains_code_blocks());
635 }
636
637 #[test]
638 fn no_code_blocks() {
639 let input = make_input("Just a plain message");
640 assert!(!input.contains_code_blocks());
641 }
642
643 #[test]
644 fn detect_question() {
645 let input = make_input("what is this?");
646 assert!(input.is_question());
647 }
648
649 #[test]
650 fn detect_single_sentence() {
651 let input = make_input("hello world");
652 assert!(input.is_single_sentence());
653 }
654
655 #[test]
656 fn not_single_sentence() {
657 let input = make_input("hello\nworld");
658 assert!(!input.is_single_sentence());
659 }
660
661 #[test]
662 fn symbol_density_plain_text() {
663 let input = make_input("hello world this is a test");
664 assert!(input.symbol_density() < 0.05);
665 }
666
667 #[test]
668 fn symbol_density_code() {
669 let input = make_input("fn main() -> Result<Vec<String>> { Ok(vec![]) }");
670 let density = input.symbol_density();
671 assert!(
672 density > 0.10,
673 "code should have high symbol density, got {density}"
674 );
675 }
676
677 #[test]
678 fn line_count_single() {
679 let input = make_input("hello");
680 assert_eq!(input.line_count(), 1);
681 }
682
683 #[test]
684 fn line_count_multi() {
685 let input = make_input("line1\nline2\nline3");
686 assert_eq!(input.line_count(), 3);
687 }
688
689 #[test]
692 fn score_always_in_bounds() {
693 let classifier = HeuristicClassifier::new();
694 let inputs = vec![
695 make_input(""),
696 make_input(&"x".repeat(10000)),
697 make_input_with_context("", 100_000, 100),
698 make_input("hello thanks 안녕 こんにちは"),
699 make_input("```python\nprint('hello')\n```"),
700 ];
701 for input in &inputs {
702 let score = classifier.classify(input);
703 assert!((0.0..=1.0).contains(&score), "score out of bounds: {score}");
704 }
705 }
706
707 #[test]
710 fn language_independence_short() {
711 let classifier = HeuristicClassifier::new();
712 let short_messages = vec!["hello", "안녕", "こんにちは", "你好", "Привет", "مرحبا"];
714 for msg in &short_messages {
715 let score = classifier.classify(&make_input(msg));
716 assert!(
717 score < 0.1,
718 "short message '{msg}' should score very low, got {score}"
719 );
720 }
721 }
722
723 #[test]
724 fn language_independence_long_with_code() {
725 let classifier = HeuristicClassifier::new();
726 let messages = vec![
728 format!(
729 "Refactor this:\n```\nfn main() {{}}\n```\n{}",
730 "x".repeat(100)
731 ),
732 format!(
733 "이 코드를 수정해:\n```\nfn main() {{}}\n```\n{}",
734 "x".repeat(100)
735 ),
736 format!(
737 "このコードを修正:\n```\nfn main() {{}}\n```\n{}",
738 "x".repeat(100)
739 ),
740 ];
741 for msg in &messages {
742 let score = classifier.classify(&make_input(msg));
743 assert!(
744 score > 0.2,
745 "long+code message should score medium+, got {score}"
746 );
747 }
748 }
749
750 #[tokio::test]
753 async fn llm_classifier_no_model_configured() {
754 let classifier = LlmClassifier::new(None);
755 let input = make_input("test");
756 let result = classifier.classify(&input, 0.5).await;
757 assert!(result.is_err());
758 }
759
760 #[test]
761 fn llm_classifier_default() {
762 let classifier = LlmClassifier::default();
763 assert!(classifier.model.is_none());
764 }
765
766 #[test]
769 fn parse_high_response() {
770 let score = super::parse_tier_from_response("high", 0.5).unwrap();
771 assert!((score - 0.9).abs() < 1e-6);
772 }
773
774 #[test]
775 fn parse_medium_response() {
776 let score = super::parse_tier_from_response("medium", 0.5).unwrap();
777 assert!((score - 0.5).abs() < 1e-6);
778 }
779
780 #[test]
781 fn parse_low_response() {
782 let score = super::parse_tier_from_response("low", 0.5).unwrap();
783 assert!((score - 0.1).abs() < 1e-6);
784 }
785
786 #[test]
787 fn parse_case_insensitive() {
788 let score = super::parse_tier_from_response("HIGH", 0.5).unwrap();
789 assert!((score - 0.9).abs() < 1e-6);
790 }
791
792 #[test]
793 fn parse_with_extra_text() {
794 let score = super::parse_tier_from_response("I think this is high tier", 0.5).unwrap();
795 assert!((score - 0.9).abs() < 1e-6);
796 }
797
798 #[test]
799 fn parse_unparseable_falls_back() {
800 let score = super::parse_tier_from_response("maybe", 0.42).unwrap();
801 assert!((score - 0.42).abs() < 1e-6);
802 }
803
804 #[test]
807 fn prompt_contains_user_message() {
808 let input = make_input("Debug the authentication module");
809 let prompt = super::build_classifier_prompt(&input, 0.5);
810 assert!(prompt.contains("Debug the authentication module"));
811 assert!(prompt.contains("high"));
812 assert!(prompt.contains("medium"));
813 assert!(prompt.contains("low"));
814 }
815
816 #[test]
817 fn prompt_truncates_long_message() {
818 let input = make_input(&"x".repeat(600));
819 let prompt = super::build_classifier_prompt(&input, 0.5);
820 assert!(prompt.contains("..."));
821 assert!(prompt.len() < 1000);
822 }
823}