1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4
5use reqwest::header::{HeaderMap, CONTENT_TYPE};
6use serde_json::{json, Value};
7
8use crate::{
9 AgentProvider, CancellationToken, InvocationData, ProviderFuture, ProviderRequest,
10 ProviderResponse, RuntimeError,
11};
12
13const PROVIDER_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
14
15pub struct BinaryProviderResponse {
16 pub content_type: String,
17 pub body: Vec<u8>,
18}
19
20#[derive(Clone, PartialEq)]
22pub enum HttpCapabilityRoute {
23 OpenAiChat {
24 model: String,
25 persona: Value,
26 },
27 ElevenLabsSpeech {
28 voice_id: String,
29 },
30 OpenAiTranscription {
31 model: String,
32 file_name: String,
33 content_type: String,
34 },
35 #[cfg(feature = "local-whisper")]
36 LocalWhisper {
37 model_path: PathBuf,
38 language: Option<String>,
39 },
40}
41
42impl fmt::Debug for HttpCapabilityRoute {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 match self {
45 Self::OpenAiChat { model, .. } => formatter
46 .debug_struct("OpenAiChat")
47 .field("model", model)
48 .field("persona", &"[REDACTED]")
49 .finish(),
50 Self::ElevenLabsSpeech { voice_id } => formatter
51 .debug_struct("ElevenLabsSpeech")
52 .field("voice_id", voice_id)
53 .finish(),
54 Self::OpenAiTranscription {
55 model,
56 file_name,
57 content_type,
58 } => formatter
59 .debug_struct("OpenAiTranscription")
60 .field("model", model)
61 .field("file_name", file_name)
62 .field("content_type", content_type)
63 .finish(),
64 #[cfg(feature = "local-whisper")]
65 Self::LocalWhisper { language, .. } => formatter
66 .debug_struct("LocalWhisper")
67 .field("model_path", &"[REDACTED]")
68 .field("language", language)
69 .finish(),
70 }
71 }
72}
73
74pub struct HttpCapabilityProvider {
79 name: String,
80 base_url: String,
81 token: Option<String>,
82 routes: HashMap<String, HttpCapabilityRoute>,
83}
84
85impl fmt::Debug for HttpCapabilityProvider {
86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87 formatter
88 .debug_struct("HttpCapabilityProvider")
89 .field("name", &self.name)
90 .field("base_url", &self.base_url)
91 .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
92 .field("capabilities", &self.routes.keys().collect::<Vec<_>>())
93 .finish()
94 }
95}
96
97impl HttpCapabilityProvider {
98 pub fn new(
99 name: impl Into<String>,
100 base_url: impl Into<String>,
101 token: Option<String>,
102 ) -> Result<Self, RuntimeError> {
103 let name = name.into();
104 if name.trim().is_empty() {
105 return Err(RuntimeError::InvalidDefinition(
106 "provider name is required".to_string(),
107 ));
108 }
109 let base_url = base_url.into();
110 provider_url(&base_url, "models").map_err(RuntimeError::InvalidDefinition)?;
111 Ok(Self {
112 name,
113 base_url,
114 token: token
115 .map(|value| value.trim().to_string())
116 .filter(|value| !value.is_empty()),
117 routes: HashMap::new(),
118 })
119 }
120
121 pub fn add_route(
122 &mut self,
123 capability: impl Into<String>,
124 route: HttpCapabilityRoute,
125 ) -> Result<(), RuntimeError> {
126 let capability = capability.into().trim().to_ascii_lowercase();
127 if capability.is_empty() || capability.len() > 128 {
128 return Err(RuntimeError::InvalidDefinition(
129 "provider capability is invalid".to_string(),
130 ));
131 }
132 self.routes.insert(capability, route);
133 Ok(())
134 }
135
136 pub fn with_route(
137 mut self,
138 capability: impl Into<String>,
139 route: HttpCapabilityRoute,
140 ) -> Result<Self, RuntimeError> {
141 self.add_route(capability, route)?;
142 Ok(self)
143 }
144}
145
146impl AgentProvider for HttpCapabilityProvider {
147 fn supports(&self, capability: &str) -> bool {
148 self.routes.contains_key(capability)
149 }
150
151 fn invoke<'a>(
152 &'a self,
153 request: ProviderRequest,
154 cancellation: CancellationToken,
155 ) -> ProviderFuture<'a> {
156 Box::pin(async move {
157 let route = self.routes.get(&request.capability).ok_or_else(|| {
158 RuntimeError::CapabilityUnavailable {
159 provider: self.name.clone(),
160 capability: request.capability.clone(),
161 }
162 })?;
163 if cancellation.is_cancelled() {
164 return Err(RuntimeError::Cancelled);
165 }
166 let response = match route {
167 HttpCapabilityRoute::OpenAiChat { model, persona } => {
168 let InvocationData::Json(payload) = &request.data else {
169 return Err(RuntimeError::InvalidDefinition(
170 "chat capability requires JSON input".to_string(),
171 ));
172 };
173 ProviderResponse {
174 data: InvocationData::Json(
175 openai_chat_completion(
176 &self.base_url,
177 self.token.as_deref(),
178 model,
179 payload,
180 persona,
181 )
182 .await
183 .map_err(|message| RuntimeError::provider(&self.name, message))?,
184 ),
185 metadata: json!({ "contentType": "application/json" }),
186 state: None,
187 }
188 }
189 HttpCapabilityRoute::ElevenLabsSpeech { voice_id } => {
190 let InvocationData::Json(payload) = &request.data else {
191 return Err(RuntimeError::InvalidDefinition(
192 "speech capability requires JSON input".to_string(),
193 ));
194 };
195 let response =
196 elevenlabs_speech(&self.base_url, self.token.as_deref(), voice_id, payload)
197 .await
198 .map_err(|message| RuntimeError::provider(&self.name, message))?;
199 ProviderResponse {
200 data: InvocationData::Binary(response.body),
201 metadata: json!({ "contentType": response.content_type }),
202 state: None,
203 }
204 }
205 HttpCapabilityRoute::OpenAiTranscription {
206 model,
207 file_name,
208 content_type,
209 } => {
210 let InvocationData::Binary(audio) = &request.data else {
211 return Err(RuntimeError::InvalidDefinition(
212 "transcription capability requires binary input".to_string(),
213 ));
214 };
215 ProviderResponse {
216 data: InvocationData::Json(
217 openai_audio_transcription(
218 &self.base_url,
219 self.token.as_deref(),
220 model,
221 audio.clone(),
222 file_name,
223 content_type,
224 )
225 .await
226 .map_err(|message| RuntimeError::provider(&self.name, message))?,
227 ),
228 metadata: json!({ "contentType": "application/json" }),
229 state: None,
230 }
231 }
232 #[cfg(feature = "local-whisper")]
233 HttpCapabilityRoute::LocalWhisper {
234 model_path,
235 language,
236 } => {
237 let InvocationData::Binary(audio) = &request.data else {
238 return Err(RuntimeError::InvalidDefinition(
239 "transcription capability requires binary input".to_string(),
240 ));
241 };
242 ProviderResponse {
243 data: InvocationData::Json(json!({
244 "text": local_whisper_transcription(
245 model_path,
246 audio,
247 language.as_deref(),
248 )
249 .map_err(|message| RuntimeError::provider(&self.name, message))?,
250 })),
251 metadata: json!({ "contentType": "application/json" }),
252 state: None,
253 }
254 }
255 };
256 if cancellation.is_cancelled() {
257 return Err(RuntimeError::Cancelled);
258 }
259 Ok(response)
260 })
261 }
262}
263
264pub async fn openai_chat_completion(
265 base_url: &str,
266 token: Option<&str>,
267 model: &str,
268 request: &Value,
269 persona: &Value,
270) -> Result<Value, String> {
271 let mut request = request.clone();
272 apply_persona_to_chat_request(&mut request, persona)?;
273 request
274 .as_object_mut()
275 .ok_or_else(|| "chat completion request must be an object".to_string())?
276 .insert("model".to_string(), Value::String(model.to_string()));
277
278 let response = authorized(
279 reqwest::Client::new().post(provider_url(base_url, "chat/completions")?),
280 token,
281 )
282 .json(&request)
283 .send()
284 .await
285 .map_err(|error| format!("provider request failed: {error}"))?;
286 decode_json_response(response, "chat completion").await
287}
288
289pub fn apply_persona_to_chat_request(request: &mut Value, persona: &Value) -> Result<(), String> {
290 let object = request
291 .as_object_mut()
292 .ok_or_else(|| "chat completion request must be an object".to_string())?;
293 apply_persona(object, persona)
294}
295
296pub async fn elevenlabs_speech(
297 base_url: &str,
298 token: Option<&str>,
299 voice_id: &str,
300 request: &Value,
301) -> Result<BinaryProviderResponse, String> {
302 let url = format!(
303 "{}/text-to-speech/{}",
304 base_url.trim_end_matches('/'),
305 encode_path_segment(voice_id)?
306 );
307 let response = authorized(reqwest::Client::new().post(url), token)
308 .header("xi-api-key", token.unwrap_or_default())
309 .json(request)
310 .send()
311 .await
312 .map_err(|error| format!("speech provider request failed: {error}"))?;
313 decode_binary_response(response, "speech synthesis").await
314}
315
316pub async fn openai_audio_transcription(
317 base_url: &str,
318 token: Option<&str>,
319 model: &str,
320 audio: Vec<u8>,
321 file_name: &str,
322 content_type: &str,
323) -> Result<Value, String> {
324 let part = reqwest::multipart::Part::bytes(audio)
325 .file_name(file_name.to_string())
326 .mime_str(content_type)
327 .map_err(|error| format!("audio content type is invalid: {error}"))?;
328 let form = reqwest::multipart::Form::new()
329 .text("model", model.to_string())
330 .part("file", part);
331 let response = authorized(
332 reqwest::Client::new().post(provider_url(base_url, "audio/transcriptions")?),
333 token,
334 )
335 .multipart(form)
336 .send()
337 .await
338 .map_err(|error| format!("transcription provider request failed: {error}"))?;
339 decode_json_response(response, "audio transcription").await
340}
341
342pub async fn probe_openai_compatible(base_url: &str, token: Option<&str>) -> Result<(), String> {
343 let client = reqwest::Client::builder()
344 .timeout(PROVIDER_PROBE_TIMEOUT)
345 .build()
346 .map_err(|error| format!("provider client could not be created: {error}"))?;
347 let response = authorized(client.get(provider_url(base_url, "models")?), token)
348 .send()
349 .await
350 .map_err(|error| format!("provider probe failed: {error}"))?;
351 require_success(response, "probe").await
352}
353
354pub async fn probe_elevenlabs(base_url: &str, token: Option<&str>) -> Result<(), String> {
355 let client = reqwest::Client::builder()
356 .timeout(PROVIDER_PROBE_TIMEOUT)
357 .build()
358 .map_err(|error| format!("provider client could not be created: {error}"))?;
359 let response = authorized(client.get(provider_url(base_url, "models")?), token)
360 .header("xi-api-key", token.unwrap_or_default())
361 .send()
362 .await
363 .map_err(|error| format!("provider probe failed: {error}"))?;
364 require_success(response, "probe").await
365}
366
367#[cfg(feature = "local-whisper")]
368pub fn local_whisper_transcription(
369 model_path: &Path,
370 wav: &[u8],
371 language: Option<&str>,
372) -> Result<String, String> {
373 use std::io::Cursor;
374
375 use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
376
377 let mut reader = hound::WavReader::new(Cursor::new(wav))
378 .map_err(|error| format!("audio must be a valid WAV file: {error}"))?;
379 let spec = reader.spec();
380 let channels = usize::from(spec.channels);
381 if channels == 0 || spec.sample_rate == 0 {
382 return Err("WAV audio has an invalid channel count or sample rate".to_string());
383 }
384 let interleaved = match spec.sample_format {
385 hound::SampleFormat::Float => reader
386 .samples::<f32>()
387 .collect::<Result<Vec<_>, _>>()
388 .map_err(|error| format!("WAV samples could not be decoded: {error}"))?,
389 hound::SampleFormat::Int => {
390 let scale = 2_f32.powi(i32::from(spec.bits_per_sample.saturating_sub(1)));
391 reader
392 .samples::<i32>()
393 .map(|sample| {
394 sample
395 .map(|sample| sample as f32 / scale)
396 .map_err(|error| format!("WAV samples could not be decoded: {error}"))
397 })
398 .collect::<Result<Vec<_>, _>>()?
399 }
400 };
401 let mono = interleaved
402 .chunks(channels)
403 .map(|frame| frame.iter().copied().sum::<f32>() / frame.len() as f32)
404 .collect::<Vec<_>>();
405 let samples = resample_linear(&mono, spec.sample_rate, 16_000);
406 if samples.is_empty() {
407 return Err("WAV audio does not contain samples".to_string());
408 }
409
410 let model_path = model_path
411 .to_str()
412 .ok_or_else(|| "Whisper model path is not valid UTF-8".to_string())?;
413 let context = WhisperContext::new_with_params(model_path, WhisperContextParameters::default())
414 .map_err(|error| format!("Whisper model could not be loaded: {error}"))?;
415 let mut state = context
416 .create_state()
417 .map_err(|error| format!("Whisper state could not be created: {error}"))?;
418 let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
419 params.set_print_progress(false);
420 params.set_print_realtime(false);
421 params.set_print_timestamps(false);
422 params.set_language(language);
423 state
424 .full(params, &samples)
425 .map_err(|error| format!("Whisper transcription failed: {error}"))?;
426 let segments = state
427 .as_iter()
428 .map(|segment| {
429 segment
430 .to_str_lossy()
431 .map(|text| text.into_owned())
432 .map_err(|error| format!("Whisper segment could not be decoded: {error}"))
433 })
434 .collect::<Result<Vec<_>, _>>()?;
435 Ok(segments.join("").trim().to_string())
436}
437
438#[cfg(not(feature = "local-whisper"))]
439pub fn local_whisper_transcription(
440 _model_path: &Path,
441 _wav: &[u8],
442 _language: Option<&str>,
443) -> Result<String, String> {
444 Err("this Vifu build does not include local Whisper support".to_string())
445}
446
447pub fn resolve_local_model_path(home_dir: &Path, model: &str) -> Result<PathBuf, String> {
448 let model = model.trim();
449 if model.is_empty()
450 || model.len() > 255
451 || model.contains('/')
452 || model.contains('\\')
453 || model == "."
454 || model == ".."
455 {
456 return Err("local model must be a file name inside ~/.vifu/models".to_string());
457 }
458 Ok(home_dir.join("models").join(model))
459}
460
461fn apply_persona(
462 request: &mut serde_json::Map<String, Value>,
463 persona: &Value,
464) -> Result<(), String> {
465 let prompt = persona_prompt(persona);
466 if prompt.is_empty() {
467 return Ok(());
468 }
469 let messages = request
470 .get_mut("messages")
471 .and_then(Value::as_array_mut)
472 .ok_or_else(|| "chat completion messages must be an array".to_string())?;
473 messages.insert(0, json!({ "role": "system", "content": prompt }));
474 Ok(())
475}
476
477fn persona_prompt(persona: &Value) -> String {
478 let mut sections = Vec::new();
479 if let Some(prompt) = persona
480 .get("systemPrompt")
481 .and_then(Value::as_str)
482 .map(str::trim)
483 .filter(|value| !value.is_empty())
484 {
485 sections.push(prompt.to_string());
486 }
487 if let Some(files) = persona.get("files").and_then(Value::as_object) {
488 for (name, content) in files {
489 let Some(content) = content
490 .as_str()
491 .map(str::trim)
492 .filter(|value| !value.is_empty())
493 else {
494 continue;
495 };
496 sections.push(format!("# {name}\n\n{content}"));
497 }
498 }
499 sections.join("\n\n")
500}
501
502fn provider_url(base_url: &str, path: &str) -> Result<String, String> {
503 let base_url = base_url.trim();
504 if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
505 return Err("provider URL must use http or https".to_string());
506 }
507 Ok(format!("{}/{}", base_url.trim_end_matches('/'), path))
508}
509
510fn authorized(builder: reqwest::RequestBuilder, token: Option<&str>) -> reqwest::RequestBuilder {
511 match token.map(str::trim).filter(|token| !token.is_empty()) {
512 Some(token) => builder.bearer_auth(token),
513 None => builder,
514 }
515}
516
517async fn decode_json_response(
518 response: reqwest::Response,
519 operation: &str,
520) -> Result<Value, String> {
521 let status = response.status();
522 let body = response
523 .bytes()
524 .await
525 .map_err(|error| format!("{operation} response could not be read: {error}"))?;
526 if !status.is_success() {
527 return Err(provider_error(operation, status.as_u16(), &body));
528 }
529 serde_json::from_slice(&body)
530 .map_err(|error| format!("{operation} response is not valid JSON: {error}"))
531}
532
533async fn decode_binary_response(
534 response: reqwest::Response,
535 operation: &str,
536) -> Result<BinaryProviderResponse, String> {
537 let status = response.status();
538 let content_type = response_content_type(response.headers());
539 let body = response
540 .bytes()
541 .await
542 .map_err(|error| format!("{operation} response could not be read: {error}"))?;
543 if !status.is_success() {
544 return Err(provider_error(operation, status.as_u16(), &body));
545 }
546 Ok(BinaryProviderResponse {
547 content_type,
548 body: body.to_vec(),
549 })
550}
551
552async fn require_success(response: reqwest::Response, operation: &str) -> Result<(), String> {
553 let status = response.status();
554 if status.is_success() {
555 return Ok(());
556 }
557 let body = response
558 .bytes()
559 .await
560 .map_err(|error| format!("provider {operation} response could not be read: {error}"))?;
561 Err(provider_error(operation, status.as_u16(), &body))
562}
563
564fn response_content_type(headers: &HeaderMap) -> String {
565 headers
566 .get(CONTENT_TYPE)
567 .and_then(|value| value.to_str().ok())
568 .unwrap_or("application/octet-stream")
569 .to_string()
570}
571
572fn provider_error(operation: &str, status: u16, body: &[u8]) -> String {
573 let message = serde_json::from_slice::<Value>(body)
574 .ok()
575 .and_then(|value| {
576 value
577 .pointer("/error/message")
578 .or_else(|| value.get("error"))
579 .and_then(Value::as_str)
580 .map(str::trim)
581 .filter(|value| !value.is_empty())
582 .map(|value| value.chars().take(512).collect::<String>())
583 })
584 .unwrap_or_else(|| format!("HTTP {status}"));
585 format!("provider {operation} failed: {message}")
586}
587
588fn encode_path_segment(value: &str) -> Result<String, String> {
589 let value = value.trim();
590 if value.is_empty()
591 || value.len() > 256
592 || !value
593 .bytes()
594 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
595 {
596 return Err("provider resource ID contains unsupported characters".to_string());
597 }
598 Ok(value.to_string())
599}
600
601#[cfg(feature = "local-whisper")]
602fn resample_linear(input: &[f32], from_hz: u32, to_hz: u32) -> Vec<f32> {
603 if input.is_empty() || from_hz == 0 || to_hz == 0 {
604 return Vec::new();
605 }
606 if from_hz == to_hz {
607 return input.to_vec();
608 }
609 let output_len = (input.len() as u64 * u64::from(to_hz) / u64::from(from_hz)) as usize;
610 (0..output_len)
611 .map(|index| {
612 let source = index as f64 * f64::from(from_hz) / f64::from(to_hz);
613 let left = source.floor() as usize;
614 let right = (left + 1).min(input.len() - 1);
615 let fraction = (source - left as f64) as f32;
616 input[left] + (input[right] - input[left]) * fraction
617 })
618 .collect()
619}
620
621#[cfg(test)]
622mod tests {
623 use serde_json::json;
624
625 use super::{persona_prompt, provider_url, resolve_local_model_path};
626
627 #[test]
628 fn builds_a_portable_persona_prompt() {
629 assert_eq!(
630 persona_prompt(&json!({
631 "systemPrompt": "Stay concise.",
632 "files": { "SOUL.md": "You are the steward." }
633 })),
634 "Stay concise.\n\n# SOUL.md\n\nYou are the steward."
635 );
636 }
637
638 #[test]
639 fn appends_openai_compatible_paths() {
640 assert_eq!(
641 provider_url("https://example.com/v1/", "chat/completions").unwrap(),
642 "https://example.com/v1/chat/completions"
643 );
644 }
645
646 #[test]
647 fn keeps_local_models_inside_the_vifu_model_directory() {
648 let path =
649 resolve_local_model_path(std::path::Path::new("/tmp/.vifu"), "tiny.bin").unwrap();
650 assert_eq!(path, std::path::Path::new("/tmp/.vifu/models/tiny.bin"));
651 assert!(resolve_local_model_path(std::path::Path::new("/tmp/.vifu"), "../key").is_err());
652 }
653}