use super::*;
use async_trait::async_trait;
use futures_util::Stream;
use lc_core::language_models::{LLMResult, StreamChunk};
use lc_core::runnables::RunnableConfig;
use lc_core::tools::ToolDefinition;
use lc_core::BaseChatModel;
use lc_core::{BaseLanguageModel, Runnable};
use lc_providers::ProviderError;
use lc_schema::Message;
use serde_json::json;
use serde_json::Value;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use crate::base::{BaseChain, ChainError, ChainResult};
#[derive(Clone)]
struct MockRouterLLM {
response: LLMResult,
supports_tools: bool,
}
impl MockRouterLLM {
fn tools(response: LLMResult) -> Self {
Self {
response,
supports_tools: true,
}
}
fn plain(response: LLMResult) -> Self {
Self {
response,
supports_tools: false,
}
}
}
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for MockRouterLLM {
type Error = ProviderError;
async fn invoke(
&self,
_input: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Ok(self.response.clone())
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for MockRouterLLM {
fn model_name(&self) -> &str {
"mock"
}
fn get_num_tokens(&self, t: &str) -> usize {
t.len()
}
fn with_temperature(self, _: f32) -> Self {
self
}
fn with_max_tokens(self, _: usize) -> Self {
self
}
}
#[async_trait]
impl BaseChatModel for MockRouterLLM {
async fn chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Ok(self.response.clone())
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
{
let tokens = [Ok(StreamChunk::new(self.response.content.clone()))];
Ok(Box::pin(futures_util::stream::iter(tokens)))
}
fn bind_tools(
&self,
_tools: Vec<ToolDefinition>,
) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
if self.supports_tools {
Some(Box::new(self.clone()))
} else {
None
}
}
}
struct EchoChain;
#[async_trait]
impl BaseChain for EchoChain {
fn input_keys(&self) -> Vec<&str> {
vec!["input"]
}
fn output_keys(&self) -> Vec<&str> {
vec!["output"]
}
async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
let mut result = HashMap::new();
if let Some(v) = inputs.get("input") {
result.insert("output".to_string(), v.clone());
}
Ok(result)
}
}
fn router_with(llm: MockRouterLLM) -> LLMRouterChain {
LLMRouterChain::new(llm)
.add_route(
"math",
"handles mathematical questions",
Arc::new(EchoChain),
)
.add_route(
"science",
"handles scientific questions",
Arc::new(EchoChain),
)
}
fn decision_response(destination: &str, reason: &str) -> LLMResult {
LLMResult {
content: String::new(),
model: "mock".to_string(),
token_usage: None,
tool_calls: Some(vec![lc_core::tools::ToolCall::builder("call_1")
.name("route_to_destination")
.arguments(format!(
r#"{{"destination": "{}", "reason": "{}"}}"#,
destination, reason
))
.build()]),
thinking_content: None,
}
}
fn text_response(content: &str) -> LLMResult {
LLMResult {
content: content.to_string(),
model: "mock".to_string(),
token_usage: None,
tool_calls: None,
thinking_content: None,
}
}
#[tokio::test]
async fn test_llm_router_routes_via_tool_call() {
let chain = router_with(MockRouterLLM::tools(decision_response(
"math",
"user asked a calculation",
)));
let inputs = HashMap::from([("input".to_string(), json!("what is 2 + 2?"))]);
let result = chain.invoke(inputs).await.unwrap();
assert_eq!(
result.get("output").unwrap(),
&json!("what is 2 + 2?"),
"routed to the math destination"
);
}
#[tokio::test]
async fn test_llm_router_json_text_fallback() {
let chain = router_with(MockRouterLLM::plain(text_response(
r#"{"destination": "science", "reason": "explains a phenomenon"}"#,
)));
let inputs = HashMap::from([("input".to_string(), json!("why is the sky blue?"))]);
let result = chain.invoke(inputs).await.unwrap();
assert_eq!(
result.get("output").unwrap(),
&json!("why is the sky blue?"),
"routed to the science destination"
);
}
#[tokio::test]
async fn test_llm_router_bare_name_fallback() {
let chain = router_with(MockRouterLLM::plain(text_response("math")));
let inputs = HashMap::from([("input".to_string(), json!("calculate something"))]);
let result = chain.invoke(inputs).await.unwrap();
assert_eq!(result.get("output").unwrap(), &json!("calculate something"));
}
#[tokio::test]
async fn test_llm_router_unknown_destination_reports_diagnostics() {
let chain = router_with(MockRouterLLM::tools(decision_response(
"physics", "closest",
)));
let inputs = HashMap::from([("input".to_string(), json!("what is 2+2"))]);
let err = match chain.invoke(inputs).await {
Ok(_) => panic!("expected a routing failure"),
Err(e) => e,
};
let msg = format!("{err:?}");
assert!(
msg.contains("unknown route destination"),
"expected the LLM diagnostics in the error, got: {msg}"
);
}
#[tokio::test]
async fn test_llm_router_unknown_destination_keyword_still_routes() {
let chain = LLMRouterChain::new(MockRouterLLM::tools(decision_response(
"physics", "nonsense",
)))
.add_route_with_keywords(
"math",
"handles mathematical questions",
Arc::new(EchoChain),
vec!["calculate", "2+2"],
)
.add_route(
"science",
"handles scientific questions",
Arc::new(EchoChain),
);
let inputs = HashMap::from([("input".to_string(), json!("please calculate 2+2"))]);
let result = chain.invoke(inputs).await.unwrap();
assert_eq!(
result.get("output").unwrap(),
&json!("please calculate 2+2")
);
}
#[test]
fn test_route_decision_from_text_json() {
let d = RouteDecision::from_text(r#"{"destination": "math", "reason": "why"}"#);
assert_eq!(d.destination, "math");
assert_eq!(d.reason.as_deref(), Some("why"));
}
#[test]
fn test_route_decision_from_text_bare_name() {
let d = RouteDecision::from_text(" science ");
assert_eq!(d.destination, "science");
assert!(d.reason.is_none());
}
#[test]
fn test_route_tool_schema() {
let tool = route_tool();
assert_eq!(tool.function.name, "route_to_destination");
assert!(tool.function.parameters.is_some());
}