Skip to main content

vifu_runtime/
providers.rs

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/// Capability protocol used by [`HttpCapabilityProvider`].
21#[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
74/// A runtime-registered provider assembled from capability protocol routes.
75///
76/// This is one provider object regardless of vendor count. Add routes at
77/// runtime instead of selecting provider-specific Cargo features.
78pub 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}