raqeem_core/lib.rs
1//! `raqeem-core` — رقيم. Turn an audio file into Arabic text by delegating
2//! inference to a transcription endpoint (Cohere's hosted API, or your own
3//! vLLM). This crate never loads model weights: it decodes nothing, runs no
4//! model — it POSTs the audio and folds the result. That is what keeps it
5//! light and callable from any language over the CLI.
6//!
7//! ```no_run
8//! use raqeem_core::{Endpoint, Transcriber};
9//!
10//! let endpoint = Endpoint::cohere(std::env::var("COHERE_API_KEY").unwrap(), None);
11//! let transcript = Transcriber::new(endpoint)?
12//! .language("ar")
13//! .transcribe(std::path::Path::new("voice_note.ogg"))?;
14//! println!("{}", transcript.text);
15//! # Ok::<(), raqeem_core::Error>(())
16//! ```
17
18#![forbid(unsafe_code)]
19
20mod arabic;
21mod credentials;
22mod endpoint;
23mod error;
24mod output;
25mod provider;
26
27pub use arabic::normalize_ar;
28pub use credentials::resolve_api_key;
29pub use endpoint::{Endpoint, COHERE_URL, DEFAULT_COHERE_MODEL};
30pub use error::{Error, Result};
31pub use output::OutputFormat;
32pub use provider::{Provider, UnknownProvider};
33
34use std::path::Path;
35use std::time::Duration;
36
37/// Default transcription language (ISO-639-1). Arabic-first, by design.
38pub const DEFAULT_LANGUAGE: &str = "ar";
39
40/// Default total-request timeout, in seconds. Generous on purpose: it must cover
41/// upload + model inference + download, and CPU inference of a voice note can take
42/// many seconds. (reqwest's blocking client otherwise silently defaults to 30s,
43/// which truncates a slow transcription mid-inference.)
44pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
45
46/// Time allowed to establish the TCP/TLS connection, within the total timeout above.
47/// Separate because a host that never answers should fail in seconds, not in minutes.
48pub const CONNECT_TIMEOUT_SECS: u64 = 15;
49
50/// Largest response body accepted from an endpoint, in bytes.
51///
52/// A transcript is kilobytes; 64 MiB is roughly six hundred times more headroom than any
53/// real one needs. The cap exists because everything downstream — the UTF-8 decode, the
54/// JSON parse, [`normalize_ar`] — allocates again, so an endpoint returning 200 MB cost
55/// ~580 MB of resident memory before this was here.
56pub const MAX_RESPONSE_BYTES: u64 = 64 * 1024 * 1024;
57
58/// A completed transcription. `text` is the model's verbatim output; the folded
59/// `text_normalized` (see [`normalize_ar`]) is what downstream parsers consume.
60#[derive(Debug, Clone, serde::Serialize)]
61pub struct Transcript {
62 pub text: String,
63 pub text_normalized: String,
64 pub provider: String,
65 pub model: String,
66 pub language: String,
67}
68
69/// Transcribes audio by POSTing it to a configured [`Endpoint`].
70pub struct Transcriber {
71 endpoint: Endpoint,
72 language: String,
73 client: reqwest::blocking::Client,
74}
75
76impl Transcriber {
77 /// Build a transcriber for the given endpoint, defaulting to Arabic and a
78 /// [`DEFAULT_TIMEOUT_SECS`] total-request timeout.
79 pub fn new(endpoint: Endpoint) -> Result<Self> {
80 Self::with_timeout(endpoint, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
81 }
82
83 /// Like [`new`](Self::new) but with an explicit total-request timeout — raise it
84 /// for slow CPU inference or large files, lower it to fail faster. The timeout
85 /// spans the whole round-trip (connect + upload + inference + download).
86 ///
87 /// Fails only if the HTTP client cannot be built, which in practice means the TLS
88 /// backend would not initialize. This used to fall back to `Client::new()` on
89 /// error, which is not a fallback at all: that constructor panics on the very same
90 /// failure, and had it succeeded it would have installed reqwest's default 30s
91 /// timeout — silently truncating exactly the slow inference this parameter exists
92 /// to accommodate.
93 pub fn with_timeout(endpoint: Endpoint, timeout: Duration) -> Result<Self> {
94 let client = reqwest::blocking::Client::builder()
95 .timeout(timeout)
96 // A black-holed host would otherwise burn the entire total timeout — five
97 // minutes by default — before admitting it never connected.
98 .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
99 // Redirects are never followed. reqwest's default is to follow up to ten,
100 // and for a client posting to one known API route that only causes harm:
101 // a 302/303 downgrades POST to GET and drops the body, so the audio is never
102 // uploaded and whatever comes back gets parsed as a transcript. See
103 // `Error::Redirect`.
104 .redirect(reqwest::redirect::Policy::none())
105 .build()
106 .map_err(|source| Error::Client { source })?;
107 Ok(Transcriber {
108 endpoint,
109 language: DEFAULT_LANGUAGE.to_string(),
110 client,
111 })
112 }
113
114 /// Override the transcription language (ISO-639-1).
115 pub fn language(mut self, lang: impl Into<String>) -> Self {
116 self.language = lang.into();
117 self
118 }
119
120 /// Stream `audio` to the endpoint and return the transcript.
121 pub fn transcribe(&self, audio: &Path) -> Result<Transcript> {
122 let read_err = |source| Error::ReadFile {
123 path: audio.to_path_buf(),
124 source,
125 };
126 // Opened and streamed rather than read into a Vec: an hour of 16-bit 44.1kHz WAV
127 // is ~600MB, and buffering it made peak memory scale with the recording.
128 let file = std::fs::File::open(audio).map_err(read_err)?;
129 let len = file.metadata().map_err(read_err)?.len();
130
131 let filename = audio
132 .file_name()
133 .and_then(|n| n.to_str())
134 .unwrap_or("audio")
135 .to_string();
136
137 // Same multipart shape for every backend: model + language + file.
138 // Order matters: Cohere rejects the request unless the text fields
139 // (model, language) appear BEFORE the file part in the body.
140 //
141 // reader_with_length, not reader: the length is what keeps this a
142 // Content-Length body. Without it reqwest switches to chunked transfer-encoding,
143 // which not every endpoint accepts.
144 let file_part =
145 reqwest::blocking::multipart::Part::reader_with_length(file, len).file_name(filename);
146 let form = reqwest::blocking::multipart::Form::new()
147 .text("model", self.endpoint.model.clone())
148 .text("language", self.language.clone())
149 .part("file", file_part);
150
151 let mut req = self.client.post(&self.endpoint.url).multipart(form);
152 if let Some(key) = &self.endpoint.api_key {
153 req = req.bearer_auth(key);
154 }
155
156 let http_err = |source: reqwest::Error| Error::Http {
157 // Redacted, and reqwest's own copy dropped: it appends " for url (...)" to its
158 // Display, which would print the endpoint twice — credentials included.
159 url: error::redact_url(&self.endpoint.url),
160 source: source.without_url(),
161 };
162
163 let resp = req.send().map_err(http_err)?;
164 let status = resp.status();
165
166 // Refuse a redirect rather than follow it. See `Error::Redirect` for why following
167 // is worse than failing here.
168 if status.is_redirection() {
169 return Err(Error::Redirect {
170 status: status.as_u16(),
171 location: resp
172 .headers()
173 .get(reqwest::header::LOCATION)
174 .and_then(|v| v.to_str().ok())
175 // A redirect target can carry credentials too.
176 .map(error::redact_url),
177 });
178 }
179
180 // Bound the response before reading it. `Content-Length` is the cheap check; a
181 // chunked reply advertises none, so that case is caught after the fact instead.
182 if let Some(len) = resp.content_length() {
183 if len > MAX_RESPONSE_BYTES {
184 return Err(Error::ResponseTooLarge {
185 got: len,
186 limit: MAX_RESPONSE_BYTES,
187 });
188 }
189 }
190 let body = resp.text().map_err(http_err)?;
191 if body.len() as u64 > MAX_RESPONSE_BYTES {
192 return Err(Error::ResponseTooLarge {
193 got: body.len() as u64,
194 limit: MAX_RESPONSE_BYTES,
195 });
196 }
197 // Anything the endpoint sent back may be quoted into an error below, so take the
198 // caller's own key out of it first.
199 let body = error::scrub_secret(body, self.endpoint.api_key.as_deref());
200 if !status.is_success() {
201 return Err(Error::Api {
202 status: status.as_u16(),
203 body: error::excerpt(body),
204 });
205 }
206
207 let text = match extract_text(&body) {
208 Some(text) => text,
209 None => {
210 return Err(Error::BadResponse {
211 body: error::excerpt(body),
212 })
213 }
214 };
215 Ok(Transcript {
216 text_normalized: normalize_ar(&text),
217 text,
218 provider: self.endpoint.provider.as_str().to_string(),
219 model: self.endpoint.model.clone(),
220 language: self.language.clone(),
221 })
222 }
223}
224
225/// The OpenAI-standard transcription response, which vLLM and (per Cohere's docs) the
226/// hosted API both return. Unknown fields are ignored, so a backend sending extra
227/// metadata alongside `text` still parses.
228#[derive(serde::Deserialize)]
229struct Response {
230 text: String,
231}
232
233/// Pull the transcript out of an endpoint response.
234///
235// Assumes the `{"text": "..."}` shape. If a backend nests it differently, add a branch
236// here rather than a whole response type — an excerpt of the raw body is surfaced in
237// `Error::BadResponse` so a mismatch is obvious on first run.
238fn extract_text(body: &str) -> Option<String> {
239 serde_json::from_str::<Response>(body).ok().map(|r| r.text)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::{Transcriber, Transcript};
245
246 use super::extract_text;
247
248 /// The README tells people to share one `Transcriber` across threads instead of
249 /// building one per file, which is only sound if this holds. Compile-time check: it
250 /// fails to build, not at runtime.
251 #[test]
252 fn transcriber_and_transcript_are_send_and_sync() {
253 fn assert_send_sync<T: Send + Sync>() {}
254 assert_send_sync::<Transcriber>();
255 assert_send_sync::<Transcript>();
256 }
257
258 #[test]
259 fn pulls_text_and_ignores_extra_fields() {
260 let body = r#"{"text": "مرحبا", "duration": 1.5, "language": "ar"}"#;
261 assert_eq!(extract_text(body).as_deref(), Some("مرحبا"));
262 }
263
264 #[test]
265 fn rejects_responses_that_are_not_a_transcript() {
266 // Each of these used to be indistinguishable from "no text field" — they still
267 // are, but the point is that none of them panic or yield a bogus transcript.
268 assert_eq!(extract_text("not json at all"), None);
269 assert_eq!(extract_text(r#"{"error": "model not found"}"#), None);
270 assert_eq!(extract_text(r#"{"text": 42}"#), None);
271 assert_eq!(extract_text(r#"{"text": null}"#), None);
272 assert_eq!(extract_text("[]"), None);
273 assert_eq!(extract_text(""), None);
274 }
275}