1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4#[cfg(feature = "local-whisper")]
5use std::sync::{Arc, Mutex};
6use web_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 #[cfg(not(target_arch = "wasm32"))]
1182 let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
1183 #[cfg(target_arch = "wasm32")]
1184 let builder = reqwest::Client::builder();
1185 #[cfg(target_arch = "wasm32")]
1186 let _ = timeout;
1187 #[cfg(not(target_arch = "wasm32"))]
1188 if let Some(timeout) = timeout {
1189 builder = builder.timeout(timeout);
1190 }
1191 builder
1192 .build()
1193 .map_err(|error| format!("provider client could not be created: {error}"))
1194}
1195
1196fn authorized(builder: reqwest::RequestBuilder, token: Option<&str>) -> reqwest::RequestBuilder {
1197 match token.map(str::trim).filter(|token| !token.is_empty()) {
1198 Some(token) => builder.bearer_auth(token),
1199 None => builder,
1200 }
1201}
1202
1203async fn decode_json_response(
1204 response: reqwest::Response,
1205 operation: &str,
1206) -> Result<Value, JsonProviderError> {
1207 let status = response.status();
1208 let body = response.bytes().await.map_err(|error| {
1209 JsonProviderError::Provider(format!("{operation} response could not be read: {error}"))
1210 })?;
1211 if !status.is_success() {
1212 return Err(JsonProviderError::Provider(provider_error(
1213 operation,
1214 status.as_u16(),
1215 &body,
1216 )));
1217 }
1218 serde_json::from_slice(&body).map_err(|error| {
1219 JsonProviderError::MalformedResponse(format!(
1220 "{operation} response is not valid JSON: {error}"
1221 ))
1222 })
1223}
1224
1225async fn decode_binary_response(
1226 response: reqwest::Response,
1227 operation: &str,
1228) -> Result<BinaryProviderResponse, String> {
1229 let status = response.status();
1230 let content_type = response_content_type(response.headers());
1231 let body = response
1232 .bytes()
1233 .await
1234 .map_err(|error| format!("{operation} response could not be read: {error}"))?;
1235 if !status.is_success() {
1236 return Err(provider_error(operation, status.as_u16(), &body));
1237 }
1238 Ok(BinaryProviderResponse {
1239 content_type,
1240 body: body.to_vec(),
1241 })
1242}
1243
1244async fn require_success(response: reqwest::Response, operation: &str) -> Result<(), String> {
1245 let status = response.status();
1246 if status.is_success() {
1247 return Ok(());
1248 }
1249 let body = response
1250 .bytes()
1251 .await
1252 .map_err(|error| format!("provider {operation} response could not be read: {error}"))?;
1253 Err(provider_error(operation, status.as_u16(), &body))
1254}
1255
1256fn response_content_type(headers: &HeaderMap) -> String {
1257 headers
1258 .get(CONTENT_TYPE)
1259 .and_then(|value| value.to_str().ok())
1260 .unwrap_or("application/octet-stream")
1261 .to_string()
1262}
1263
1264fn provider_error(operation: &str, status: u16, body: &[u8]) -> String {
1265 let message = serde_json::from_slice::<Value>(body)
1266 .ok()
1267 .and_then(|value| {
1268 value
1269 .pointer("/error/message")
1270 .or_else(|| value.get("error"))
1271 .and_then(Value::as_str)
1272 .map(str::trim)
1273 .filter(|value| !value.is_empty())
1274 .map(|value| value.chars().take(512).collect::<String>())
1275 })
1276 .unwrap_or_else(|| format!("HTTP {status}"));
1277 format!("provider {operation} failed: {message}")
1278}
1279
1280fn encode_path_segment(value: &str) -> Result<String, String> {
1281 let value = value.trim();
1282 if value.is_empty()
1283 || value.len() > 256
1284 || !value
1285 .bytes()
1286 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1287 {
1288 return Err("provider resource ID contains unsupported characters".to_string());
1289 }
1290 Ok(value.to_string())
1291}
1292
1293#[cfg(feature = "local-whisper")]
1294fn resample_linear(input: &[f32], from_hz: u32, to_hz: u32) -> Vec<f32> {
1295 if input.is_empty() || from_hz == 0 || to_hz == 0 {
1296 return Vec::new();
1297 }
1298 if from_hz == to_hz {
1299 return input.to_vec();
1300 }
1301 let output_len = (input.len() as u64 * u64::from(to_hz) / u64::from(from_hz)) as usize;
1302 (0..output_len)
1303 .map(|index| {
1304 let source = index as f64 * f64::from(from_hz) / f64::from(to_hz);
1305 let left = source.floor() as usize;
1306 let right = (left + 1).min(input.len() - 1);
1307 let fraction = (source - left as f64) as f32;
1308 input[left] + (input[right] - input[left]) * fraction
1309 })
1310 .collect()
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use std::io::{Read, Write};
1316 use std::net::TcpListener;
1317 use std::sync::{Arc, Mutex};
1318 use std::thread;
1319
1320 use serde_json::json;
1321
1322 use super::{
1323 openai_chat_completion, persona_prompt, probe_openai_compatible, provider_json_result,
1324 provider_url, resolve_local_model_path, validate_openai_chat_response,
1325 validate_openai_embedding_response, validate_transcription_response, validate_with_events,
1326 JsonProviderError,
1327 };
1328 use crate::{ProviderEvent, ProviderEventSink, ProviderStage, RuntimeError};
1329
1330 #[test]
1331 fn builds_a_portable_persona_prompt() {
1332 assert_eq!(
1333 persona_prompt(&json!({
1334 "systemPrompt": "Stay concise.",
1335 "files": { "SOUL.md": "You are the steward." }
1336 })),
1337 "Stay concise.\n\n# SOUL.md\n\nYou are the steward."
1338 );
1339 }
1340
1341 #[test]
1342 fn appends_openai_compatible_paths() {
1343 assert_eq!(
1344 provider_url("https://example.com/v1/", "chat/completions").unwrap(),
1345 "https://example.com/v1/chat/completions"
1346 );
1347 }
1348
1349 #[test]
1350 fn appends_the_openai_embedding_path() {
1351 assert_eq!(
1352 provider_url("https://example.com/v1/", "embeddings").unwrap(),
1353 "https://example.com/v1/embeddings"
1354 );
1355 }
1356
1357 #[tokio::test]
1358 async fn provider_requests_do_not_follow_redirects() {
1359 let (base_url, server) = redirecting_provider();
1360
1361 let error = openai_chat_completion(
1362 &base_url,
1363 Some("test-token"),
1364 "local-model",
1365 &json!({"messages": [{"role": "user", "content": "hello"}]}),
1366 &json!({}),
1367 )
1368 .await
1369 .unwrap_err();
1370
1371 server.join().unwrap();
1372 assert!(error.contains("307"), "unexpected provider error: {error}");
1373 }
1374
1375 #[tokio::test]
1376 async fn provider_probes_do_not_follow_redirects() {
1377 let (base_url, server) = redirecting_provider();
1378
1379 let error = probe_openai_compatible(&base_url, Some("test-token"))
1380 .await
1381 .unwrap_err();
1382
1383 server.join().unwrap();
1384 assert!(error.contains("307"), "unexpected probe error: {error}");
1385 }
1386
1387 fn redirecting_provider() -> (String, thread::JoinHandle<()>) {
1388 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1389 let address = listener.local_addr().unwrap();
1390 let server = thread::spawn(move || {
1391 let (mut stream, _) = listener.accept().unwrap();
1392 let mut request = [0_u8; 4096];
1393 let _ = stream.read(&mut request);
1394 stream
1395 .write_all(
1396 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",
1397 )
1398 .unwrap();
1399 });
1400 (format!("http://{address}/v1"), server)
1401 }
1402
1403 #[test]
1404 fn keeps_local_models_inside_the_vifu_model_directory() {
1405 let path =
1406 resolve_local_model_path(std::path::Path::new("/tmp/.vifu"), "tiny.bin").unwrap();
1407 assert_eq!(path, std::path::Path::new("/tmp/.vifu/models/tiny.bin"));
1408 assert!(resolve_local_model_path(std::path::Path::new("/tmp/.vifu"), "../key").is_err());
1409 }
1410
1411 #[test]
1412 fn validates_openai_chat_assistant_text() {
1413 let metadata = validate_openai_chat_response(&json!({
1414 "choices": [{
1415 "message": {
1416 "role": "assistant",
1417 "content": [{ "type": "text", "text": "Ready." }]
1418 }
1419 }]
1420 }))
1421 .unwrap();
1422
1423 assert_eq!(
1424 metadata,
1425 json!({ "kind": "chat", "choices": 1, "toolCalls": 0 })
1426 );
1427 }
1428
1429 #[test]
1430 fn accepts_empty_and_tool_call_only_chat_outputs() {
1431 let empty = validate_openai_chat_response(&json!({
1432 "choices": [{ "message": { "role": "assistant", "content": "" } }]
1433 }))
1434 .unwrap();
1435 let tool_call = validate_openai_chat_response(&json!({
1436 "choices": [{
1437 "message": {
1438 "role": "assistant",
1439 "content": null,
1440 "tool_calls": [{
1441 "id": "call-1",
1442 "type": "function",
1443 "function": { "name": "move", "arguments": "{}" }
1444 }]
1445 }
1446 }]
1447 }))
1448 .unwrap();
1449
1450 assert_eq!(empty["toolCalls"], 0);
1451 assert_eq!(tool_call["toolCalls"], 1);
1452 }
1453
1454 #[test]
1455 fn validates_consistent_numeric_embedding_rows() {
1456 let metadata = validate_openai_embedding_response(&json!({
1457 "data": [
1458 { "embedding": [1, 2.5] },
1459 { "embedding": [-3, 4] }
1460 ]
1461 }))
1462 .unwrap();
1463
1464 assert_eq!(
1465 metadata,
1466 json!({
1467 "kind": "embedding",
1468 "rows": 2,
1469 "dimensions": 2,
1470 "encoding": "float"
1471 })
1472 );
1473 }
1474
1475 #[test]
1476 fn validates_consistent_base64_float32_embedding_rows() {
1477 let metadata = validate_openai_embedding_response(&json!({
1478 "data": [
1479 { "embedding": "AACAPwAAAMA=" },
1480 { "embedding": "AACAPwAAAMA=" }
1481 ]
1482 }))
1483 .unwrap();
1484
1485 assert_eq!(metadata["dimensions"], 2);
1486 assert_eq!(metadata["encoding"], "base64");
1487 }
1488
1489 #[test]
1490 fn rejects_embedding_rows_with_different_dimensions() {
1491 let error = validate_openai_embedding_response(&json!({
1492 "data": [
1493 { "embedding": [1, 2] },
1494 { "embedding": [3] }
1495 ]
1496 }))
1497 .unwrap_err();
1498
1499 assert_eq!(error, "embedding row 1 changed dimension from 2 to 1");
1500 }
1501
1502 #[test]
1503 fn rejects_malformed_base64_embedding_vectors() {
1504 let error = validate_openai_embedding_response(&json!({
1505 "data": [{ "embedding": "not base64" }]
1506 }))
1507 .unwrap_err();
1508
1509 assert!(error.contains("invalid base64"));
1510 }
1511
1512 #[test]
1513 fn accepts_silence_and_rejects_transcription_without_a_text_field() {
1514 assert_eq!(
1515 validate_transcription_response(&json!({ "text": "" })).unwrap(),
1516 json!({ "kind": "transcription", "characters": 0 })
1517 );
1518 let error = validate_transcription_response(&json!({})).unwrap_err();
1519
1520 assert_eq!(
1521 error,
1522 "transcription response text is missing or is not a string"
1523 );
1524 }
1525
1526 #[test]
1527 fn malformed_chat_emits_validate_failed_and_returns_provider_error() {
1528 let captured = Arc::new(Mutex::new(Vec::new()));
1529 let event_capture = Arc::clone(&captured);
1530 let events = ProviderEventSink::from_fn(move |event| {
1531 event_capture.lock().unwrap().push(event);
1532 });
1533
1534 let error = validate_with_events("remote", &events, "chat", || {
1535 validate_openai_chat_response(&json!({
1536 "choices": [{ "message": { "role": "assistant", "content": 42 } }]
1537 }))
1538 })
1539 .unwrap_err();
1540
1541 assert!(matches!(
1542 error,
1543 RuntimeError::Provider { ref provider, ref message }
1544 if provider == "remote"
1545 && message == "chat response assistant content has an invalid type"
1546 ));
1547 let captured = captured.lock().unwrap();
1548 assert_eq!(captured.len(), 2);
1549 assert!(matches!(
1550 captured[0],
1551 ProviderEvent::StageStarted {
1552 stage: ProviderStage::Validate,
1553 ..
1554 }
1555 ));
1556 assert!(matches!(
1557 captured[1],
1558 ProviderEvent::StageFailed {
1559 stage: ProviderStage::Validate,
1560 ..
1561 }
1562 ));
1563 }
1564
1565 #[test]
1566 fn malformed_embedding_emits_validate_failed_and_returns_provider_error() {
1567 let captured = Arc::new(Mutex::new(Vec::new()));
1568 let event_capture = Arc::clone(&captured);
1569 let events = ProviderEventSink::from_fn(move |event| {
1570 event_capture.lock().unwrap().push(event);
1571 });
1572
1573 let error = validate_with_events("remote", &events, "embedding", || {
1574 validate_openai_embedding_response(&json!({
1575 "data": [
1576 { "embedding": [1, 2] },
1577 { "embedding": [3] }
1578 ]
1579 }))
1580 })
1581 .unwrap_err();
1582
1583 assert!(matches!(
1584 error,
1585 RuntimeError::Provider { ref provider, ref message }
1586 if provider == "remote"
1587 && message == "embedding row 1 changed dimension from 2 to 1"
1588 ));
1589 let captured = captured.lock().unwrap();
1590 assert_eq!(captured.len(), 2);
1591 assert!(matches!(
1592 captured[0],
1593 ProviderEvent::StageStarted {
1594 stage: ProviderStage::Validate,
1595 ..
1596 }
1597 ));
1598 assert!(matches!(
1599 captured[1],
1600 ProviderEvent::StageFailed {
1601 stage: ProviderStage::Validate,
1602 ..
1603 }
1604 ));
1605 }
1606
1607 #[test]
1608 fn invalid_json_output_emits_validate_failed() {
1609 let captured = Arc::new(Mutex::new(Vec::new()));
1610 let event_capture = Arc::clone(&captured);
1611 let events = ProviderEventSink::from_fn(move |event| {
1612 event_capture.lock().unwrap().push(event);
1613 });
1614
1615 let error = provider_json_result(
1616 "remote",
1617 &events,
1618 "chat",
1619 Err(JsonProviderError::MalformedResponse(
1620 "chat completion response is not valid JSON".to_string(),
1621 )),
1622 )
1623 .unwrap_err();
1624
1625 assert!(matches!(error, RuntimeError::Provider { .. }));
1626 let captured = captured.lock().unwrap();
1627 assert!(matches!(
1628 captured.as_slice(),
1629 [
1630 ProviderEvent::StageStarted {
1631 stage: ProviderStage::Validate,
1632 ..
1633 },
1634 ProviderEvent::StageFailed {
1635 stage: ProviderStage::Validate,
1636 ..
1637 }
1638 ]
1639 ));
1640 }
1641}