use std::time::Duration;
use zeph_llm::any::AnyProvider;
use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
pub(crate) async fn embed_with_timeout_fail_open(
provider: &AnyProvider,
content: &str,
embed_timeout: Duration,
fail_open_score: f32,
log_context: &str,
) -> Result<Vec<f32>, f32> {
match tokio::time::timeout(embed_timeout, provider.embed(content)).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => {
tracing::debug!(error = %e, "{log_context}: embed failed, using fail-open score");
Err(fail_open_score)
}
Err(_) => {
tracing::warn!("{log_context}: embed timed out, using fail-open score");
Err(fail_open_score)
}
}
}
pub(crate) async fn llm_judge_score(
provider: &AnyProvider,
system_prompt: &str,
user_prompt: String,
timeout: Duration,
fail_open_score: f32,
log_context: &str,
parse: impl Fn(&str) -> Option<f32>,
) -> f32 {
let messages = vec![
Message {
role: Role::System,
content: system_prompt.to_owned(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: user_prompt,
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let result = match tokio::time::timeout(timeout, provider.chat(&messages)).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::debug!(error = %e, "{log_context}: LLM call failed, using fail-open score");
return fail_open_score;
}
Err(_) => {
tracing::debug!("{log_context}: LLM call timed out, using fail-open score");
return fail_open_score;
}
};
parse(&result).unwrap_or(fail_open_score).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn embed_with_timeout_fail_open_returns_ok_on_success() {
let mock = zeph_llm::mock::MockProvider::default().with_embedding(vec![1.0, 2.0, 3.0]);
let provider = zeph_llm::any::AnyProvider::Mock(mock);
let result =
embed_with_timeout_fail_open(&provider, "hello", Duration::from_secs(5), 0.5, "test")
.await;
assert_eq!(result, Ok(vec![1.0, 2.0, 3.0]));
}
#[tokio::test]
async fn embed_with_timeout_fail_open_fails_open_on_timeout() {
tokio::time::pause();
let mock = zeph_llm::mock::MockProvider::default()
.with_embed_delay(10_000)
.with_embedding(vec![0.0; 4]);
let provider = zeph_llm::any::AnyProvider::Mock(mock);
let fut = async move {
embed_with_timeout_fail_open(&provider, "hello", Duration::from_secs(5), 0.42, "test")
.await
};
let handle = tokio::spawn(fut); tokio::time::advance(std::time::Duration::from_secs(6)).await;
let result = handle.await.expect("task panicked");
assert_eq!(result, Err(0.42));
}
#[tokio::test]
async fn embed_with_timeout_fail_open_fails_open_on_error() {
let mock = zeph_llm::mock::MockProvider::default().with_embed_invalid_input();
let provider = zeph_llm::any::AnyProvider::Mock(mock);
let result =
embed_with_timeout_fail_open(&provider, "hello", Duration::from_secs(5), 0.77, "test")
.await;
assert_eq!(result, Err(0.77));
}
#[tokio::test]
async fn llm_judge_score_parses_valid_response() {
let mock = zeph_llm::mock::MockProvider::with_responses(vec!["0.75".into()]);
let provider = zeph_llm::any::AnyProvider::Mock(mock);
let score = llm_judge_score(
&provider,
"system",
"user".to_owned(),
Duration::from_secs(5),
0.5,
"test",
|s| s.trim().parse::<f32>().ok(),
)
.await;
assert!((score - 0.75).abs() < f32::EPSILON);
}
#[tokio::test]
async fn llm_judge_score_fails_open_on_provider_error() {
let mock = zeph_llm::mock::MockProvider::default()
.with_errors(vec![zeph_llm::LlmError::Other("boom".into())]);
let provider = zeph_llm::any::AnyProvider::Mock(mock);
let score = llm_judge_score(
&provider,
"system",
"user".to_owned(),
Duration::from_secs(5),
0.33,
"test",
|s| s.trim().parse::<f32>().ok(),
)
.await;
assert!((score - 0.33).abs() < f32::EPSILON);
}
#[tokio::test]
async fn llm_judge_score_falls_back_unclamped_on_timeout() {
tokio::time::pause();
let mock = zeph_llm::mock::MockProvider::default().with_delay(10_000);
let provider = zeph_llm::any::AnyProvider::Mock(mock);
let fut = async move {
llm_judge_score(
&provider,
"system",
"user".to_owned(),
Duration::from_secs(5),
1.5,
"test",
|s| s.trim().parse::<f32>().ok(),
)
.await
};
let handle = tokio::spawn(fut); tokio::time::advance(std::time::Duration::from_secs(6)).await;
let score = handle.await.expect("task panicked");
assert!(
(score - 1.5).abs() < f32::EPSILON,
"timeout fail-open must be returned unclamped, got {score}"
);
}
#[tokio::test]
async fn llm_judge_score_clamps_unparseable_response_fallback() {
let mock = zeph_llm::mock::MockProvider::with_responses(vec!["not-a-number".into()]);
let provider = zeph_llm::any::AnyProvider::Mock(mock);
let score = llm_judge_score(
&provider,
"system",
"user".to_owned(),
Duration::from_secs(5),
1.5,
"test",
|s| s.trim().parse::<f32>().ok(),
)
.await;
assert!(
(score - 1.0).abs() < f32::EPSILON,
"unparseable-response fallback must be clamped to [0,1], got {score}"
);
}
}