feat(transcription): add Chat Completions API provider for audio transcription (#1130)

* feat(transcription): add Chat Completions API provider for audio transcription

The existing transcription pipeline only supports the OpenAI Whisper API
(/v1/audio/transcriptions with multipart upload). Providers like OpenRouter
expose audio transcription through the Chat Completions API instead, using
base64-encoded audio in the `input_audio` content type.

Add `ChatCompletionsTranscriptionProvider` that sends audio as base64 in
a chat completion request and extracts the transcript from the response.
Compatible with OpenRouter, OpenAI GPT-4o-audio, and any provider that
supports audio input via Chat Completions.

Config changes:
- TRANSCRIPTION_PROVIDER=chat_completions selects the new provider
- TRANSCRIPTION_API_KEY overrides provider-specific keys
- LLM_API_KEY used as fallback for chat_completions provider
- Default model per provider (whisper-1 for openai, gemini-2.0-flash for
  chat_completions)

* style: address review feedback — formatting, idiomatic patterns

- Fix rustfmt formatting for provider constructor chain
- Use or_else for resolve_api_key priority chain (Gemini review)
- Use trim_end_matches('/') instead of while loop (Gemini review)

---------

Co-authored-by: SMKRV <[email protected]>
This commit is contained in:
smkrv
2026-03-16 07:57:58 +00:00
committed by GitHub
co-authored by SMKRV
parent c4e098d4e3
commit 877f117096
3 changed files with 245 additions and 13 deletions
+64 -13
View File
@@ -9,11 +9,15 @@ use crate::settings::Settings;
pub struct TranscriptionConfig {
/// Whether audio transcription is enabled.
pub enabled: bool,
/// Provider: "openai" (default).
/// Provider: "openai" (default) or "chat_completions".
pub provider: String,
/// OpenAI API key (reuses OPENAI_API_KEY).
pub openai_api_key: Option<SecretString>,
/// Model to use (default: "whisper-1").
/// Explicit transcription API key (overrides provider-specific keys).
pub api_key: Option<SecretString>,
/// LLM API key (reuses LLM_API_KEY, used as fallback for chat_completions).
pub llm_api_key: Option<SecretString>,
/// Model to use (default depends on provider).
pub model: String,
/// Base URL override for the transcription API.
pub base_url: Option<String>,
@@ -25,6 +29,8 @@ impl Default for TranscriptionConfig {
enabled: false,
provider: "openai".to_string(),
openai_api_key: None,
api_key: None,
llm_api_key: None,
model: "whisper-1".to_string(),
base_url: None,
}
@@ -42,8 +48,15 @@ impl TranscriptionConfig {
optional_env("TRANSCRIPTION_PROVIDER")?.unwrap_or_else(|| "openai".to_string());
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
let api_key = optional_env("TRANSCRIPTION_API_KEY")?.map(SecretString::from);
let llm_api_key = optional_env("LLM_API_KEY")?.map(SecretString::from);
let model = optional_env("TRANSCRIPTION_MODEL")?.unwrap_or_else(|| "whisper-1".to_string());
let default_model = match provider.as_str() {
"chat_completions" => "google/gemini-2.0-flash-001",
_ => "whisper-1",
};
let model =
optional_env("TRANSCRIPTION_MODEL")?.unwrap_or_else(|| default_model.to_string());
let base_url = optional_env("TRANSCRIPTION_BASE_URL")?;
@@ -51,29 +64,67 @@ impl TranscriptionConfig {
enabled,
provider,
openai_api_key,
api_key,
llm_api_key,
model,
base_url,
})
}
/// Resolve the API key for the configured provider.
///
/// Priority: `TRANSCRIPTION_API_KEY` > provider-specific key.
fn resolve_api_key(&self) -> Option<&SecretString> {
self.api_key
.as_ref()
.or_else(|| match self.provider.as_str() {
"chat_completions" => self.llm_api_key.as_ref().or(self.openai_api_key.as_ref()),
_ => self.openai_api_key.as_ref(),
})
}
/// Create the transcription provider if enabled and configured.
pub fn create_provider(&self) -> Option<Box<dyn crate::transcription::TranscriptionProvider>> {
if !self.enabled {
return None;
}
// Currently only OpenAI Whisper is supported; more providers can be
// added here with a match on self.provider.
let api_key = self.openai_api_key.as_ref()?;
tracing::info!(model = %self.model, "Audio transcription enabled via OpenAI Whisper");
let api_key = self.resolve_api_key()?;
let mut provider = crate::transcription::OpenAiWhisperProvider::new(api_key.clone())
.with_model(&self.model);
match self.provider.as_str() {
"chat_completions" => {
tracing::info!(
model = %self.model,
"Audio transcription enabled via Chat Completions API"
);
if let Some(ref base_url) = self.base_url {
provider = provider.with_base_url(base_url);
let mut provider = crate::transcription::ChatCompletionsTranscriptionProvider::new(
api_key.clone(),
)
.with_model(&self.model);
if let Some(ref base_url) = self.base_url {
provider = provider.with_base_url(base_url);
}
Some(Box::new(provider))
}
_ => {
tracing::info!(
model = %self.model,
"Audio transcription enabled via OpenAI Whisper"
);
let mut provider =
crate::transcription::OpenAiWhisperProvider::new(api_key.clone())
.with_model(&self.model);
if let Some(ref base_url) = self.base_url {
provider = provider.with_base_url(base_url);
}
Some(Box::new(provider))
}
}
Some(Box::new(provider))
}
}