use std::collections::HashMap;
use std::future::Future;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use regex::Regex;
use crate::client::LlmClient;
use crate::types::{ChatCompletionRequest, EmbeddingInput, EmbeddingRequest, Message, SystemMessage, UserMessage};
#[inline]
fn record_classify_duration(tier: &'static str, duration_secs: f64) {
#[cfg(feature = "otel")]
{
use opentelemetry::KeyValue;
if let Some(meter) = super::metrics::global_meter() {
meter
.f64_histogram("gen_ai.route.classify.duration")
.with_description("Semantic routing classifier latency per tier")
.with_unit("s")
.build()
.record(duration_secs, &[KeyValue::new("route.classifier.tier", tier)]);
}
}
let _ = (tier, duration_secs);
}
#[inline]
fn record_classify_hit(tier: &'static str) {
#[cfg(feature = "otel")]
{
use opentelemetry::KeyValue;
if let Some(meter) = super::metrics::global_meter() {
meter
.u64_counter("gen_ai.route.classify.tier.hit")
.with_description("Semantic routing classifier tier hits")
.build()
.add(1, &[KeyValue::new("route.classifier.tier", tier)]);
}
}
let _ = tier;
}
#[cfg_attr(alef, alef(skip))]
pub struct ClassifyContext<'a> {
pub prompt: &'a str,
pub system_prompt: Option<&'a str>,
pub metadata: &'a HashMap<String, String>,
pub available_models: &'a [String],
}
pub trait RouteClassifier: Send + Sync + 'static {
fn classify<'a>(
&'a self,
ctx: &'a ClassifyContext<'a>,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>>;
fn confidence_threshold(&self) -> f32 {
0.0
}
}
pub struct KeywordClassifier {
rules: Vec<(Regex, String)>,
}
impl KeywordClassifier {
pub fn new(rules: Vec<(Regex, String)>) -> Self {
Self { rules }
}
}
impl RouteClassifier for KeywordClassifier {
fn classify<'a>(
&'a self,
ctx: &'a ClassifyContext<'a>,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
let start = Instant::now();
let result = self
.rules
.iter()
.find(|(pattern, _)| pattern.is_match(ctx.prompt))
.map(|(_, model)| model.clone());
if result.is_some() {
record_classify_duration("keyword", start.elapsed().as_secs_f64());
record_classify_hit("keyword");
}
Box::pin(async move { result })
}
fn confidence_threshold(&self) -> f32 {
0.0
}
}
pub struct IntentPrototype {
pub name: String,
pub embedding: Vec<f64>,
pub model: String,
}
pub struct EmbeddingSimilarityClassifier {
client: Arc<dyn LlmClient>,
embedding_model: String,
prototypes: Vec<IntentPrototype>,
threshold: f64,
}
impl EmbeddingSimilarityClassifier {
pub fn new(
client: Arc<dyn LlmClient>,
embedding_model: impl Into<String>,
prototypes: Vec<IntentPrototype>,
threshold: f64,
) -> Self {
Self {
client,
embedding_model: embedding_model.into(),
prototypes,
threshold,
}
}
}
fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let mag_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
let mag_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
if mag_a == 0.0 || mag_b == 0.0 {
return 0.0;
}
dot / (mag_a * mag_b)
}
impl RouteClassifier for EmbeddingSimilarityClassifier {
fn classify<'a>(
&'a self,
ctx: &'a ClassifyContext<'a>,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
Box::pin(async move {
let start = Instant::now();
let embed_req = EmbeddingRequest {
model: self.embedding_model.clone(),
input: EmbeddingInput::Single(ctx.prompt.to_owned()),
encoding_format: None,
dimensions: None,
user: None,
};
let resp = match self.client.embed(embed_req).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "embedding classifier: embed request failed; deferring");
return None;
}
};
let prompt_vec = match resp.data.into_iter().next() {
Some(obj) => obj.embedding,
None => {
tracing::warn!("embedding classifier: empty embedding response; deferring");
return None;
}
};
let best = self
.prototypes
.iter()
.map(|p| (cosine_similarity(&prompt_vec, &p.embedding), p))
.max_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
match best {
Some((score, prototype)) if score >= self.threshold => {
record_classify_duration("embedding", start.elapsed().as_secs_f64());
record_classify_hit("embedding");
tracing::debug!(
intent = %prototype.name,
model = %prototype.model,
score,
"embedding classifier: routed to intent prototype"
);
Some(prototype.model.clone())
}
Some((score, _)) => {
tracing::debug!(
score,
threshold = self.threshold,
"embedding classifier: best score below threshold; deferring"
);
None
}
None => None,
}
})
}
fn confidence_threshold(&self) -> f32 {
#[allow(clippy::cast_possible_truncation)]
let t = self.threshold as f32;
t
}
}
pub struct LlmClassifier {
client: Arc<dyn LlmClient>,
model: String,
system_prompt: String,
}
impl LlmClassifier {
pub fn new(client: Arc<dyn LlmClient>, model: impl Into<String>, system_prompt: impl Into<String>) -> Self {
Self {
client,
model: model.into(),
system_prompt: system_prompt.into(),
}
}
fn build_routing_prompt(ctx: &ClassifyContext<'_>) -> String {
let models = ctx.available_models.join(", ");
format!(
"Available models: [{models}]\n\
User prompt: {prompt}\n\n\
Respond with ONLY a JSON object in this exact format: {{\"model\": \"<model_id>\"}}\n\
Choose the most appropriate model from the available models list.",
models = models,
prompt = ctx.prompt,
)
}
fn parse_model_from_response(text: &str) -> Option<String> {
let start = text.find('{')?;
let end = text.rfind('}')?;
if end < start {
return None;
}
let json_str = &text[start..=end];
let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
value.get("model").and_then(|v| v.as_str()).map(ToOwned::to_owned)
}
}
impl RouteClassifier for LlmClassifier {
fn classify<'a>(
&'a self,
ctx: &'a ClassifyContext<'a>,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
Box::pin(async move {
let start = Instant::now();
let routing_prompt = Self::build_routing_prompt(ctx);
let req = ChatCompletionRequest {
model: self.model.clone(),
messages: vec![
Message::System(SystemMessage {
content: self.system_prompt.clone(),
name: None,
}),
Message::User(UserMessage {
content: crate::types::UserContent::Text(routing_prompt),
name: None,
}),
],
..Default::default()
};
let resp = match self.client.chat(req).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "llm classifier: chat call failed; deferring");
return None;
}
};
let text = resp.choices.into_iter().next().and_then(|c| c.message.content)?;
let model_id = Self::parse_model_from_response(&text);
if model_id.is_some() {
record_classify_duration("llm", start.elapsed().as_secs_f64());
record_classify_hit("llm");
tracing::debug!(
model = ?model_id,
"llm classifier: parsed routing decision"
);
} else {
tracing::warn!(
raw_response = %text,
"llm classifier: could not parse model from response; deferring"
);
}
model_id.filter(|m| ctx.available_models.contains(m))
})
}
fn confidence_threshold(&self) -> f32 {
0.0
}
}
pub struct CascadeClassifier {
classifiers: Vec<Arc<dyn RouteClassifier>>,
}
impl CascadeClassifier {
pub fn new(classifiers: Vec<Arc<dyn RouteClassifier>>) -> Self {
Self { classifiers }
}
}
impl RouteClassifier for CascadeClassifier {
fn classify<'a>(
&'a self,
ctx: &'a ClassifyContext<'a>,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
Box::pin(async move {
for classifier in &self.classifiers {
if let Some(model) = classifier.classify(ctx).await {
return Some(model);
}
}
None
})
}
fn confidence_threshold(&self) -> f32 {
0.0
}
}
struct VerdictEntry {
model: String,
inserted_at: Instant,
}
pub struct ClassifierVerdictCache<C> {
inner: C,
ttl: Duration,
cache: Arc<RwLock<HashMap<u64, VerdictEntry>>>,
}
impl<C: RouteClassifier> ClassifierVerdictCache<C> {
pub const DEFAULT_TTL: Duration = Duration::from_secs(3600);
pub fn new(inner: C) -> Self {
Self::with_ttl(inner, Self::DEFAULT_TTL)
}
pub fn with_ttl(inner: C, ttl: Duration) -> Self {
Self {
inner,
ttl,
cache: Arc::new(RwLock::new(HashMap::new())),
}
}
fn cache_key(ctx: &ClassifyContext<'_>) -> u64 {
let mut h = DefaultHasher::new();
ctx.prompt.hash(&mut h);
ctx.system_prompt.hash(&mut h);
h.finish()
}
fn get_cached(&self, key: u64) -> Option<String> {
let cache = self.cache.read().ok()?;
let entry = cache.get(&key)?;
if entry.inserted_at.elapsed() > self.ttl {
return None;
}
Some(entry.model.clone())
}
fn put_cached(&self, key: u64, model: String) {
if let Ok(mut cache) = self.cache.write() {
cache.insert(
key,
VerdictEntry {
model,
inserted_at: Instant::now(),
},
);
}
}
}
impl<C: RouteClassifier> RouteClassifier for ClassifierVerdictCache<C> {
fn classify<'a>(
&'a self,
ctx: &'a ClassifyContext<'a>,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
Box::pin(async move {
let key = Self::cache_key(ctx);
if let Some(model) = self.get_cached(key) {
record_classify_hit("cache");
tracing::debug!(%model, "classifier verdict cache hit");
return Some(model);
}
let result = self.inner.classify(ctx).await;
if let Some(ref model) = result {
self.put_cached(key, model.clone());
}
result
})
}
fn confidence_threshold(&self) -> f32 {
self.inner.confidence_threshold()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use regex::Regex;
use super::*;
use crate::client::{BoxFuture, BoxStream, LlmClient};
use crate::error::{LiterLlmError, Result};
use crate::types::audio::{CreateSpeechRequest, CreateTranscriptionRequest, TranscriptionResponse};
use crate::types::image::{CreateImageRequest, ImagesResponse};
use crate::types::moderation::{ModerationRequest, ModerationResponse};
use crate::types::ocr::{OcrRequest, OcrResponse};
use crate::types::rerank::{RerankRequest, RerankResponse};
use crate::types::search::{SearchRequest, SearchResponse};
use crate::types::{
AssistantMessage, ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse, Choice, EmbeddingObject,
EmbeddingResponse, FinishReason, ModelsListResponse, Usage,
};
fn empty_meta() -> HashMap<String, String> {
HashMap::new()
}
fn models(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
fn ctx<'a>(prompt: &'a str, meta: &'a HashMap<String, String>, available: &'a [String]) -> ClassifyContext<'a> {
ClassifyContext {
prompt,
system_prompt: None,
metadata: meta,
available_models: available,
}
}
#[derive(Clone)]
struct MockLlmClient {
chat_response: Option<String>,
embed_vec: Option<Vec<f64>>,
chat_call_count: Arc<AtomicUsize>,
embed_call_count: Arc<AtomicUsize>,
}
impl MockLlmClient {
fn with_chat_response(response: impl Into<String>) -> Self {
Self {
chat_response: Some(response.into()),
embed_vec: None,
chat_call_count: Arc::new(AtomicUsize::new(0)),
embed_call_count: Arc::new(AtomicUsize::new(0)),
}
}
fn with_embed_vec(vec: Vec<f64>) -> Self {
Self {
chat_response: None,
embed_vec: Some(vec),
chat_call_count: Arc::new(AtomicUsize::new(0)),
embed_call_count: Arc::new(AtomicUsize::new(0)),
}
}
fn no_response() -> Self {
Self {
chat_response: None,
embed_vec: None,
chat_call_count: Arc::new(AtomicUsize::new(0)),
embed_call_count: Arc::new(AtomicUsize::new(0)),
}
}
#[allow(dead_code)]
fn chat_calls(&self) -> usize {
self.chat_call_count.load(Ordering::SeqCst)
}
#[allow(dead_code)]
fn embed_calls(&self) -> usize {
self.embed_call_count.load(Ordering::SeqCst)
}
}
impl LlmClient for MockLlmClient {
fn chat(&self, _req: ChatCompletionRequest) -> BoxFuture<'_, Result<ChatCompletionResponse>> {
self.chat_call_count.fetch_add(1, Ordering::SeqCst);
let response = self.chat_response.clone();
Box::pin(async move {
match response {
Some(text) => Ok(ChatCompletionResponse {
id: "test".into(),
object: "chat.completion".into(),
created: 0,
model: "classifier-model".into(),
choices: vec![Choice {
index: 0,
message: AssistantMessage {
content: Some(text),
name: None,
tool_calls: None,
refusal: None,
function_call: None,
},
finish_reason: Some(FinishReason::Stop),
}],
usage: Some(Usage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
prompt_tokens_details: None,
}),
system_fingerprint: None,
service_tier: None,
}),
None => Err(LiterLlmError::ServerError {
message: "no response configured".into(),
status: 500,
}),
}
})
}
fn chat_stream(
&self,
_req: ChatCompletionRequest,
) -> BoxFuture<'_, Result<BoxStream<'static, Result<ChatCompletionChunk>>>> {
Box::pin(async move {
Err(LiterLlmError::EndpointNotSupported {
endpoint: "chat_stream".into(),
provider: "mock".into(),
})
})
}
fn embed(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<EmbeddingResponse>> {
self.embed_call_count.fetch_add(1, Ordering::SeqCst);
let vec = self.embed_vec.clone();
Box::pin(async move {
match vec {
Some(embedding) => Ok(EmbeddingResponse {
object: "list".into(),
data: vec![EmbeddingObject {
object: "embedding".into(),
embedding,
index: 0,
}],
model: req.model,
usage: None,
}),
None => Err(LiterLlmError::ServerError {
message: "no embed vec configured".into(),
status: 500,
}),
}
})
}
fn list_models(&self) -> BoxFuture<'_, Result<ModelsListResponse>> {
Box::pin(async move {
Ok(ModelsListResponse {
object: "list".into(),
data: vec![],
})
})
}
fn image_generate(&self, _req: CreateImageRequest) -> BoxFuture<'_, Result<ImagesResponse>> {
Box::pin(async move {
Ok(ImagesResponse {
created: 0,
data: vec![],
})
})
}
fn speech(&self, _req: CreateSpeechRequest) -> BoxFuture<'_, Result<bytes::Bytes>> {
Box::pin(async move { Ok(bytes::Bytes::new()) })
}
fn transcribe(&self, _req: CreateTranscriptionRequest) -> BoxFuture<'_, Result<TranscriptionResponse>> {
Box::pin(async move {
Ok(TranscriptionResponse {
text: String::new(),
language: None,
duration: None,
segments: None,
})
})
}
fn moderate(&self, _req: ModerationRequest) -> BoxFuture<'_, Result<ModerationResponse>> {
Box::pin(async move {
Ok(ModerationResponse {
id: String::new(),
model: String::new(),
results: vec![],
})
})
}
fn rerank(&self, _req: RerankRequest) -> BoxFuture<'_, Result<RerankResponse>> {
Box::pin(async move {
Ok(RerankResponse {
id: None,
results: vec![],
meta: None,
})
})
}
fn search(&self, _req: SearchRequest) -> BoxFuture<'_, Result<SearchResponse>> {
Box::pin(async move {
Err(LiterLlmError::EndpointNotSupported {
endpoint: "search".into(),
provider: "mock".into(),
})
})
}
fn ocr(&self, _req: OcrRequest) -> BoxFuture<'_, Result<OcrResponse>> {
Box::pin(async move {
Err(LiterLlmError::EndpointNotSupported {
endpoint: "ocr".into(),
provider: "mock".into(),
})
})
}
}
#[tokio::test]
async fn keyword_classifier_first_match_wins() {
let rules = vec![
(Regex::new(r"(?i)image").unwrap(), "dall-e-3".into()),
(Regex::new(r"(?i)python|code").unwrap(), "gpt-4o".into()),
(Regex::new(r"(?i)essay|write").unwrap(), "claude-3-5-sonnet".into()),
];
let kw = KeywordClassifier::new(rules);
let meta = empty_meta();
let avail = models(&["dall-e-3", "gpt-4o", "claude-3-5-sonnet"]);
let ctx = ctx("Write me python code", &meta, &avail);
let result = kw.classify(&ctx).await;
assert_eq!(result, Some("gpt-4o".into()));
}
#[tokio::test]
async fn keyword_classifier_no_match_returns_none() {
let rules = vec![
(Regex::new(r"(?i)image").unwrap(), "dall-e-3".into()),
(Regex::new(r"(?i)code").unwrap(), "gpt-4o".into()),
];
let kw = KeywordClassifier::new(rules);
let meta = empty_meta();
let avail = models(&["dall-e-3", "gpt-4o"]);
let ctx = ctx("Tell me a joke", &meta, &avail);
let result = kw.classify(&ctx).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn embedding_classifier_returns_nearest_above_threshold() {
let prototypes = vec![
IntentPrototype {
name: "coding".into(),
embedding: vec![1.0, 0.0, 0.0],
model: "gpt-4o".into(),
},
IntentPrototype {
name: "creative".into(),
embedding: vec![0.0, 1.0, 0.0],
model: "claude-3-5-sonnet".into(),
},
];
let client = Arc::new(MockLlmClient::with_embed_vec(vec![0.9, 0.1, 0.0]));
let classifier = EmbeddingSimilarityClassifier::new(client, "text-embedding-3-small", prototypes, 0.5);
let meta = empty_meta();
let avail = models(&["gpt-4o", "claude-3-5-sonnet"]);
let ctx = ctx("Debug my Rust code", &meta, &avail);
let result = classifier.classify(&ctx).await;
assert_eq!(result, Some("gpt-4o".into()));
}
#[tokio::test]
async fn embedding_classifier_below_threshold_returns_none() {
let prototypes = vec![IntentPrototype {
name: "coding".into(),
embedding: vec![1.0, 0.0, 0.0],
model: "gpt-4o".into(),
}];
let client = Arc::new(MockLlmClient::with_embed_vec(vec![0.0, 1.0, 0.0]));
let classifier = EmbeddingSimilarityClassifier::new(client, "text-embedding-3-small", prototypes, 0.8);
let meta = empty_meta();
let avail = models(&["gpt-4o"]);
let ctx = ctx("What is the weather?", &meta, &avail);
let result = classifier.classify(&ctx).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn llm_classifier_parses_model_id_from_response() {
let client = Arc::new(MockLlmClient::with_chat_response(r#"{"model":"gpt-4o"}"#));
let classifier = LlmClassifier::new(
client,
"gpt-4o-mini",
"You are a routing assistant. Reply with JSON only.",
);
let meta = empty_meta();
let avail = models(&["gpt-4o", "claude-3-5-sonnet"]);
let ctx = ctx("Explain quantum computing", &meta, &avail);
let result = classifier.classify(&ctx).await;
assert_eq!(result, Some("gpt-4o".into()));
}
#[tokio::test]
async fn llm_classifier_ignores_unavailable_model() {
let client = Arc::new(MockLlmClient::with_chat_response(r#"{"model":"unknown-model"}"#));
let classifier = LlmClassifier::new(client, "gpt-4o-mini", "Route the request.");
let meta = empty_meta();
let avail = models(&["gpt-4o", "claude-3-5-sonnet"]);
let ctx = ctx("Hello", &meta, &avail);
let result = classifier.classify(&ctx).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn cascade_keyword_hit_short_circuits_embedding() {
let embed_client = Arc::new(MockLlmClient::with_embed_vec(vec![1.0, 0.0]));
let llm_client = Arc::new(MockLlmClient::with_chat_response(r#"{"model":"gpt-4o"}"#));
let embed_call_count = Arc::clone(&embed_client.embed_call_count);
let llm_call_count = Arc::clone(&llm_client.chat_call_count);
let kw = KeywordClassifier::new(vec![(Regex::new(r"(?i)code").unwrap(), "gpt-4o".into())]);
let embedding_cls = EmbeddingSimilarityClassifier::new(
embed_client,
"text-embedding-3-small",
vec![IntentPrototype {
name: "test".into(),
embedding: vec![1.0, 0.0],
model: "gpt-4o".into(),
}],
0.5,
);
let llm_cls = LlmClassifier::new(llm_client, "gpt-4o-mini", "Route.");
let cascade = CascadeClassifier::new(vec![
Arc::new(kw) as Arc<dyn RouteClassifier>,
Arc::new(embedding_cls) as Arc<dyn RouteClassifier>,
Arc::new(llm_cls) as Arc<dyn RouteClassifier>,
]);
let meta = empty_meta();
let avail = models(&["gpt-4o"]);
let ctx = ctx("Write some code", &meta, &avail);
let result = cascade.classify(&ctx).await;
assert_eq!(result, Some("gpt-4o".into()));
assert_eq!(
embed_call_count.load(Ordering::SeqCst),
0,
"embedding should not be called"
);
assert_eq!(llm_call_count.load(Ordering::SeqCst), 0, "llm should not be called");
}
#[tokio::test]
async fn cascade_keyword_miss_falls_to_embedding() {
let embed_client = Arc::new(MockLlmClient::with_embed_vec(vec![1.0, 0.0]));
let llm_client = Arc::new(MockLlmClient::with_chat_response(r#"{"model":"gpt-4o"}"#));
let llm_call_count = Arc::clone(&llm_client.chat_call_count);
let kw = KeywordClassifier::new(vec![(Regex::new(r"(?i)image").unwrap(), "dall-e-3".into())]);
let embedding_cls = EmbeddingSimilarityClassifier::new(
embed_client,
"text-embedding-3-small",
vec![IntentPrototype {
name: "coding".into(),
embedding: vec![1.0, 0.0],
model: "gpt-4o".into(),
}],
0.5,
);
let llm_cls = LlmClassifier::new(llm_client, "gpt-4o-mini", "Route.");
let cascade = CascadeClassifier::new(vec![
Arc::new(kw) as Arc<dyn RouteClassifier>,
Arc::new(embedding_cls) as Arc<dyn RouteClassifier>,
Arc::new(llm_cls) as Arc<dyn RouteClassifier>,
]);
let meta = empty_meta();
let avail = models(&["gpt-4o", "dall-e-3"]);
let ctx = ctx("Debug my Rust program", &meta, &avail);
let result = cascade.classify(&ctx).await;
assert_eq!(result, Some("gpt-4o".into()));
assert_eq!(llm_call_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn cascade_all_defer_returns_none() {
let kw = KeywordClassifier::new(vec![]);
let embed_client = Arc::new(MockLlmClient::with_embed_vec(vec![0.0, 1.0]));
let embedding_cls = EmbeddingSimilarityClassifier::new(
embed_client,
"text-embedding-3-small",
vec![IntentPrototype {
name: "coding".into(),
embedding: vec![1.0, 0.0],
model: "gpt-4o".into(),
}],
0.99, );
let llm_client = Arc::new(MockLlmClient::no_response());
let llm_cls = LlmClassifier::new(llm_client, "gpt-4o-mini", "Route.");
let cascade = CascadeClassifier::new(vec![
Arc::new(kw) as Arc<dyn RouteClassifier>,
Arc::new(embedding_cls) as Arc<dyn RouteClassifier>,
Arc::new(llm_cls) as Arc<dyn RouteClassifier>,
]);
let meta = empty_meta();
let avail = models(&["gpt-4o"]);
let ctx = ctx("What is 2+2?", &meta, &avail);
let result = cascade.classify(&ctx).await;
assert_eq!(result, None);
}
struct CountingClassifier {
model: String,
call_count: Arc<AtomicUsize>,
}
impl RouteClassifier for CountingClassifier {
fn classify<'a>(
&'a self,
_ctx: &'a ClassifyContext<'a>,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
self.call_count.fetch_add(1, Ordering::SeqCst);
let model = self.model.clone();
Box::pin(async move { Some(model) })
}
}
#[tokio::test]
async fn classifier_verdict_cache_returns_cached_verdict_within_ttl() {
let call_count = Arc::new(AtomicUsize::new(0));
let inner = CountingClassifier {
model: "gpt-4o".into(),
call_count: Arc::clone(&call_count),
};
let cached = ClassifierVerdictCache::new(inner);
let meta = empty_meta();
let avail = models(&["gpt-4o"]);
let prompt = "Tell me a joke";
let ctx1 = ClassifyContext {
prompt,
system_prompt: None,
metadata: &meta,
available_models: &avail,
};
let r1 = cached.classify(&ctx1).await;
assert_eq!(r1, Some("gpt-4o".into()));
assert_eq!(call_count.load(Ordering::SeqCst), 1);
let ctx2 = ClassifyContext {
prompt,
system_prompt: None,
metadata: &meta,
available_models: &avail,
};
let r2 = cached.classify(&ctx2).await;
assert_eq!(r2, Some("gpt-4o".into()));
assert_eq!(call_count.load(Ordering::SeqCst), 1, "inner should not be called again");
}
#[tokio::test]
async fn classifier_verdict_cache_expires_after_ttl() {
let call_count = Arc::new(AtomicUsize::new(0));
let inner = CountingClassifier {
model: "gpt-4o".into(),
call_count: Arc::clone(&call_count),
};
let cached = ClassifierVerdictCache::with_ttl(inner, Duration::from_millis(10));
let meta = empty_meta();
let avail = models(&["gpt-4o"]);
let prompt = "Expire me";
let ctx1 = ClassifyContext {
prompt,
system_prompt: None,
metadata: &meta,
available_models: &avail,
};
let r1 = cached.classify(&ctx1).await;
assert_eq!(r1, Some("gpt-4o".into()));
assert_eq!(call_count.load(Ordering::SeqCst), 1);
tokio::time::sleep(Duration::from_millis(20)).await;
let ctx2 = ClassifyContext {
prompt,
system_prompt: None,
metadata: &meta,
available_models: &avail,
};
let r2 = cached.classify(&ctx2).await;
assert_eq!(r2, Some("gpt-4o".into()));
assert_eq!(call_count.load(Ordering::SeqCst), 2, "inner should be called after TTL");
}
#[test]
fn cosine_similarity_identical_vectors() {
let v = vec![1.0, 2.0, 3.0];
let sim = cosine_similarity(&v, &v);
assert!((sim - 1.0).abs() < 1e-6, "identical vectors should have similarity 1.0");
}
#[test]
fn cosine_similarity_orthogonal_vectors() {
let a = vec![1.0, 0.0];
let b = vec![0.0, 1.0];
let sim = cosine_similarity(&a, &b);
assert!(sim.abs() < 1e-6, "orthogonal vectors should have similarity ~0");
}
#[test]
fn cosine_similarity_zero_vector() {
let zero = vec![0.0, 0.0];
let other = vec![1.0, 1.0];
let sim = cosine_similarity(&zero, &other);
assert_eq!(sim, 0.0, "zero vector should yield similarity 0.0");
}
#[test]
fn llm_classifier_parse_with_preamble() {
let text = r#"Sure! Here is my answer: {"model": "claude-3-5-sonnet"} Hope that helps!"#;
let result = LlmClassifier::parse_model_from_response(text);
assert_eq!(result, Some("claude-3-5-sonnet".into()));
}
#[test]
fn llm_classifier_parse_malformed_returns_none() {
let result = LlmClassifier::parse_model_from_response("I cannot decide.");
assert_eq!(result, None);
}
}