use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use std::path::Path;
use std::time::Duration;
use tracing::{debug, warn};
#[async_trait]
pub trait TranscriptionProvider: Send + Sync {
async fn transcribe(&self, file_path: &Path) -> anyhow::Result<String>;
fn is_configured(&self) -> bool;
}
pub struct GroqTranscriptionProvider {
client: Client,
api_key: String,
api_url: String,
}
impl GroqTranscriptionProvider {
pub fn new(api_key: impl Into<String>) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("Failed to create HTTP client");
Self {
client,
api_key: api_key.into(),
api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
}
}
pub fn from_env() -> Option<Self> {
let api_key = std::env::var("GROQ_API_KEY").ok()?;
if api_key.is_empty() {
None
} else {
Some(Self::new(api_key))
}
}
pub fn with_api_url(mut self, url: impl Into<String>) -> Self {
self.api_url = url.into();
self
}
}
impl Default for GroqTranscriptionProvider {
fn default() -> Self {
Self::new("")
}
}
#[async_trait]
impl TranscriptionProvider for GroqTranscriptionProvider {
async fn transcribe(&self, file_path: &Path) -> anyhow::Result<String> {
if !self.is_configured() {
return Ok(String::new());
}
if !file_path.exists() {
return Err(anyhow::anyhow!(
"Audio file not found: {}",
file_path.display()
));
}
debug!("Transcribing audio file: {}", file_path.display());
let file_content = tokio::fs::read(file_path)
.await
.map_err(|e| anyhow::anyhow!("Failed to read audio file: {}", e))?;
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audio.ogg");
let part = reqwest::multipart::Part::bytes(file_content)
.file_name(file_name.to_string())
.mime_str("audio/*")
.map_err(|e| anyhow::anyhow!("Failed to create multipart: {}", e))?;
let form = reqwest::multipart::Form::new()
.part("file", part)
.text("model", "whisper-large-v3-turbo");
let response = self
.client
.post(&self.api_url)
.header("Authorization", format!("Bearer {}", self.api_key))
.multipart(form)
.send()
.await
.map_err(|e| anyhow::anyhow!("Groq API error: {}", e))?;
let status = response.status();
let response_text = response
.text()
.await
.map_err(|e| anyhow::anyhow!("Failed to read response: {}", e))?;
if !status.is_success() {
warn!("Groq API error: {}", response_text);
return Err(anyhow::anyhow!(
"Groq API returned status {}: {}",
status,
response_text
));
}
#[derive(Deserialize)]
struct TranscriptionResponse {
text: String,
}
if let Ok(data) = serde_json::from_str::<TranscriptionResponse>(&response_text) {
debug!("Transcription successful: {} chars", data.text.len());
Ok(data.text)
} else {
warn!("Failed to parse transcription response: {}", response_text);
Ok(String::new())
}
}
fn is_configured(&self) -> bool {
!self.api_key.is_empty()
}
}
pub struct OpenAITranscriptionProvider {
client: Client,
api_key: String,
api_url: String,
}
impl OpenAITranscriptionProvider {
pub fn new(api_key: impl Into<String>) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("Failed to create HTTP client");
Self {
client,
api_key: api_key.into(),
api_url: "https://api.openai.com/v1/audio/transcriptions".to_string(),
}
}
pub fn from_env() -> Option<Self> {
let api_key = std::env::var("OPENAI_API_KEY").ok()?;
if api_key.is_empty() {
None
} else {
Some(Self::new(api_key))
}
}
pub fn with_api_url(mut self, url: impl Into<String>) -> Self {
self.api_url = url.into();
self
}
}
impl Default for OpenAITranscriptionProvider {
fn default() -> Self {
Self::new("")
}
}
#[async_trait]
impl TranscriptionProvider for OpenAITranscriptionProvider {
async fn transcribe(&self, file_path: &Path) -> anyhow::Result<String> {
if !self.is_configured() {
return Ok(String::new());
}
if !file_path.exists() {
return Err(anyhow::anyhow!(
"Audio file not found: {}",
file_path.display()
));
}
debug!(
"Transcribing audio file with OpenAI: {}",
file_path.display()
);
let file_content = tokio::fs::read(file_path)
.await
.map_err(|e| anyhow::anyhow!("Failed to read audio file: {}", e))?;
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audio.mp3");
let part = reqwest::multipart::Part::bytes(file_content)
.file_name(file_name.to_string())
.mime_str("audio/mp3")
.map_err(|e| anyhow::anyhow!("Failed to create multipart: {}", e))?;
let form = reqwest::multipart::Form::new()
.part("file", part)
.text("model", "whisper-1");
let response = self
.client
.post(&self.api_url)
.header("Authorization", format!("Bearer {}", self.api_key))
.multipart(form)
.send()
.await
.map_err(|e| anyhow::anyhow!("OpenAI API error: {}", e))?;
let status = response.status();
let response_text = response
.text()
.await
.map_err(|e| anyhow::anyhow!("Failed to read response: {}", e))?;
if !status.is_success() {
warn!("OpenAI API error: {}", response_text);
return Err(anyhow::anyhow!(
"OpenAI API returned status {}: {}",
status,
response_text
));
}
#[derive(Deserialize)]
struct TranscriptionResponse {
text: String,
}
if let Ok(data) = serde_json::from_str::<TranscriptionResponse>(&response_text) {
debug!("Transcription successful: {} chars", data.text.len());
Ok(data.text)
} else {
warn!("Failed to parse transcription response: {}", response_text);
Ok(String::new())
}
}
fn is_configured(&self) -> bool {
!self.api_key.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_groq_provider_creation() {
let provider = GroqTranscriptionProvider::new("test-key");
assert_eq!(provider.api_key, "test-key");
assert!(provider.is_configured());
}
#[test]
fn test_groq_provider_empty_key() {
let provider = GroqTranscriptionProvider::new("");
assert!(!provider.is_configured());
}
#[test]
fn test_groq_provider_default() {
let provider = GroqTranscriptionProvider::default();
assert!(!provider.is_configured());
}
#[test]
fn test_openai_provider_creation() {
let provider = OpenAITranscriptionProvider::new("test-key");
assert_eq!(provider.api_key, "test-key");
assert!(provider.is_configured());
}
#[test]
fn test_openai_provider_custom_url() {
let provider = OpenAITranscriptionProvider::new("test-key")
.with_api_url("https://custom.api/v1/audio/transcriptions");
assert_eq!(
provider.api_url,
"https://custom.api/v1/audio/transcriptions"
);
}
}