Skip to main content

lc_providers/openai/
multimodal.rs

1// lc-providers/src/openai/multimodal.rs
2//! OpenAI multimodal capabilities: Whisper (STT), TTS, DALL-E.
3//!
4//! Implements `MultimodalModel` for `OpenAIChat`, providing:
5//! - `transcribe()` — Whisper speech-to-text
6//! - `generate_speech()` — TTS text-to-speech
7//! - `generate_image()` — DALL-E image generation
8
9use async_trait::async_trait;
10use lc_core::language_models::{MultimodalError, MultimodalModel};
11use lc_schema::{AudioContent, ImageContent};
12use serde::Deserialize;
13
14use super::chat::OpenAIChat;
15
16/// Whisper API response.
17#[derive(Debug, Deserialize)]
18struct WhisperResponse {
19    text: String,
20}
21
22// TTS is returned as raw bytes — no structured response needed.
23
24/// DALL-E API response.
25#[derive(Debug, Deserialize)]
26struct DallEImage {
27    url: Option<String>,
28    b64_json: Option<String>,
29}
30
31#[derive(Debug, Deserialize)]
32struct DallEResponse {
33    data: Vec<DallEImage>,
34}
35
36/// TTS voice options.
37#[derive(Debug, Clone, Copy, serde::Serialize)]
38#[serde(rename_all = "lowercase")]
39pub enum TtsVoice {
40    /// The "alloy" voice.
41    Alloy,
42    /// The "echo" voice.
43    Echo,
44    /// The "fable" voice.
45    Fable,
46    /// The "onyx" voice.
47    Onyx,
48    /// The "nova" voice.
49    Nova,
50    /// The "shimmer" voice.
51    Shimmer,
52}
53
54impl TtsVoice {
55    /// Returns the API string for this voice.
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            TtsVoice::Alloy => "alloy",
59            TtsVoice::Echo => "echo",
60            TtsVoice::Fable => "fable",
61            TtsVoice::Onyx => "onyx",
62            TtsVoice::Nova => "nova",
63            TtsVoice::Shimmer => "shimmer",
64        }
65    }
66}
67
68/// DALL-E image size options.
69#[derive(Debug, Clone, Copy, serde::Serialize)]
70pub enum DallEImageSize {
71    /// 256x256 image size.
72    #[serde(rename = "256x256")]
73    S256,
74    /// 512x512 image size.
75    #[serde(rename = "512x512")]
76    S512,
77    /// 1024x1024 image size.
78    #[serde(rename = "1024x1024")]
79    S1024,
80    /// 1792x1024 image size.
81    #[serde(rename = "1792x1024")]
82    S1792x1024,
83    /// 1024x1792 image size.
84    #[serde(rename = "1024x1792")]
85    S1024x1792,
86}
87
88impl DallEImageSize {
89    /// Returns the API string for this size.
90    pub fn as_str(&self) -> &'static str {
91        match self {
92            DallEImageSize::S256 => "256x256",
93            DallEImageSize::S512 => "512x512",
94            DallEImageSize::S1024 => "1024x1024",
95            DallEImageSize::S1792x1024 => "1792x1024",
96            DallEImageSize::S1024x1792 => "1024x1792",
97        }
98    }
99}
100
101/// OpenAI multimodal extensions.
102///
103/// These methods are on `OpenAIChat` directly (not through the trait)
104/// because they require provider-specific parameters (voice, size, etc.).
105impl OpenAIChat {
106    /// Transcribes audio using Whisper.
107    ///
108    /// Sends audio to the `/v1/audio/transcriptions` endpoint.
109    pub async fn whisper_transcribe(&self, audio: AudioContent) -> Result<String, MultimodalError> {
110        let url = format!("{}/audio/transcriptions", self.config.base_url);
111
112        let audio_data = if audio.is_base64() {
113            // Decode base64 data
114            let b64 = audio.base64_data().unwrap_or("");
115            base64_decode(b64)?
116        } else {
117            // Fetch from URL through the SSRF-guarded GET (0.20.0 S4 P1): the audio
118            // URL is caller-supplied, so it must not be able to reach private/internal
119            // addresses or be redirected into the intranet.
120            let response = lc_core::ssrf::guarded_get(&audio.url, true, None)
121                .await
122                .map_err(|e| MultimodalError::HttpError(e.to_string()))?;
123            response
124                .bytes()
125                .await
126                .map_err(|e| MultimodalError::HttpError(e.to_string()))?
127                .to_vec()
128        };
129
130        let part = reqwest::multipart::Part::bytes(audio_data)
131            .file_name("audio.wav")
132            .mime_str("audio/wav")
133            .map_err(|e| MultimodalError::HttpError(e.to_string()))?;
134
135        let form = reqwest::multipart::Form::new()
136            .part("file", part)
137            .text("model", "whisper-1");
138
139        let response = self
140            .client
141            .post(&url)
142            .header("Authorization", format!("Bearer {}", self.config.api_key))
143            .multipart(form)
144            .send()
145            .await
146            .map_err(|e| MultimodalError::HttpError(e.to_string()))?;
147
148        let status = response.status();
149        if !status.is_success() {
150            let error_text = response.text().await.unwrap_or_default();
151            return Err(MultimodalError::ApiError(format!(
152                "HTTP {}: {}",
153                status, error_text
154            )));
155        }
156
157        let whisper_response: WhisperResponse = response
158            .json()
159            .await
160            .map_err(|e| MultimodalError::ParseError(e.to_string()))?;
161
162        Ok(whisper_response.text)
163    }
164
165    /// Generates speech using OpenAI TTS.
166    ///
167    /// Sends text to the `/v1/audio/speech` endpoint and returns raw audio bytes.
168    pub async fn tts_generate(
169        &self,
170        text: &str,
171        voice: TtsVoice,
172    ) -> Result<Vec<u8>, MultimodalError> {
173        let url = format!("{}/audio/speech", self.config.base_url);
174
175        let body = serde_json::json!({
176            "model": "tts-1",
177            "input": text,
178            "voice": voice.as_str(),
179        });
180
181        let response = self
182            .client
183            .post(&url)
184            .header("Authorization", format!("Bearer {}", self.config.api_key))
185            .header("Content-Type", "application/json")
186            .json(&body)
187            .send()
188            .await
189            .map_err(|e| MultimodalError::HttpError(e.to_string()))?;
190
191        let status = response.status();
192        if !status.is_success() {
193            let error_text = response.text().await.unwrap_or_default();
194            return Err(MultimodalError::ApiError(format!(
195                "HTTP {}: {}",
196                status, error_text
197            )));
198        }
199
200        let bytes = response
201            .bytes()
202            .await
203            .map_err(|e| MultimodalError::HttpError(e.to_string()))?;
204
205        Ok(bytes.to_vec())
206    }
207
208    /// Generates an image using DALL-E.
209    ///
210    /// Sends a prompt to the `/v1/images/generations` endpoint.
211    pub async fn dalle_generate(
212        &self,
213        prompt: &str,
214        size: DallEImageSize,
215    ) -> Result<ImageContent, MultimodalError> {
216        let url = format!("{}/images/generations", self.config.base_url);
217
218        let body = serde_json::json!({
219            "model": "dall-e-3",
220            "prompt": prompt,
221            "n": 1,
222            "size": size.as_str(),
223        });
224
225        let response = self
226            .client
227            .post(&url)
228            .header("Authorization", format!("Bearer {}", self.config.api_key))
229            .header("Content-Type", "application/json")
230            .json(&body)
231            .send()
232            .await
233            .map_err(|e| MultimodalError::HttpError(e.to_string()))?;
234
235        let status = response.status();
236        if !status.is_success() {
237            let error_text = response.text().await.unwrap_or_default();
238            return Err(MultimodalError::ApiError(format!(
239                "HTTP {}: {}",
240                status, error_text
241            )));
242        }
243
244        let dalle_response: DallEResponse = response
245            .json()
246            .await
247            .map_err(|e| MultimodalError::ParseError(e.to_string()))?;
248
249        let image = dalle_response
250            .data
251            .first()
252            .ok_or_else(|| MultimodalError::ApiError("No image in response".to_string()))?;
253
254        if let Some(url) = &image.url {
255            Ok(ImageContent::from_url(url))
256        } else if let Some(b64) = &image.b64_json {
257            Ok(ImageContent::from_base64(b64))
258        } else {
259            Err(MultimodalError::ApiError(
260                "No image URL or base64 data in response".to_string(),
261            ))
262        }
263    }
264}
265
266/// Implement MultimodalModel trait for OpenAIChat.
267#[async_trait]
268impl MultimodalModel for OpenAIChat {
269    async fn transcribe(&self, audio: AudioContent) -> Result<String, MultimodalError> {
270        self.whisper_transcribe(audio).await
271    }
272
273    async fn generate_speech(&self, text: &str) -> Result<Vec<u8>, MultimodalError> {
274        self.tts_generate(text, TtsVoice::Alloy).await
275    }
276
277    async fn generate_image(&self, prompt: &str) -> Result<ImageContent, MultimodalError> {
278        self.dalle_generate(prompt, DallEImageSize::S1024).await
279    }
280}
281
282/// Helper to decode base64 string.
283fn base64_decode(input: &str) -> Result<Vec<u8>, MultimodalError> {
284    use base64::Engine;
285    base64::engine::general_purpose::STANDARD
286        .decode(input)
287        .map_err(|e| MultimodalError::ParseError(format!("Base64 decode error: {}", e)))
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_tts_voice_str() {
296        assert_eq!(TtsVoice::Alloy.as_str(), "alloy");
297        assert_eq!(TtsVoice::Shimmer.as_str(), "shimmer");
298    }
299
300    #[test]
301    fn test_dalle_size_str() {
302        assert_eq!(DallEImageSize::S256.as_str(), "256x256");
303        assert_eq!(DallEImageSize::S1024.as_str(), "1024x1024");
304        assert_eq!(DallEImageSize::S1792x1024.as_str(), "1792x1024");
305    }
306
307    #[test]
308    fn test_base64_decode_valid() {
309        let decoded = base64_decode("aGVsbG8=").unwrap();
310        assert_eq!(String::from_utf8_lossy(&decoded), "hello");
311    }
312
313    #[test]
314    fn test_base64_decode_invalid() {
315        let result = base64_decode("!!!invalid!!!");
316        assert!(result.is_err());
317    }
318
319    #[tokio::test]
320    async fn test_whisper_transcribe_blocks_private_audio_url() {
321        // 0.20.0 S4 P1: caller-supplied audio URLs go through the SSRF guard, so
322        // private/internal addresses are rejected before any network call.
323        let chat = OpenAIChat::new(crate::OpenAIConfig::new("test_key"));
324        let audio = AudioContent::from_url("http://127.0.0.1:9/audio.wav");
325        let result = chat.whisper_transcribe(audio).await;
326        assert!(result.is_err());
327        let err = result.unwrap_err().to_string();
328        assert!(err.contains("SSRF"), "expected SSRF block, got: {}", err);
329    }
330}