use crate::error::{
NetworkKind, PipelineFailure, PipelineFailureKind, PipelinePhase, TalkError, TimerLabel,
};
use crate::telemetry::{TelemetrySink, TranscriptionEvent};
use futures::Stream;
use serde::Deserialize;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
#[cfg(target_os = "linux")]
const TCP_USER_TIMEOUT: Duration = Duration::from_secs(3);
const TCP_KEEPALIVE: Duration = Duration::from_secs(5);
const TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(1);
const TCP_KEEPALIVE_RETRIES: u32 = 3;
const REQUEST_TIMEOUT_FLOOR_SECS: u64 = 3;
const REQUEST_TIMEOUT_KB_DIVISOR: u64 = 10;
pub(crate) fn proportional_timeout(audio_bytes: u64) -> Duration {
let kb = audio_bytes / 1024;
let secs = std::cmp::max(REQUEST_TIMEOUT_FLOOR_SECS, kb / REQUEST_TIMEOUT_KB_DIVISOR);
Duration::from_secs(secs)
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct TimerSpec {
pub name: &'static str,
pub budget: Duration,
}
pub(crate) fn build_pipeline_failure_kind(
err: reqwest::Error,
timers: &[TimerSpec],
) -> PipelineFailureKind {
let (kind, timer) = classify_reqwest_error(&err, timers);
PipelineFailureKind::Network {
kind,
timer,
source: Box::new(err),
}
}
fn classify_reqwest_error(
err: &reqwest::Error,
timers: &[TimerSpec],
) -> (NetworkKind, Option<TimerLabel>) {
use std::error::Error as _;
if err.is_connect() {
let timer = timers
.iter()
.find(|t| t.name == "connect_timeout")
.map(|t| TimerLabel::from_duration(t.name, t.budget));
return (NetworkKind::Connect, timer);
}
if err.is_timeout() && !err.is_connect() {
let timer = timers
.iter()
.find(|t| matches!(t.name, "request_wall_clock" | "validate_request"))
.map(|t| TimerLabel::from_duration(t.name, t.budget));
return (NetworkKind::WallClock, timer);
}
let mut current: Option<&dyn std::error::Error> = err.source();
while let Some(e) = current {
if let Some(io) = e.downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::TimedOut {
let keepalive_dead = TCP_KEEPALIVE
.saturating_add(TCP_KEEPALIVE_INTERVAL.saturating_mul(TCP_KEEPALIVE_RETRIES));
#[cfg(target_os = "linux")]
let user_timeout = TCP_USER_TIMEOUT;
#[cfg(not(target_os = "linux"))]
let user_timeout = Duration::ZERO;
let budget_str = if cfg!(target_os = "linux") {
format!(
"{}+{}",
fmt_duration_compact(user_timeout),
fmt_duration_compact(keepalive_dead),
)
} else {
fmt_duration_compact(keepalive_dead)
};
return (
NetworkKind::KernelTcp,
Some(TimerLabel {
name: "kernel_tcp_unspecified".to_string(),
budget: budget_str,
}),
);
}
}
current = e.source();
}
(NetworkKind::Other, None)
}
fn fmt_duration_compact(d: Duration) -> String {
if d.subsec_nanos() == 0 {
format!("{}s", d.as_secs())
} else {
format!("{:.3}s", d.as_secs_f64())
}
}
const PROGRESS_BODY_CHUNK_BYTES: usize = 8 * 1024;
pub(crate) struct ProgressBody {
data: Vec<u8>,
offset: usize,
total: u64,
sink: Arc<dyn TelemetrySink>,
emitted_connection: bool,
emitted_complete: bool,
}
impl ProgressBody {
pub(crate) fn new(data: Vec<u8>, sink: Arc<dyn TelemetrySink>) -> Self {
let total = data.len() as u64;
Self {
data,
offset: 0,
total,
sink,
emitted_connection: false,
emitted_complete: false,
}
}
pub(crate) fn len(&self) -> u64 {
self.total
}
}
impl Stream for ProgressBody {
type Item = Result<Vec<u8>, std::io::Error>;
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if !self.emitted_connection {
self.emitted_connection = true;
self.sink
.emit(TranscriptionEvent::ConnectionEstablished { t: Instant::now() });
}
if self.offset >= self.data.len() {
if !self.emitted_complete {
self.emitted_complete = true;
self.sink.emit(TranscriptionEvent::UploadComplete {
total: self.total,
t: Instant::now(),
});
}
return Poll::Ready(None);
}
let end = (self.offset + PROGRESS_BODY_CHUNK_BYTES).min(self.data.len());
let chunk = self.data[self.offset..end].to_vec();
self.offset = end;
self.sink.emit(TranscriptionEvent::UploadProgress {
bytes_sent: self.offset as u64,
total: self.total,
t: Instant::now(),
});
Poll::Ready(Some(Ok(chunk)))
}
}
pub(crate) fn parse_u64_field(value: &serde_json::Value, key: &str) -> Option<u64> {
value.get(key).and_then(|v| {
v.as_u64().or_else(|| {
v.as_i64()
.and_then(|n| if n >= 0 { Some(n as u64) } else { None })
})
})
}
#[derive(Debug, Deserialize)]
pub(crate) struct ModelsResponse {
pub data: Vec<ModelInfo>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ModelInfo {
pub id: String,
}
pub(crate) async fn validate_model(
provider: crate::config::Provider,
provider_name: &str,
api_key: &str,
model: &str,
api_base: &str,
is_transcription_model: fn(&str) -> bool,
sink: &std::sync::Arc<dyn TelemetrySink>,
) -> Result<(), TalkError> {
if super::validate_cache::is_fresh(provider, model, api_base) {
log::debug!(
"validate_model: cache hit for {}:{} on {}",
provider_name,
model,
api_base
);
return Ok(());
}
sink.emit(TranscriptionEvent::PreflightStarted { t: Instant::now() });
let result = validate_model_uncached(
provider_name,
api_key,
model,
api_base,
is_transcription_model,
sink,
)
.await;
sink.emit(TranscriptionEvent::PreflightCompleted {
success: result.is_ok(),
t: Instant::now(),
});
if result.is_ok() {
super::validate_cache::record(provider, model, api_base);
}
result
}
async fn validate_model_uncached(
provider_name: &str,
api_key: &str,
model: &str,
api_base: &str,
is_transcription_model: fn(&str) -> bool,
sink: &Arc<dyn TelemetrySink>,
) -> Result<(), TalkError> {
use super::{Method, Request, RequestBody};
let models_url = format!("{}/v1/models", api_base);
let provider_enum = match provider_name {
"OpenAI" => crate::config::Provider::OpenAI,
_ => crate::config::Provider::Mistral,
};
let req = Request {
method: Method::Get,
url: models_url.clone(),
headers: vec![("Authorization".into(), format!("Bearer {}", api_key))],
body: RequestBody::Empty,
provider: provider_enum,
provider_name: provider_name.to_string(),
phase: PipelinePhase::Validate,
wall_clock: Some(Duration::from_secs(15)),
};
let response =
match super::http_request(req, sink, tokio_util::sync::CancellationToken::new()).await {
Ok(r) => r,
Err(pf) => return Err(pf.into()),
};
let models: ModelsResponse = match serde_json::from_slice(&response.body) {
Ok(m) => m,
Err(e) => {
return Err(PipelineFailure::new(
provider_name,
PipelinePhase::Validate,
1,
1,
&models_url,
PipelineFailureKind::Decode(e.to_string()),
)
.into());
}
};
if models.data.iter().any(|m| m.id == model) {
return Ok(());
}
let mut suggestions: Vec<String> = models
.data
.iter()
.map(|m| m.id.as_str())
.filter(|id| is_transcription_model(id))
.map(String::from)
.collect();
suggestions.sort();
Err(PipelineFailure::new(
provider_name,
PipelinePhase::Validate,
1,
1,
&models_url,
PipelineFailureKind::ModelRejected {
model: model.to_string(),
suggestions,
},
)
.into())
}
pub(crate) async fn enrich_model_error(
error: TalkError,
api_key: &str,
model: &str,
api_base: &str,
is_model_error: fn(&TalkError) -> bool,
is_transcription_model: fn(&str) -> bool,
) -> TalkError {
if !is_model_error(&error) {
return error;
}
match super::super::model_suggestions::fetch_transcription_models(
api_key,
api_base,
is_transcription_model,
)
.await
{
Ok(models) if !models.is_empty() => TalkError::Transcription(format!(
"Model '{}' not found. Available transcription models: {}",
model,
models.join(", ")
)),
Ok(_) => TalkError::Transcription(format!(
"Model '{}' not found (no transcription models available in account)",
model
)),
Err(e) => {
log::warn!("could not fetch model suggestions: {}", e);
error
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proportional_timeout_returns_floor_for_small_audio() {
assert_eq!(proportional_timeout(0), Duration::from_secs(3));
assert_eq!(proportional_timeout(1024), Duration::from_secs(3));
assert_eq!(proportional_timeout(10 * 1024), Duration::from_secs(3));
assert_eq!(proportional_timeout(29 * 1024), Duration::from_secs(3));
}
#[test]
fn proportional_timeout_transitions_above_floor_at_30kb() {
assert_eq!(proportional_timeout(30 * 1024), Duration::from_secs(3));
assert_eq!(proportional_timeout(31 * 1024), Duration::from_secs(3));
assert_eq!(proportional_timeout(40 * 1024), Duration::from_secs(4));
}
#[test]
fn proportional_timeout_scales_linearly_with_kb() {
assert_eq!(proportional_timeout(100 * 1024), Duration::from_secs(10));
assert_eq!(proportional_timeout(147 * 1024), Duration::from_secs(14));
assert_eq!(proportional_timeout(500 * 1024), Duration::from_secs(50));
assert_eq!(proportional_timeout(1024 * 1024), Duration::from_secs(102));
}
#[test]
fn proportional_timeout_handles_large_audio_without_overflow() {
let t = proportional_timeout(16 * 1024 * 1024);
assert_eq!(t, Duration::from_secs(1638));
}
#[test]
fn proportional_timeout_rounds_down_on_non_round_kb() {
assert_eq!(proportional_timeout(1500), Duration::from_secs(3));
assert_eq!(proportional_timeout(45_678), Duration::from_secs(4));
}
use futures::StreamExt;
use std::sync::Mutex;
struct RecordingSink {
events: Mutex<Vec<TranscriptionEvent>>,
}
impl RecordingSink {
fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
fn events(&self) -> Vec<TranscriptionEvent> {
self.events
.lock()
.expect("test: recording sink lock poisoned")
.clone()
}
}
impl TelemetrySink for RecordingSink {
fn emit(&self, event: TranscriptionEvent) {
self.events
.lock()
.expect("test: recording sink lock poisoned")
.push(event);
}
}
#[tokio::test]
async fn progress_body_empty_buffer_emits_connection_then_complete() {
let sink = Arc::new(RecordingSink::new());
let dyn_sink: Arc<dyn TelemetrySink> = sink.clone();
let body = ProgressBody::new(Vec::new(), dyn_sink);
let chunks: Vec<_> = body.collect().await;
assert_eq!(chunks.len(), 0, "empty buffer should yield zero chunks");
let events = sink.events();
assert_eq!(events.len(), 2);
assert!(matches!(
events[0],
TranscriptionEvent::ConnectionEstablished { .. }
));
assert!(matches!(
events[1],
TranscriptionEvent::UploadComplete { total: 0, .. }
));
}
#[tokio::test]
async fn progress_body_single_chunk_emits_three_events_in_order() {
let sink = Arc::new(RecordingSink::new());
let dyn_sink: Arc<dyn TelemetrySink> = sink.clone();
let body = ProgressBody::new(vec![0x42u8; 100], dyn_sink);
let chunks: Vec<_> = body.collect().await;
assert_eq!(chunks.len(), 1);
let chunk = chunks[0]
.as_ref()
.expect("test: first chunk should be Ok")
.clone();
assert_eq!(chunk.len(), 100);
assert!(chunk.iter().all(|&b| b == 0x42));
let events = sink.events();
assert_eq!(events.len(), 3);
assert!(matches!(
events[0],
TranscriptionEvent::ConnectionEstablished { .. }
));
assert!(matches!(
events[1],
TranscriptionEvent::UploadProgress {
bytes_sent: 100,
total: 100,
..
}
));
assert!(matches!(
events[2],
TranscriptionEvent::UploadComplete { total: 100, .. }
));
}
#[tokio::test]
async fn progress_body_multi_chunk_reports_cumulative_progress() {
let total_bytes: usize = 20 * 1024;
let sink = Arc::new(RecordingSink::new());
let dyn_sink: Arc<dyn TelemetrySink> = sink.clone();
let body = ProgressBody::new(vec![0u8; total_bytes], dyn_sink);
let chunks: Vec<_> = body.collect().await;
assert_eq!(chunks.len(), 3, "20 KB / 8 KB chunks → 3 chunks");
let sizes: Vec<usize> = chunks
.iter()
.map(|r| r.as_ref().expect("test: all chunks Ok").len())
.collect();
assert_eq!(sizes, vec![8 * 1024, 8 * 1024, 4 * 1024]);
let events = sink.events();
assert_eq!(events.len(), 5);
assert!(matches!(
events[0],
TranscriptionEvent::ConnectionEstablished { .. }
));
assert!(matches!(
events[1],
TranscriptionEvent::UploadProgress {
bytes_sent: 8192,
total: 20480,
..
}
));
assert!(matches!(
events[2],
TranscriptionEvent::UploadProgress {
bytes_sent: 16384,
total: 20480,
..
}
));
assert!(matches!(
events[3],
TranscriptionEvent::UploadProgress {
bytes_sent: 20480,
total: 20480,
..
}
));
assert!(matches!(
events[4],
TranscriptionEvent::UploadComplete { total: 20480, .. }
));
}
#[tokio::test]
async fn progress_body_len_reports_total_bytes() {
let sink: Arc<dyn TelemetrySink> = Arc::new(RecordingSink::new());
let body = ProgressBody::new(vec![0u8; 12345], sink);
assert_eq!(body.len(), 12345);
}
#[tokio::test]
async fn progress_body_complete_event_emitted_exactly_once() {
let sink = Arc::new(RecordingSink::new());
let dyn_sink: Arc<dyn TelemetrySink> = sink.clone();
let body = ProgressBody::new(vec![0u8; 50], dyn_sink);
let _chunks: Vec<_> = body.collect().await;
let events = sink.events();
let complete_count = events
.iter()
.filter(|e| matches!(e, TranscriptionEvent::UploadComplete { .. }))
.count();
assert_eq!(
complete_count, 1,
"UploadComplete must be emitted exactly once, got {}",
complete_count
);
}
#[tokio::test]
async fn classify_attributes_connect_phase_to_connect_timeout() {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(2))
.build()
.expect("test: build client");
let err = client
.get("http://127.0.0.1:1/")
.send()
.await
.expect_err("test: connection to :1 must fail");
let timers = [
TimerSpec {
name: "connect_timeout",
budget: Duration::from_secs(2),
},
TimerSpec {
name: "request_wall_clock",
budget: Duration::from_secs(5),
},
];
let (kind, timer) = classify_reqwest_error(&err, &timers);
if err.is_connect() {
assert_eq!(kind, NetworkKind::Connect);
let t = timer.expect("connect_timeout timer must be picked");
assert_eq!(t.name, "connect_timeout");
assert_eq!(t.budget, "2s");
}
}
#[tokio::test]
async fn classify_attributes_request_timeout_to_wall_clock() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("test: bind ephemeral port");
let addr = listener.local_addr().expect("test: local_addr");
tokio::spawn(async move {
loop {
if listener.accept().await.is_err() {
break;
}
}
});
let url = format!("http://{}/", addr);
let client = reqwest::Client::builder()
.build()
.expect("test: build client");
let err = client
.get(&url)
.timeout(Duration::from_millis(50))
.send()
.await
.expect_err("test: send must fail when peer never replies");
let timers = [
TimerSpec {
name: "connect_timeout",
budget: Duration::from_secs(2),
},
TimerSpec {
name: "request_wall_clock",
budget: Duration::from_millis(50),
},
];
let (kind, timer) = classify_reqwest_error(&err, &timers);
if err.is_timeout() && !err.is_connect() {
assert_eq!(kind, NetworkKind::WallClock);
let t = timer.expect("request_wall_clock timer must be picked");
assert_eq!(t.name, "request_wall_clock");
}
}
#[tokio::test]
async fn classify_attributes_validate_preflight_separately() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("test: bind ephemeral port");
let addr = listener.local_addr().expect("test: local_addr");
tokio::spawn(async move {
loop {
if listener.accept().await.is_err() {
break;
}
}
});
let url = format!("http://{}/", addr);
let client = reqwest::Client::builder()
.build()
.expect("test: build client");
let err = client
.get(&url)
.timeout(Duration::from_millis(50))
.send()
.await
.expect_err("test: send must fail when peer never replies");
let timers = [
TimerSpec {
name: "connect_timeout",
budget: Duration::from_secs(2),
},
TimerSpec {
name: "validate_request",
budget: Duration::from_secs(10),
},
];
let (_kind, timer) = classify_reqwest_error(&err, &timers);
if err.is_timeout() && !err.is_connect() {
let t = timer.expect("validate_request timer must be picked");
assert_eq!(t.name, "validate_request");
assert_ne!(t.name, "request_wall_clock");
}
}
#[tokio::test]
async fn classify_without_timers_omits_attribution() {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(2))
.build()
.expect("test: build client");
let err = client
.get("http://127.0.0.1:1/")
.send()
.await
.expect_err("test: connection to :1 must fail");
let (_kind, timer) = classify_reqwest_error(&err, &[]);
assert!(
timer.is_none(),
"classify must not synthesize a timer when none declared",
);
}
#[tokio::test]
async fn classify_without_timeout_or_connect_returns_other() {
use std::convert::Infallible;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("test: bind");
let addr = listener.local_addr().expect("test: local_addr");
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let resp = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 12\r\n\r\nnot-json{{{}";
let _ = sock.write_all(resp).await;
let _ = sock.shutdown().await;
}
Ok::<(), Infallible>(())
});
let url = format!("http://{}/", addr);
let client = reqwest::Client::builder()
.build()
.expect("test: build client");
let resp = client
.get(&url)
.send()
.await
.expect("test: HTTP exchange completes");
let err = resp
.json::<serde_json::Value>()
.await
.expect_err("test: decode must fail on invalid JSON");
let timers = [
TimerSpec {
name: "connect_timeout",
budget: Duration::from_secs(2),
},
TimerSpec {
name: "request_wall_clock",
budget: Duration::from_secs(5),
},
];
let (kind, timer) = classify_reqwest_error(&err, &timers);
assert_eq!(kind, NetworkKind::Other);
assert!(timer.is_none());
}
#[tokio::test]
async fn progress_body_connection_established_emitted_exactly_once() {
let sink = Arc::new(RecordingSink::new());
let dyn_sink: Arc<dyn TelemetrySink> = sink.clone();
let body = ProgressBody::new(vec![0u8; 40 * 1024], dyn_sink);
let _chunks: Vec<_> = body.collect().await;
let events = sink.events();
let conn_count = events
.iter()
.filter(|e| matches!(e, TranscriptionEvent::ConnectionEstablished { .. }))
.count();
assert_eq!(
conn_count, 1,
"ConnectionEstablished must be emitted exactly once"
);
}
#[test]
fn validate_budget_schedule_is_2_5_8_11_15_30_120() {
const EXPECTED_ATTEMPTS: usize = 7;
let expected_total_secs: u64 = 2 + 5 + 8 + 11 + 15 + 30 + 120;
assert_eq!(expected_total_secs, 191);
assert_eq!(EXPECTED_ATTEMPTS, 7);
}
fn cache_test_guard() -> CacheTestGuard {
let lock = super::super::validate_cache::__TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::TempDir::new().expect("test: tempdir");
let path = tmp.path().join("validate-cache.yaml");
let prev = std::env::var_os("TALK_RS_VALIDATE_CACHE_PATH");
unsafe {
std::env::set_var("TALK_RS_VALIDATE_CACHE_PATH", &path);
}
super::super::validate_cache::__test_reset();
CacheTestGuard {
_tmp: tmp,
_lock: lock,
prev,
}
}
struct CacheTestGuard {
_tmp: tempfile::TempDir,
_lock: std::sync::MutexGuard<'static, ()>,
prev: Option<std::ffi::OsString>,
}
impl Drop for CacheTestGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("TALK_RS_VALIDATE_CACHE_PATH", v),
None => std::env::remove_var("TALK_RS_VALIDATE_CACHE_PATH"),
}
}
super::super::validate_cache::__test_reset();
}
}
#[tokio::test]
async fn validate_model_emits_preflight_pair_on_cache_miss() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _cache_guard = cache_test_guard();
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{ "id": "voxtral-mini-2602" }]
})))
.mount(&mock_server)
.await;
let sink = Arc::new(RecordingSink::new());
let dyn_sink: Arc<dyn TelemetrySink> = sink.clone();
let api_base = mock_server.uri();
let result = validate_model(
crate::config::Provider::Mistral,
"Mistral",
"test-key",
"voxtral-mini-2602",
&api_base,
|id| id.contains("voxtral"),
&dyn_sink,
)
.await;
assert!(
result.is_ok(),
"validate must succeed against mock: {:?}",
result
);
let events = sink.events();
let started_count = events
.iter()
.filter(|e| matches!(e, TranscriptionEvent::PreflightStarted { .. }))
.count();
let completed_success_count = events
.iter()
.filter(|e| {
matches!(
e,
TranscriptionEvent::PreflightCompleted { success: true, .. }
)
})
.count();
assert_eq!(
started_count, 1,
"PreflightStarted fires exactly once on cache miss"
);
assert_eq!(
completed_success_count, 1,
"PreflightCompleted{{success:true}} fires once"
);
}
#[tokio::test]
async fn validate_model_does_not_retry_on_permanent_http_error() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _cache_guard = cache_test_guard();
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
.expect(1) .mount(&mock_server)
.await;
let sink: Arc<dyn TelemetrySink> = Arc::new(RecordingSink::new());
let api_base = mock_server.uri();
let result = validate_model(
crate::config::Provider::Mistral,
"Mistral",
"bad-key",
"voxtral-mini-2602",
&api_base,
|id| id.contains("voxtral"),
&sink,
)
.await;
let err = result.unwrap_err();
let pf = match &err {
crate::error::TalkError::Pipeline(pf) => pf,
other => panic!("expected TalkError::Pipeline, got: {}", other),
};
assert_eq!(pf.phase, crate::error::PipelinePhase::Validate);
match &pf.kind {
crate::error::PipelineFailureKind::HttpStatus { status, body } => {
assert_eq!(*status, 401);
assert!(
body.contains("Unauthorized"),
"expected body to mention 'Unauthorized', got: {}",
body
);
}
other => panic!("expected HttpStatus kind, got: {:?}", other),
}
}
#[tokio::test]
async fn validate_model_does_not_retry_on_model_not_found() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _cache_guard = cache_test_guard();
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{ "id": "voxtral-mini-2507" },
{ "id": "voxtral-mini-2602" }
]
})))
.expect(1)
.mount(&mock_server)
.await;
let sink: Arc<dyn TelemetrySink> = Arc::new(RecordingSink::new());
let api_base = mock_server.uri();
let result = validate_model(
crate::config::Provider::Mistral,
"Mistral",
"test-key",
"voxtral-i-do-not-exist",
&api_base,
|id| id.contains("voxtral"),
&sink,
)
.await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("not found"),
"expected not-found message, got: {}",
msg
);
assert!(
msg.contains("voxtral-mini-2507") && msg.contains("voxtral-mini-2602"),
"expected suggestions in error, got: {}",
msg
);
}
#[tokio::test]
async fn validate_model_cache_hit_skips_network() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _cache_guard = cache_test_guard();
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{ "id": "voxtral-mini-2602" }]
})))
.expect(1) .mount(&mock_server)
.await;
let sink_concrete = Arc::new(RecordingSink::new());
let sink: Arc<dyn TelemetrySink> = sink_concrete.clone();
let api_base = mock_server.uri();
let model = "voxtral-mini-2602";
let r1 = validate_model(
crate::config::Provider::Mistral,
"Mistral",
"test-key",
model,
&api_base,
|id| id.contains("voxtral"),
&sink,
)
.await;
assert!(r1.is_ok());
let r2 = validate_model(
crate::config::Provider::Mistral,
"Mistral",
"test-key",
model,
&api_base,
|id| id.contains("voxtral"),
&sink,
)
.await;
assert!(r2.is_ok());
let events = sink_concrete
.events()
.into_iter()
.filter(|e| {
matches!(
e,
TranscriptionEvent::PreflightStarted { .. }
| TranscriptionEvent::PreflightCompleted { .. }
)
})
.count();
assert_eq!(
events, 2,
"exactly one Preflight pair (start+complete) across both calls; got {} events",
events
);
}
#[tokio::test]
async fn validate_model_does_not_retry_on_malformed_response_body() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _cache_guard = cache_test_guard();
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_string("{not-json"))
.mount(&mock_server)
.await;
let sink_concrete = Arc::new(RecordingSink::new());
let sink: Arc<dyn TelemetrySink> = sink_concrete.clone();
let api_base = mock_server.uri();
let result = validate_model(
crate::config::Provider::Mistral,
"Mistral",
"test-key",
"voxtral-mini-2602",
&api_base,
|id| id.contains("voxtral"),
&sink,
)
.await;
let err = result.expect_err("malformed body must surface as Err");
let s = err.to_string();
assert!(
s.contains("could not parse response") || s.contains("decode"),
"expected decode failure rendering; got: {}",
s
);
let retries: Vec<_> = sink_concrete
.events()
.into_iter()
.filter_map(|e| {
if let TranscriptionEvent::RetryScheduled { attempt, max, .. } = e {
Some((attempt, max))
} else {
None
}
})
.collect();
assert!(
retries.is_empty(),
"decode failures must not be retried by transport; got: {:?}",
retries
);
}
#[test]
fn is_model_error_detects_structural_model_rejected() {
use crate::error::{PipelineFailure, PipelineFailureKind, PipelinePhase, TalkError};
let pf = PipelineFailure::new(
"Mistral",
PipelinePhase::Validate,
1,
5,
"https://x",
PipelineFailureKind::ModelRejected {
model: "ghost".into(),
suggestions: vec![],
},
);
let err: TalkError = pf.into();
assert!(
crate::transcription::is_model_error(crate::config::Provider::Mistral, &err),
"structural ModelRejected must be detected as model_error",
);
}
#[test]
fn is_model_error_does_not_match_structural_network_failure() {
use crate::error::{
NetworkKind, PipelineFailure, PipelineFailureKind, PipelinePhase, TalkError, TimerLabel,
};
let inner = std::io::Error::new(std::io::ErrorKind::TimedOut, "operation timed out");
let pf = PipelineFailure::new(
"Mistral",
PipelinePhase::Validate,
5,
5,
"https://x",
PipelineFailureKind::Network {
kind: NetworkKind::Connect,
timer: Some(TimerLabel::from_duration(
"connect_timeout",
Duration::from_secs(2),
)),
source: Box::new(inner),
},
);
let err: TalkError = pf.into();
assert!(
!crate::transcription::is_model_error(crate::config::Provider::Mistral, &err),
"Network failure must not be treated as model_error",
);
}
}