rig_core/providers/mistral/
transcription.rs1use bytes::Bytes;
3use serde::Deserialize;
4
5use crate::http_client::HttpClientExt;
6use crate::providers::internal::transcription::{TranscriptionFields, transcription_form};
7use crate::providers::mistral::Client;
8use crate::transcription::{self, TranscriptionError};
9use crate::wasm_compat::WasmCompatSend;
10
11pub const VOXTRAL_MINI: &str = "voxtral-mini-latest";
17pub const VOXTRAL_SMALL: &str = "voxtral-small-latest";
19
20#[derive(Debug, Deserialize)]
22pub struct TranscriptionUsage {
23 pub prompt_audio_seconds: Option<i32>,
24 pub prompt_tokens: i32,
25 pub total_tokens: i32,
26 pub completion_tokens: i32,
27 pub prompt_tokens_details: Option<serde_json::Value>,
28}
29
30impl std::fmt::Display for TranscriptionUsage {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 writeln!(f, "Usage:")?;
33 writeln!(f, " prompt_tokens: {}", self.prompt_tokens)?;
34 writeln!(f, " completion_tokens: {}", self.completion_tokens)?;
35 writeln!(f, " total_tokens: {}", self.total_tokens)?;
36 if let Some(details) = &self.prompt_tokens_details {
37 writeln!(f, " prompt_token_details: {:?}", details)?;
38 } else {
39 writeln!(f, " prompt_token_details: N/A")?;
40 }
41 if let Some(secs) = self.prompt_audio_seconds {
42 write!(f, " audio_seconds: {secs}")?;
43 } else {
44 write!(f, " audio_seconds: N/A")?;
45 }
46 Ok(())
47 }
48}
49
50#[derive(Debug, Deserialize)]
52pub struct SegmentChunk {
53 pub start: f32,
55 pub end: f32,
57 pub text: String,
59 pub score: Option<f32>,
60 pub speaker_id: Option<String>,
62 #[serde(rename = "type")]
63 pub segment_type: String,
64}
65
66#[derive(Debug, Deserialize)]
67pub struct MistralTranscriptionResponse {
68 pub language: Option<String>,
70 pub model: String,
72 pub segments: Vec<SegmentChunk>,
74 pub text: String,
76 pub usage: TranscriptionUsage,
78}
79
80impl TryFrom<MistralTranscriptionResponse>
81 for transcription::TranscriptionResponse<MistralTranscriptionResponse>
82{
83 type Error = TranscriptionError;
84
85 fn try_from(value: MistralTranscriptionResponse) -> Result<Self, Self::Error> {
86 Ok(transcription::TranscriptionResponse {
87 text: value.text.clone(),
88 response: value,
89 })
90 }
91}
92
93pub type TranscriptionModel<T = reqwest::Client> =
94 crate::providers::internal::transcription::GenericTranscriptionModel<
95 crate::providers::mistral::client::MistralExt,
96 T,
97 >;
98
99impl<T> transcription::TranscriptionModel for TranscriptionModel<T>
100where
101 T: HttpClientExt + Clone + std::fmt::Debug + Default + WasmCompatSend + 'static,
102{
103 type Response = MistralTranscriptionResponse;
104 type Client = Client<T>;
105
106 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
107 Self::new(client.clone(), model)
108 }
109
110 async fn transcription(
111 &self,
112 mut request: transcription::TranscriptionRequest,
113 ) -> Result<transcription::TranscriptionResponse<Self::Response>, TranscriptionError> {
114 request.prompt = None;
117
118 let body = transcription_form(
119 request,
120 TranscriptionFields {
121 model: Some(&self.model),
122 },
123 )?;
124
125 let req = self
126 .client
127 .post("/v1/audio/transcriptions")?
128 .body(body)
129 .map_err(|e| TranscriptionError::RequestError(e.into()))?;
130
131 let response = self
132 .client
133 .send_multipart::<Bytes>(req)
134 .await
135 .map_err(TranscriptionError::HttpError)?;
136
137 let status = response.status();
138 let response_bytes = response.into_body().await?;
139
140 if status.is_success() {
141 let response_body: MistralTranscriptionResponse =
142 serde_json::from_slice(&response_bytes)?;
143
144 tracing::info!(target: "rig", "Mistral transcription token usage: {}", &response_body.usage);
145
146 Ok(transcription::TranscriptionResponse::try_from(
147 response_body,
148 )?)
149 } else {
150 Err(TranscriptionError::from_http_response(
151 status,
152 String::from_utf8_lossy(&response_bytes),
153 ))
154 }
155 }
156}
157
158#[cfg(test)]
159mod test {
160 use super::*;
161 use crate::transcription::TranscriptionResponse;
162
163 #[test]
164 fn test_mistral_transcription_response_deserialize() {
165 let json = r#" {
166 "model": "voxtral-mini-latest",
167 "text": "The sun was setting slowly, casting long shadows across the empty field.",
168 "language": null,
169 "segments": [
170 {
171 "text": "The sun was setting slowly, casting long shadows across the empty field.",
172 "start": 0.2,
173 "end": 4.6,
174 "speaker_id": "speaker_1",
175 "type": "transcription_segment"
176 }
177 ],
178 "usage": {
179 "prompt_audio_seconds": 5,
180 "prompt_tokens": 5,
181 "total_tokens": 404,
182 "completion_tokens": 24,
183 "prompt_tokens_details": {
184 "cached_tokens": 368
185 }
186 },
187 "finish_reason": null
188 }"#;
189
190 let response: MistralTranscriptionResponse =
191 serde_json::from_str(json).expect("should deserialize");
192
193 assert_eq!(response.language, None);
194 assert_eq!(response.model, VOXTRAL_MINI);
195 assert_eq!(response.segments.len(), 1);
196
197 let seg0 = &response.segments[0];
198 assert_eq!(seg0.start, 0.2);
199 assert_eq!(seg0.end, 4.6);
200 assert_eq!(seg0.score, None);
201 assert_eq!(seg0.speaker_id, Some("speaker_1".to_string()));
202 assert_eq!(seg0.segment_type, "transcription_segment");
203
204 assert_eq!(response.usage.prompt_audio_seconds, Some(5));
205 assert_eq!(response.usage.prompt_tokens, 5);
206 assert_eq!(response.usage.total_tokens, 404);
207 let usage_token_details = response.usage.prompt_tokens_details.unwrap();
208 let cached_token = usage_token_details.get("cached_tokens").unwrap();
209
210 assert_eq!(cached_token.to_string().parse::<i32>().unwrap(), 368);
211 }
212
213 #[test]
214 fn test_response_conversion() {
215 let mistral_response = MistralTranscriptionResponse {
216 language: Some("en".to_string()),
217 model: VOXTRAL_MINI.to_string(),
218 segments: vec![SegmentChunk {
219 start: 0.0,
220 end: 1.0,
221 text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry."
222 .into(),
223 score: None,
224 speaker_id: None,
225 segment_type: "speech".to_string(),
226 }],
227 text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry."
228 .to_string(),
229 usage: TranscriptionUsage {
230 prompt_audio_seconds: Some(1),
231 prompt_tokens: 10,
232 total_tokens: 20,
233 completion_tokens: 10,
234 prompt_tokens_details: None,
235 },
236 };
237
238 let response: TranscriptionResponse<MistralTranscriptionResponse> = mistral_response
239 .try_into()
240 .expect("conversion should succeed");
241
242 assert_eq!(
243 response.text,
244 "Lorem Ipsum is simply dummy text of the printing and typesetting industry."
245 );
246 assert_eq!(response.response.model, VOXTRAL_MINI);
247 assert_eq!(response.response.language, Some("en".to_string()));
248 }
249
250 #[tokio::test]
251 async fn transcription_non_success_preserves_status_and_body() {
252 use crate::client::transcription::TranscriptionClient;
253 use crate::test_utils::RecordingHttpClient;
254 use crate::transcription::{TranscriptionError, TranscriptionModel as _};
255
256 let body = r#"{"error":{"message":"boom"}}"#;
257 let http_client =
258 RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
259 let client = Client::builder()
260 .api_key("test-key")
261 .http_client(http_client)
262 .build()
263 .expect("build client");
264 let model = client.transcription_model(VOXTRAL_MINI);
265
266 let error = match model
267 .transcription_request()
268 .data(vec![0u8; 16])
269 .send()
270 .await
271 {
272 Err(error) => error,
273 Ok(_) => panic!("transcription should fail with non-success status"),
274 };
275
276 assert!(matches!(error, TranscriptionError::HttpError(_)));
277 assert_eq!(
278 error.provider_response_status(),
279 Some(http::StatusCode::SERVICE_UNAVAILABLE)
280 );
281 assert_eq!(error.provider_response_body(), Some(body));
282 }
283}