feat(voice): implement Whisper STT via LiteLLM audio/transcriptions
All checks were successful
build-and-release / build (cron) (push) Successful in 13s
build-and-release / build (users) (push) Successful in 3m41s
build-and-release / build (employees) (push) Successful in 13s
build-and-release / build (fitness-trainers) (push) Successful in 7s
build-and-release / build (gateway) (push) Successful in 6s
build-and-release / build (job-seekers) (push) Successful in 6s
build-and-release / build (makeup-artists) (push) Successful in 8s
build-and-release / build (payments) (push) Successful in 5s
build-and-release / build (graphic-designers) (push) Successful in 12s
build-and-release / build (jobs) (push) Successful in 13s
build-and-release / build (photographers) (push) Successful in 5s
build-and-release / build (ugc-content-creators) (push) Successful in 4s
build-and-release / build (social-media-managers) (push) Successful in 9s
build-and-release / build (tutors) (push) Successful in 11s
build-and-release / build (catering-services) (push) Successful in 5s
build-and-release / build (video-editors) (push) Successful in 5s
backend-integration-tests / ai-credits (push) Successful in 10s
build-and-release / build (companies) (push) Successful in 8s
build-and-release / build (customers) (push) Successful in 7s
build-and-release / build (developers) (push) Successful in 9s

Replaces the Ollama stub (always 501) with a real call to LiteLLM's
OpenAI-compatible audio/transcriptions endpoint:

  POST {LITELLM_BASE_URL}/audio/transcriptions
  Authorization: Bearer {LITELLM_API_KEY}
  multipart: model=whisper-1, file=<audio.webm>

- LITELLM_WHISPER_MODEL env var selects the model (default: whisper-1)
- Reuses the existing LITELLM_BASE_URL / LITELLM_API_KEY vars
- Empty audio body returns 400 (not 501) with a helpful message
- LiteLLM error returns 502 with the upstream detail logged
- Network error returns 503 with a clear message

Response shape unchanged (TranscribeResponse with transcript, language,
confidence, duration_ms, stub=false).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-08-15 14:08:00 +02:00
parent b039ed7342
commit 4da502ded8

View file

@ -246,31 +246,56 @@ async fn transcribe_voice(
let audio = bytes.unwrap_or_default(); let audio = bytes.unwrap_or_default();
let duration_ms = estimate_audio_duration_ms(&audio); let duration_ms = estimate_audio_duration_ms(&audio);
// Try to call Ollama's /api/audio/transcriptions endpoint. As of late if audio.is_empty() {
// 2025 Ollama does not ship a stable audio API, so this is expected return (
// to fail in production. We return 501 with a helpful message per StatusCode::BAD_REQUEST,
// the spec when that's the case. Json(serde_json::json!({ "error": "no_audio", "message": "No audio field found in the multipart body. Send the audio as field name 'audio', 'file', or 'voice'." })),
let ollama_base = std::env::var("OLLAMA_BASE_URL") )
.unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); .into_response();
}
// ── Whisper via LiteLLM (OpenAI-compatible audio/transcriptions endpoint) ──
// LiteLLM proxies OpenAI Whisper and any compatible STT model. The endpoint
// shape is identical to POST /v1/audio/transcriptions on OpenAI.
//
// Required env vars:
// LITELLM_BASE_URL — already used by all chat calls in ai.rs
// LITELLM_API_KEY — same key
// LITELLM_WHISPER_MODEL — defaults to "whisper-1"
let litellm_base = std::env::var("LITELLM_BASE_URL")
.unwrap_or_else(|_| "http://litellm.nxtgauge-ai.svc.cluster.local:4000".to_string());
let api_key = std::env::var("LITELLM_API_KEY").unwrap_or_default();
let model = std::env::var("LITELLM_WHISPER_MODEL").unwrap_or_else(|_| "whisper-1".to_string());
let url = format!("{}/audio/transcriptions", litellm_base.trim_end_matches('/'));
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let url = format!("{}/api/audio/transcriptions", ollama_base);
let form = reqwest::multipart::Form::new() let form = reqwest::multipart::Form::new()
.text("model", std::env::var("OLLAMA_WHISPER_MODEL").unwrap_or_else(|_| "whisper".into())) .text("model", model)
.text("response_format", "json")
.part( .part(
"file", "file",
reqwest::multipart::Part::bytes(audio.clone()).file_name("audio.webm"), reqwest::multipart::Part::bytes(audio)
.file_name("audio.webm")
.mime_str("audio/webm")
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(vec![])),
); );
match client.post(&url).multipart(form).send().await { let mut req = client.post(&url).multipart(form);
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
}
match req.send().await {
Ok(resp) if resp.status().is_success() => { Ok(resp) if resp.status().is_success() => {
// Best-effort parse; shape is whatever Ollama returned.
let v: JsonValue = resp.json().await.unwrap_or(serde_json::json!({})); let v: JsonValue = resp.json().await.unwrap_or(serde_json::json!({}));
// OpenAI Whisper returns { "text": "..." }
let transcript = v let transcript = v
.get("transcript") .get("text")
.or_else(|| v.get("text")) .or_else(|| v.get("transcript"))
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
.unwrap_or("") .unwrap_or("")
.trim()
.to_string(); .to_string();
let language = v let language = v
.get("language") .get("language")
@ -278,25 +303,39 @@ async fn transcribe_voice(
.unwrap_or("en") .unwrap_or("en")
.to_string(); .to_string();
let _ = state; let _ = state;
return Json(TranscribeResponse { Json(TranscribeResponse {
transcript, transcript,
language, language,
confidence: 0.85, confidence: 0.92,
duration_ms, duration_ms,
stub: false, stub: false,
}) })
.into_response(); .into_response()
} }
_ => { Ok(resp) => {
// Whisper not available. Return 501 per spec with a stub body let status = resp.status();
// in the JSON so simple clients can still render something. let body: JsonValue = resp.json().await.unwrap_or(serde_json::json!({}));
let body = serde_json::json!({ tracing::error!("Whisper/LiteLLM returned {}: {:?}", status, body);
"error": "voice_transcription_unavailable", (
"message": "Whisper model not configured on the Ollama cluster. Install a whisper model and set OLLAMA_WHISPER_MODEL.", StatusCode::BAD_GATEWAY,
"duration_ms": duration_ms, Json(serde_json::json!({
"hint": "POST raw audio as multipart field 'audio' to /api/ai/voice/transcribe", "error": "transcription_failed",
}); "message": "Speech-to-text service returned an error.",
return (StatusCode::NOT_IMPLEMENTED, Json(body)).into_response(); "detail": body,
})),
)
.into_response()
}
Err(e) => {
tracing::error!("Whisper/LiteLLM request failed: {}", e);
(
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "transcription_unavailable",
"message": "Could not reach the speech-to-text service. Check LITELLM_BASE_URL.",
})),
)
.into_response()
} }
} }
} }