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 },
349 )],
350 tools: vec![],
351 };
352
353 let stream = provider
355 .stream(&model, &context, None)
356 .await
357 .map_err(|e| anyhow::anyhow!("classifier stream error: {e}"))?;
358
359 let text = collect_stream_text(stream).await?;
360 parse_tier_from_response(&text, heuristic_score)
361 }
362}
363
364fn build_classifier_prompt(input: &ClassifierInput, heuristic_score: f64) -> String {
366 let msg_preview = if input.message.len() > 500 {
367 format!("{}...", &input.message[..500])
368 } else {
369 input.message.clone()
370 };
371
372 format!(
373 "Categorize this request into one tier:\n\
374 - high: architecture, design, planning, complex debugging, large refactors\n\
375 - medium: implementation, normal coding, multi-file edits\n\
376 - low: summaries, formatting, quick questions, simple lookups\n\
377 \n\
378 Context tokens: {}\n\
379 Turn count: {}\n\
380 Heuristic score: {heuristic_score:.2}\n\
381 \n\
382 User request:\n\
383 {msg_preview}\n\
384 \n\
385 Reply with exactly one word: high, medium, or low",
386 input.context_tokens, input.turn_count
387 )
388}
389
390async fn collect_stream_text(
392 stream: std::pin::Pin<Box<dyn futures::Stream<Item = crate::ProviderEvent> + Send>>,
393) -> Result<String> {
394 use futures::StreamExt;
395
396 let mut text = String::new();
397 let mut stream = stream;
398
399 while let Some(event) = stream.next().await {
400 match event {
401 crate::ProviderEvent::TextDelta { delta, .. } => {
402 text.push_str(&delta);
403 }
404 crate::ProviderEvent::Done { .. } => break,
405 crate::ProviderEvent::Error { reason, .. } => {
406 anyhow::bail!("classifier stream error: {reason:?}");
407 }
408 _ => {}
409 }
410 }
411
412 Ok(text)
413}
414
415fn parse_tier_from_response(text: &str, fallback: f64) -> Result<f64> {
420 let lower = text.to_lowercase();
421
422 if lower.contains("high") {
424 return Ok(0.9);
425 }
426 if lower.contains("medium") {
427 return Ok(0.5);
428 }
429 if lower.contains("low") {
430 return Ok(0.1);
431 }
432
433 tracing::warn!(
435 "LLM classifier returned unparseable response: '{text}', falling back to heuristic score {fallback:.2}"
436 );
437 Ok(fallback)
438}
439
440#[cfg(test)]
443mod tests {
444 use super::*;
445
446 fn make_input(message: &str) -> ClassifierInput {
447 ClassifierInput {
448 message: message.to_string(),
449 context_tokens: 0,
450 turn_count: 0,
451 available_tools: vec![],
452 }
453 }
454
455 fn make_input_with_context(message: &str, tokens: usize, turns: usize) -> ClassifierInput {
456 ClassifierInput {
457 message: message.to_string(),
458 context_tokens: tokens,
459 turn_count: turns,
460 available_tools: vec![],
461 }
462 }
463
464 #[test]
467 fn simple_greeting() {
468 let classifier = HeuristicClassifier::new();
469 let score = classifier.classify(&make_input("hello"));
470 assert!(score < 0.1, "greeting should score very low, got {score}");
471 }
472
473 #[test]
474 fn simple_thanks() {
475 let classifier = HeuristicClassifier::new();
476 let score = classifier.classify(&make_input("thank you!"));
477 assert!(score < 0.1, "thanks should score very low, got {score}");
478 }
479
480 #[test]
481 fn korean_greeting() {
482 let classifier = HeuristicClassifier::new();
483 let score = classifier.classify(&make_input("안녕하세요"));
484 assert!(
485 score < 0.1,
486 "korean greeting should score very low, got {score}"
487 );
488 }
489
490 #[test]
491 fn short_question() {
492 let classifier = HeuristicClassifier::new();
493 let score = classifier.classify(&make_input("what is rust?"));
494 assert!(score < 0.1, "short question should score low, got {score}");
495 }
496
497 #[test]
498 fn japanese_short() {
499 let classifier = HeuristicClassifier::new();
500 let score = classifier.classify(&make_input("これ何?"));
501 assert!(score < 0.1, "japanese short should score low, got {score}");
502 }
503
504 #[test]
507 fn medium_request() {
508 let classifier = HeuristicClassifier::new();
509 let score = classifier.classify(&make_input(
510 "Modify the config file to add the new endpoint for the auth service",
511 ));
512 assert!(
513 (0.05..0.35).contains(&score),
514 "medium request should score modest, got {score}"
515 );
516 }
517
518 #[test]
519 fn multi_line_request() {
520 let classifier = HeuristicClassifier::new();
521 let score = classifier.classify(&make_input(
522 "I need to update the following:\n- config file\n- router\n- middleware",
523 ));
524 assert!(
525 score > 0.1,
526 "multi-line should score higher than single line, got {score}"
527 );
528 }
529
530 #[test]
533 fn long_request_with_code_blocks() {
534 let classifier = HeuristicClassifier::new();
535 let score = classifier.classify(&make_input(
536 "Debug this error:\n```rust\nfn main() { panic!() }\n```\nThe stack trace shows a null pointer.",
537 ));
538 assert!(
539 score >= 0.25,
540 "code block request should score medium+, got {score}"
541 );
542 }
543
544 #[test]
545 fn multi_file_request() {
546 let classifier = HeuristicClassifier::new();
547 let score = classifier.classify(&make_input(
548 "Update src/main.rs and lib/config.rs to implement the new API",
549 ));
550 assert!(
551 score >= 0.15,
552 "multi-file should score medium+, got {score}"
553 );
554 }
555
556 #[test]
557 fn long_technical_request() {
558 let classifier = HeuristicClassifier::new();
559 let score = classifier.classify(&make_input(&format!(
560 "I need to implement a distributed event sourcing system with CQRS. \
561 The system should support: (1) event store with append-only log, \
562 (2) command bus with validation, (3) query side with materialized views, \
563 (4) saga orchestration for distributed transactions. \
564 Here's my current architecture:\n{}\nPlease review and suggest improvements.",
565 "x".repeat(200)
566 )));
567 assert!(
568 score >= 0.2,
569 "long technical request should score medium+, got {score}"
570 );
571 }
572
573 #[test]
574 fn high_symbol_density() {
575 let classifier = HeuristicClassifier::new();
576 let score = classifier.classify(&make_input(
577 r#"{"type": "router", "config": {"high": {"model": "opus"}, "low": {"model": "haiku"}}}"#,
578 ));
579 assert!(
580 score >= 0.15,
581 "json/config should score higher due to symbol density, got {score}"
582 );
583 }
584
585 #[test]
588 fn large_context_boosts_score() {
589 let classifier = HeuristicClassifier::new();
590 let low = classifier.classify(&make_input_with_context("hello", 0, 0));
591 let high = classifier.classify(&make_input_with_context("hello", 30_000, 15));
592 assert!(
593 high > low,
594 "large context should boost score: {low} vs {high}"
595 );
596 }
597
598 #[test]
599 fn turn_count_increases_score() {
600 let classifier = HeuristicClassifier::new();
601 let low = classifier.classify(&make_input_with_context("update this", 0, 0));
602 let high = classifier.classify(&make_input_with_context("update this", 10_000, 12));
603 assert!(
604 high >= low,
605 "more turns+context should boost score: {low} vs {high}"
606 );
607 }
608
609 #[test]
612 fn detect_file_paths() {
613 let input = make_input("Look at src/main.rs for the bug");
614 assert!(input.contains_file_paths());
615 assert_eq!(input.file_path_count(), 1);
616 }
617
618 #[test]
619 fn detect_multiple_file_paths() {
620 let input = make_input("Update src/main.rs and lib/config.rs");
621 assert_eq!(input.file_path_count(), 2);
622 }
623
624 #[test]
625 fn no_file_paths() {
626 let input = make_input("What is a closure?");
627 assert!(!input.contains_file_paths());
628 }
629
630 #[test]
631 fn detect_code_blocks() {
632 let input = make_input("Here is the code:\n```rust\nfn main() {}\n```");
633 assert!(input.contains_code_blocks());
634 }
635
636 #[test]
637 fn no_code_blocks() {
638 let input = make_input("Just a plain message");
639 assert!(!input.contains_code_blocks());
640 }
641
642 #[test]
643 fn detect_question() {
644 let input = make_input("what is this?");
645 assert!(input.is_question());
646 }
647
648 #[test]
649 fn detect_single_sentence() {
650 let input = make_input("hello world");
651 assert!(input.is_single_sentence());
652 }
653
654 #[test]
655 fn not_single_sentence() {
656 let input = make_input("hello\nworld");
657 assert!(!input.is_single_sentence());
658 }
659
660 #[test]
661 fn symbol_density_plain_text() {
662 let input = make_input("hello world this is a test");
663 assert!(input.symbol_density() < 0.05);
664 }
665
666 #[test]
667 fn symbol_density_code() {
668 let input = make_input("fn main() -> Result<Vec<String>> { Ok(vec![]) }");
669 let density = input.symbol_density();
670 assert!(
671 density > 0.10,
672 "code should have high symbol density, got {density}"
673 );
674 }
675
676 #[test]
677 fn line_count_single() {
678 let input = make_input("hello");
679 assert_eq!(input.line_count(), 1);
680 }
681
682 #[test]
683 fn line_count_multi() {
684 let input = make_input("line1\nline2\nline3");
685 assert_eq!(input.line_count(), 3);
686 }
687
688 #[test]
691 fn score_always_in_bounds() {
692 let classifier = HeuristicClassifier::new();
693 let inputs = vec![
694 make_input(""),
695 make_input(&"x".repeat(10000)),
696 make_input_with_context("", 100_000, 100),
697 make_input("hello thanks 안녕 こんにちは"),
698 make_input("```python\nprint('hello')\n```"),
699 ];
700 for input in &inputs {
701 let score = classifier.classify(input);
702 assert!((0.0..=1.0).contains(&score), "score out of bounds: {score}");
703 }
704 }
705
706 #[test]
709 fn language_independence_short() {
710 let classifier = HeuristicClassifier::new();
711 let short_messages = vec!["hello", "안녕", "こんにちは", "你好", "Привет", "مرحبا"];
713 for msg in &short_messages {
714 let score = classifier.classify(&make_input(msg));
715 assert!(
716 score < 0.1,
717 "short message '{msg}' should score very low, got {score}"
718 );
719 }
720 }
721
722 #[test]
723 fn language_independence_long_with_code() {
724 let classifier = HeuristicClassifier::new();
725 let messages = vec![
727 format!(
728 "Refactor this:\n```\nfn main() {{}}\n```\n{}",
729 "x".repeat(100)
730 ),
731 format!(
732 "이 코드를 수정해:\n```\nfn main() {{}}\n```\n{}",
733 "x".repeat(100)
734 ),
735 format!(
736 "このコードを修正:\n```\nfn main() {{}}\n```\n{}",
737 "x".repeat(100)
738 ),
739 ];
740 for msg in &messages {
741 let score = classifier.classify(&make_input(msg));
742 assert!(
743 score > 0.2,
744 "long+code message should score medium+, got {score}"
745 );
746 }
747 }
748
749 #[tokio::test]
752 async fn llm_classifier_no_model_configured() {
753 let classifier = LlmClassifier::new(None);
754 let input = make_input("test");
755 let result = classifier.classify(&input, 0.5).await;
756 assert!(result.is_err());
757 }
758
759 #[test]
760 fn llm_classifier_default() {
761 let classifier = LlmClassifier::default();
762 assert!(classifier.model.is_none());
763 }
764
765 #[test]
768 fn parse_high_response() {
769 let score = super::parse_tier_from_response("high", 0.5).unwrap();
770 assert!((score - 0.9).abs() < 1e-6);
771 }
772
773 #[test]
774 fn parse_medium_response() {
775 let score = super::parse_tier_from_response("medium", 0.5).unwrap();
776 assert!((score - 0.5).abs() < 1e-6);
777 }
778
779 #[test]
780 fn parse_low_response() {
781 let score = super::parse_tier_from_response("low", 0.5).unwrap();
782 assert!((score - 0.1).abs() < 1e-6);
783 }
784
785 #[test]
786 fn parse_case_insensitive() {
787 let score = super::parse_tier_from_response("HIGH", 0.5).unwrap();
788 assert!((score - 0.9).abs() < 1e-6);
789 }
790
791 #[test]
792 fn parse_with_extra_text() {
793 let score = super::parse_tier_from_response("I think this is high tier", 0.5).unwrap();
794 assert!((score - 0.9).abs() < 1e-6);
795 }
796
797 #[test]
798 fn parse_unparseable_falls_back() {
799 let score = super::parse_tier_from_response("maybe", 0.42).unwrap();
800 assert!((score - 0.42).abs() < 1e-6);
801 }
802
803 #[test]
806 fn prompt_contains_user_message() {
807 let input = make_input("Debug the authentication module");
808 let prompt = super::build_classifier_prompt(&input, 0.5);
809 assert!(prompt.contains("Debug the authentication module"));
810 assert!(prompt.contains("high"));
811 assert!(prompt.contains("medium"));
812 assert!(prompt.contains("low"));
813 }
814
815 #[test]
816 fn prompt_truncates_long_message() {
817 let input = make_input(&"x".repeat(600));
818 let prompt = super::build_classifier_prompt(&input, 0.5);
819 assert!(prompt.contains("..."));
820 assert!(prompt.len() < 1000);
821 }
822}