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#[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#[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
220pub 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}