use std::sync::atomic::Ordering;
use super::chat::collect_stream;
use super::embed_cache::TurnEmbedCache;
use super::*;
use crate::any::AnyProvider;
use crate::error::LlmError;
use crate::provider::{LlmProvider, Message, Role};
use std::assert_matches;
#[test]
fn empty_router_name() {
let r = RouterProvider::new(vec![]);
assert_eq!(r.name(), "router");
}
#[test]
fn empty_router_supports_nothing() {
let r = RouterProvider::new(vec![]);
assert!(!r.supports_streaming());
assert!(!r.supports_embeddings());
assert!(!r.supports_tool_use());
}
#[test]
fn empty_router_context_window_none() {
let r = RouterProvider::new(vec![]);
assert!(r.context_window().is_none());
}
#[tokio::test]
async fn empty_router_chat_returns_no_providers() {
let r = RouterProvider::new(vec![]);
let msgs = vec![Message::from_legacy(Role::User, "hello")];
let err = r.chat(&msgs).await.unwrap_err();
assert_matches!(err, LlmError::NoProviders);
}
#[tokio::test]
async fn empty_router_chat_stream_returns_no_providers() {
let r = RouterProvider::new(vec![]);
let msgs = vec![Message::from_legacy(Role::User, "hello")];
let result = r.chat_stream(&msgs).await;
assert!(matches!(result, Err(LlmError::NoProviders)));
}
#[tokio::test]
async fn empty_router_embed_returns_no_providers() {
let r = RouterProvider::new(vec![]);
let err = r.embed("test").await.unwrap_err();
assert_matches!(err, LlmError::NoProviders);
}
#[tokio::test]
async fn empty_router_chat_with_tools_returns_no_providers() {
let r = RouterProvider::new(vec![]);
let msgs = vec![Message::from_legacy(Role::User, "hello")];
let err = r.chat_with_tools(&msgs, &[]).await.unwrap_err();
assert_matches!(err, LlmError::NoProviders);
}
#[tokio::test]
async fn router_falls_back_on_unreachable() {
use crate::ollama::OllamaProvider;
let p1 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let p2 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:2",
"m".into(),
"e".into(),
));
let r = RouterProvider::new(vec![p1, p2]);
let msgs = vec![Message::from_legacy(Role::User, "hello")];
let err = r.chat(&msgs).await.unwrap_err();
assert!(
matches!(&err, LlmError::Other(msg) if msg.contains("Ollama chat request failed")),
"expected the last provider's actual error to survive exhaustion, got {err:?}"
);
}
#[test]
fn router_with_streaming_provider() {
use crate::ollama::OllamaProvider;
let p = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let r = RouterProvider::new(vec![p]);
assert!(r.supports_streaming());
assert!(r.supports_embeddings());
}
#[test]
fn clone_preserves_providers() {
use crate::ollama::OllamaProvider;
let p = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let r = RouterProvider::new(vec![p]);
let c = r.clone();
assert_eq!(c.state.providers.len(), 1);
assert_eq!(c.name(), "router");
}
#[test]
fn last_cache_usage_returns_none() {
let r = RouterProvider::new(vec![]);
assert!(r.last_cache_usage().is_none());
}
#[test]
fn thompson_strategy_is_set() {
let r = RouterProvider::new(vec![]).with_thompson(None);
assert_eq!(r.strategy, RouterStrategy::Thompson);
assert!(r.thompson.is_some());
}
#[tokio::test]
async fn save_thompson_state_noop_without_thompson() {
let r = RouterProvider::new(vec![]);
r.save_thompson_state().await; }
#[test]
fn thompson_ordered_providers_empty() {
let r = RouterProvider::new(vec![]).with_thompson(None);
let ordered = r.ordered_providers();
assert!(ordered.is_empty());
}
#[test]
fn concurrent_record_outcome_does_not_deadlock() {
use std::sync::Arc;
let r = Arc::new(RouterProvider::new(vec![]).with_thompson(None));
let handles: Vec<_> = (0..8)
.map(|i| {
let router = Arc::clone(&r);
std::thread::spawn(move || {
router.record_availability(&format!("p{i}"), i % 2 == 0, 10);
})
})
.collect();
for h in handles {
h.join().expect("thread panicked");
}
let stats = r.thompson_stats();
assert_eq!(stats.len(), 8);
}
#[test]
fn cascade_strategy_is_set() {
let r = RouterProvider::new(vec![]).with_cascade(CascadeRouterConfig::default());
assert_eq!(r.strategy, RouterStrategy::Cascade);
assert!(r.cascade_state.is_some());
assert!(r.cascade_config.is_some());
}
#[test]
fn cascade_ordered_providers_preserves_chain_order() {
use crate::ollama::OllamaProvider;
let p1 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"a".into(),
String::new(),
));
let p2 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:2",
"b".into(),
String::new(),
));
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig::default());
let ordered = r.ordered_providers();
assert_eq!(ordered.len(), 2);
}
#[tokio::test]
async fn cascade_empty_router_returns_no_providers() {
let r = RouterProvider::new(vec![]).with_cascade(CascadeRouterConfig::default());
let msgs = vec![Message::from_legacy(Role::User, "hello")];
let err = r.chat(&msgs).await.unwrap_err();
assert_matches!(err, LlmError::NoProviders);
}
#[tokio::test]
async fn cascade_returns_best_seen_when_all_fail_after_good_response() {
use crate::mock::MockProvider;
let cheap =
AnyProvider::Mock(MockProvider::with_responses(vec!["ok".to_owned()]).with_delay(0));
let expensive = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![cheap, expensive]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.9, max_escalations: 2,
..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "hello")];
let result = r.chat(&msgs).await.unwrap();
assert_eq!(result, "ok");
}
#[tokio::test]
async fn cascade_accepts_good_quality_response() {
use crate::mock::MockProvider;
let good_response = "This is a comprehensive, well-structured response that provides \
detailed information about the topic. It covers multiple aspects and explains \
the reasoning clearly with proper sentence structure.";
let cheap = AnyProvider::Mock(
MockProvider::with_responses(vec![good_response.to_owned()]).with_delay(0),
);
let expensive = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![cheap, expensive]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.5,
max_escalations: 1,
..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "explain something")];
let result = r.chat(&msgs).await.unwrap();
assert_eq!(result, good_response);
}
#[tokio::test]
async fn cascade_max_escalations_budget_exhausted_returns_last_attempted() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::with_responses(vec!["x".to_owned()]).with_delay(0));
let p2 = AnyProvider::Mock(MockProvider::with_responses(vec!["x".to_owned()]).with_delay(0));
let p3 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2, p3]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.9,
max_escalations: 1, ..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "test")];
let result = r.chat(&msgs).await.unwrap();
assert_eq!(result, "x");
}
#[tokio::test]
async fn cascade_token_budget_stops_escalation() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::with_responses(vec!["x".to_owned()]).with_delay(0));
let p2 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.9, max_escalations: 5,
max_cascade_tokens: Some(1), ..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "test")];
let result = r.chat(&msgs).await.unwrap();
assert_eq!(result, "x"); }
#[tokio::test]
async fn cascade_budget_returns_best_seen_not_current() {
use crate::mock::MockProvider;
let good_response = "This is a reasonable response with enough content to score well.";
let bad_response = "x";
let p1 = AnyProvider::Mock(
MockProvider::with_responses(vec![good_response.to_owned()]).with_delay(0),
);
let p2 = AnyProvider::Mock(
MockProvider::with_responses(vec![bad_response.to_owned()]).with_delay(0),
);
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.95, max_escalations: 5,
max_cascade_tokens: Some(1), ..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "test")];
let result = r.chat(&msgs).await.unwrap();
assert_ne!(result, bad_response, "should return best-seen, not current");
}
#[tokio::test]
async fn cascade_escalations_exhausted_returns_best_seen_not_current() {
use crate::mock::MockProvider;
let good_response = "This is a reasonable response with enough content to score well.";
let bad_response = "x";
let p1 = AnyProvider::Mock(
MockProvider::with_responses(vec![good_response.to_owned()]).with_delay(0),
);
let p2 = AnyProvider::Mock(
MockProvider::with_responses(vec![bad_response.to_owned()]).with_delay(0),
);
let p3 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2, p3]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.95, max_escalations: 1, ..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "test")];
let result = r.chat(&msgs).await.unwrap();
assert_eq!(
result, good_response,
"should return best-seen (p1), not the degenerate current response (p2)"
);
assert_ne!(
result, bad_response,
"must not return degenerate p2 response"
);
}
#[tokio::test]
async fn cascade_stream_escalations_exhausted_returns_best_seen_not_current() {
use crate::mock::MockProvider;
let good_response = "This is a reasonable response with enough content to score well.";
let bad_response = "x";
let p1 = AnyProvider::Mock(
MockProvider::with_responses(vec![good_response.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p2 = AnyProvider::Mock(
MockProvider::with_responses(vec![bad_response.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p3 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2, p3]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.95, max_escalations: 1, ..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "test")];
let stream = r.chat_stream(&msgs).await.unwrap();
let collected = collect_stream(stream).await.unwrap();
assert_eq!(
collected.content, good_response,
"should return best-seen (p1), not the degenerate current response (p2)"
);
assert_ne!(
collected.content, bad_response,
"must not return degenerate p2 response"
);
}
#[tokio::test]
async fn cascade_all_providers_fail_preserves_last_error() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p1".into(),
status: 500,
}])
.with_name("p1"),
);
let p2 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p2".into(),
status: 503,
}])
.with_name("p2"),
);
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig::default());
let msgs = vec![Message::from_legacy(Role::User, "test")];
let err = r.chat(&msgs).await.unwrap_err();
assert!(
matches!(&err, LlmError::ApiError { provider, status } if provider == "p2" && *status == 503),
"expected the last provider's (p2) ApiError to survive exhaustion, got {err:?}"
);
}
#[tokio::test]
async fn cascade_stream_good_quality_no_escalation() {
use crate::mock::MockProvider;
let good = "This is a well-formed response with sufficient length and coherent structure.";
let p1 = AnyProvider::Mock(
MockProvider::with_responses(vec![good.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p2 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.5,
max_escalations: 1,
..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "q")];
let stream = r.chat_stream(&msgs).await.unwrap();
let collected = collect_stream(stream).await.unwrap();
assert_eq!(collected.content, good);
}
#[tokio::test]
async fn cascade_stream_escalates_to_last_provider() {
use crate::mock::MockProvider;
let bad = "x"; let good = "This is the expensive model's comprehensive response.";
let p1 = AnyProvider::Mock(
MockProvider::with_responses(vec![bad.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p2 = AnyProvider::Mock(
MockProvider::with_responses(vec![good.to_owned()])
.with_delay(0)
.with_streaming(),
);
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.9, max_escalations: 1,
..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "q")];
let stream = r.chat_stream(&msgs).await.unwrap();
let collected = collect_stream(stream).await.unwrap();
assert_eq!(collected.content, good);
}
#[tokio::test]
async fn cascade_stream_budget_returns_best_seen() {
use crate::mock::MockProvider;
let good_response = "This is a reasonable response with enough content to score well.";
let bad_response = "x";
let p1 = AnyProvider::Mock(
MockProvider::with_responses(vec![good_response.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p2 = AnyProvider::Mock(
MockProvider::with_responses(vec![bad_response.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p3 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2, p3]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.95, max_escalations: 5,
max_cascade_tokens: Some(1), ..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "test")];
let stream = r.chat_stream(&msgs).await.unwrap();
let collected = collect_stream(stream).await.unwrap();
assert_eq!(
collected.content, good_response,
"should return best-seen p1 response when budget exhausted"
);
}
#[tokio::test]
async fn cascade_stream_budget_returns_best_seen_not_current() {
use crate::mock::MockProvider;
let good_response = "This is a reasonable response with enough content to score well.";
let bad_response = "x";
let p1 = AnyProvider::Mock(
MockProvider::with_responses(vec![good_response.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p2 = AnyProvider::Mock(
MockProvider::with_responses(vec![bad_response.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p3 = AnyProvider::Mock(MockProvider::failing()); let p4 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2, p3, p4]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.95, max_escalations: 5,
max_cascade_tokens: Some(17), ..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "test")];
let stream = r.chat_stream(&msgs).await.unwrap();
let collected = collect_stream(stream).await.unwrap();
assert_eq!(
collected.content, good_response,
"should return best-seen (p1), not current degenerate (p2)"
);
assert_ne!(
collected.content, bad_response,
"must not return the degenerate p2 response"
);
}
#[tokio::test]
async fn cascade_stream_last_fails_returns_best_seen() {
use crate::mock::MockProvider;
let low_quality = "ok"; let p1 = AnyProvider::Mock(
MockProvider::with_responses(vec![low_quality.to_owned()])
.with_delay(0)
.with_streaming(),
);
let p2 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
quality_threshold: 0.9, max_escalations: 2,
..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "hello")];
let stream = r.chat_stream(&msgs).await.unwrap();
let collected = collect_stream(stream).await.unwrap();
assert_eq!(collected.content, low_quality);
}
#[tokio::test]
async fn cascade_stream_all_fail_returns_error() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::failing());
let p2 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig::default());
let msgs = vec![Message::from_legacy(Role::User, "test")];
let result = r.chat_stream(&msgs).await;
assert!(
result.is_err(),
"expected error when all providers fail with no best_seen"
);
}
#[test]
fn cascade_config_default_values() {
let cfg = CascadeRouterConfig::default();
assert!((cfg.quality_threshold - 0.5).abs() < f64::EPSILON);
assert_eq!(cfg.max_escalations, 2);
assert_eq!(cfg.window_size, 50);
assert!(cfg.max_cascade_tokens.is_none());
assert_eq!(cfg.classifier_mode, cascade::ClassifierMode::Heuristic);
}
#[test]
fn evaluate_heuristic_empty_should_escalate_above_threshold() {
let verdict = RouterProvider::evaluate_heuristic("", 0.05);
assert!(verdict.should_escalate);
}
#[test]
fn evaluate_heuristic_good_response_does_not_escalate() {
let text = "The answer to your question is straightforward. Consider the options and pick the best one.";
let verdict = RouterProvider::evaluate_heuristic(text, 0.5);
assert!(!verdict.should_escalate, "score={}", verdict.score);
}
#[tokio::test]
async fn cascade_empty_response_not_stored_as_best_seen() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(MockProvider::with_responses(vec![String::new()]));
let cfg = CascadeRouterConfig {
quality_threshold: 0.0,
..Default::default()
};
let r = RouterProvider::new(vec![p]).with_cascade(cfg);
let msgs = vec![Message::from_legacy(Role::User, "hi")];
let result = r.chat(&msgs).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "");
}
#[tokio::test]
async fn cascade_empty_best_seen_not_returned_on_all_fail() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::with_responses(vec![String::new()]));
let p2 = AnyProvider::Mock(MockProvider::failing());
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig::default());
let msgs = vec![Message::from_legacy(Role::User, "hi")];
let result = r.chat(&msgs).await;
assert!(
result.is_err(),
"expected error, not silent empty string; got: {result:?}"
);
}
#[tokio::test]
async fn cascade_stream_empty_response_not_stored_as_best_seen() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::with_responses(vec![String::new()]));
let p2 = AnyProvider::Mock(
MockProvider::with_responses(vec!["real answer".to_owned()]).with_streaming(),
);
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig::default());
let msgs = vec![Message::from_legacy(Role::User, "hi")];
let stream = r.chat_stream(&msgs).await.expect("should not error");
let collected = collect_stream(stream).await.expect("stream should succeed");
assert_eq!(collected.content, "real answer");
}
#[test]
fn arc_providers_clone_shares_allocation() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(MockProvider::default());
let r = RouterProvider::new(vec![p]);
let c = r.clone();
assert!(Arc::ptr_eq(&r.state.providers, &c.state.providers));
}
#[test]
fn cost_tiers_reorders_providers_at_construction() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::default().with_name("claude"));
let p2 = AnyProvider::Mock(MockProvider::default().with_name("ollama"));
let p3 = AnyProvider::Mock(MockProvider::default().with_name("openai"));
let r = RouterProvider::new(vec![p1, p2, p3]).with_cascade(CascadeRouterConfig {
cost_tiers: Some(vec!["ollama".into(), "claude".into()]),
..CascadeRouterConfig::default()
});
let names: Vec<&str> = r.state.providers.iter().map(LlmProvider::name).collect();
assert_eq!(names, vec!["ollama", "claude", "openai"]);
}
#[test]
fn cost_tiers_none_preserves_chain_order() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::default().with_name("claude"));
let p2 = AnyProvider::Mock(MockProvider::default().with_name("ollama"));
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
cost_tiers: None,
..CascadeRouterConfig::default()
});
let names: Vec<&str> = r.state.providers.iter().map(LlmProvider::name).collect();
assert_eq!(names, vec!["claude", "ollama"]);
}
#[test]
fn cost_tiers_empty_vec_preserves_chain_order() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::default().with_name("claude"));
let p2 = AnyProvider::Mock(MockProvider::default().with_name("ollama"));
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
cost_tiers: Some(vec![]),
..CascadeRouterConfig::default()
});
let names: Vec<&str> = r.state.providers.iter().map(LlmProvider::name).collect();
assert_eq!(names, vec!["claude", "ollama"]);
}
#[test]
fn cost_tiers_unknown_name_ignored() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::default().with_name("ollama"));
let p2 = AnyProvider::Mock(MockProvider::default().with_name("claude"));
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
cost_tiers: Some(vec!["nonexistent".into(), "ollama".into()]),
..CascadeRouterConfig::default()
});
let names: Vec<&str> = r.state.providers.iter().map(LlmProvider::name).collect();
assert_eq!(names, vec!["ollama", "claude"]);
}
#[test]
fn cost_tiers_all_providers_listed() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::default().with_name("c"));
let p2 = AnyProvider::Mock(MockProvider::default().with_name("b"));
let p3 = AnyProvider::Mock(MockProvider::default().with_name("a"));
let r = RouterProvider::new(vec![p1, p2, p3]).with_cascade(CascadeRouterConfig {
cost_tiers: Some(vec!["a".into(), "b".into(), "c".into()]),
..CascadeRouterConfig::default()
});
let names: Vec<&str> = r.state.providers.iter().map(LlmProvider::name).collect();
assert_eq!(names, vec!["a", "b", "c"]);
}
#[test]
fn cost_tiers_duplicate_name_uses_last_position() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::default().with_name("ollama"));
let p2 = AnyProvider::Mock(MockProvider::default().with_name("claude"));
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
cost_tiers: Some(vec!["claude".into(), "ollama".into(), "ollama".into()]),
..CascadeRouterConfig::default()
});
let names: Vec<&str> = r.state.providers.iter().map(LlmProvider::name).collect();
assert_eq!(names, vec!["claude", "ollama"]);
}
#[test]
fn cost_tiers_empty_router_does_not_panic() {
let r = RouterProvider::new(vec![]).with_cascade(CascadeRouterConfig {
cost_tiers: Some(vec!["foo".into()]),
..CascadeRouterConfig::default()
});
assert_eq!(r.state.providers.len(), 0);
}
#[test]
fn set_status_tx_works_with_arc() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(MockProvider::default());
let mut r = RouterProvider::new(vec![p]);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
r.set_status_tx(tx); }
#[tokio::test]
async fn cascade_chat_with_tools_unaffected_by_cost_tiers() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(MockProvider::failing().with_name("cheap"));
let p2 = AnyProvider::Mock(MockProvider::failing().with_name("expensive"));
let r = RouterProvider::new(vec![p1, p2]).with_cascade(CascadeRouterConfig {
cost_tiers: Some(vec!["cheap".into()]),
..CascadeRouterConfig::default()
});
let msgs = vec![Message::from_legacy(Role::User, "hi")];
let err = r.chat_with_tools(&msgs, &[]).await.unwrap_err();
assert_matches!(err, LlmError::Other(_));
}
#[tokio::test]
async fn embed_retries_on_rate_limited_then_succeeds() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock({
let mut m = MockProvider::default()
.with_errors(vec![LlmError::RateLimited, LlmError::RateLimited])
.with_name("p1");
m.supports_embeddings = true;
m.embedding = vec![0.1, 0.2];
m
});
let r = RouterProvider::new(vec![p]);
let result = r.embed("text").await.unwrap();
assert_eq!(result, vec![0.1, 0.2]);
}
#[tokio::test]
async fn embed_falls_back_after_all_retries_exhausted() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock({
let mut m = MockProvider::default()
.with_errors(vec![
LlmError::RateLimited,
LlmError::RateLimited,
LlmError::RateLimited,
LlmError::RateLimited,
])
.with_name("p1");
m.supports_embeddings = true;
m
});
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("p2");
m.supports_embeddings = true;
m.embedding = vec![9.0, 8.0];
m
});
let r = RouterProvider::new(vec![p1, p2]);
let result = r.embed("text").await.unwrap();
assert_eq!(result, vec![9.0, 8.0]);
}
#[tokio::test]
async fn embed_falls_back_immediately_on_unavailable() {
use crate::mock::MockProvider;
let p1_mock = {
let mut m = MockProvider::default()
.with_errors(vec![LlmError::Unavailable])
.with_name("p1");
m.supports_embeddings = true;
m
};
let p1_embed_calls = Arc::clone(&p1_mock.embed_call_count);
let p1 = AnyProvider::Mock(p1_mock);
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("p2");
m.supports_embeddings = true;
m.embedding = vec![9.0, 8.0];
m
});
let r = RouterProvider::new(vec![p1, p2]);
let result = r.embed("text").await.unwrap();
assert_eq!(result, vec![9.0, 8.0]);
assert_eq!(
p1_embed_calls.load(Ordering::Relaxed),
1,
"p1 must be called exactly once — Unavailable must not enter the embed-retry loop"
);
}
#[tokio::test]
async fn embed_prefers_dedicated_provider_over_chat_only_fallback() {
use crate::mock::MockProvider;
let chat_only = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("chat");
m.supports_embeddings = true;
m.embedding = vec![1.0, 0.0];
m
});
let dedicated = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("embedder");
m.supports_embeddings = true;
m.embedding = vec![0.0, 1.0];
m
});
let r = RouterProvider::new(vec![chat_only]).with_embed_provider(dedicated);
let result = r.embed("text").await.unwrap();
assert_eq!(
result,
vec![0.0, 1.0],
"embed() must route to the dedicated provider, not the chat-only fallback"
);
}
#[test]
fn embed_candidates_prepends_and_dedupes_dedicated_provider() {
use crate::mock::MockProvider;
let dedicated = AnyProvider::Mock(MockProvider::default().with_name("embedder"));
let other = AnyProvider::Mock(MockProvider::default().with_name("chat"));
let r = RouterProvider::new(vec![other, dedicated.clone()]).with_embed_provider(dedicated);
let candidates = r.embed_candidates();
let names: Vec<&str> = candidates.iter().map(AnyProvider::name).collect();
assert_eq!(
names,
vec!["embedder", "chat"],
"dedicated provider must be first and not duplicated"
);
}
#[tokio::test]
async fn embed_batch_retries_on_rate_limited_then_succeeds() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock({
let mut m = MockProvider::default()
.with_errors(vec![LlmError::RateLimited, LlmError::RateLimited])
.with_name("p1");
m.supports_embeddings = true;
m.embedding = vec![0.5, 0.6];
m
});
let r = RouterProvider::new(vec![p]);
let result = r.embed_batch(&["a", "b"]).await.unwrap();
assert_eq!(result, vec![vec![0.5, 0.6], vec![0.5, 0.6]]);
}
#[tokio::test]
async fn embed_batch_falls_back_after_all_retries_exhausted() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock({
let mut m = MockProvider::default()
.with_errors(vec![
LlmError::RateLimited,
LlmError::RateLimited,
LlmError::RateLimited,
LlmError::RateLimited,
])
.with_name("p1");
m.supports_embeddings = true;
m
});
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("p2");
m.supports_embeddings = true;
m.embedding = vec![7.0, 8.0];
m
});
let r = RouterProvider::new(vec![p1, p2]);
let result = r.embed_batch(&["x"]).await.unwrap();
assert_eq!(result, vec![vec![7.0, 8.0]]);
}
#[tokio::test]
async fn embed_invalid_input_breaks_loop_and_returns_invalid_input() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(MockProvider::default().with_embed_invalid_input());
let r = RouterProvider::new(vec![p]).with_thompson(None);
let err = r.embed("some text").await.unwrap_err();
assert!(
matches!(err, LlmError::InvalidInput { .. }),
"expected InvalidInput, got {err:?}"
);
}
#[tokio::test]
async fn embed_invalid_input_does_not_fall_through_to_second_provider() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_embed_invalid_input()
.with_name("p1"),
);
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default();
m.supports_embeddings = true;
m.name_override = Some("p2".into());
m
});
let r = RouterProvider::new(vec![p1, p2]);
let err = r.embed("test").await.unwrap_err();
assert!(
matches!(&err, LlmError::InvalidInput { provider, .. } if provider == "p1"),
"expected InvalidInput from p1, got {err:?}"
);
}
#[tokio::test]
async fn chat_with_tools_model_capability_mismatch_falls_through_to_second_provider() {
use crate::mock::MockProvider;
use crate::provider::ToolDefinition;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ModelCapabilityMismatch {
provider: "p1".into(),
message: "reasoning_effort incompatible with tools".into(),
}])
.with_name("p1"),
);
let p2 = AnyProvider::Mock(MockProvider::default().with_name("p2"));
let r = RouterProvider::new(vec![p1, p2]);
let result = r.chat_with_tools(&[], &[] as &[ToolDefinition]).await;
assert!(
result.is_ok(),
"expected fallback to p2 to succeed, got {result:?}"
);
}
#[tokio::test]
async fn chat_with_tools_single_provider_mismatch_exhaustion_preserves_error() {
use crate::mock::MockProvider;
use crate::provider::ToolDefinition;
let p = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ModelCapabilityMismatch {
provider: "p1".into(),
message: "reasoning_effort incompatible with tools".into(),
}])
.with_name("p1"),
);
let r = RouterProvider::new(vec![p]).with_thompson(None);
let err = r
.chat_with_tools(&[], &[] as &[ToolDefinition])
.await
.unwrap_err();
assert!(
matches!(&err, LlmError::ModelCapabilityMismatch { provider, message }
if provider == "p1" && message.contains("reasoning_effort")),
"expected ModelCapabilityMismatch from p1 to survive exhaustion, got {err:?}"
);
}
#[tokio::test]
async fn chat_with_tools_all_providers_mismatch_exhaustion_preserves_last_error() {
use crate::mock::MockProvider;
use crate::provider::ToolDefinition;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ModelCapabilityMismatch {
provider: "p1".into(),
message: "p1 reasoning_effort incompatible with tools".into(),
}])
.with_name("p1"),
);
let p2 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ModelCapabilityMismatch {
provider: "p2".into(),
message: "p2 reasoning_effort incompatible with tools".into(),
}])
.with_name("p2"),
);
let r = RouterProvider::new(vec![p1, p2]);
let err = r
.chat_with_tools(&[], &[] as &[ToolDefinition])
.await
.unwrap_err();
assert!(
matches!(&err, LlmError::ModelCapabilityMismatch { provider, .. } if provider == "p2"),
"expected the last provider's (p2) ModelCapabilityMismatch to survive exhaustion, got {err:?}"
);
}
#[tokio::test]
async fn chat_with_tools_invalid_input_breaks_loop_and_returns_invalid_input() {
use crate::mock::MockProvider;
use crate::provider::ToolDefinition;
let p = AnyProvider::Mock(MockProvider::default().with_tool_chat_invalid_input());
let r = RouterProvider::new(vec![p]).with_thompson(None);
let err = r
.chat_with_tools(&[], &[] as &[ToolDefinition])
.await
.unwrap_err();
assert!(
matches!(err, LlmError::InvalidInput { .. }),
"expected InvalidInput, got {err:?}"
);
}
#[tokio::test]
async fn chat_with_tools_invalid_input_does_not_fall_through_to_second_provider() {
use crate::mock::MockProvider;
use crate::provider::ToolDefinition;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_tool_chat_invalid_input()
.with_name("p1"),
);
let p2 = AnyProvider::Mock(MockProvider::default().with_name("p2"));
let r = RouterProvider::new(vec![p1, p2]);
let err = r
.chat_with_tools(&[], &[] as &[ToolDefinition])
.await
.unwrap_err();
assert!(
matches!(&err, LlmError::InvalidInput { provider, .. } if provider == "p1"),
"expected InvalidInput from p1, got {err:?}"
);
}
#[tokio::test]
async fn embed_skips_non_embedding_providers_and_falls_through() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("p1");
m.supports_embeddings = false;
m
});
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("p2");
m.supports_embeddings = true;
m.embedding = vec![1.0, 2.0, 3.0];
m
});
let r = RouterProvider::new(vec![p1, p2]);
let result = r.embed("hello").await.unwrap();
assert_eq!(result, vec![1.0, 2.0, 3.0]);
}
#[tokio::test]
async fn embed_invalid_input_does_not_record_availability() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(
MockProvider::default()
.with_embed_invalid_input()
.with_name("test-provider"),
);
let r = RouterProvider::new(vec![p]).with_thompson(None);
let _ = r.embed("text").await;
let stats = r.thompson_stats();
let provider_in_stats = stats.iter().any(|(name, ..)| name == "test-provider");
assert!(
!provider_in_stats,
"InvalidInput must not update provider reputation; stats: {stats:?}"
);
}
#[tokio::test]
async fn embed_timeout_single_provider_preserves_timeout_error() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(
MockProvider::default()
.with_embed_delay(200)
.with_name("slow"),
);
let r = RouterProvider::new(vec![p]).with_embed_timeout(10);
let err = r.embed("hello").await.unwrap_err();
assert!(
matches!(err, LlmError::Timeout),
"expected Timeout after fallback exhaustion, got {err:?}"
);
}
#[tokio::test]
async fn embed_timeout_falls_back_to_next_provider() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_embed_delay(200)
.with_name("slow"),
);
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("fast");
m.supports_embeddings = true;
m.embedding = vec![1.0, 2.0, 3.0];
m
});
let r = RouterProvider::new(vec![p1, p2]).with_embed_timeout(10);
let result = r.embed("hello").await.unwrap();
assert_eq!(result, vec![1.0, 2.0, 3.0]);
}
#[tokio::test]
async fn embed_batch_timeout_single_provider_preserves_timeout_error() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(
MockProvider::default()
.with_embed_delay(200)
.with_name("slow"),
);
let r = RouterProvider::new(vec![p]).with_embed_timeout(10);
let err = r.embed_batch(&["hello"]).await.unwrap_err();
assert!(
matches!(err, LlmError::Timeout),
"expected Timeout after fallback exhaustion, got {err:?}"
);
}
#[tokio::test]
async fn embed_batch_timeout_falls_back_to_next_provider() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_embed_delay(200)
.with_name("slow"),
);
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default().with_name("fast");
m.supports_embeddings = true;
m.embedding = vec![1.0, 2.0, 3.0];
m
});
let r = RouterProvider::new(vec![p1, p2]).with_embed_timeout(10);
let result = r.embed_batch(&["hello"]).await.unwrap();
assert_eq!(result, vec![vec![1.0, 2.0, 3.0]]);
}
#[tokio::test]
async fn embed_timeout_records_provider_unavailable() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(
MockProvider::default()
.with_embed_delay(200)
.with_name("slow"),
);
let r = RouterProvider::new(vec![p])
.with_thompson(None)
.with_embed_timeout(10);
let _ = r.embed("hello").await;
let stats = r.thompson_stats();
let entry = stats.iter().find(|(name, ..)| name == "slow");
let (_, alpha, beta) = entry.unwrap_or_else(|| {
panic!("embed timeout must call record_availability so 'slow' appears in thompson_stats")
});
assert!(
*beta > *alpha,
"timeout must be recorded as a failure (beta > alpha), got alpha={alpha}, beta={beta}"
);
}
#[tokio::test]
async fn embed_batch_timeout_records_provider_unavailable() {
use crate::mock::MockProvider;
let p = AnyProvider::Mock(
MockProvider::default()
.with_embed_delay(200)
.with_name("slow"),
);
let r = RouterProvider::new(vec![p])
.with_thompson(None)
.with_embed_timeout(10);
let _ = r.embed_batch(&["hello"]).await;
let stats = r.thompson_stats();
let entry = stats.iter().find(|(name, ..)| name == "slow");
let (_, alpha, beta) = entry.unwrap_or_else(|| {
panic!(
"embed_batch timeout must call record_availability so 'slow' appears in thompson_stats"
)
});
assert!(
*beta > *alpha,
"timeout must be recorded as a failure (beta > alpha), got alpha={alpha}, beta={beta}"
);
}
#[tokio::test]
async fn embed_all_providers_exhausted_preserves_last_error() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock({
let mut m = MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p1".into(),
status: 500,
}])
.with_name("p1");
m.supports_embeddings = true;
m
});
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p2".into(),
status: 503,
}])
.with_name("p2");
m.supports_embeddings = true;
m
});
let r = RouterProvider::new(vec![p1, p2]);
let err = r.embed("text").await.unwrap_err();
assert!(
matches!(&err, LlmError::ApiError { provider, status } if provider == "p2" && *status == 503),
"expected the last provider's (p2) ApiError to survive exhaustion, got {err:?}"
);
}
#[tokio::test]
async fn embed_batch_all_providers_exhausted_preserves_last_error() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock({
let mut m = MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p1".into(),
status: 500,
}])
.with_name("p1");
m.supports_embeddings = true;
m
});
let p2 = AnyProvider::Mock({
let mut m = MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p2".into(),
status: 503,
}])
.with_name("p2");
m.supports_embeddings = true;
m
});
let r = RouterProvider::new(vec![p1, p2]);
let err = r.embed_batch(&["text"]).await.unwrap_err();
assert!(
matches!(&err, LlmError::ApiError { provider, status } if provider == "p2" && *status == 503),
"expected the last provider's (p2) ApiError to survive exhaustion, got {err:?}"
);
}
#[tokio::test]
async fn quality_gate_passes_when_similarity_above_threshold() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock({
let mut m = MockProvider::with_responses(vec!["answer".to_owned()]).with_name("p1");
m.supports_embeddings = true;
m.embedding = vec![1.0, 0.0];
m
});
let r = RouterProvider::new(vec![p1])
.with_thompson(None)
.with_quality_gate(0.5);
let msgs = vec![Message::from_legacy(Role::User, "question")];
let result = r.chat(&msgs).await.unwrap();
assert_eq!(result, "answer");
}
#[tokio::test]
async fn quality_gate_exhaustion_returns_best_seen() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock({
let mut m = MockProvider::with_responses(vec!["best_so_far".to_owned()]).with_name("p1");
m.supports_embeddings = true;
m.embedding = vec![0.0, 1.0];
m
});
let p2 = AnyProvider::Mock(MockProvider::failing().with_name("p2"));
let r = RouterProvider::new(vec![p1, p2])
.with_thompson(None)
.with_quality_gate(0.9);
let msgs = vec![Message::from_legacy(Role::User, "question")];
let result = r.chat(&msgs).await.unwrap();
assert_eq!(result, "best_so_far");
}
#[test]
fn routing_signals_quality_gate_above_one_is_ignored() {
let threshold: f32 = 5.0;
let mut router = RouterProvider::new(vec![]);
if threshold.is_finite() && threshold > 0.0 && threshold <= 1.0 {
router = router.with_quality_gate(threshold);
}
assert!(
router.quality_gate.is_none(),
"out-of-range quality_gate must not be wired; got {:?}",
router.quality_gate
);
}
#[test]
fn routing_signals_quality_gate_valid_is_wired() {
let threshold: f32 = 0.8;
let mut router = RouterProvider::new(vec![]);
if threshold.is_finite() && threshold > 0.0 && threshold <= 1.0 {
router = router.with_quality_gate(threshold);
}
assert_eq!(
router.quality_gate,
Some(0.8),
"valid quality_gate must be wired"
);
}
#[test]
fn asi_debounce_same_turn_fires_once() {
let router = RouterProvider::new(vec![]);
let turn_id = 42u64;
let prev1 = router.state.asi_last_turn.swap(turn_id, Ordering::AcqRel);
let first_dropped = prev1 == turn_id;
let prev2 = router.state.asi_last_turn.swap(turn_id, Ordering::AcqRel);
let second_dropped = prev2 == turn_id;
assert!(!first_dropped, "first call in turn must not be dropped");
assert!(second_dropped, "second call in same turn must be dropped");
}
#[test]
fn asi_debounce_next_turn_fires_again() {
let router = RouterProvider::new(vec![]);
let prev1 = router.state.asi_last_turn.swap(1u64, Ordering::AcqRel);
assert_ne!(prev1, 1u64, "turn 1: initial value != 1, should proceed");
let prev2 = router.state.asi_last_turn.swap(2u64, Ordering::AcqRel);
let dropped = prev2 == 2u64;
assert!(!dropped, "turn 2 must not be dropped (different turn_id)");
}
#[test]
fn turn_counter_increments_across_clones() {
let router = RouterProvider::new(vec![]);
let clone = router.clone();
let t0 = router.state.turn_counter.fetch_add(1, Ordering::Relaxed);
let t1 = clone.state.turn_counter.fetch_add(1, Ordering::Relaxed);
assert_eq!(t1, t0 + 1, "cloned router shares turn_counter");
}
#[test]
fn with_embed_concurrency_zero_means_no_semaphore() {
let r = RouterProvider::new(vec![]).with_embed_concurrency(0);
assert!(
r.state.embed_semaphore.is_none(),
"0 should disable semaphore"
);
}
#[test]
fn with_embed_concurrency_positive_creates_semaphore() {
let r = RouterProvider::new(vec![]).with_embed_concurrency(4);
let sem = r
.state
.embed_semaphore
.as_ref()
.expect("semaphore should exist");
assert_eq!(sem.available_permits(), 4);
}
#[tokio::test]
async fn embed_semaphore_limits_concurrency() {
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering as AO};
let sem = Arc::new(tokio::sync::Semaphore::new(2));
let concurrent_peak = StdArc::new(AtomicUsize::new(0));
let active = StdArc::new(AtomicUsize::new(0));
let mut handles = vec![];
for _ in 0..6 {
let sem_clone = sem.clone();
let peak = concurrent_peak.clone();
let active = active.clone();
handles.push(tokio::spawn(async move {
let _permit = sem_clone.acquire().await.unwrap();
let cur = active.fetch_add(1, AO::SeqCst) + 1;
let mut p = peak.load(AO::SeqCst);
while p < cur {
match peak.compare_exchange(p, cur, AO::SeqCst, AO::SeqCst) {
Ok(_) => break,
Err(new) => p = new,
}
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
active.fetch_sub(1, AO::SeqCst);
}));
}
for h in handles {
h.await.unwrap();
}
assert!(
concurrent_peak.load(AO::SeqCst) <= 2,
"peak concurrency should not exceed semaphore limit"
);
}
#[tokio::test]
async fn turn_embed_cache_hit_increments_counter() {
use crate::mock::MockProvider;
let mut m = MockProvider::default();
m.supports_embeddings = true;
m.embedding = vec![0.5, 0.5];
let provider_embed_calls = Arc::clone(&m.embed_call_count);
let r = RouterProvider::new(vec![AnyProvider::Mock(m)]);
let cache = Mutex::new(TurnEmbedCache::default());
let emb1 = r.embed_cached("hello", &cache).await.unwrap();
let emb2 = r.embed_cached("hello", &cache).await.unwrap();
assert_eq!(emb1, emb2, "cached embedding must match original");
assert_eq!(
provider_embed_calls.load(Ordering::Relaxed),
1,
"provider embed() must be called exactly once (second call hits cache)"
);
let (total, hits) = r.embed_cache_metrics();
assert_eq!(
total, 2,
"embed_call_count must be 2 (two embed_cached calls)"
);
assert_eq!(hits, 1, "embed_cache_hits must be 1 (one cache hit)");
}
#[tokio::test]
async fn spawn_asi_update_with_precomputed_skips_embed() {
use crate::mock::MockProvider;
let mut m = MockProvider::with_responses(vec!["ok".to_owned()]);
m.supports_embeddings = true;
m.embedding = vec![1.0, 0.0];
let provider_embed_calls = Arc::clone(&m.embed_call_count);
let r = RouterProvider::new(vec![AnyProvider::Mock(m)]).with_asi(AsiRouterConfig::default());
let precomputed = vec![0.9_f32, 0.1];
let turn_id = 42u64;
r.state.asi_last_turn.store(u64::MAX, Ordering::SeqCst);
r.spawn_asi_update(
"p1",
"response".to_owned(),
turn_id,
Some(precomputed.clone()),
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(
provider_embed_calls.load(Ordering::Relaxed),
0,
"embed() must not be called when precomputed_embedding is Some"
);
let asi = r.asi.as_ref().unwrap().lock();
let coherence = asi.coherence("p1");
let _ = coherence; }
#[tokio::test]
async fn blocking_load_runs_closure_on_current_thread_runtime() {
let result = super::blocking_load(|| 42_u32);
assert_eq!(result, 42, "blocking_load must return the closure result");
}
#[tokio::test]
async fn spawn_asi_update_reaped_after_cap_full() {
use crate::mock::MockProvider;
use std::sync::atomic::Ordering;
let mut m = MockProvider::with_responses(vec!["ok".to_owned()]);
m.supports_embeddings = true;
m.embedding = vec![1.0, 0.0];
let embed_calls = Arc::clone(&m.embed_call_count);
let r = RouterProvider::new(vec![AnyProvider::Mock(m)]).with_asi(AsiRouterConfig::default());
r.state.asi_last_turn.store(u64::MAX, Ordering::SeqCst);
for i in 0..super::MAX_ASI_TASKS {
r.spawn_asi_update("p1", format!("resp{i}"), i as u64, Some(vec![0.5, 0.5]));
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
r.spawn_asi_update(
"p1",
"extra".to_owned(),
super::MAX_ASI_TASKS as u64,
Some(vec![0.9, 0.1]),
);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(
embed_calls.load(Ordering::Relaxed),
0,
"embed() must not be called when precomputed_embedding is Some"
);
r.spawn_asi_update(
"p1",
"probe".to_owned(),
(super::MAX_ASI_TASKS + 1) as u64,
Some(vec![0.1, 0.9]),
);
let remaining = r.asi_tasks.lock().len();
assert!(
remaining <= 1,
"completed tasks must be reaped; at most 1 in-flight task expected, got {remaining}"
);
}
#[tokio::test]
async fn spawn_asi_update_embed_timeout_does_not_update_asi() {
use crate::mock::MockProvider;
use std::sync::atomic::Ordering;
let mut m = MockProvider::with_responses(vec!["ok".to_owned()]);
m.supports_embeddings = true;
m.embedding = vec![1.0, 0.0];
m.embed_delay_ms = 200;
let provider_embed_calls = Arc::clone(&m.embed_call_count);
let r = RouterProvider::new(vec![AnyProvider::Mock(m)])
.with_asi(AsiRouterConfig::default())
.with_embed_timeout(10);
r.state.asi_last_turn.store(u64::MAX, Ordering::SeqCst);
r.spawn_asi_update("p1", "response".to_owned(), 1u64, None);
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(
provider_embed_calls.load(Ordering::Relaxed) >= 1,
"embed() must have been attempted"
);
let asi = r.asi.as_ref().unwrap().lock();
let coherence = asi.coherence("p1");
assert!(
(coherence - 1.0).abs() < f32::EPSILON,
"ASI window must be empty after embed timeout; coherence={coherence}"
);
}
#[tokio::test]
async fn chat_all_providers_exhausted_preserves_last_error() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p1".into(),
status: 500,
}])
.with_name("p1"),
);
let p2 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p2".into(),
status: 503,
}])
.with_name("p2"),
);
let r = RouterProvider::new(vec![p1, p2]);
let err = r.chat(&[]).await.unwrap_err();
assert!(
matches!(&err, LlmError::ApiError { provider, status } if provider == "p2" && *status == 503),
"expected the last provider's (p2) ApiError to survive exhaustion, got {err:?}"
);
}
#[tokio::test]
async fn chat_stream_all_providers_exhausted_preserves_last_error() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p1".into(),
status: 500,
}])
.with_name("p1"),
);
let p2 = AnyProvider::Mock(
MockProvider::default()
.with_errors(vec![LlmError::ApiError {
provider: "p2".into(),
status: 503,
}])
.with_name("p2"),
);
let r = RouterProvider::new(vec![p1, p2]);
match r.chat_stream(&[]).await {
Err(LlmError::ApiError { provider, status }) => {
assert_eq!(provider, "p2");
assert_eq!(status, 503);
}
other => panic!(
"expected the last provider's (p2) ApiError to survive exhaustion, got {}",
match &other {
Ok(_) => "Ok(_)".to_owned(),
Err(e) => format!("{e:?}"),
}
),
}
}
#[test]
fn router_set_thinking_budget_delegates_to_last_active_provider() {
use crate::claude::ClaudeProvider;
use crate::ollama::OllamaProvider;
let ollama = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let claude = AnyProvider::Claude(ClaudeProvider::new("k".into(), "m".into(), 1024));
let mut r = RouterProvider::new(vec![ollama, claude]);
*r.state.last_active_provider.lock() = Some("claude".to_owned());
r.set_thinking_budget_delegated(Some(4096)).unwrap();
assert_eq!(r.state.providers[1].current_thinking_budget(), Some(4096));
assert_eq!(r.state.providers[0].current_thinking_budget(), None);
}
#[test]
fn router_capability_target_falls_back_to_first_when_no_dispatch_yet() {
use crate::ollama::OllamaProvider;
let p1 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let p2 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:2",
"m".into(),
"e".into(),
));
let r = RouterProvider::new(vec![p1, p2]);
assert_eq!(r.capability_target_index(), Some(0));
}
#[test]
fn router_capability_target_falls_back_to_first_on_config_drift() {
use crate::ollama::OllamaProvider;
let p1 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let r = RouterProvider::new(vec![p1]);
*r.state.last_active_provider.lock() = Some("stale-provider".to_owned());
assert_eq!(r.capability_target_index(), Some(0));
}
#[test]
fn router_capability_target_none_for_empty_pool() {
let r = RouterProvider::new(vec![]);
assert_eq!(r.capability_target_index(), None);
}
#[test]
fn router_capability_target_resolves_duplicate_name_to_first_slot() {
use crate::ollama::OllamaProvider;
let p1 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m1".into(),
"e".into(),
));
let p2 = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:2",
"m2".into(),
"e".into(),
));
assert_eq!(p1.name(), "ollama");
assert_eq!(p2.name(), "ollama");
let r = RouterProvider::new(vec![p1, p2]);
*r.state.last_active_provider.lock() = Some("ollama".to_owned());
assert_eq!(r.capability_target_index(), Some(0));
}
#[test]
fn router_set_thinking_budget_names_real_inner_provider_on_mismatch() {
use crate::ollama::OllamaProvider;
let ollama = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let mut r = RouterProvider::new(vec![ollama]);
let err = r.set_thinking_budget_delegated(Some(1024)).unwrap_err();
match err {
LlmError::ModelCapabilityMismatch { provider, .. } => assert_eq!(provider, "ollama"),
other => panic!("expected ModelCapabilityMismatch naming ollama, got {other:?}"),
}
}
#[test]
fn router_set_thinking_budget_rebuild_path_persists_on_authoritative_instance() {
use crate::claude::ClaudeProvider;
let claude = AnyProvider::Claude(ClaudeProvider::new("k".into(), "m".into(), 1024));
let mut r = RouterProvider::new(vec![claude]);
let stale_clone = r.clone();
assert!(Arc::ptr_eq(
&r.state.providers,
&stale_clone.state.providers
));
r.set_thinking_budget_delegated(Some(2048)).unwrap();
assert!(!Arc::ptr_eq(
&r.state.providers,
&stale_clone.state.providers
));
assert_eq!(r.state.providers[0].current_thinking_budget(), Some(2048));
assert_eq!(
stale_clone.state.providers[0].current_thinking_budget(),
None
);
}
#[test]
fn router_capability_delegation_advisory_present_for_resampling_multi_provider_pool() {
use crate::claude::ClaudeProvider;
use crate::ollama::OllamaProvider;
let claude = AnyProvider::Claude(ClaudeProvider::new("k".into(), "m".into(), 1024));
let ollama = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let mut r = RouterProvider::new(vec![claude, ollama]);
r.strategy = RouterStrategy::Thompson;
let advisory = r.capability_delegation_advisory();
assert!(advisory.is_some());
assert!(advisory.unwrap().contains("claude"));
}
#[test]
fn router_capability_delegation_advisory_absent_for_cascade() {
use crate::claude::ClaudeProvider;
use crate::ollama::OllamaProvider;
let claude = AnyProvider::Claude(ClaudeProvider::new("k".into(), "m".into(), 1024));
let ollama = AnyProvider::Ollama(OllamaProvider::new(
"http://127.0.0.1:1",
"m".into(),
"e".into(),
));
let mut r = RouterProvider::new(vec![claude, ollama]);
r.strategy = RouterStrategy::Cascade;
assert_eq!(r.capability_delegation_advisory(), None);
}
#[test]
fn router_capability_delegation_advisory_absent_for_single_provider_pool() {
use crate::claude::ClaudeProvider;
let claude = AnyProvider::Claude(ClaudeProvider::new("k".into(), "m".into(), 1024));
let mut r = RouterProvider::new(vec![claude]);
r.strategy = RouterStrategy::Thompson;
assert_eq!(r.capability_delegation_advisory(), None);
}
#[test]
fn masked_router_set_thinking_budget_delegates_through_inner() {
use crate::claude::ClaudeProvider;
use crate::masking::MaskedProvider;
#[derive(Debug)]
struct NoopMasker;
impl crate::masking::OutboundMasker for NoopMasker {
fn mask(&self, _text: &str) -> Option<String> {
None
}
}
let claude = AnyProvider::Claude(ClaudeProvider::new("k".into(), "m".into(), 1024));
let router = RouterProvider::new(vec![claude]);
let mut masked = AnyProvider::Masked(Box::new(MaskedProvider::new(
AnyProvider::Router(Box::new(router)),
std::sync::Arc::new(NoopMasker),
)));
masked.set_thinking_budget(Some(4096)).unwrap();
assert_eq!(masked.current_thinking_budget(), Some(4096));
}
#[test]
fn masked_triage_set_thinking_budget_delegates_through_inner() {
use crate::claude::ClaudeProvider;
use crate::masking::MaskedProvider;
use crate::router::triage::{ComplexityTier, TriageRouter};
#[derive(Debug)]
struct NoopMasker;
impl crate::masking::OutboundMasker for NoopMasker {
fn mask(&self, _text: &str) -> Option<String> {
None
}
}
let triage_provider = AnyProvider::Claude(ClaudeProvider::new("k".into(), "m".into(), 1024));
let claude = AnyProvider::Claude(ClaudeProvider::new("k".into(), "m".into(), 1024));
let router = TriageRouter::new(
triage_provider,
vec![(ComplexityTier::Simple, claude)],
5,
100,
);
let mut masked = AnyProvider::Masked(Box::new(MaskedProvider::new(
AnyProvider::Triage(Box::new(router)),
std::sync::Arc::new(NoopMasker),
)));
masked.set_thinking_budget(Some(4096)).unwrap();
assert_eq!(masked.current_thinking_budget(), Some(4096));
}
#[test]
fn router_effective_model_identifier_before_dispatch_is_router_label() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_name("p1")
.with_model_identifier("o3"),
);
let r = RouterProvider::new(vec![p1]);
assert_eq!(r.effective_model_identifier(), "router");
}
#[test]
fn router_effective_model_identifier_resolves_last_active_reasoning_model() {
use crate::mock::MockProvider;
let openai = AnyProvider::Mock(
MockProvider::default()
.with_name("openai")
.with_model_identifier("gpt-4o"),
);
let reasoner = AnyProvider::Mock(
MockProvider::default()
.with_name("reasoner")
.with_model_identifier("o3-mini"),
);
let r = RouterProvider::new(vec![openai, reasoner]);
*r.state.last_active_provider.lock() = Some("reasoner".to_owned());
assert_eq!(r.effective_model_identifier(), "o3-mini");
}
#[test]
fn router_effective_model_identifier_resolves_last_active_non_reasoning_model() {
use crate::mock::MockProvider;
let openai = AnyProvider::Mock(
MockProvider::default()
.with_name("openai")
.with_model_identifier("gpt-4o"),
);
let r = RouterProvider::new(vec![openai]);
*r.state.last_active_provider.lock() = Some("openai".to_owned());
assert_eq!(r.effective_model_identifier(), "gpt-4o");
}
#[test]
fn router_effective_model_identifier_falls_back_to_router_on_stale_name() {
use crate::mock::MockProvider;
let p1 = AnyProvider::Mock(
MockProvider::default()
.with_name("p1")
.with_model_identifier("gpt-4o"),
);
let r = RouterProvider::new(vec![p1]);
*r.state.last_active_provider.lock() = Some("gone".to_owned());
assert_eq!(r.effective_model_identifier(), "router");
}
#[tokio::test]
async fn router_effective_model_identifier_resolves_via_real_dispatch() {
use crate::mock::MockProvider;
let reasoner = AnyProvider::Mock(
MockProvider::default()
.with_name("reasoner")
.with_model_identifier("deepseek-r1"),
);
let r = RouterProvider::new(vec![reasoner]);
r.chat_with_tools(&[], &[]).await.unwrap();
assert_eq!(r.effective_model_identifier(), "deepseek-r1");
}
#[test]
fn mock_provider_effective_model_identifier_defaults_to_model_identifier() {
use crate::mock::MockProvider;
let p = MockProvider::default()
.with_name("openai")
.with_model_identifier("o3-mini");
assert_eq!(p.effective_model_identifier(), p.model_identifier());
assert_eq!(p.effective_model_identifier(), "o3-mini");
}
fn image_msg() -> Message {
Message {
role: Role::User,
content: String::new(),
parts: vec![crate::provider::MessagePart::Image(Box::new(
crate::provider::ImageData {
data: vec![1, 2, 3],
mime_type: "image/png".into(),
},
))],
metadata: crate::provider::MessageMetadata::default(),
}
}
#[test]
fn router_supports_vision_true_when_any_provider_supports_it() {
use crate::mock::MockProvider;
let text_only = AnyProvider::Mock(MockProvider::default().with_name("text-only"));
let vision = AnyProvider::Mock(MockProvider::default().with_name("vision").with_vision());
let r = RouterProvider::new(vec![text_only, vision]);
assert!(r.supports_vision());
}
#[test]
fn router_supports_vision_false_when_no_provider_supports_it() {
use crate::mock::MockProvider;
let text_only = AnyProvider::Mock(MockProvider::default().with_name("text-only"));
let r = RouterProvider::new(vec![text_only]);
assert!(!r.supports_vision());
}
#[tokio::test]
async fn chat_with_tools_strips_image_for_non_vision_provider_in_ordered_dispatch() {
use crate::mock::MockProvider;
let (non_vision, recorded) =
MockProvider::with_responses(vec!["ok".to_owned()]).with_recording();
let non_vision = AnyProvider::Mock(non_vision);
let vision = AnyProvider::Mock(
MockProvider::with_responses(vec!["unused".to_owned()])
.with_name("vision-p")
.with_vision(),
);
let r = RouterProvider::new(vec![non_vision, vision]);
assert!(r.supports_vision());
let messages = vec![image_msg()];
let result = r.chat_with_tools(&messages, &[]).await.unwrap();
assert!(matches!(result, crate::provider::ChatResponse::Text(t) if t == "ok"));
let calls = recorded.lock().unwrap();
assert_eq!(calls.len(), 1);
assert!(
!messages_contain_image(&calls[0]),
"non-vision-capable provider must never receive an Image part (C3, AC-6)"
);
}
#[tokio::test]
async fn chat_with_tools_strips_image_for_non_vision_bandit_selected_provider() {
use crate::mock::MockProvider;
let (non_vision, recorded) =
MockProvider::with_responses(vec!["ok".to_owned()]).with_recording();
let non_vision = AnyProvider::Mock(non_vision);
let r = RouterProvider::new(vec![non_vision]).with_bandit(
BanditRouterConfig::default(),
None,
None,
);
let messages = vec![image_msg()];
let result = r.chat_with_tools(&messages, &[]).await.unwrap();
assert!(matches!(result, crate::provider::ChatResponse::Text(t) if t == "ok"));
let calls = recorded.lock().unwrap();
assert_eq!(calls.len(), 1);
assert!(
!messages_contain_image(&calls[0]),
"bandit-selected non-vision-capable provider must never receive an Image part"
);
}
#[tokio::test]
async fn chat_with_tools_preserves_image_for_vision_capable_provider() {
use crate::mock::MockProvider;
let (vision_provider, recorded) = MockProvider::with_responses(vec!["ok".to_owned()])
.with_vision()
.with_recording();
let r = RouterProvider::new(vec![AnyProvider::Mock(vision_provider)]);
let messages = vec![image_msg()];
r.chat_with_tools(&messages, &[]).await.unwrap();
let calls = recorded.lock().unwrap();
assert_eq!(calls.len(), 1);
assert!(
messages_contain_image(&calls[0]),
"a vision-capable provider must still receive the Image part"
);
}