Skip to main content

rig_core/providers/mistral/
transcription.rs

1//! Implements Mistral (basic) transcription API
2use 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
11// ================================================================
12// Mistral Transcription API
13// ================================================================
14
15/// Voxtral Mini model (latest version)
16pub const VOXTRAL_MINI: &str = "voxtral-mini-latest";
17/// Voxtral Small model (latest version)
18pub const VOXTRAL_SMALL: &str = "voxtral-small-latest";
19
20/// Request usage statistics
21#[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/// Diarization information, tells when each speaker started and ended talking plus what they said.
51#[derive(Debug, Deserialize)]
52pub struct SegmentChunk {
53    /// Start time in seconds
54    pub start: f32,
55    /// End time in seconds
56    pub end: f32,
57    /// Segment transcribed text
58    pub text: String,
59    pub score: Option<f32>,
60    /// Speaker identification.
61    pub speaker_id: Option<String>,
62    #[serde(rename = "type")]
63    pub segment_type: String,
64}
65
66#[derive(Debug, Deserialize)]
67pub struct MistralTranscriptionResponse {
68    /// Audio language
69    pub language: Option<String>,
70    /// Model name (e.g. voxtra-mini-latest)
71    pub model: String,
72    /// An array of transcript segments, each containing a portion of the transcribed text along with its start and end times in seconds and speaker id (if diarization was enabled).
73    pub segments: Vec<SegmentChunk>,
74    /// Audio Transcription
75    pub text: String,
76    /// Request token usage statistics
77    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        // Mistral's transcription endpoint has no `prompt` field; it has
115        // always been dropped rather than sent.
116        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}