Skip to main content

ryu_stt/
lib.rs

1//! Speech-to-text (STT) modality primitive: `transcribe(audio) -> text` behind a
2//! swappable engine seam.
3//!
4//! Three engines, one dispatch ([`transcribe_wav_detailed`]):
5//! - **parakeet** (default where the `voice-parakeet` feature is compiled): the
6//!   in-process ONNX engine — the genuinely in-process hot path, never IPC (see
7//!   [`parakeet`]).
8//! - **whisper**: forwarded to a local whisper.cpp voice server's `/inference`
9//!   (a thin HTTP proxy).
10//! - **gateway**: the swappable cloud STT slot, routed through the Gateway's
11//!   `/v1/audio/transcriptions` with the per-attribute `x-ryu-slot-stt-*` headers
12//!   (a thin HTTP proxy).
13//!
14//! Per the Core-vs-Gateway rule the *dispatch* is a Core concern (it decides
15//! *what runs* — which local voice engine handles the audio); this crate owns the
16//! reusable transcription logic + result types, while the host couplings it
17//! cannot own — the whisper base-url, the Gateway url/bearer, and the parakeet
18//! model directory — are injected via the narrow [`SttHost`] trait. The crate has
19//! ZERO dependency on `apps/core` (mirrors `ryu-search`'s `SearchEmbedder` seam).
20
21use std::path::PathBuf;
22
23use serde_json::{json, Value};
24
25pub mod parakeet;
26
27/// Narrow host seam for the STT dispatch: the couplings the crate cannot own
28/// because they read Core config/paths (the whisper sidecar base-url, the Gateway
29/// url + bearer, and the extracted parakeet model directory). Core implements
30/// this in `apps/core/src/stt_host.rs`.
31pub trait SttHost: Send + Sync {
32    /// Base URL of the local whisper.cpp voice server (`{base}/inference`).
33    fn whisper_base_url(&self) -> String;
34    /// Base URL of the Gateway (`{base}/v1/audio/transcriptions`).
35    fn gateway_url(&self) -> String;
36    /// The Gateway bearer token slot (never a raw provider API key).
37    fn gateway_bearer(&self) -> Result<String, String>;
38    /// The extracted parakeet ONNX model directory (a `~/.ryu` path Core owns).
39    fn parakeet_model_dir(&self) -> PathBuf;
40}
41
42/// One timestamped transcript segment. Serialized camelCase
43/// (`startMs`/`endMs`/`text`) so it matches the cross-surface clip contract.
44#[derive(Debug, Clone, Default, serde::Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct TranscriptSegment {
47    pub start_ms: u64,
48    pub end_ms: u64,
49    pub text: String,
50}
51
52/// A transcription result: the full text plus optional timestamped segments.
53/// Segments are populated whenever the engine returns them (Whisper
54/// `verbose_json` via the Gateway or local whisper.cpp); parakeet returns text
55/// only, so its `segments` is empty.
56#[derive(Debug, Clone, Default)]
57pub struct Transcription {
58    pub text: String,
59    pub segments: Vec<TranscriptSegment>,
60}
61
62/// Parse OpenAI/whisper `verbose_json` `segments` (each with `start`/`end` in
63/// seconds and `text`) into millisecond [`TranscriptSegment`]s. An absent or
64/// malformed array yields an empty vec.
65fn parse_verbose_segments(body: &Value) -> Vec<TranscriptSegment> {
66    body.get("segments")
67        .and_then(Value::as_array)
68        .map(|arr| {
69            arr.iter()
70                .filter_map(|s| {
71                    let start = s.get("start").and_then(Value::as_f64)?;
72                    let end = s.get("end").and_then(Value::as_f64)?;
73                    let text = s
74                        .get("text")
75                        .and_then(Value::as_str)
76                        .unwrap_or("")
77                        .trim()
78                        .to_string();
79                    Some(TranscriptSegment {
80                        start_ms: (start.max(0.0) * 1000.0) as u64,
81                        end_ms: (end.max(0.0) * 1000.0) as u64,
82                        text,
83                    })
84                })
85                .collect()
86        })
87        .unwrap_or_default()
88}
89
90/// The cross-surface default STT engine, resolved as a swappable default (never
91/// a hardcoded literal). Parakeet v3 (in-process ONNX) is the default whenever
92/// this build compiled the `voice-parakeet` feature. Lean builds omit the feature
93/// and default to whisper.cpp so transcription still works there.
94/// `RYU_STT_ENGINE` overrides both, so one env var re-points every surface.
95///
96/// Which builds carry the feature is a *release-pipeline* fact, and it has been
97/// wrong before: the flag lived only in `apps/core/package.json` (`dev`/
98/// `dev:watch`/`build`), so every developer had parakeet while the three binaries
99/// users actually install — `.github/workflows/release.yml`,
100/// `scripts/release/release-local.sh`, `Dockerfile` — were built featureless and
101/// silently defaulted to whisper. All four sites now pass
102/// `--features sandbox-wasmtime,voice-parakeet,voice-vad`; keep them in sync (the
103/// release workflow asserts the resolved feature graph).
104pub fn default_stt_engine() -> String {
105    if let Ok(env_engine) = std::env::var("RYU_STT_ENGINE") {
106        let trimmed = env_engine.trim();
107        if !trimmed.is_empty() {
108            return trimmed.to_string();
109        }
110    }
111    #[cfg(feature = "voice-parakeet")]
112    {
113        "parakeet".to_string()
114    }
115    #[cfg(not(feature = "voice-parakeet"))]
116    {
117        "whisper".to_string()
118    }
119}
120
121/// Transcribe raw audio bytes to text. Routes to the in-process parakeet engine
122/// (the default — see [`default_stt_engine`]) or the whisper.cpp voice server
123/// (`engine == Some("whisper")`).
124///
125/// The reusable core of the `/api/voice/transcribe` route, factored out so other
126/// Core callers (e.g. the meetings pipeline) can transcribe a WAV chunk without
127/// going through an HTTP multipart handler. Returns the transcript or a
128/// human-readable error string.
129pub async fn transcribe_wav(
130    client: &reqwest::Client,
131    host: &dyn SttHost,
132    bytes: Vec<u8>,
133    filename: String,
134    engine: Option<&str>,
135) -> Result<String, String> {
136    transcribe_wav_detailed(client, host, bytes, filename, engine)
137        .await
138        .map(|t| t.text)
139}
140
141/// Like [`transcribe_wav`] but also returns timestamped segments when the engine
142/// provides them (Whisper `verbose_json` via the Gateway or local whisper.cpp).
143/// Parakeet (the in-process default) returns text only, so its segments are empty.
144pub async fn transcribe_wav_detailed(
145    client: &reqwest::Client,
146    host: &dyn SttHost,
147    bytes: Vec<u8>,
148    filename: String,
149    engine: Option<&str>,
150) -> Result<Transcription, String> {
151    // Resolve the engine: an explicit non-empty selector wins; otherwise fall
152    // back to the swappable cross-surface default (parakeet where compiled in).
153    let engine = engine
154        .map(str::trim)
155        .filter(|s| !s.is_empty())
156        .map(str::to_string)
157        .unwrap_or_else(default_stt_engine);
158
159    // Route to the in-process parakeet engine (default). Text only — no segments.
160    //
161    // No silent fallback to whisper on a lean build. `parakeet::transcribe` hard-
162    // errors when `voice-parakeet` is off, and that error is surfaced verbatim on
163    // purpose: reaching here at all means parakeet was *named* — either explicitly
164    // per-request, via `RYU_STT_ENGINE`, or by a stored preference — because
165    // [`default_stt_engine`] already picks whisper when the feature is absent.
166    // Honouring a named engine by quietly running a different one is the silent
167    // swap `ryu_sandbox::select_backend` refuses for the same reason: the caller
168    // would get whisper's accuracy/latency while believing it measured parakeet.
169    // The error text names both remedies (switch to whisper, or rebuild).
170    if engine == "parakeet" {
171        return parakeet::transcribe(bytes, host.parakeet_model_dir())
172            .await
173            .map(|text| Transcription {
174                text,
175                segments: Vec::new(),
176            })
177            .map_err(|e| format!("parakeet transcription failed: {e:#}"));
178    }
179
180    // Gateway-routed Whisper: the swappable cloud STT slot (default provider
181    // OpenAI, default model Groq's `whisper-large-v3`). Core emits only the
182    // per-attribute slot headers + a bearer to the Gateway — never a raw provider
183    // key (CLAUDE.md §1: routing/measuring the model call is a Gateway concern).
184    if engine == "gateway" {
185        return transcribe_via_gateway(client, host, bytes).await;
186    }
187
188    // Default: forward to whisper.cpp's `/inference` multipart endpoint. Request
189    // `verbose_json` so the response carries per-segment timings (whisper.cpp
190    // degrades to a plain `{ "text": ... }` when it can't, which parses to no
191    // segments — never an error).
192    let part = reqwest::multipart::Part::bytes(bytes).file_name(filename);
193    let form = reqwest::multipart::Form::new()
194        .part("file", part)
195        .text("response_format", "verbose_json");
196
197    let url = format!("{}/inference", host.whisper_base_url());
198    let resp = client
199        .post(&url)
200        .multipart(form)
201        .send()
202        .await
203        .map_err(|e| {
204            format!(
205                "whisper voice engine not reachable at {url}: {e}. \
206             Install + start `whispercpp` from the Store first."
207            )
208        })?;
209
210    if !resp.status().is_success() {
211        let status = resp.status();
212        let body = resp.text().await.unwrap_or_default();
213        return Err(format!("whisper returned {status}: {body}"));
214    }
215
216    // whisper.cpp returns `{ "text": "...", "segments": [...] }` for verbose_json.
217    let value: Value = resp
218        .json()
219        .await
220        .map_err(|e| format!("could not parse whisper response: {e}"))?;
221    let text = value
222        .get("text")
223        .and_then(Value::as_str)
224        .unwrap_or("")
225        .trim()
226        .to_string();
227    let segments = parse_verbose_segments(&value);
228    Ok(Transcription { text, segments })
229}
230
231/// Transcribe audio through the Gateway's `/v1/audio/transcriptions`, the
232/// swappable cloud STT slot. The audio is base64-encoded into a JSON body (Core
233/// carries no multipart to the Gateway) with the per-attribute slot headers that
234/// tell the Gateway which provider/model to route to. Bearer is the Gateway
235/// token slot — never a raw provider API key.
236///
237/// FLAG (whisper-gateway, pre-existing gap owned by `apps/gateway`, out of scope
238/// here): for true end-to-end the Gateway's OpenAI provider must re-multipart
239/// this base64 audio upstream — real Groq/OpenAI `/audio/transcriptions` need a
240/// multipart file, but `providers/openai.rs` currently forwards JSON verbatim.
241/// The Gateway owner must also point `modality_map[Stt]`/`base_url` at Groq. Until
242/// then, set `RYU_CLIP_STT_ENGINE=whisper` (local whisper.cpp) to ship without
243/// waiting — and captions-first means most YouTube ingests never hit Whisper.
244async fn transcribe_via_gateway(
245    client: &reqwest::Client,
246    host: &dyn SttHost,
247    bytes: Vec<u8>,
248) -> Result<Transcription, String> {
249    use base64::Engine as _;
250
251    let audio_b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
252
253    let provider = std::env::var("RYU_STT_GATEWAY_PROVIDER")
254        .ok()
255        .map(|s| s.trim().to_string())
256        .filter(|s| !s.is_empty())
257        .unwrap_or_else(|| "openai".to_string());
258    let model = std::env::var("RYU_STT_GATEWAY_MODEL")
259        .ok()
260        .map(|s| s.trim().to_string())
261        .filter(|s| !s.is_empty())
262        .unwrap_or_else(|| "whisper-large-v3".to_string());
263
264    let base = host.gateway_url();
265    let base = base.trim_end_matches('/');
266    let url = format!("{base}/v1/audio/transcriptions");
267    let bearer = host.gateway_bearer()?;
268
269    let payload = json!({
270        "model": model,
271        "file": audio_b64,
272        "response_format": "verbose_json",
273    });
274
275    let resp = client
276        .post(&url)
277        .bearer_auth(bearer)
278        .header("x-ryu-slot-stt-provider", &provider)
279        .header("x-ryu-slot-stt-model", &model)
280        .json(&payload)
281        .send()
282        .await
283        .map_err(|e| format!("gateway STT unreachable at {url}: {e}"))?;
284
285    if !resp.status().is_success() {
286        let status = resp.status();
287        let detail = resp.text().await.unwrap_or_default();
288        return Err(format!("gateway STT returned {status}: {detail}"));
289    }
290
291    let value: Value = resp
292        .json()
293        .await
294        .map_err(|e| format!("could not parse gateway STT response: {e}"))?;
295    let text = value
296        .get("text")
297        .and_then(Value::as_str)
298        .unwrap_or("")
299        .trim()
300        .to_string();
301    let segments = parse_verbose_segments(&value);
302    Ok(Transcription { text, segments })
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn parses_verbose_segments_seconds_to_ms() {
311        let body = json!({
312            "text": "hello world",
313            "segments": [
314                { "start": 0.0, "end": 1.5, "text": " hello" },
315                { "start": 1.5, "end": 2.25, "text": " world " },
316            ]
317        });
318        let segs = parse_verbose_segments(&body);
319        assert_eq!(segs.len(), 2);
320        assert_eq!(segs[0].start_ms, 0);
321        assert_eq!(segs[0].end_ms, 1500);
322        assert_eq!(segs[0].text, "hello");
323        assert_eq!(segs[1].start_ms, 1500);
324        assert_eq!(segs[1].end_ms, 2250);
325        assert_eq!(segs[1].text, "world");
326    }
327
328    #[test]
329    fn missing_or_malformed_segments_yield_empty() {
330        assert!(parse_verbose_segments(&json!({ "text": "x" })).is_empty());
331        assert!(parse_verbose_segments(&json!({ "segments": "not-an-array" })).is_empty());
332        // An entry missing start/end is skipped, not an error.
333        let partial = json!({ "segments": [ { "text": "no timings" } ] });
334        assert!(parse_verbose_segments(&partial).is_empty());
335    }
336
337    #[test]
338    fn default_engine_env_override_wins() {
339        // Save/restore to avoid leaking into other tests in the same process.
340        let prev = std::env::var("RYU_STT_ENGINE").ok();
341        std::env::set_var("RYU_STT_ENGINE", "gateway");
342        assert_eq!(default_stt_engine(), "gateway");
343        std::env::set_var("RYU_STT_ENGINE", "   ");
344        // Blank falls through to the compiled default (parakeet or whisper).
345        let compiled = default_stt_engine();
346        assert!(compiled == "parakeet" || compiled == "whisper");
347        match prev {
348            Some(v) => std::env::set_var("RYU_STT_ENGINE", v),
349            None => std::env::remove_var("RYU_STT_ENGINE"),
350        }
351    }
352
353    #[test]
354    fn transcript_segment_serializes_camel_case() {
355        let seg = TranscriptSegment {
356            start_ms: 10,
357            end_ms: 20,
358            text: "hi".into(),
359        };
360        let v = serde_json::to_value(&seg).unwrap();
361        assert_eq!(v["startMs"], 10);
362        assert_eq!(v["endMs"], 20);
363        assert_eq!(v["text"], "hi");
364    }
365
366    // ── negative / clamp edge cases for the parser ────────────────────────────
367
368    #[test]
369    fn verbose_segments_clamp_negative_timestamps_to_zero() {
370        // A defensive engine could emit a negative offset; the parser clamps to 0
371        // rather than underflowing the u64 cast.
372        let body = json!({
373            "segments": [ { "start": -1.0, "end": -0.5, "text": "  neg  " } ]
374        });
375        let segs = parse_verbose_segments(&body);
376        assert_eq!(segs.len(), 1);
377        assert_eq!(segs[0].start_ms, 0);
378        assert_eq!(segs[0].end_ms, 0);
379        assert_eq!(segs[0].text, "neg");
380    }
381
382    #[test]
383    fn verbose_segment_missing_text_defaults_empty_but_keeps_timing() {
384        // start/end present but no `text` → kept with an empty string, not dropped.
385        let body = json!({ "segments": [ { "start": 1.0, "end": 2.0 } ] });
386        let segs = parse_verbose_segments(&body);
387        assert_eq!(segs.len(), 1);
388        assert_eq!(segs[0].start_ms, 1000);
389        assert_eq!(segs[0].end_ms, 2000);
390        assert_eq!(segs[0].text, "");
391    }
392
393    #[test]
394    fn transcription_and_segment_defaults_are_empty() {
395        let t = Transcription::default();
396        assert!(t.text.is_empty());
397        assert!(t.segments.is_empty());
398        let s = TranscriptSegment::default();
399        assert_eq!(s.start_ms, 0);
400        assert_eq!(s.end_ms, 0);
401        assert!(s.text.is_empty());
402        // Clone/Debug are derived — exercise them so they count.
403        let _ = format!("{:?}", t.clone());
404        let _ = format!("{:?}", s.clone());
405    }
406
407    // ── HTTP-proxy engine dispatch (whisper.cpp + Gateway) ────────────────────
408    //
409    // These stand up a loopback axum server so the success / non-2xx / bad-body
410    // branches run deterministically without any real voice server or network.
411
412    use std::path::PathBuf;
413    use std::sync::{Arc, Mutex};
414
415    use axum::{http::HeaderMap, http::StatusCode, Router};
416    use tokio::net::TcpListener;
417
418    /// Serializes the few tests that read/write process-global env vars
419    /// (`RYU_STT_ENGINE`, `RYU_STT_GATEWAY_PROVIDER`, `RYU_STT_GATEWAY_MODEL`).
420    static ENV_LOCK: Mutex<()> = Mutex::new(());
421    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
422        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
423    }
424
425    struct FakeHost {
426        whisper: String,
427        gateway: String,
428        bearer: Result<String, String>,
429    }
430
431    impl Default for FakeHost {
432        fn default() -> Self {
433            Self {
434                whisper: "http://127.0.0.1:0".to_string(),
435                gateway: "http://127.0.0.1:0".to_string(),
436                bearer: Ok("testtoken".to_string()),
437            }
438        }
439    }
440
441    impl SttHost for FakeHost {
442        fn whisper_base_url(&self) -> String {
443            self.whisper.clone()
444        }
445        fn gateway_url(&self) -> String {
446            self.gateway.clone()
447        }
448        fn gateway_bearer(&self) -> Result<String, String> {
449            self.bearer.clone()
450        }
451        fn parakeet_model_dir(&self) -> PathBuf {
452            PathBuf::from("/nonexistent/parakeet-model")
453        }
454    }
455
456    #[derive(Clone, Default)]
457    struct Captured {
458        provider: Option<String>,
459        model: Option<String>,
460        authorization: Option<String>,
461    }
462
463    struct TestServer {
464        addr: std::net::SocketAddr,
465        captured: Arc<Mutex<Vec<Captured>>>,
466        _handle: tokio::task::JoinHandle<()>,
467    }
468
469    impl TestServer {
470        fn url(&self) -> String {
471            format!("http://{}", self.addr)
472        }
473        fn last(&self) -> Captured {
474            self.captured
475                .lock()
476                .unwrap()
477                .last()
478                .cloned()
479                .unwrap_or_default()
480        }
481    }
482
483    /// A loopback HTTP server that records the request headers of each call and
484    /// replies with a fixed status + body on every path (fallback route).
485    async fn spawn_server(status: StatusCode, resp_body: &'static str) -> TestServer {
486        let captured: Arc<Mutex<Vec<Captured>>> = Arc::new(Mutex::new(Vec::new()));
487        let cap = captured.clone();
488        let app = Router::new().fallback(move |headers: HeaderMap, _body: String| {
489            let cap = cap.clone();
490            async move {
491                let get = |k: &str| {
492                    headers
493                        .get(k)
494                        .and_then(|v| v.to_str().ok())
495                        .map(str::to_string)
496                };
497                cap.lock().unwrap().push(Captured {
498                    provider: get("x-ryu-slot-stt-provider"),
499                    model: get("x-ryu-slot-stt-model"),
500                    authorization: get("authorization"),
501                });
502                (status, resp_body)
503            }
504        });
505        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
506        let addr = listener.local_addr().unwrap();
507        let handle = tokio::spawn(async move {
508            let _ = axum::serve(listener, app).await;
509        });
510        TestServer {
511            addr,
512            captured,
513            _handle: handle,
514        }
515    }
516
517    /// A bound-then-dropped loopback address: connecting to it refuses instantly.
518    async fn dead_url() -> String {
519        let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
520        let addr = l.local_addr().unwrap();
521        drop(l);
522        format!("http://{addr}")
523    }
524
525    // ── whisper.cpp engine ────────────────────────────────────────────────────
526
527    #[tokio::test]
528    async fn whisper_success_parses_text_and_segments() {
529        let server = spawn_server(
530            StatusCode::OK,
531            r#"{"text":"  hello there  ","segments":[{"start":0.0,"end":1.0,"text":" hello"},{"start":1.0,"end":2.0,"text":" there"}]}"#,
532        )
533        .await;
534        let host = FakeHost {
535            whisper: server.url(),
536            ..FakeHost::default()
537        };
538        let client = reqwest::Client::new();
539        let out = transcribe_wav_detailed(
540            &client,
541            &host,
542            b"fakeaudio".to_vec(),
543            "clip.wav".to_string(),
544            Some("whisper"),
545        )
546        .await
547        .expect("whisper transcription should succeed");
548        assert_eq!(out.text, "hello there");
549        assert_eq!(out.segments.len(), 2);
550        assert_eq!(out.segments[0].text, "hello");
551        assert_eq!(out.segments[1].end_ms, 2000);
552    }
553
554    #[tokio::test]
555    async fn whisper_text_only_wrapper_returns_string() {
556        let server = spawn_server(StatusCode::OK, r#"{"text":"just text"}"#).await;
557        let host = FakeHost {
558            whisper: server.url(),
559            ..FakeHost::default()
560        };
561        let client = reqwest::Client::new();
562        // The text-only wrapper `transcribe_wav` drops segments.
563        let text = transcribe_wav(
564            &client,
565            &host,
566            b"a".to_vec(),
567            "c.wav".to_string(),
568            Some("whisper"),
569        )
570        .await
571        .unwrap();
572        assert_eq!(text, "just text");
573    }
574
575    #[tokio::test]
576    async fn whisper_non_success_status_is_error() {
577        let server = spawn_server(StatusCode::INTERNAL_SERVER_ERROR, "model exploded").await;
578        let host = FakeHost {
579            whisper: server.url(),
580            ..FakeHost::default()
581        };
582        let client = reqwest::Client::new();
583        let err = transcribe_wav_detailed(
584            &client,
585            &host,
586            b"a".to_vec(),
587            "c.wav".to_string(),
588            Some("whisper"),
589        )
590        .await
591        .unwrap_err();
592        assert!(err.contains("whisper returned 500"), "got: {err}");
593        assert!(err.contains("model exploded"), "got: {err}");
594    }
595
596    #[tokio::test]
597    async fn whisper_unparseable_body_is_error() {
598        let server = spawn_server(StatusCode::OK, "this is not json").await;
599        let host = FakeHost {
600            whisper: server.url(),
601            ..FakeHost::default()
602        };
603        let client = reqwest::Client::new();
604        let err = transcribe_wav_detailed(
605            &client,
606            &host,
607            b"a".to_vec(),
608            "c.wav".to_string(),
609            Some("whisper"),
610        )
611        .await
612        .unwrap_err();
613        assert!(
614            err.contains("could not parse whisper response"),
615            "got: {err}"
616        );
617    }
618
619    #[tokio::test]
620    async fn whisper_unreachable_host_is_error() {
621        let host = FakeHost {
622            whisper: dead_url().await,
623            ..FakeHost::default()
624        };
625        let client = reqwest::Client::new();
626        let err = transcribe_wav_detailed(
627            &client,
628            &host,
629            b"a".to_vec(),
630            "c.wav".to_string(),
631            Some("whisper"),
632        )
633        .await
634        .unwrap_err();
635        assert!(err.contains("not reachable"), "got: {err}");
636        assert!(
637            err.contains("whispercpp"),
638            "actionable hint expected: {err}"
639        );
640    }
641
642    #[tokio::test]
643    async fn empty_engine_selector_falls_through_to_compiled_default() {
644        // Without `voice-parakeet` (the cargo-test build), the compiled default is
645        // whisper, so a blank selector must hit the whisper arm. Hold ENV_LOCK
646        // because this path reads `RYU_STT_ENGINE`.
647        let _g = env_guard();
648        let prev = std::env::var("RYU_STT_ENGINE").ok();
649        std::env::remove_var("RYU_STT_ENGINE");
650        let server = spawn_server(StatusCode::OK, r#"{"text":"fallback"}"#).await;
651        let host = FakeHost {
652            whisper: server.url(),
653            ..FakeHost::default()
654        };
655        let client = reqwest::Client::new();
656        let out = transcribe_wav_detailed(
657            &client,
658            &host,
659            b"a".to_vec(),
660            "c.wav".to_string(),
661            Some("   "),
662        )
663        .await
664        .unwrap();
665        assert_eq!(out.text, "fallback");
666        if let Some(v) = prev {
667            std::env::set_var("RYU_STT_ENGINE", v);
668        }
669    }
670
671    // ── Gateway engine ────────────────────────────────────────────────────────
672
673    #[tokio::test]
674    async fn gateway_success_sends_default_slot_headers_and_bearer() {
675        let _g = env_guard();
676        std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
677        std::env::remove_var("RYU_STT_GATEWAY_MODEL");
678        let server = spawn_server(
679            StatusCode::OK,
680            r#"{"text":" gw text ","segments":[{"start":0.0,"end":0.5,"text":"gw"}]}"#,
681        )
682        .await;
683        let host = FakeHost {
684            gateway: server.url(),
685            bearer: Ok("secret-slot-token".to_string()),
686            ..FakeHost::default()
687        };
688        let client = reqwest::Client::new();
689        let out = transcribe_wav_detailed(
690            &client,
691            &host,
692            b"a".to_vec(),
693            "c.wav".to_string(),
694            Some("gateway"),
695        )
696        .await
697        .unwrap();
698        assert_eq!(out.text, "gw text");
699        assert_eq!(out.segments.len(), 1);
700        let cap = server.last();
701        assert_eq!(cap.provider.as_deref(), Some("openai"));
702        assert_eq!(cap.model.as_deref(), Some("whisper-large-v3"));
703        assert_eq!(
704            cap.authorization.as_deref(),
705            Some("Bearer secret-slot-token")
706        );
707    }
708
709    #[tokio::test]
710    async fn gateway_env_overrides_provider_and_model() {
711        let _g = env_guard();
712        std::env::set_var("RYU_STT_GATEWAY_PROVIDER", "groq");
713        std::env::set_var("RYU_STT_GATEWAY_MODEL", "whisper-turbo");
714        let server = spawn_server(StatusCode::OK, r#"{"text":"x"}"#).await;
715        // Trailing slash on the gateway base must be trimmed before the path join.
716        let host = FakeHost {
717            gateway: format!("{}/", server.url()),
718            ..FakeHost::default()
719        };
720        let client = reqwest::Client::new();
721        let out = transcribe_wav_detailed(
722            &client,
723            &host,
724            b"a".to_vec(),
725            "c.wav".to_string(),
726            Some("gateway"),
727        )
728        .await
729        .unwrap();
730        assert_eq!(out.text, "x");
731        let cap = server.last();
732        assert_eq!(cap.provider.as_deref(), Some("groq"));
733        assert_eq!(cap.model.as_deref(), Some("whisper-turbo"));
734        std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
735        std::env::remove_var("RYU_STT_GATEWAY_MODEL");
736    }
737
738    #[tokio::test]
739    async fn gateway_bearer_error_short_circuits() {
740        let _g = env_guard();
741        std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
742        std::env::remove_var("RYU_STT_GATEWAY_MODEL");
743        // No server is contacted — the bearer failure must propagate before the POST.
744        let host = FakeHost {
745            gateway: "http://127.0.0.1:0".to_string(),
746            bearer: Err("no gateway token configured".to_string()),
747            ..FakeHost::default()
748        };
749        let client = reqwest::Client::new();
750        let err = transcribe_wav_detailed(
751            &client,
752            &host,
753            b"a".to_vec(),
754            "c.wav".to_string(),
755            Some("gateway"),
756        )
757        .await
758        .unwrap_err();
759        assert_eq!(err, "no gateway token configured");
760    }
761
762    #[tokio::test]
763    async fn gateway_non_success_status_is_error() {
764        let _g = env_guard();
765        std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
766        std::env::remove_var("RYU_STT_GATEWAY_MODEL");
767        let server = spawn_server(StatusCode::UNAUTHORIZED, "bad key").await;
768        let host = FakeHost {
769            gateway: server.url(),
770            ..FakeHost::default()
771        };
772        let client = reqwest::Client::new();
773        let err = transcribe_wav_detailed(
774            &client,
775            &host,
776            b"a".to_vec(),
777            "c.wav".to_string(),
778            Some("gateway"),
779        )
780        .await
781        .unwrap_err();
782        assert!(err.contains("gateway STT returned 401"), "got: {err}");
783        assert!(err.contains("bad key"), "got: {err}");
784    }
785
786    #[tokio::test]
787    async fn gateway_unparseable_body_is_error() {
788        let _g = env_guard();
789        std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
790        std::env::remove_var("RYU_STT_GATEWAY_MODEL");
791        let server = spawn_server(StatusCode::OK, "<html>not json</html>").await;
792        let host = FakeHost {
793            gateway: server.url(),
794            ..FakeHost::default()
795        };
796        let client = reqwest::Client::new();
797        let err = transcribe_wav_detailed(
798            &client,
799            &host,
800            b"a".to_vec(),
801            "c.wav".to_string(),
802            Some("gateway"),
803        )
804        .await
805        .unwrap_err();
806        assert!(
807            err.contains("could not parse gateway STT response"),
808            "got: {err}"
809        );
810    }
811
812    #[tokio::test]
813    async fn gateway_unreachable_host_is_error() {
814        let _g = env_guard();
815        std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
816        std::env::remove_var("RYU_STT_GATEWAY_MODEL");
817        let host = FakeHost {
818            gateway: dead_url().await,
819            ..FakeHost::default()
820        };
821        let client = reqwest::Client::new();
822        let err = transcribe_wav_detailed(
823            &client,
824            &host,
825            b"a".to_vec(),
826            "c.wav".to_string(),
827            Some("gateway"),
828        )
829        .await
830        .unwrap_err();
831        assert!(err.contains("gateway STT unreachable"), "got: {err}");
832    }
833
834    // ── parakeet dispatch (feature off in `cargo test`) ───────────────────────
835
836    #[tokio::test]
837    async fn parakeet_engine_without_feature_reports_not_built() {
838        let host = FakeHost::default();
839        let client = reqwest::Client::new();
840        let err = transcribe_wav_detailed(
841            &client,
842            &host,
843            b"a".to_vec(),
844            "c.wav".to_string(),
845            Some("parakeet"),
846        )
847        .await
848        .unwrap_err();
849        assert!(err.contains("parakeet transcription failed"), "got: {err}");
850        assert!(err.contains("not built"), "got: {err}");
851    }
852}