Skip to main content

vifu_runtime/
providers.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4#[cfg(feature = "local-whisper")]
5use std::sync::{Arc, Mutex};
6use std::time::Instant;
7
8use reqwest::header::{HeaderMap, CONTENT_TYPE};
9use serde_json::{json, Value};
10
11use crate::{
12    AgentProvider, CancellationToken, InvocationData, ProviderEventSink, ProviderFuture,
13    ProviderRequest, ProviderResponse, ProviderStage, RuntimeError,
14};
15
16const PROVIDER_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
17
18pub struct BinaryProviderResponse {
19    pub content_type: String,
20    pub body: Vec<u8>,
21}
22
23/// In-process Whisper provider whose model context remains resident between
24/// transcription requests.
25#[cfg(feature = "local-whisper")]
26pub struct LocalWhisperProvider {
27    name: String,
28    model_path: PathBuf,
29    language: Option<String>,
30    context: Arc<Mutex<Option<whisper_rs::WhisperContext>>>,
31}
32
33#[cfg(feature = "local-whisper")]
34impl fmt::Debug for LocalWhisperProvider {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter
37            .debug_struct("LocalWhisperProvider")
38            .field("name", &self.name)
39            .field("model_path", &"[REDACTED]")
40            .field("language", &self.language)
41            .finish()
42    }
43}
44
45#[cfg(feature = "local-whisper")]
46impl LocalWhisperProvider {
47    pub fn new(
48        name: impl Into<String>,
49        model_path: impl Into<PathBuf>,
50        language: Option<String>,
51    ) -> Result<Self, RuntimeError> {
52        let name = name.into();
53        if name.trim().is_empty() {
54            return Err(RuntimeError::InvalidDefinition(
55                "provider name is required".to_string(),
56            ));
57        }
58        let model_path = model_path.into();
59        if model_path.as_os_str().is_empty() {
60            return Err(RuntimeError::InvalidDefinition(
61                "Whisper model path is required".to_string(),
62            ));
63        }
64        let language = language
65            .map(|value| value.trim().to_string())
66            .filter(|value| !value.is_empty());
67        Ok(Self {
68            name,
69            model_path,
70            language,
71            context: Arc::new(Mutex::new(None)),
72        })
73    }
74}
75
76#[cfg(feature = "local-whisper")]
77impl AgentProvider for LocalWhisperProvider {
78    fn supports(&self, capability: &str) -> bool {
79        capability == "transcription"
80    }
81
82    fn invoke<'a>(
83        &'a self,
84        request: ProviderRequest,
85        cancellation: CancellationToken,
86    ) -> ProviderFuture<'a> {
87        self.invoke_with_events(request, cancellation, ProviderEventSink::discard())
88    }
89
90    fn invoke_with_events<'a>(
91        &'a self,
92        request: ProviderRequest,
93        cancellation: CancellationToken,
94        events: ProviderEventSink,
95    ) -> ProviderFuture<'a> {
96        let name = self.name.clone();
97        let model_path = self.model_path.clone();
98        let context = Arc::clone(&self.context);
99        let language = request
100            .metadata
101            .pointer("/binding/language")
102            .and_then(Value::as_str)
103            .map(str::to_string)
104            .or_else(|| self.language.clone());
105        Box::pin(async move {
106            let InvocationData::Binary(audio) = request.data else {
107                return Err(RuntimeError::InvalidDefinition(
108                    "transcription capability requires binary input".to_string(),
109                ));
110            };
111            if cancellation.is_cancelled() {
112                return Err(RuntimeError::Cancelled);
113            }
114            events.stage_started(ProviderStage::Queue, Value::Null);
115            events.stage_completed(ProviderStage::Queue, 0, Value::Null);
116            let worker_events = events.clone();
117            let worker_name = name.clone();
118            let worker = tokio::task::spawn_blocking(move || {
119                transcribe_with_resident_whisper(
120                    &worker_name,
121                    &model_path,
122                    &context,
123                    &audio,
124                    language.as_deref(),
125                    &worker_events,
126                )
127            });
128            let text = tokio::select! {
129                _ = cancellation.cancelled() => return Err(RuntimeError::Cancelled),
130                result = worker => result
131                    .map_err(|_| RuntimeError::provider(&name, "Whisper worker stopped"))?
132                    .map_err(|message| RuntimeError::provider(&name, message))?,
133            };
134            if cancellation.is_cancelled() {
135                return Err(RuntimeError::Cancelled);
136            }
137            let validate_started = Instant::now();
138            events.stage_started(ProviderStage::Validate, Value::Null);
139            let data = json!({ "text": text });
140            if let Err(error) = validate_transcription_response(&data) {
141                let elapsed = elapsed_ms(validate_started);
142                events.stage_failed(ProviderStage::Validate, elapsed, &error, Value::Null);
143                return Err(RuntimeError::provider(&name, error));
144            }
145            events.stage_completed(
146                ProviderStage::Validate,
147                elapsed_ms(validate_started),
148                Value::Null,
149            );
150            Ok(ProviderResponse {
151                data: InvocationData::Json(data),
152                metadata: json!({ "contentType": "application/json" }),
153                state: None,
154            })
155        })
156    }
157}
158
159/// Capability protocol used by [`HttpCapabilityProvider`].
160#[derive(Clone, PartialEq)]
161pub enum HttpCapabilityRoute {
162    OpenAiChat {
163        model: String,
164        persona: Value,
165    },
166    OpenAiEmbedding {
167        model: String,
168    },
169    ElevenLabsSpeech {
170        voice_id: String,
171    },
172    OpenAiTranscription {
173        model: String,
174        file_name: String,
175        content_type: String,
176    },
177    #[cfg(feature = "local-whisper")]
178    LocalWhisper {
179        model_path: PathBuf,
180        language: Option<String>,
181    },
182}
183
184impl fmt::Debug for HttpCapabilityRoute {
185    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186        match self {
187            Self::OpenAiChat { model, .. } => formatter
188                .debug_struct("OpenAiChat")
189                .field("model", model)
190                .field("persona", &"[REDACTED]")
191                .finish(),
192            Self::OpenAiEmbedding { model } => formatter
193                .debug_struct("OpenAiEmbedding")
194                .field("model", model)
195                .finish(),
196            Self::ElevenLabsSpeech { voice_id } => formatter
197                .debug_struct("ElevenLabsSpeech")
198                .field("voice_id", voice_id)
199                .finish(),
200            Self::OpenAiTranscription {
201                model,
202                file_name,
203                content_type,
204            } => formatter
205                .debug_struct("OpenAiTranscription")
206                .field("model", model)
207                .field("file_name", file_name)
208                .field("content_type", content_type)
209                .finish(),
210            #[cfg(feature = "local-whisper")]
211            Self::LocalWhisper { language, .. } => formatter
212                .debug_struct("LocalWhisper")
213                .field("model_path", &"[REDACTED]")
214                .field("language", language)
215                .finish(),
216        }
217    }
218}
219
220/// A runtime-registered provider assembled from capability protocol routes.
221///
222/// This is one provider object regardless of vendor count. Add routes at
223/// runtime instead of selecting provider-specific Cargo features.
224pub struct HttpCapabilityProvider {
225    name: String,
226    base_url: String,
227    token: Option<String>,
228    routes: HashMap<String, HttpCapabilityRoute>,
229}
230
231impl fmt::Debug for HttpCapabilityProvider {
232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233        formatter
234            .debug_struct("HttpCapabilityProvider")
235            .field("name", &self.name)
236            .field("base_url", &self.base_url)
237            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
238            .field("capabilities", &self.routes.keys().collect::<Vec<_>>())
239            .finish()
240    }
241}
242
243impl HttpCapabilityProvider {
244    pub fn new(
245        name: impl Into<String>,
246        base_url: impl Into<String>,
247        token: Option<String>,
248    ) -> Result<Self, RuntimeError> {
249        let name = name.into();
250        if name.trim().is_empty() {
251            return Err(RuntimeError::InvalidDefinition(
252                "provider name is required".to_string(),
253            ));
254        }
255        let base_url = base_url.into();
256        provider_url(&base_url, "models").map_err(RuntimeError::InvalidDefinition)?;
257        Ok(Self {
258            name,
259            base_url,
260            token: token
261                .map(|value| value.trim().to_string())
262                .filter(|value| !value.is_empty()),
263            routes: HashMap::new(),
264        })
265    }
266
267    pub fn local(name: impl Into<String>) -> Result<Self, RuntimeError> {
268        let name = name.into();
269        if name.trim().is_empty() {
270            return Err(RuntimeError::InvalidDefinition(
271                "provider name is required".to_string(),
272            ));
273        }
274        Ok(Self {
275            name,
276            base_url: String::new(),
277            token: None,
278            routes: HashMap::new(),
279        })
280    }
281
282    pub fn add_route(
283        &mut self,
284        capability: impl Into<String>,
285        route: HttpCapabilityRoute,
286    ) -> Result<(), RuntimeError> {
287        let capability = capability.into().trim().to_ascii_lowercase();
288        if capability.is_empty() || capability.len() > 128 {
289            return Err(RuntimeError::InvalidDefinition(
290                "provider capability is invalid".to_string(),
291            ));
292        }
293        self.routes.insert(capability, route);
294        Ok(())
295    }
296
297    pub fn with_route(
298        mut self,
299        capability: impl Into<String>,
300        route: HttpCapabilityRoute,
301    ) -> Result<Self, RuntimeError> {
302        self.add_route(capability, route)?;
303        Ok(self)
304    }
305}
306
307impl AgentProvider for HttpCapabilityProvider {
308    fn supports(&self, capability: &str) -> bool {
309        self.routes.contains_key(capability)
310    }
311
312    fn invoke<'a>(
313        &'a self,
314        request: ProviderRequest,
315        cancellation: CancellationToken,
316    ) -> ProviderFuture<'a> {
317        self.invoke_with_events(request, cancellation, ProviderEventSink::discard())
318    }
319
320    fn invoke_with_events<'a>(
321        &'a self,
322        request: ProviderRequest,
323        cancellation: CancellationToken,
324        events: ProviderEventSink,
325    ) -> ProviderFuture<'a> {
326        Box::pin(async move {
327            let route = self.routes.get(&request.capability).ok_or_else(|| {
328                RuntimeError::CapabilityUnavailable {
329                    provider: self.name.clone(),
330                    capability: request.capability.clone(),
331                }
332            })?;
333            if cancellation.is_cancelled() {
334                return Err(RuntimeError::Cancelled);
335            }
336            let response = match route {
337                HttpCapabilityRoute::OpenAiChat { model, persona } => {
338                    let InvocationData::Json(payload) = &request.data else {
339                        return Err(RuntimeError::InvalidDefinition(
340                            "chat capability requires JSON input".to_string(),
341                        ));
342                    };
343                    let data = provider_json_result(
344                        &self.name,
345                        &events,
346                        "chat",
347                        openai_chat_completion_result(
348                            &self.base_url,
349                            self.token.as_deref(),
350                            model,
351                            payload,
352                            persona,
353                        )
354                        .await,
355                    )?;
356                    if cancellation.is_cancelled() {
357                        return Err(RuntimeError::Cancelled);
358                    }
359                    validate_with_events(&self.name, &events, "chat", || {
360                        validate_openai_chat_response(&data)
361                    })?;
362                    ProviderResponse {
363                        data: InvocationData::Json(data),
364                        metadata: json!({ "contentType": "application/json" }),
365                        state: None,
366                    }
367                }
368                HttpCapabilityRoute::OpenAiEmbedding { model } => {
369                    let InvocationData::Json(payload) = &request.data else {
370                        return Err(RuntimeError::InvalidDefinition(
371                            "embedding capability requires JSON input".to_string(),
372                        ));
373                    };
374                    let data = provider_json_result(
375                        &self.name,
376                        &events,
377                        "embedding",
378                        openai_embeddings_result(
379                            &self.base_url,
380                            self.token.as_deref(),
381                            model,
382                            payload,
383                        )
384                        .await,
385                    )?;
386                    if cancellation.is_cancelled() {
387                        return Err(RuntimeError::Cancelled);
388                    }
389                    validate_with_events(&self.name, &events, "embedding", || {
390                        validate_openai_embedding_response(&data)
391                    })?;
392                    ProviderResponse {
393                        data: InvocationData::Json(data),
394                        metadata: json!({ "contentType": "application/json" }),
395                        state: None,
396                    }
397                }
398                HttpCapabilityRoute::ElevenLabsSpeech { voice_id } => {
399                    let InvocationData::Json(payload) = &request.data else {
400                        return Err(RuntimeError::InvalidDefinition(
401                            "speech capability requires JSON input".to_string(),
402                        ));
403                    };
404                    let response =
405                        elevenlabs_speech(&self.base_url, self.token.as_deref(), voice_id, payload)
406                            .await
407                            .map_err(|message| RuntimeError::provider(&self.name, message))?;
408                    ProviderResponse {
409                        data: InvocationData::Binary(response.body),
410                        metadata: json!({ "contentType": response.content_type }),
411                        state: None,
412                    }
413                }
414                HttpCapabilityRoute::OpenAiTranscription {
415                    model,
416                    file_name,
417                    content_type,
418                } => {
419                    let InvocationData::Binary(audio) = &request.data else {
420                        return Err(RuntimeError::InvalidDefinition(
421                            "transcription capability requires binary input".to_string(),
422                        ));
423                    };
424                    let data = provider_json_result(
425                        &self.name,
426                        &events,
427                        "transcription",
428                        openai_audio_transcription_result(
429                            &self.base_url,
430                            self.token.as_deref(),
431                            model,
432                            audio.clone(),
433                            file_name,
434                            content_type,
435                        )
436                        .await,
437                    )?;
438                    if cancellation.is_cancelled() {
439                        return Err(RuntimeError::Cancelled);
440                    }
441                    validate_with_events(&self.name, &events, "transcription", || {
442                        validate_transcription_response(&data)
443                    })?;
444                    ProviderResponse {
445                        data: InvocationData::Json(data),
446                        metadata: json!({ "contentType": "application/json" }),
447                        state: None,
448                    }
449                }
450                #[cfg(feature = "local-whisper")]
451                HttpCapabilityRoute::LocalWhisper {
452                    model_path,
453                    language,
454                } => {
455                    let InvocationData::Binary(audio) = &request.data else {
456                        return Err(RuntimeError::InvalidDefinition(
457                            "transcription capability requires binary input".to_string(),
458                        ));
459                    };
460                    let data = json!({
461                        "text": local_whisper_transcription(
462                            model_path,
463                            audio,
464                            request
465                                .metadata
466                                .pointer("/binding/language")
467                                .and_then(Value::as_str)
468                                .or(language.as_deref()),
469                        )
470                        .map_err(|message| RuntimeError::provider(&self.name, message))?,
471                    });
472                    if cancellation.is_cancelled() {
473                        return Err(RuntimeError::Cancelled);
474                    }
475                    validate_with_events(&self.name, &events, "transcription", || {
476                        validate_transcription_response(&data)
477                    })?;
478                    ProviderResponse {
479                        data: InvocationData::Json(data),
480                        metadata: json!({ "contentType": "application/json" }),
481                        state: None,
482                    }
483                }
484            };
485            if cancellation.is_cancelled() {
486                return Err(RuntimeError::Cancelled);
487            }
488            Ok(response)
489        })
490    }
491}
492
493pub async fn openai_chat_completion(
494    base_url: &str,
495    token: Option<&str>,
496    model: &str,
497    request: &Value,
498    persona: &Value,
499) -> Result<Value, String> {
500    openai_chat_completion_result(base_url, token, model, request, persona)
501        .await
502        .map_err(JsonProviderError::into_message)
503}
504
505async fn openai_chat_completion_result(
506    base_url: &str,
507    token: Option<&str>,
508    model: &str,
509    request: &Value,
510    persona: &Value,
511) -> Result<Value, JsonProviderError> {
512    let mut request = request.clone();
513    apply_persona_to_chat_request(&mut request, persona).map_err(JsonProviderError::Provider)?;
514    request
515        .as_object_mut()
516        .ok_or_else(|| {
517            JsonProviderError::Provider("chat completion request must be an object".to_string())
518        })?
519        .insert("model".to_string(), Value::String(model.to_string()));
520
521    let client = provider_http_client(None).map_err(JsonProviderError::Provider)?;
522    let response = authorized(
523        client
524            .post(provider_url(base_url, "chat/completions").map_err(JsonProviderError::Provider)?),
525        token,
526    )
527    .json(&request)
528    .send()
529    .await
530    .map_err(|error| JsonProviderError::Provider(format!("provider request failed: {error}")))?;
531    decode_json_response(response, "chat completion").await
532}
533
534pub async fn openai_embeddings(
535    base_url: &str,
536    token: Option<&str>,
537    model: &str,
538    request: &Value,
539) -> Result<Value, String> {
540    openai_embeddings_result(base_url, token, model, request)
541        .await
542        .map_err(JsonProviderError::into_message)
543}
544
545async fn openai_embeddings_result(
546    base_url: &str,
547    token: Option<&str>,
548    model: &str,
549    request: &Value,
550) -> Result<Value, JsonProviderError> {
551    let mut request = request.clone();
552    request
553        .as_object_mut()
554        .ok_or_else(|| {
555            JsonProviderError::Provider("embedding request must be an object".to_string())
556        })?
557        .insert("model".to_string(), Value::String(model.to_string()));
558
559    let client = provider_http_client(None).map_err(JsonProviderError::Provider)?;
560    let response = authorized(
561        client.post(provider_url(base_url, "embeddings").map_err(JsonProviderError::Provider)?),
562        token,
563    )
564    .json(&request)
565    .send()
566    .await
567    .map_err(|error| {
568        JsonProviderError::Provider(format!("embedding provider request failed: {error}"))
569    })?;
570    decode_json_response(response, "embedding").await
571}
572
573pub fn apply_persona_to_chat_request(request: &mut Value, persona: &Value) -> Result<(), String> {
574    let object = request
575        .as_object_mut()
576        .ok_or_else(|| "chat completion request must be an object".to_string())?;
577    apply_persona(object, persona)
578}
579
580pub async fn elevenlabs_speech(
581    base_url: &str,
582    token: Option<&str>,
583    voice_id: &str,
584    request: &Value,
585) -> Result<BinaryProviderResponse, String> {
586    let url = format!(
587        "{}/text-to-speech/{}",
588        base_url.trim_end_matches('/'),
589        encode_path_segment(voice_id)?
590    );
591    let client = provider_http_client(None)?;
592    let response = authorized(client.post(url), token)
593        .header("xi-api-key", token.unwrap_or_default())
594        .json(request)
595        .send()
596        .await
597        .map_err(|error| format!("speech provider request failed: {error}"))?;
598    decode_binary_response(response, "speech synthesis").await
599}
600
601pub async fn openai_audio_transcription(
602    base_url: &str,
603    token: Option<&str>,
604    model: &str,
605    audio: Vec<u8>,
606    file_name: &str,
607    content_type: &str,
608) -> Result<Value, String> {
609    openai_audio_transcription_result(base_url, token, model, audio, file_name, content_type)
610        .await
611        .map_err(JsonProviderError::into_message)
612}
613
614async fn openai_audio_transcription_result(
615    base_url: &str,
616    token: Option<&str>,
617    model: &str,
618    audio: Vec<u8>,
619    file_name: &str,
620    content_type: &str,
621) -> Result<Value, JsonProviderError> {
622    let part = reqwest::multipart::Part::bytes(audio)
623        .file_name(file_name.to_string())
624        .mime_str(content_type)
625        .map_err(|error| {
626            JsonProviderError::Provider(format!("audio content type is invalid: {error}"))
627        })?;
628    let form = reqwest::multipart::Form::new()
629        .text("model", model.to_string())
630        .part("file", part);
631    let client = provider_http_client(None).map_err(JsonProviderError::Provider)?;
632    let response = authorized(
633        client.post(
634            provider_url(base_url, "audio/transcriptions").map_err(JsonProviderError::Provider)?,
635        ),
636        token,
637    )
638    .multipart(form)
639    .send()
640    .await
641    .map_err(|error| {
642        JsonProviderError::Provider(format!("transcription provider request failed: {error}"))
643    })?;
644    decode_json_response(response, "audio transcription").await
645}
646
647pub async fn probe_openai_compatible(base_url: &str, token: Option<&str>) -> Result<(), String> {
648    let client = provider_http_client(Some(PROVIDER_PROBE_TIMEOUT))?;
649    let response = authorized(client.get(provider_url(base_url, "models")?), token)
650        .send()
651        .await
652        .map_err(|error| format!("provider probe failed: {error}"))?;
653    require_success(response, "probe").await
654}
655
656pub async fn probe_elevenlabs(base_url: &str, token: Option<&str>) -> Result<(), String> {
657    let client = provider_http_client(Some(PROVIDER_PROBE_TIMEOUT))?;
658    let response = authorized(client.get(provider_url(base_url, "models")?), token)
659        .header("xi-api-key", token.unwrap_or_default())
660        .send()
661        .await
662        .map_err(|error| format!("provider probe failed: {error}"))?;
663    require_success(response, "probe").await
664}
665
666#[cfg(feature = "local-whisper")]
667pub fn local_whisper_transcription(
668    model_path: &Path,
669    wav: &[u8],
670    language: Option<&str>,
671) -> Result<String, String> {
672    use std::io::Cursor;
673
674    use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
675
676    let mut reader = hound::WavReader::new(Cursor::new(wav))
677        .map_err(|error| format!("audio must be a valid WAV file: {error}"))?;
678    let spec = reader.spec();
679    let channels = usize::from(spec.channels);
680    if channels == 0 || spec.sample_rate == 0 {
681        return Err("WAV audio has an invalid channel count or sample rate".to_string());
682    }
683    let interleaved = match spec.sample_format {
684        hound::SampleFormat::Float => reader
685            .samples::<f32>()
686            .collect::<Result<Vec<_>, _>>()
687            .map_err(|error| format!("WAV samples could not be decoded: {error}"))?,
688        hound::SampleFormat::Int => {
689            let scale = 2_f32.powi(i32::from(spec.bits_per_sample.saturating_sub(1)));
690            reader
691                .samples::<i32>()
692                .map(|sample| {
693                    sample
694                        .map(|sample| sample as f32 / scale)
695                        .map_err(|error| format!("WAV samples could not be decoded: {error}"))
696                })
697                .collect::<Result<Vec<_>, _>>()?
698        }
699    };
700    let mono = interleaved
701        .chunks(channels)
702        .map(|frame| frame.iter().copied().sum::<f32>() / frame.len() as f32)
703        .collect::<Vec<_>>();
704    let samples = resample_linear(&mono, spec.sample_rate, 16_000);
705    if samples.is_empty() {
706        return Err("WAV audio does not contain samples".to_string());
707    }
708
709    let model_path = model_path
710        .to_str()
711        .ok_or_else(|| "Whisper model path is not valid UTF-8".to_string())?;
712    let context = WhisperContext::new_with_params(model_path, WhisperContextParameters::default())
713        .map_err(|error| format!("Whisper model could not be loaded: {error}"))?;
714    let mut state = context
715        .create_state()
716        .map_err(|error| format!("Whisper state could not be created: {error}"))?;
717    let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
718    params.set_print_progress(false);
719    params.set_print_realtime(false);
720    params.set_print_timestamps(false);
721    params.set_language(language);
722    state
723        .full(params, &samples)
724        .map_err(|error| format!("Whisper transcription failed: {error}"))?;
725    let segments = state
726        .as_iter()
727        .map(|segment| {
728            segment
729                .to_str_lossy()
730                .map(|text| text.into_owned())
731                .map_err(|error| format!("Whisper segment could not be decoded: {error}"))
732        })
733        .collect::<Result<Vec<_>, _>>()?;
734    Ok(segments.join("").trim().to_string())
735}
736
737#[cfg(feature = "local-whisper")]
738fn transcribe_with_resident_whisper(
739    provider_name: &str,
740    model_path: &Path,
741    context: &Mutex<Option<whisper_rs::WhisperContext>>,
742    wav: &[u8],
743    language: Option<&str>,
744    events: &ProviderEventSink,
745) -> Result<String, String> {
746    use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
747
748    let mut context = context
749        .lock()
750        .map_err(|_| "Whisper model context is unavailable".to_string())?;
751    let resident = context.is_some();
752    let load_started = Instant::now();
753    events.stage_started(ProviderStage::Load, json!({ "resident": resident }));
754    if context.is_none() {
755        let model_path = model_path
756            .to_str()
757            .ok_or_else(|| "Whisper model path is not valid UTF-8".to_string())?;
758        match WhisperContext::new_with_params(model_path, WhisperContextParameters::default()) {
759            Ok(loaded) => *context = Some(loaded),
760            Err(error) => {
761                let message = format!("Whisper model could not be loaded: {error}");
762                events.stage_failed(
763                    ProviderStage::Load,
764                    elapsed_ms(load_started),
765                    &message,
766                    json!({ "resident": false }),
767                );
768                return Err(message);
769            }
770        }
771    }
772    events.stage_completed(
773        ProviderStage::Load,
774        elapsed_ms(load_started),
775        json!({ "resident": resident }),
776    );
777
778    let samples = decode_whisper_wav(wav)?;
779    let decode_started = Instant::now();
780    events.stage_started(ProviderStage::Decode, Value::Null);
781    let loaded = context
782        .as_ref()
783        .ok_or_else(|| "Whisper model context is unavailable".to_string())?;
784    let mut state = loaded
785        .create_state()
786        .map_err(|error| format!("Whisper state could not be created: {error}"))?;
787    let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
788    params.set_print_progress(false);
789    params.set_print_realtime(false);
790    params.set_print_timestamps(false);
791    params.set_language(language);
792    if let Err(error) = state.full(params, &samples) {
793        let message = format!("Whisper transcription failed: {error}");
794        events.stage_failed(
795            ProviderStage::Decode,
796            elapsed_ms(decode_started),
797            &message,
798            Value::Null,
799        );
800        return Err(message);
801    }
802    let segments = state
803        .as_iter()
804        .map(|segment| {
805            segment
806                .to_str_lossy()
807                .map(|text| text.into_owned())
808                .map_err(|error| format!("Whisper segment could not be decoded: {error}"))
809        })
810        .collect::<Result<Vec<_>, _>>()?;
811    events.stage_completed(
812        ProviderStage::Decode,
813        elapsed_ms(decode_started),
814        json!({ "provider": provider_name }),
815    );
816    Ok(segments.join("").trim().to_string())
817}
818
819#[cfg(feature = "local-whisper")]
820fn decode_whisper_wav(wav: &[u8]) -> Result<Vec<f32>, String> {
821    use std::io::Cursor;
822
823    let mut reader = hound::WavReader::new(Cursor::new(wav))
824        .map_err(|error| format!("audio must be a valid WAV file: {error}"))?;
825    let spec = reader.spec();
826    let channels = usize::from(spec.channels);
827    if channels == 0 || spec.sample_rate == 0 {
828        return Err("WAV audio has an invalid channel count or sample rate".to_string());
829    }
830    let interleaved = match spec.sample_format {
831        hound::SampleFormat::Float => reader
832            .samples::<f32>()
833            .collect::<Result<Vec<_>, _>>()
834            .map_err(|error| format!("WAV samples could not be decoded: {error}"))?,
835        hound::SampleFormat::Int => {
836            let scale = 2_f32.powi(i32::from(spec.bits_per_sample.saturating_sub(1)));
837            reader
838                .samples::<i32>()
839                .map(|sample| {
840                    sample
841                        .map(|sample| sample as f32 / scale)
842                        .map_err(|error| format!("WAV samples could not be decoded: {error}"))
843                })
844                .collect::<Result<Vec<_>, _>>()?
845        }
846    };
847    let mono = interleaved
848        .chunks(channels)
849        .map(|frame| frame.iter().copied().sum::<f32>() / frame.len() as f32)
850        .collect::<Vec<_>>();
851    let samples = resample_linear(&mono, spec.sample_rate, 16_000);
852    if samples.is_empty() {
853        return Err("WAV audio does not contain samples".to_string());
854    }
855    Ok(samples)
856}
857
858#[cfg(not(feature = "local-whisper"))]
859pub fn local_whisper_transcription(
860    _model_path: &Path,
861    _wav: &[u8],
862    _language: Option<&str>,
863) -> Result<String, String> {
864    Err("this Vifu build does not include local Whisper support".to_string())
865}
866
867pub fn resolve_local_model_path(home_dir: &Path, model: &str) -> Result<PathBuf, String> {
868    let model = model.trim();
869    if model.is_empty()
870        || model.len() > 255
871        || model.contains('/')
872        || model.contains('\\')
873        || model == "."
874        || model == ".."
875    {
876        return Err("local model must be a file name inside ~/.vifu/models".to_string());
877    }
878    Ok(home_dir.join("models").join(model))
879}
880
881fn apply_persona(
882    request: &mut serde_json::Map<String, Value>,
883    persona: &Value,
884) -> Result<(), String> {
885    let prompt = persona_prompt(persona);
886    if prompt.is_empty() {
887        return Ok(());
888    }
889    let messages = request
890        .get_mut("messages")
891        .and_then(Value::as_array_mut)
892        .ok_or_else(|| "chat completion messages must be an array".to_string())?;
893    messages.insert(0, json!({ "role": "system", "content": prompt }));
894    Ok(())
895}
896
897fn persona_prompt(persona: &Value) -> String {
898    let mut sections = Vec::new();
899    if let Some(prompt) = persona
900        .get("systemPrompt")
901        .and_then(Value::as_str)
902        .map(str::trim)
903        .filter(|value| !value.is_empty())
904    {
905        sections.push(prompt.to_string());
906    }
907    if let Some(files) = persona.get("files").and_then(Value::as_object) {
908        for (name, content) in files {
909            let Some(content) = content
910                .as_str()
911                .map(str::trim)
912                .filter(|value| !value.is_empty())
913            else {
914                continue;
915            };
916            sections.push(format!("# {name}\n\n{content}"));
917        }
918    }
919    sections.join("\n\n")
920}
921
922enum JsonProviderError {
923    Provider(String),
924    MalformedResponse(String),
925}
926
927impl JsonProviderError {
928    fn into_message(self) -> String {
929        match self {
930            Self::Provider(message) | Self::MalformedResponse(message) => message,
931        }
932    }
933}
934
935fn provider_json_result(
936    provider_name: &str,
937    events: &ProviderEventSink,
938    kind: &str,
939    result: Result<Value, JsonProviderError>,
940) -> Result<Value, RuntimeError> {
941    match result {
942        Ok(response) => Ok(response),
943        Err(JsonProviderError::Provider(message)) => {
944            Err(RuntimeError::provider(provider_name, message))
945        }
946        Err(JsonProviderError::MalformedResponse(message)) => {
947            let started = Instant::now();
948            events.stage_started(ProviderStage::Validate, json!({ "kind": kind }));
949            Err(validation_error(
950                provider_name,
951                events,
952                kind,
953                started,
954                message,
955            ))
956        }
957    }
958}
959
960fn validate_with_events(
961    provider_name: &str,
962    events: &ProviderEventSink,
963    kind: &str,
964    validate: impl FnOnce() -> Result<Value, String>,
965) -> Result<(), RuntimeError> {
966    let started = Instant::now();
967    events.stage_started(ProviderStage::Validate, json!({ "kind": kind }));
968    match validate() {
969        Ok(metadata) => {
970            events.stage_completed(ProviderStage::Validate, elapsed_ms(started), metadata);
971            Ok(())
972        }
973        Err(message) => Err(validation_error(
974            provider_name,
975            events,
976            kind,
977            started,
978            message,
979        )),
980    }
981}
982
983fn validation_error(
984    provider_name: &str,
985    events: &ProviderEventSink,
986    kind: &str,
987    started: Instant,
988    message: String,
989) -> RuntimeError {
990    let error = RuntimeError::provider(provider_name, message);
991    events.stage_failed(
992        ProviderStage::Validate,
993        elapsed_ms(started),
994        error.to_string(),
995        json!({ "kind": kind }),
996    );
997    error
998}
999
1000fn validate_openai_chat_response(response: &Value) -> Result<Value, String> {
1001    let choices = response
1002        .get("choices")
1003        .and_then(Value::as_array)
1004        .filter(|choices| !choices.is_empty())
1005        .ok_or_else(|| "chat response has no choices".to_string())?;
1006    let message = choices[0]
1007        .get("message")
1008        .and_then(Value::as_object)
1009        .ok_or_else(|| "chat response first choice has no assistant message".to_string())?;
1010    if let Some(role) = message.get("role") {
1011        if role.as_str() != Some("assistant") {
1012            return Err("chat response first message is not from the assistant".to_string());
1013        }
1014    }
1015    let content = message.get("content");
1016    if let Some(content) = content {
1017        if !matches!(content, Value::Null | Value::String(_) | Value::Array(_)) {
1018            return Err("chat response assistant content has an invalid type".to_string());
1019        }
1020    }
1021    let tool_calls = message.get("tool_calls");
1022    if tool_calls.is_some_and(|calls| !calls.is_array()) {
1023        return Err("chat response assistant tool_calls is not an array".to_string());
1024    }
1025    let function_call = message.get("function_call");
1026    if function_call.is_some_and(|call| !call.is_object() && !call.is_null()) {
1027        return Err("chat response assistant function_call is not an object".to_string());
1028    }
1029    if content.is_none() && tool_calls.is_none() && function_call.is_none() {
1030        return Err("chat response assistant message has no content or tool calls".to_string());
1031    }
1032    Ok(json!({
1033        "kind": "chat",
1034        "choices": choices.len(),
1035        "toolCalls": tool_calls.and_then(Value::as_array).map_or(0, Vec::len),
1036    }))
1037}
1038
1039fn validate_openai_embedding_response(response: &Value) -> Result<Value, String> {
1040    let rows = response
1041        .get("data")
1042        .and_then(Value::as_array)
1043        .filter(|rows| !rows.is_empty())
1044        .ok_or_else(|| "embedding response has no data rows".to_string())?;
1045    let mut dimensions = None;
1046    let mut encoding = None;
1047    for (index, row) in rows.iter().enumerate() {
1048        let embedding = row
1049            .get("embedding")
1050            .ok_or_else(|| format!("embedding row {index} has no vector"))?;
1051        let (row_dimensions, row_encoding) = if let Some(values) = embedding.as_array() {
1052            if values.is_empty()
1053                || values
1054                    .iter()
1055                    .any(|value| !value.as_f64().is_some_and(f64::is_finite))
1056            {
1057                return Err(format!(
1058                    "embedding row {index} has an invalid numeric vector"
1059                ));
1060            }
1061            (values.len(), "float")
1062        } else if let Some(encoded) = embedding.as_str() {
1063            let row_dimensions = decode_base64_float32_dimensions(encoded)
1064                .map_err(|message| format!("embedding row {index} {message}"))?;
1065            (row_dimensions, "base64")
1066        } else {
1067            return Err(format!("embedding row {index} has an invalid vector type"));
1068        };
1069        if let Some(expected_dimensions) = dimensions {
1070            if expected_dimensions != row_dimensions {
1071                return Err(format!(
1072                    "embedding row {index} changed dimension from {expected_dimensions} to {row_dimensions}"
1073                ));
1074            }
1075        } else {
1076            dimensions = Some(row_dimensions);
1077        }
1078        if let Some(expected_encoding) = encoding {
1079            if expected_encoding != row_encoding {
1080                return Err(format!(
1081                    "embedding row {index} changed encoding from {expected_encoding} to {row_encoding}"
1082                ));
1083            }
1084        } else {
1085            encoding = Some(row_encoding);
1086        }
1087    }
1088    Ok(json!({
1089        "kind": "embedding",
1090        "rows": rows.len(),
1091        "dimensions": dimensions,
1092        "encoding": encoding,
1093    }))
1094}
1095
1096fn validate_transcription_response(response: &Value) -> Result<Value, String> {
1097    let text = response
1098        .get("text")
1099        .and_then(Value::as_str)
1100        .ok_or_else(|| "transcription response text is missing or is not a string".to_string())?;
1101    Ok(json!({ "kind": "transcription", "characters": text.chars().count() }))
1102}
1103
1104fn decode_base64_float32_dimensions(encoded: &str) -> Result<usize, String> {
1105    let mut bytes = encoded.as_bytes().to_vec();
1106    if bytes.is_empty() || bytes.len() % 4 == 1 {
1107        return Err("has invalid base64 float32 data".to_string());
1108    }
1109    match bytes.len() % 4 {
1110        2 => bytes.extend_from_slice(b"=="),
1111        3 => bytes.push(b'='),
1112        _ => {}
1113    }
1114    let mut decoded = Vec::with_capacity(bytes.len() / 4 * 3);
1115    let chunks = bytes.len() / 4;
1116    for (chunk_index, chunk) in bytes.chunks_exact(4).enumerate() {
1117        let last = chunk_index + 1 == chunks;
1118        let padding = chunk.iter().rev().take_while(|byte| **byte == b'=').count();
1119        if padding > 2 || (!last && padding > 0) || chunk[..4 - padding].contains(&b'=') {
1120            return Err("has invalid base64 padding".to_string());
1121        }
1122        let a = base64_value(chunk[0])?;
1123        let b = base64_value(chunk[1])?;
1124        let c = if padding >= 2 {
1125            0
1126        } else {
1127            base64_value(chunk[2])?
1128        };
1129        let d = if padding >= 1 {
1130            0
1131        } else {
1132            base64_value(chunk[3])?
1133        };
1134        if (padding == 2 && b & 0x0f != 0) || (padding == 1 && c & 0x03 != 0) {
1135            return Err("has non-canonical base64 padding".to_string());
1136        }
1137        decoded.push((a << 2) | (b >> 4));
1138        if padding < 2 {
1139            decoded.push((b << 4) | (c >> 2));
1140        }
1141        if padding == 0 {
1142            decoded.push((c << 6) | d);
1143        }
1144    }
1145    if decoded.is_empty() || decoded.len() % std::mem::size_of::<f32>() != 0 {
1146        return Err("does not contain whole float32 values".to_string());
1147    }
1148    for bytes in decoded.chunks_exact(4) {
1149        let value = f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
1150        if !value.is_finite() {
1151            return Err("contains a non-finite float32 value".to_string());
1152        }
1153    }
1154    Ok(decoded.len() / std::mem::size_of::<f32>())
1155}
1156
1157fn base64_value(byte: u8) -> Result<u8, String> {
1158    match byte {
1159        b'A'..=b'Z' => Ok(byte - b'A'),
1160        b'a'..=b'z' => Ok(byte - b'a' + 26),
1161        b'0'..=b'9' => Ok(byte - b'0' + 52),
1162        b'+' | b'-' => Ok(62),
1163        b'/' | b'_' => Ok(63),
1164        _ => Err("has invalid base64 characters".to_string()),
1165    }
1166}
1167
1168fn elapsed_ms(started: Instant) -> u64 {
1169    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1170}
1171
1172fn provider_url(base_url: &str, path: &str) -> Result<String, String> {
1173    let base_url = base_url.trim();
1174    if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
1175        return Err("provider URL must use http or https".to_string());
1176    }
1177    Ok(format!("{}/{}", base_url.trim_end_matches('/'), path))
1178}
1179
1180fn provider_http_client(timeout: Option<std::time::Duration>) -> Result<reqwest::Client, String> {
1181    let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
1182    if let Some(timeout) = timeout {
1183        builder = builder.timeout(timeout);
1184    }
1185    builder
1186        .build()
1187        .map_err(|error| format!("provider client could not be created: {error}"))
1188}
1189
1190fn authorized(builder: reqwest::RequestBuilder, token: Option<&str>) -> reqwest::RequestBuilder {
1191    match token.map(str::trim).filter(|token| !token.is_empty()) {
1192        Some(token) => builder.bearer_auth(token),
1193        None => builder,
1194    }
1195}
1196
1197async fn decode_json_response(
1198    response: reqwest::Response,
1199    operation: &str,
1200) -> Result<Value, JsonProviderError> {
1201    let status = response.status();
1202    let body = response.bytes().await.map_err(|error| {
1203        JsonProviderError::Provider(format!("{operation} response could not be read: {error}"))
1204    })?;
1205    if !status.is_success() {
1206        return Err(JsonProviderError::Provider(provider_error(
1207            operation,
1208            status.as_u16(),
1209            &body,
1210        )));
1211    }
1212    serde_json::from_slice(&body).map_err(|error| {
1213        JsonProviderError::MalformedResponse(format!(
1214            "{operation} response is not valid JSON: {error}"
1215        ))
1216    })
1217}
1218
1219async fn decode_binary_response(
1220    response: reqwest::Response,
1221    operation: &str,
1222) -> Result<BinaryProviderResponse, String> {
1223    let status = response.status();
1224    let content_type = response_content_type(response.headers());
1225    let body = response
1226        .bytes()
1227        .await
1228        .map_err(|error| format!("{operation} response could not be read: {error}"))?;
1229    if !status.is_success() {
1230        return Err(provider_error(operation, status.as_u16(), &body));
1231    }
1232    Ok(BinaryProviderResponse {
1233        content_type,
1234        body: body.to_vec(),
1235    })
1236}
1237
1238async fn require_success(response: reqwest::Response, operation: &str) -> Result<(), String> {
1239    let status = response.status();
1240    if status.is_success() {
1241        return Ok(());
1242    }
1243    let body = response
1244        .bytes()
1245        .await
1246        .map_err(|error| format!("provider {operation} response could not be read: {error}"))?;
1247    Err(provider_error(operation, status.as_u16(), &body))
1248}
1249
1250fn response_content_type(headers: &HeaderMap) -> String {
1251    headers
1252        .get(CONTENT_TYPE)
1253        .and_then(|value| value.to_str().ok())
1254        .unwrap_or("application/octet-stream")
1255        .to_string()
1256}
1257
1258fn provider_error(operation: &str, status: u16, body: &[u8]) -> String {
1259    let message = serde_json::from_slice::<Value>(body)
1260        .ok()
1261        .and_then(|value| {
1262            value
1263                .pointer("/error/message")
1264                .or_else(|| value.get("error"))
1265                .and_then(Value::as_str)
1266                .map(str::trim)
1267                .filter(|value| !value.is_empty())
1268                .map(|value| value.chars().take(512).collect::<String>())
1269        })
1270        .unwrap_or_else(|| format!("HTTP {status}"));
1271    format!("provider {operation} failed: {message}")
1272}
1273
1274fn encode_path_segment(value: &str) -> Result<String, String> {
1275    let value = value.trim();
1276    if value.is_empty()
1277        || value.len() > 256
1278        || !value
1279            .bytes()
1280            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1281    {
1282        return Err("provider resource ID contains unsupported characters".to_string());
1283    }
1284    Ok(value.to_string())
1285}
1286
1287#[cfg(feature = "local-whisper")]
1288fn resample_linear(input: &[f32], from_hz: u32, to_hz: u32) -> Vec<f32> {
1289    if input.is_empty() || from_hz == 0 || to_hz == 0 {
1290        return Vec::new();
1291    }
1292    if from_hz == to_hz {
1293        return input.to_vec();
1294    }
1295    let output_len = (input.len() as u64 * u64::from(to_hz) / u64::from(from_hz)) as usize;
1296    (0..output_len)
1297        .map(|index| {
1298            let source = index as f64 * f64::from(from_hz) / f64::from(to_hz);
1299            let left = source.floor() as usize;
1300            let right = (left + 1).min(input.len() - 1);
1301            let fraction = (source - left as f64) as f32;
1302            input[left] + (input[right] - input[left]) * fraction
1303        })
1304        .collect()
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309    use std::io::{Read, Write};
1310    use std::net::TcpListener;
1311    use std::sync::{Arc, Mutex};
1312    use std::thread;
1313
1314    use serde_json::json;
1315
1316    use super::{
1317        openai_chat_completion, persona_prompt, probe_openai_compatible, provider_json_result,
1318        provider_url, resolve_local_model_path, validate_openai_chat_response,
1319        validate_openai_embedding_response, validate_transcription_response, validate_with_events,
1320        JsonProviderError,
1321    };
1322    use crate::{ProviderEvent, ProviderEventSink, ProviderStage, RuntimeError};
1323
1324    #[test]
1325    fn builds_a_portable_persona_prompt() {
1326        assert_eq!(
1327            persona_prompt(&json!({
1328                "systemPrompt": "Stay concise.",
1329                "files": { "SOUL.md": "You are the steward." }
1330            })),
1331            "Stay concise.\n\n# SOUL.md\n\nYou are the steward."
1332        );
1333    }
1334
1335    #[test]
1336    fn appends_openai_compatible_paths() {
1337        assert_eq!(
1338            provider_url("https://example.com/v1/", "chat/completions").unwrap(),
1339            "https://example.com/v1/chat/completions"
1340        );
1341    }
1342
1343    #[test]
1344    fn appends_the_openai_embedding_path() {
1345        assert_eq!(
1346            provider_url("https://example.com/v1/", "embeddings").unwrap(),
1347            "https://example.com/v1/embeddings"
1348        );
1349    }
1350
1351    #[tokio::test]
1352    async fn provider_requests_do_not_follow_redirects() {
1353        let (base_url, server) = redirecting_provider();
1354
1355        let error = openai_chat_completion(
1356            &base_url,
1357            Some("test-token"),
1358            "local-model",
1359            &json!({"messages": [{"role": "user", "content": "hello"}]}),
1360            &json!({}),
1361        )
1362        .await
1363        .unwrap_err();
1364
1365        server.join().unwrap();
1366        assert!(error.contains("307"), "unexpected provider error: {error}");
1367    }
1368
1369    #[tokio::test]
1370    async fn provider_probes_do_not_follow_redirects() {
1371        let (base_url, server) = redirecting_provider();
1372
1373        let error = probe_openai_compatible(&base_url, Some("test-token"))
1374            .await
1375            .unwrap_err();
1376
1377        server.join().unwrap();
1378        assert!(error.contains("307"), "unexpected probe error: {error}");
1379    }
1380
1381    fn redirecting_provider() -> (String, thread::JoinHandle<()>) {
1382        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1383        let address = listener.local_addr().unwrap();
1384        let server = thread::spawn(move || {
1385            let (mut stream, _) = listener.accept().unwrap();
1386            let mut request = [0_u8; 4096];
1387            let _ = stream.read(&mut request);
1388            stream
1389                .write_all(
1390                    b"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://127.0.0.1:9/exfil\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1391                )
1392                .unwrap();
1393        });
1394        (format!("http://{address}/v1"), server)
1395    }
1396
1397    #[test]
1398    fn keeps_local_models_inside_the_vifu_model_directory() {
1399        let path =
1400            resolve_local_model_path(std::path::Path::new("/tmp/.vifu"), "tiny.bin").unwrap();
1401        assert_eq!(path, std::path::Path::new("/tmp/.vifu/models/tiny.bin"));
1402        assert!(resolve_local_model_path(std::path::Path::new("/tmp/.vifu"), "../key").is_err());
1403    }
1404
1405    #[test]
1406    fn validates_openai_chat_assistant_text() {
1407        let metadata = validate_openai_chat_response(&json!({
1408            "choices": [{
1409                "message": {
1410                    "role": "assistant",
1411                    "content": [{ "type": "text", "text": "Ready." }]
1412                }
1413            }]
1414        }))
1415        .unwrap();
1416
1417        assert_eq!(
1418            metadata,
1419            json!({ "kind": "chat", "choices": 1, "toolCalls": 0 })
1420        );
1421    }
1422
1423    #[test]
1424    fn accepts_empty_and_tool_call_only_chat_outputs() {
1425        let empty = validate_openai_chat_response(&json!({
1426            "choices": [{ "message": { "role": "assistant", "content": "" } }]
1427        }))
1428        .unwrap();
1429        let tool_call = validate_openai_chat_response(&json!({
1430            "choices": [{
1431                "message": {
1432                    "role": "assistant",
1433                    "content": null,
1434                    "tool_calls": [{
1435                        "id": "call-1",
1436                        "type": "function",
1437                        "function": { "name": "move", "arguments": "{}" }
1438                    }]
1439                }
1440            }]
1441        }))
1442        .unwrap();
1443
1444        assert_eq!(empty["toolCalls"], 0);
1445        assert_eq!(tool_call["toolCalls"], 1);
1446    }
1447
1448    #[test]
1449    fn validates_consistent_numeric_embedding_rows() {
1450        let metadata = validate_openai_embedding_response(&json!({
1451            "data": [
1452                { "embedding": [1, 2.5] },
1453                { "embedding": [-3, 4] }
1454            ]
1455        }))
1456        .unwrap();
1457
1458        assert_eq!(
1459            metadata,
1460            json!({
1461                "kind": "embedding",
1462                "rows": 2,
1463                "dimensions": 2,
1464                "encoding": "float"
1465            })
1466        );
1467    }
1468
1469    #[test]
1470    fn validates_consistent_base64_float32_embedding_rows() {
1471        let metadata = validate_openai_embedding_response(&json!({
1472            "data": [
1473                { "embedding": "AACAPwAAAMA=" },
1474                { "embedding": "AACAPwAAAMA=" }
1475            ]
1476        }))
1477        .unwrap();
1478
1479        assert_eq!(metadata["dimensions"], 2);
1480        assert_eq!(metadata["encoding"], "base64");
1481    }
1482
1483    #[test]
1484    fn rejects_embedding_rows_with_different_dimensions() {
1485        let error = validate_openai_embedding_response(&json!({
1486            "data": [
1487                { "embedding": [1, 2] },
1488                { "embedding": [3] }
1489            ]
1490        }))
1491        .unwrap_err();
1492
1493        assert_eq!(error, "embedding row 1 changed dimension from 2 to 1");
1494    }
1495
1496    #[test]
1497    fn rejects_malformed_base64_embedding_vectors() {
1498        let error = validate_openai_embedding_response(&json!({
1499            "data": [{ "embedding": "not base64" }]
1500        }))
1501        .unwrap_err();
1502
1503        assert!(error.contains("invalid base64"));
1504    }
1505
1506    #[test]
1507    fn accepts_silence_and_rejects_transcription_without_a_text_field() {
1508        assert_eq!(
1509            validate_transcription_response(&json!({ "text": "" })).unwrap(),
1510            json!({ "kind": "transcription", "characters": 0 })
1511        );
1512        let error = validate_transcription_response(&json!({})).unwrap_err();
1513
1514        assert_eq!(
1515            error,
1516            "transcription response text is missing or is not a string"
1517        );
1518    }
1519
1520    #[test]
1521    fn malformed_chat_emits_validate_failed_and_returns_provider_error() {
1522        let captured = Arc::new(Mutex::new(Vec::new()));
1523        let event_capture = Arc::clone(&captured);
1524        let events = ProviderEventSink::from_fn(move |event| {
1525            event_capture.lock().unwrap().push(event);
1526        });
1527
1528        let error = validate_with_events("remote", &events, "chat", || {
1529            validate_openai_chat_response(&json!({
1530                "choices": [{ "message": { "role": "assistant", "content": 42 } }]
1531            }))
1532        })
1533        .unwrap_err();
1534
1535        assert!(matches!(
1536            error,
1537            RuntimeError::Provider { ref provider, ref message }
1538                if provider == "remote"
1539                    && message == "chat response assistant content has an invalid type"
1540        ));
1541        let captured = captured.lock().unwrap();
1542        assert_eq!(captured.len(), 2);
1543        assert!(matches!(
1544            captured[0],
1545            ProviderEvent::StageStarted {
1546                stage: ProviderStage::Validate,
1547                ..
1548            }
1549        ));
1550        assert!(matches!(
1551            captured[1],
1552            ProviderEvent::StageFailed {
1553                stage: ProviderStage::Validate,
1554                ..
1555            }
1556        ));
1557    }
1558
1559    #[test]
1560    fn malformed_embedding_emits_validate_failed_and_returns_provider_error() {
1561        let captured = Arc::new(Mutex::new(Vec::new()));
1562        let event_capture = Arc::clone(&captured);
1563        let events = ProviderEventSink::from_fn(move |event| {
1564            event_capture.lock().unwrap().push(event);
1565        });
1566
1567        let error = validate_with_events("remote", &events, "embedding", || {
1568            validate_openai_embedding_response(&json!({
1569                "data": [
1570                    { "embedding": [1, 2] },
1571                    { "embedding": [3] }
1572                ]
1573            }))
1574        })
1575        .unwrap_err();
1576
1577        assert!(matches!(
1578            error,
1579            RuntimeError::Provider { ref provider, ref message }
1580                if provider == "remote"
1581                    && message == "embedding row 1 changed dimension from 2 to 1"
1582        ));
1583        let captured = captured.lock().unwrap();
1584        assert_eq!(captured.len(), 2);
1585        assert!(matches!(
1586            captured[0],
1587            ProviderEvent::StageStarted {
1588                stage: ProviderStage::Validate,
1589                ..
1590            }
1591        ));
1592        assert!(matches!(
1593            captured[1],
1594            ProviderEvent::StageFailed {
1595                stage: ProviderStage::Validate,
1596                ..
1597            }
1598        ));
1599    }
1600
1601    #[test]
1602    fn invalid_json_output_emits_validate_failed() {
1603        let captured = Arc::new(Mutex::new(Vec::new()));
1604        let event_capture = Arc::clone(&captured);
1605        let events = ProviderEventSink::from_fn(move |event| {
1606            event_capture.lock().unwrap().push(event);
1607        });
1608
1609        let error = provider_json_result(
1610            "remote",
1611            &events,
1612            "chat",
1613            Err(JsonProviderError::MalformedResponse(
1614                "chat completion response is not valid JSON".to_string(),
1615            )),
1616        )
1617        .unwrap_err();
1618
1619        assert!(matches!(error, RuntimeError::Provider { .. }));
1620        let captured = captured.lock().unwrap();
1621        assert!(matches!(
1622            captured.as_slice(),
1623            [
1624                ProviderEvent::StageStarted {
1625                    stage: ProviderStage::Validate,
1626                    ..
1627                },
1628                ProviderEvent::StageFailed {
1629                    stage: ProviderStage::Validate,
1630                    ..
1631                }
1632            ]
1633        ));
1634    }
1635}