Skip to main content

rig_core/providers/openai/
transcription.rs

1use crate::http_client::HttpClientExt;
2use crate::providers::internal::transcription::OpenAiTranscriptionClient;
3use crate::providers::openai::{Client, CompletionsClient};
4use crate::transcription;
5use crate::transcription::TranscriptionError;
6use serde::Deserialize;
7
8// ================================================================
9// OpenAI Transcription API
10// ================================================================
11
12pub const WHISPER_1: &str = "whisper-1";
13
14#[derive(Debug, Deserialize)]
15pub struct TranscriptionResponse {
16    pub text: String,
17    /// What the transcription cost, as the endpoint reported it.
18    ///
19    /// Both live model families return this beside the transcript, and it is
20    /// the only accounting a caller gets for a transcription — the normalized
21    /// [`transcription::TranscriptionResponse`] carries no usage slot, so
22    /// dropping it here dropped it everywhere. Optional because a compatible
23    /// provider on this wire may not report one.
24    #[serde(default)]
25    pub usage: Option<TranscriptionUsage>,
26}
27
28/// The accounting an OpenAI-style transcription endpoint reports.
29///
30/// Two shapes are live and they bill differently: `whisper-1` bills by audio
31/// duration (`{"type":"duration","seconds":6}`), while the
32/// `gpt-4o-transcribe` family bills by token
33/// (`{"type":"tokens","input_tokens":54,…}`).
34///
35/// Each modeled variant pins the wire's own `type`, so selection cannot turn
36/// on which optional keys a payload happens to carry: a future shape that
37/// reported `seconds` *and* token counts would otherwise decode as a duration
38/// and silently drop every token count. Anything whose `type` is unmodeled
39/// falls to the verbatim catch-all rather than failing the whole
40/// transcription — the same invariant the Responses `Output` enum keeps for
41/// unmodeled output items.
42#[derive(Debug, Clone, Deserialize, PartialEq)]
43#[serde(untagged)]
44pub enum TranscriptionUsage {
45    /// Duration-billed models.
46    Duration {
47        /// Always `"duration"`; pins this variant to its wire tag.
48        r#type: DurationTag,
49        /// Length of the audio, in seconds.
50        seconds: f64,
51    },
52    /// Token-billed models.
53    Tokens {
54        /// Always `"tokens"`; pins this variant to its wire tag.
55        r#type: TokensTag,
56        /// Tokens consumed by the audio and any prompt.
57        input_tokens: u64,
58        /// How the input tokens split between audio and text, when the
59        /// provider breaks it down. The two are billed at different rates, so
60        /// `input_tokens` alone does not determine what a turn cost.
61        #[serde(default)]
62        input_token_details: Option<TranscriptionInputTokenDetails>,
63        /// Tokens in the transcript.
64        output_tokens: u64,
65        /// `input_tokens + output_tokens`, as the provider reported it.
66        total_tokens: u64,
67    },
68    /// A shape this version does not model, preserved as sent.
69    Other(serde_json::Value),
70}
71
72/// The wire tag of [`TranscriptionUsage::Duration`].
73#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum DurationTag {
76    /// `"duration"`.
77    Duration,
78}
79
80/// The wire tag of [`TranscriptionUsage::Tokens`].
81#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum TokensTag {
84    /// `"tokens"`.
85    Tokens,
86}
87
88/// How a token-billed transcription's input tokens split by modality.
89#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
90pub struct TranscriptionInputTokenDetails {
91    /// Input tokens attributable to the audio.
92    #[serde(default)]
93    pub audio_tokens: u64,
94    /// Input tokens attributable to text (a prompt, for instance).
95    #[serde(default)]
96    pub text_tokens: u64,
97}
98
99impl TryFrom<TranscriptionResponse>
100    for transcription::TranscriptionResponse<TranscriptionResponse>
101{
102    type Error = TranscriptionError;
103
104    fn try_from(value: TranscriptionResponse) -> Result<Self, Self::Error> {
105        Ok(transcription::TranscriptionResponse {
106            text: value.text.clone(),
107            response: value,
108        })
109    }
110}
111
112/// OpenAI transcription model using the shared OpenAI-style implementation.
113pub type TranscriptionModel<T = reqwest::Client> =
114    crate::providers::internal::transcription::OpenAiTranscriptionModel<Client<T>>;
115
116/// OpenAI transcription model for a client using Chat Completions.
117pub type CompletionsTranscriptionModel<T = reqwest::Client> =
118    crate::providers::internal::transcription::OpenAiTranscriptionModel<CompletionsClient<T>>;
119
120impl<T> OpenAiTranscriptionClient for Client<T>
121where
122    T: HttpClientExt + Clone + 'static,
123{
124    const MODEL_IN_FORM: bool = true;
125
126    fn transcription_request(
127        &self,
128        _model: &str,
129    ) -> crate::http_client::Result<crate::http_client::Builder> {
130        self.post("/audio/transcriptions")
131    }
132}
133
134impl<T> OpenAiTranscriptionClient for CompletionsClient<T>
135where
136    T: HttpClientExt + Clone + 'static,
137{
138    const MODEL_IN_FORM: bool = true;
139
140    fn transcription_request(
141        &self,
142        _model: &str,
143    ) -> crate::http_client::Result<crate::http_client::Builder> {
144        self.post("/audio/transcriptions")
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::client::transcription::TranscriptionClient;
152    use crate::test_utils::RecordingHttpClient;
153    use crate::transcription::TranscriptionModel as _;
154
155    #[tokio::test]
156    async fn transcription_routes_model_in_multipart_body() {
157        let http_client = RecordingHttpClient::new(r#"{"text":"transcribed"}"#);
158        let client = Client::builder()
159            .api_key("test-key")
160            .http_client(http_client.clone())
161            .build()
162            .expect("build client");
163        let model = client.transcription_model(WHISPER_1);
164
165        let response = model
166            .transcription_request()
167            .data(vec![1, 2, 3])
168            .filename(Some("audio.mp3".to_owned()))
169            .send()
170            .await
171            .expect("transcription should succeed");
172
173        assert_eq!(response.text, "transcribed");
174        let request = http_client
175            .requests()
176            .into_iter()
177            .next()
178            .expect("request should be captured");
179        assert_eq!(
180            request.uri,
181            "https://api.openai.com/v1/audio/transcriptions"
182        );
183        let body = String::from_utf8_lossy(&request.body);
184        assert!(
185            body.contains("name=\"model\"\r\n\r\nwhisper-1\r\n"),
186            "{body}"
187        );
188        assert!(
189            body.contains("name=\"file\"; filename=\"audio.mp3\""),
190            "{body}"
191        );
192    }
193
194    /// The two live shapes, and the catch-all that keeps a third from failing
195    /// the transcription. Recorded turns of the first two are replayed in
196    /// `transcription_usage_matrix`; this pins the decode itself, including
197    /// the shapes no live model produces.
198    #[test]
199    fn usage_decodes_both_billing_shapes_and_keeps_unknown_ones() {
200        fn usage(body: &str) -> Option<TranscriptionUsage> {
201            serde_json::from_str::<TranscriptionResponse>(body)
202                .expect("response should decode")
203                .usage
204        }
205
206        assert_eq!(
207            usage(r#"{"text":"hi","usage":{"type":"duration","seconds":6}}"#),
208            Some(TranscriptionUsage::Duration {
209                r#type: DurationTag::Duration,
210                seconds: 6.0
211            })
212        );
213        assert_eq!(
214            usage(
215                r#"{"text":"hi","usage":{"type":"tokens","input_tokens":54,
216                   "input_token_details":{"audio_tokens":54,"text_tokens":0},
217                   "output_tokens":16,"total_tokens":70}}"#
218            ),
219            Some(TranscriptionUsage::Tokens {
220                r#type: TokensTag::Tokens,
221                input_tokens: 54,
222                input_token_details: Some(TranscriptionInputTokenDetails {
223                    audio_tokens: 54,
224                    text_tokens: 0,
225                }),
226                output_tokens: 16,
227                total_tokens: 70,
228            })
229        );
230        // The breakdown is optional: a provider that omits it still decodes as
231        // a token-billed turn rather than falling to the catch-all.
232        assert_eq!(
233            usage(
234                r#"{"text":"hi","usage":{"type":"tokens","input_tokens":54,
235                   "output_tokens":16,"total_tokens":70}}"#
236            ),
237            Some(TranscriptionUsage::Tokens {
238                r#type: TokensTag::Tokens,
239                input_tokens: 54,
240                input_token_details: None,
241                output_tokens: 16,
242                total_tokens: 70,
243            })
244        );
245        // The tag decides, not which optional keys are present: a token-billed
246        // payload that also reported `seconds` must not decode as a duration
247        // and drop every token count.
248        assert!(matches!(
249            usage(
250                r#"{"text":"hi","usage":{"type":"tokens","seconds":6,"input_tokens":54,
251                   "output_tokens":16,"total_tokens":70}}"#
252            ),
253            Some(TranscriptionUsage::Tokens {
254                total_tokens: 70,
255                ..
256            })
257        ));
258        assert!(matches!(
259            usage(r#"{"text":"hi","usage":{"type":"credits","spent":3}}"#),
260            Some(TranscriptionUsage::Other(_))
261        ));
262        // A token-shaped payload missing a required total degrades to the
263        // catch-all rather than failing the transcription.
264        assert!(matches!(
265            usage(r#"{"text":"hi","usage":{"type":"tokens","input_tokens":54}}"#),
266            Some(TranscriptionUsage::Other(_))
267        ));
268        assert_eq!(usage(r#"{"text":"hi"}"#), None);
269        assert_eq!(usage(r#"{"text":"hi","usage":null}"#), None);
270    }
271
272    #[tokio::test]
273    async fn transcription_http_non_success_preserves_status_and_body() {
274        let body = r#"{"error":{"message":"bad audio","type":"invalid_request_error"}}"#;
275        let http_client =
276            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
277        let client = Client::builder()
278            .api_key("test-key")
279            .http_client(http_client)
280            .build()
281            .expect("build client");
282        let model = client.transcription_model(WHISPER_1);
283
284        let error = match model
285            .transcription_request()
286            .data(vec![0u8; 16])
287            .send()
288            .await
289        {
290            Err(error) => error,
291            Ok(_) => panic!("transcription should fail with non-success status"),
292        };
293
294        assert!(matches!(error, TranscriptionError::HttpError(_)));
295        assert_eq!(
296            error.provider_response_status(),
297            Some(http::StatusCode::BAD_REQUEST)
298        );
299        assert_eq!(error.provider_response_body(), Some(body));
300    }
301}