1use async_trait::async_trait;
7use lc_core::language_models::LLMResult;
8use lc_core::runnables::RunnableConfig;
9use lc_core::tools::ToolDefinition;
10use lc_core::{BaseChatModel, Runnable};
11use lc_schema::Message;
12use serde::Deserialize;
13use serde_json::json;
14use serde_json::Value;
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use crate::base::{
19 run_chain_with_callbacks, stream_chain_with_callbacks, BaseChain, ChainError, ChainResult,
20 ChainStream,
21};
22
23pub struct RouteDestination {
25 name: String,
27 description: String,
29 chain: Arc<dyn BaseChain>,
31 keywords: Vec<String>,
33}
34
35impl RouteDestination {
36 pub fn new(
37 name: impl Into<String>,
38 description: impl Into<String>,
39 chain: Arc<dyn BaseChain>,
40 ) -> Self {
41 Self {
42 name: name.into(),
43 description: description.into(),
44 chain,
45 keywords: Vec::new(),
46 }
47 }
48
49 pub fn with_keywords(mut self, keywords: Vec<&str>) -> Self {
50 self.keywords = keywords.into_iter().map(String::from).collect();
51 self
52 }
53
54 pub fn name(&self) -> &str {
55 &self.name
56 }
57
58 pub fn description(&self) -> &str {
59 &self.description
60 }
61
62 pub fn chain(&self) -> &Arc<dyn BaseChain> {
63 &self.chain
64 }
65
66 pub fn keywords(&self) -> &[String] {
67 &self.keywords
68 }
69}
70
71pub struct RouterChain {
75 destinations: Vec<RouteDestination>,
77
78 default_chain: Option<Arc<dyn BaseChain>>,
80
81 input_key: String,
83
84 name: String,
86
87 verbose: bool,
89}
90
91impl RouterChain {
92 pub fn new() -> Self {
93 Self {
94 destinations: Vec::new(),
95 default_chain: None,
96 input_key: "input".to_string(),
97 name: "router_chain".to_string(),
98 verbose: false,
99 }
100 }
101
102 pub fn add_route(
103 mut self,
104 name: impl Into<String>,
105 description: impl Into<String>,
106 chain: Arc<dyn BaseChain>,
107 ) -> Self {
108 self.destinations
109 .push(RouteDestination::new(name, description, chain));
110 self
111 }
112
113 pub fn add_route_with_keywords(
114 mut self,
115 name: impl Into<String>,
116 description: impl Into<String>,
117 chain: Arc<dyn BaseChain>,
118 keywords: Vec<&str>,
119 ) -> Self {
120 self.destinations
121 .push(RouteDestination::new(name, description, chain).with_keywords(keywords));
122 self
123 }
124
125 pub fn with_default(mut self, chain: Arc<dyn BaseChain>) -> Self {
126 self.default_chain = Some(chain);
127 self
128 }
129
130 pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
131 self.input_key = key.into();
132 self
133 }
134
135 pub fn with_name(mut self, name: impl Into<String>) -> Self {
136 self.name = name.into();
137 self
138 }
139
140 pub fn with_verbose(mut self, verbose: bool) -> Self {
141 self.verbose = verbose;
142 self
143 }
144
145 pub fn destinations(&self) -> &[RouteDestination] {
146 &self.destinations
147 }
148
149 pub fn default_chain(&self) -> Option<&Arc<dyn BaseChain>> {
150 self.default_chain.as_ref()
151 }
152
153 fn route_by_keywords(&self, input: &str) -> Option<&RouteDestination> {
157 let mut best_match: Option<(&RouteDestination, usize)> = None;
158 for dest in &self.destinations {
159 for keyword in &dest.keywords {
160 if input.contains(keyword) {
161 let len = keyword.len();
162 if best_match.is_none() || len > best_match.unwrap().1 {
163 best_match = Some((dest, len));
164 }
165 }
166 }
167 }
168 best_match.map(|(dest, _)| dest)
169 }
170
171 fn select_route(&self, input: &str) -> Result<Option<&RouteDestination>, ChainError> {
173 if let Some(dest) = self.route_by_keywords(input) {
174 return Ok(Some(dest));
175 }
176
177 Ok(None)
178 }
179
180 async fn route_and_invoke(
183 &self,
184 inputs: HashMap<String, Value>,
185 config: Option<RunnableConfig>,
186 ) -> Result<ChainResult, ChainError> {
187 self.validate_inputs(&inputs)?;
188
189 let input = inputs
190 .get(&self.input_key)
191 .and_then(|v| v.as_str())
192 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
193
194 if self.verbose {
195 println!("\n=== RouterChain execution ===");
196 println!("Input: {}", input);
197 println!("Route destination count: {}", self.destinations.len());
198 }
199
200 let route_result = self.select_route(input)?;
201
202 let chain = match route_result {
203 Some(dest) => {
204 if self.verbose {
205 println!("Routed to: {} ({})", dest.name(), dest.description());
206 }
207 dest.chain()
208 }
209 None => {
210 if let Some(default) = &self.default_chain {
211 if self.verbose {
212 println!("No keyword match, using default Chain");
213 }
214 default
215 } else {
216 return Err(ChainError::ExecutionError(
217 "No matching route destination and no default Chain configured".to_string(),
218 ));
219 }
220 }
221 };
222
223 let result = chain.invoke_with_config(inputs, config).await?;
224
225 if self.verbose {
226 println!("=== RouterChain complete ===\n");
227 }
228
229 Ok(result)
230 }
231
232 async fn route_and_stream(
235 &self,
236 inputs: HashMap<String, Value>,
237 config: Option<RunnableConfig>,
238 ) -> Result<ChainStream, ChainError> {
239 self.validate_inputs(&inputs)?;
240
241 let input = inputs
242 .get(&self.input_key)
243 .and_then(|v| v.as_str())
244 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
245
246 let route_result = self.select_route(input)?;
247
248 let chain = match route_result {
249 Some(dest) => dest.chain(),
250 None => self.default_chain.as_ref().ok_or_else(|| {
251 ChainError::ExecutionError(
252 "No matching route destination and no default Chain configured".to_string(),
253 )
254 })?,
255 };
256
257 chain.stream_with_config(inputs, config).await
258 }
259}
260
261impl Default for RouterChain {
262 fn default() -> Self {
263 Self::new()
264 }
265}
266
267#[async_trait]
268impl BaseChain for RouterChain {
269 fn input_keys(&self) -> Vec<&str> {
270 vec![&self.input_key]
271 }
272
273 fn output_keys(&self) -> Vec<&str> {
274 let mut seen = std::collections::HashSet::new();
275 let mut result: Vec<&str> = Vec::new();
276
277 for dest in &self.destinations {
278 for key in dest.chain().output_keys() {
279 if seen.insert(key.to_string()) {
280 result.push(key);
281 }
282 }
283 }
284 if let Some(default) = &self.default_chain {
285 for key in default.output_keys() {
286 if seen.insert(key.to_string()) {
287 result.push(key);
288 }
289 }
290 }
291
292 if result.is_empty() {
293 vec!["output"]
294 } else {
295 result
296 }
297 }
298
299 async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
300 self.route_and_invoke(inputs, None).await
301 }
302
303 async fn invoke_with_config(
308 &self,
309 inputs: HashMap<String, Value>,
310 config: Option<RunnableConfig>,
311 ) -> Result<ChainResult, ChainError> {
312 run_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
313 self.route_and_invoke(inputs, config).await
314 })
315 .await
316 }
317
318 async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
323 self.route_and_stream(inputs, None).await
324 }
325
326 async fn stream_with_config(
328 &self,
329 inputs: HashMap<String, Value>,
330 config: Option<RunnableConfig>,
331 ) -> Result<ChainStream, ChainError> {
332 stream_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
333 self.route_and_stream(inputs, config).await
334 })
335 .await
336 }
337
338 fn name(&self) -> &str {
339 &self.name
340 }
341}
342
343#[derive(Debug, Clone, Deserialize)]
350pub(crate) struct RouteDecision {
351 pub destination: String,
353 pub reason: Option<String>,
355}
356
357impl RouteDecision {
358 fn from_text(text: &str) -> Self {
360 let trimmed = text.trim();
361 if let Ok(decision) = serde_json::from_str::<RouteDecision>(trimmed) {
362 return decision;
363 }
364 Self {
365 destination: trimmed.to_string(),
366 reason: None,
367 }
368 }
369}
370
371fn route_tool() -> ToolDefinition {
374 ToolDefinition::new(
375 "route_to_destination",
376 "根据用户输入选择最合适的处理 handler,返回目标名称与理由",
377 )
378 .with_parameters(json!({
379 "type": "object",
380 "properties": {
381 "destination": { "type": "string", "description": "目标 handler 名称" },
382 "reason": { "type": "string", "description": "选择该 handler 的理由" }
383 },
384 "required": ["destination"]
385 }))
386}
387
388pub struct LLMRouterChain<M: BaseChatModel> {
392 llm: M,
394
395 destinations: Vec<RouteDestination>,
397
398 default_chain: Option<Arc<dyn BaseChain>>,
400
401 input_key: String,
403
404 name: String,
406
407 verbose: bool,
409}
410
411impl<M: BaseChatModel + Send + Sync + 'static> LLMRouterChain<M> {
412 pub fn new(llm: M) -> Self {
413 Self {
414 llm,
415 destinations: Vec::new(),
416 default_chain: None,
417 input_key: "input".to_string(),
418 name: "llm_router_chain".to_string(),
419 verbose: false,
420 }
421 }
422
423 pub fn add_route(
424 mut self,
425 name: impl Into<String>,
426 description: impl Into<String>,
427 chain: Arc<dyn BaseChain>,
428 ) -> Self {
429 self.destinations
430 .push(RouteDestination::new(name, description, chain));
431 self
432 }
433
434 pub fn add_route_with_keywords(
435 mut self,
436 name: impl Into<String>,
437 description: impl Into<String>,
438 chain: Arc<dyn BaseChain>,
439 keywords: Vec<&str>,
440 ) -> Self {
441 self.destinations
442 .push(RouteDestination::new(name, description, chain).with_keywords(keywords));
443 self
444 }
445
446 pub fn with_default(mut self, chain: Arc<dyn BaseChain>) -> Self {
447 self.default_chain = Some(chain);
448 self
449 }
450
451 pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
452 self.input_key = key.into();
453 self
454 }
455
456 pub fn with_name(mut self, name: impl Into<String>) -> Self {
457 self.name = name.into();
458 self
459 }
460
461 pub fn with_verbose(mut self, verbose: bool) -> Self {
462 self.verbose = verbose;
463 self
464 }
465
466 pub fn destinations(&self) -> &[RouteDestination] {
467 &self.destinations
468 }
469
470 pub fn default_chain(&self) -> Option<&Arc<dyn BaseChain>> {
471 self.default_chain.as_ref()
472 }
473
474 fn build_router_prompt(&self, input: &str) -> String {
476 let mut prompt =
477 String::from("Based on the user input, select the most appropriate handler.\n\n");
478 prompt.push_str("Available handlers:\n");
479
480 for (i, dest) in self.destinations.iter().enumerate() {
481 prompt.push_str(&format!(
482 "{}. {}: {}\n",
483 i + 1,
484 dest.name(),
485 dest.description()
486 ));
487 }
488
489 prompt.push_str("\nUser input: ");
490 prompt.push_str(input);
491 prompt.push_str(
494 "\n\nReturn only the chosen handler as JSON: {\"destination\": \"<handler name>\", \"reason\": \"<why this handler>\"}",
495 );
496
497 prompt
498 }
499
500 async fn route_with_llm(
509 &self,
510 input: &str,
511 config: Option<RunnableConfig>,
512 ) -> Result<RouteDecision, ChainError> {
513 let prompt = self.build_router_prompt(input);
514
515 let messages = vec![Message::human(&prompt)];
516
517 let map_err = |e| ChainError::Nested {
518 context: "LLM routing call failed".to_string(),
519 source: Box::new(e),
520 };
521
522 let result = match self.llm.bind_tools(vec![route_tool()]) {
523 Some(bound) => bound.chat(messages, config).await.map_err(map_err)?,
524 None => self.llm.chat(messages, config).await.map_err(map_err)?,
525 };
526
527 if let Some(decision) = result
528 .tool_calls
529 .as_ref()
530 .and_then(|calls| calls.first())
531 .and_then(|call| call.parse_arguments::<RouteDecision>().ok())
532 {
533 return Ok(decision);
534 }
535
536 Ok(RouteDecision::from_text(&result.content))
537 }
538
539 fn find_destination(&self, name: &str) -> Option<&RouteDestination> {
541 let name_lower = name.to_lowercase();
542 if let Some(dest) = self
544 .destinations
545 .iter()
546 .find(|d| d.name().eq_ignore_ascii_case(name))
547 {
548 return Some(dest);
549 }
550 self.destinations.iter().find(|d| {
552 let d_lower = d.name().to_lowercase();
553 name_lower.starts_with(&d_lower)
554 || name_lower.ends_with(&d_lower)
555 || name_lower
556 .split_whitespace()
557 .any(|word| word.eq_ignore_ascii_case(&d_lower))
558 })
559 }
560
561 async fn select_route(
563 &self,
564 input: &str,
565 config: Option<RunnableConfig>,
566 ) -> Result<&RouteDestination, ChainError> {
567 if self.destinations.is_empty() {
568 return Err(ChainError::ExecutionError(
569 "No route destinations configured".to_string(),
570 ));
571 }
572
573 if self.destinations.len() == 1 {
574 return Ok(&self.destinations[0]);
575 }
576
577 let llm_note: Option<String> = {
584 let llm_result = self.route_with_llm(input, config).await;
585 match llm_result {
586 Ok(decision) => {
587 if let Some(reason) = &decision.reason {
588 if self.verbose {
589 println!("LLM route reason: {}", reason);
590 }
591 }
592 if let Some(dest) = self.find_destination(&decision.destination) {
593 return Ok(dest);
594 }
595 Some(format!(
596 "LLM returned an unknown route destination {:?}",
597 decision.destination
598 ))
599 }
600 Err(e) => Some(format!("LLM routing call failed: {}", e)),
601 }
602 };
603
604 let mut best_match: Option<(&RouteDestination, usize)> = None;
606 for dest in &self.destinations {
607 for keyword in dest.keywords() {
608 if input.contains(keyword) {
609 let len = keyword.len();
610 if best_match.is_none() || len > best_match.unwrap().1 {
611 best_match = Some((dest, len));
612 }
613 }
614 }
615 }
616 if let Some((dest, _)) = best_match {
617 return Ok(dest);
618 }
619
620 Err(ChainError::ExecutionError(format!(
621 "No matching route destination found ({})",
622 llm_note.unwrap_or_else(|| "LLM and keyword matching both failed".to_string())
623 )))
624 }
625
626 async fn route_and_invoke(
629 &self,
630 inputs: HashMap<String, Value>,
631 config: Option<RunnableConfig>,
632 ) -> Result<ChainResult, ChainError> {
633 self.validate_inputs(&inputs)?;
634
635 let input = inputs
636 .get(&self.input_key)
637 .and_then(|v| v.as_str())
638 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
639
640 if self.verbose {
641 println!("\n=== LLMRouterChain execution ===");
642 println!("Input: {}", input);
643 println!("Route destination count: {}", self.destinations.len());
644 }
645
646 let route_result = self.select_route(input, config.clone()).await;
647
648 let chain = match route_result {
649 Ok(dest) => {
650 if self.verbose {
651 println!("Routed to: {} ({})", dest.name(), dest.description());
652 }
653 dest.chain()
654 }
655 Err(e) => {
656 if let Some(default) = &self.default_chain {
657 log::error!("路由失败,改用默认链(调用方可能得到与输入不匹配的答案): {e}");
660 default
661 } else {
662 return Err(e);
663 }
664 }
665 };
666
667 let result = chain.invoke_with_config(inputs, config).await?;
668
669 if self.verbose {
670 println!("=== LLMRouterChain complete ===\n");
671 }
672
673 Ok(result)
674 }
675
676 async fn route_and_stream(
679 &self,
680 inputs: HashMap<String, Value>,
681 config: Option<RunnableConfig>,
682 ) -> Result<ChainStream, ChainError> {
683 self.validate_inputs(&inputs)?;
684
685 let input = inputs
686 .get(&self.input_key)
687 .and_then(|v| v.as_str())
688 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
689
690 let route_result = self.select_route(input, config.clone()).await;
691
692 let chain = match route_result {
693 Ok(dest) => dest.chain(),
694 Err(e) => {
695 if let Some(default) = &self.default_chain {
696 default
697 } else {
698 return Err(e);
699 }
700 }
701 };
702
703 chain.stream_with_config(inputs, config).await
704 }
705}
706
707#[async_trait]
708impl<M: BaseChatModel + Send + Sync + 'static> BaseChain for LLMRouterChain<M>
709where
710 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
711{
712 fn input_keys(&self) -> Vec<&str> {
713 vec![&self.input_key]
714 }
715
716 fn output_keys(&self) -> Vec<&str> {
717 let mut seen = std::collections::HashSet::new();
718 let mut result: Vec<&str> = Vec::new();
719
720 for dest in &self.destinations {
721 for key in dest.chain().output_keys() {
722 if seen.insert(key.to_string()) {
723 result.push(key);
724 }
725 }
726 }
727 if let Some(default) = &self.default_chain {
728 for key in default.output_keys() {
729 if seen.insert(key.to_string()) {
730 result.push(key);
731 }
732 }
733 }
734
735 if result.is_empty() {
736 vec!["output"]
737 } else {
738 result
739 }
740 }
741
742 async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
743 self.route_and_invoke(inputs, None).await
744 }
745
746 async fn invoke_with_config(
752 &self,
753 inputs: HashMap<String, Value>,
754 config: Option<RunnableConfig>,
755 ) -> Result<ChainResult, ChainError> {
756 run_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
757 self.route_and_invoke(inputs, config).await
758 })
759 .await
760 }
761
762 async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
767 self.route_and_stream(inputs, None).await
768 }
769
770 async fn stream_with_config(
772 &self,
773 inputs: HashMap<String, Value>,
774 config: Option<RunnableConfig>,
775 ) -> Result<ChainStream, ChainError> {
776 stream_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
777 self.route_and_stream(inputs, config).await
778 })
779 .await
780 }
781
782 fn name(&self) -> &str {
783 &self.name
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use async_trait::async_trait;
791 use futures_util::Stream;
792 use lc_core::runnables::RunnableConfig;
793 use lc_core::{BaseLanguageModel, Runnable};
794 use std::pin::Pin;
795 use std::sync::Arc;
796
797 #[derive(Debug)]
798 struct MockError(String);
799 impl std::fmt::Display for MockError {
800 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801 write!(f, "{}", self.0)
802 }
803 }
804 impl std::error::Error for MockError {}
805
806 #[derive(Clone)]
810 struct MockRouterLLM {
811 response: LLMResult,
812 supports_tools: bool,
813 }
814
815 impl MockRouterLLM {
816 fn tools(response: LLMResult) -> Self {
817 Self {
818 response,
819 supports_tools: true,
820 }
821 }
822 fn plain(response: LLMResult) -> Self {
823 Self {
824 response,
825 supports_tools: false,
826 }
827 }
828 }
829
830 #[async_trait]
831 impl Runnable<Vec<Message>, LLMResult> for MockRouterLLM {
832 type Error = MockError;
833 async fn invoke(
834 &self,
835 _input: Vec<Message>,
836 _config: Option<RunnableConfig>,
837 ) -> Result<LLMResult, Self::Error> {
838 Ok(self.response.clone())
839 }
840 }
841
842 #[async_trait]
843 impl BaseLanguageModel<Vec<Message>, LLMResult> for MockRouterLLM {
844 fn model_name(&self) -> &str {
845 "mock"
846 }
847 fn get_num_tokens(&self, t: &str) -> usize {
848 t.len()
849 }
850 fn with_temperature(self, _: f32) -> Self {
851 self
852 }
853 fn with_max_tokens(self, _: usize) -> Self {
854 self
855 }
856 }
857
858 #[async_trait]
859 impl BaseChatModel for MockRouterLLM {
860 async fn chat(
861 &self,
862 _messages: Vec<Message>,
863 _config: Option<RunnableConfig>,
864 ) -> Result<LLMResult, Self::Error> {
865 Ok(self.response.clone())
866 }
867 async fn stream_chat(
868 &self,
869 _messages: Vec<Message>,
870 _config: Option<RunnableConfig>,
871 ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>
872 {
873 let tokens = [Ok(self.response.content.clone())];
874 Ok(Box::pin(futures_util::stream::iter(tokens)))
875 }
876 fn bind_tools(
877 &self,
878 _tools: Vec<ToolDefinition>,
879 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
880 if self.supports_tools {
881 Some(Box::new(self.clone()))
882 } else {
883 None
884 }
885 }
886 }
887
888 struct EchoChain;
890
891 #[async_trait]
892 impl BaseChain for EchoChain {
893 fn input_keys(&self) -> Vec<&str> {
894 vec!["input"]
895 }
896 fn output_keys(&self) -> Vec<&str> {
897 vec!["output"]
898 }
899 async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
900 let mut result = HashMap::new();
901 if let Some(v) = inputs.get("input") {
902 result.insert("output".to_string(), v.clone());
903 }
904 Ok(result)
905 }
906 }
907
908 fn router_with(llm: MockRouterLLM) -> LLMRouterChain<MockRouterLLM> {
909 LLMRouterChain::new(llm)
910 .add_route(
911 "math",
912 "handles mathematical questions",
913 Arc::new(EchoChain),
914 )
915 .add_route(
916 "science",
917 "handles scientific questions",
918 Arc::new(EchoChain),
919 )
920 }
921
922 fn decision_response(destination: &str, reason: &str) -> LLMResult {
923 LLMResult {
924 content: String::new(),
925 model: "mock".to_string(),
926 token_usage: None,
927 tool_calls: Some(vec![lc_core::tools::ToolCall::new(
928 "call_1",
929 "route_to_destination",
930 format!(
931 r#"{{"destination": "{}", "reason": "{}"}}"#,
932 destination, reason
933 ),
934 )]),
935 thinking_content: None,
936 }
937 }
938
939 fn text_response(content: &str) -> LLMResult {
940 LLMResult {
941 content: content.to_string(),
942 model: "mock".to_string(),
943 token_usage: None,
944 tool_calls: None,
945 thinking_content: None,
946 }
947 }
948
949 #[tokio::test]
952 async fn test_llm_router_routes_via_tool_call() {
953 let chain = router_with(MockRouterLLM::tools(decision_response(
954 "math",
955 "user asked a calculation",
956 )));
957 let inputs = HashMap::from([("input".to_string(), json!("what is 2 + 2?"))]);
958
959 let result = chain.invoke(inputs).await.unwrap();
960 assert_eq!(
961 result.get("output").unwrap(),
962 &json!("what is 2 + 2?"),
963 "routed to the math destination"
964 );
965 }
966
967 #[tokio::test]
970 async fn test_llm_router_json_text_fallback() {
971 let chain = router_with(MockRouterLLM::plain(text_response(
972 r#"{"destination": "science", "reason": "explains a phenomenon"}"#,
973 )));
974 let inputs = HashMap::from([("input".to_string(), json!("why is the sky blue?"))]);
975
976 let result = chain.invoke(inputs).await.unwrap();
977 assert_eq!(
978 result.get("output").unwrap(),
979 &json!("why is the sky blue?"),
980 "routed to the science destination"
981 );
982 }
983
984 #[tokio::test]
986 async fn test_llm_router_bare_name_fallback() {
987 let chain = router_with(MockRouterLLM::plain(text_response("math")));
988 let inputs = HashMap::from([("input".to_string(), json!("calculate something"))]);
989
990 let result = chain.invoke(inputs).await.unwrap();
991 assert_eq!(result.get("output").unwrap(), &json!("calculate something"));
992 }
993
994 #[tokio::test]
997 async fn test_llm_router_unknown_destination_reports_diagnostics() {
998 let chain = router_with(MockRouterLLM::tools(decision_response(
999 "physics", "closest",
1000 )));
1001 let inputs = HashMap::from([("input".to_string(), json!("what is 2+2"))]);
1002
1003 let err = match chain.invoke(inputs).await {
1004 Ok(_) => panic!("expected a routing failure"),
1005 Err(e) => e,
1006 };
1007 let msg = format!("{err:?}");
1008 assert!(
1009 msg.contains("unknown route destination"),
1010 "expected the LLM diagnostics in the error, got: {msg}"
1011 );
1012 }
1013
1014 #[tokio::test]
1017 async fn test_llm_router_unknown_destination_keyword_still_routes() {
1018 let chain = LLMRouterChain::new(MockRouterLLM::tools(decision_response(
1019 "physics", "nonsense",
1020 )))
1021 .add_route_with_keywords(
1022 "math",
1023 "handles mathematical questions",
1024 Arc::new(EchoChain),
1025 vec!["calculate", "2+2"],
1026 )
1027 .add_route(
1028 "science",
1029 "handles scientific questions",
1030 Arc::new(EchoChain),
1031 );
1032
1033 let inputs = HashMap::from([("input".to_string(), json!("please calculate 2+2"))]);
1034 let result = chain.invoke(inputs).await.unwrap();
1035 assert_eq!(
1036 result.get("output").unwrap(),
1037 &json!("please calculate 2+2")
1038 );
1039 }
1040
1041 #[test]
1042 fn test_route_decision_from_text_json() {
1043 let d = RouteDecision::from_text(r#"{"destination": "math", "reason": "why"}"#);
1044 assert_eq!(d.destination, "math");
1045 assert_eq!(d.reason.as_deref(), Some("why"));
1046 }
1047
1048 #[test]
1049 fn test_route_decision_from_text_bare_name() {
1050 let d = RouteDecision::from_text(" science ");
1051 assert_eq!(d.destination, "science");
1052 assert!(d.reason.is_none());
1053 }
1054
1055 #[test]
1056 fn test_route_tool_schema() {
1057 let tool = route_tool();
1058 assert_eq!(tool.function.name, "route_to_destination");
1059 assert!(tool.function.parameters.is_some());
1060 }
1061}