1use 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#[derive(Debug, Deserialize)]
18struct WhisperResponse {
19 text: String,
20}
21
22#[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#[derive(Debug, Clone, Copy, serde::Serialize)]
38#[serde(rename_all = "lowercase")]
39pub enum TtsVoice {
40 Alloy,
42 Echo,
44 Fable,
46 Onyx,
48 Nova,
50 Shimmer,
52}
53
54impl TtsVoice {
55 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#[derive(Debug, Clone, Copy, serde::Serialize)]
70pub enum DallEImageSize {
71 #[serde(rename = "256x256")]
73 S256,
74 #[serde(rename = "512x512")]
76 S512,
77 #[serde(rename = "1024x1024")]
79 S1024,
80 #[serde(rename = "1792x1024")]
82 S1792x1024,
83 #[serde(rename = "1024x1792")]
85 S1024x1792,
86}
87
88impl DallEImageSize {
89 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
101impl OpenAIChat {
106 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 let b64 = audio.base64_data().unwrap_or("");
115 base64_decode(b64)?
116 } else {
117 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 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 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#[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
282fn 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 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}