diff --git a/registry/channels/discord.json b/registry/channels/discord.json index 6f5cd4e7..50ef85ee 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -2,7 +2,7 @@ "name": "discord", "display_name": "Discord Channel", "kind": "channel", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Talk to your agent in Discord", "keywords": [ diff --git a/registry/tools/github.json b/registry/tools/github.json index e84f756d..e775ac82 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -2,7 +2,7 @@ "name": "github", "display_name": "GitHub", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "GitHub integration for issues, PRs, repos, and code search", "keywords": [ diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 4da5744b..1722c391 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -2,7 +2,7 @@ "name": "web-search", "display_name": "Web Search", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Search the web using Brave Search API", "keywords": [ diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 4301c09a..9be0d654 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -148,6 +148,7 @@ impl Agent { "notify_channel": message.channel, "notify_user": message.user_id, "notify_thread_id": message.thread_id, + "notify_metadata": message.metadata, }); // Build system prompts once for this turn. Two variants: with tools diff --git a/src/config/llm.rs b/src/config/llm.rs index 4ad24399..64bf4ab8 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -38,6 +38,8 @@ impl LlmConfig { provider: None, bedrock: None, request_timeout_secs: 120, + cheap_model: None, + smart_routing_cascade: false, } } @@ -168,6 +170,14 @@ impl LlmConfig { let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?; + // Generic cheap model (works with any backend). + // Falls back to NearAI-specific cheap_model in provider chain logic. + let cheap_model = optional_env("LLM_CHEAP_MODEL")?; + + // Generic smart routing cascade flag. + // Defaults to true. Overrides NearAI-specific smart_routing_cascade. + let smart_routing_cascade = parse_optional_env("SMART_ROUTING_CASCADE", true)?; + Ok(Self { backend: if is_nearai { "nearai".to_string() @@ -183,6 +193,8 @@ impl LlmConfig { provider, bedrock, request_timeout_secs, + cheap_model, + smart_routing_cascade, }) } diff --git a/src/config/transcription.rs b/src/config/transcription.rs index b0f76066..da2bac25 100644 --- a/src/config/transcription.rs +++ b/src/config/transcription.rs @@ -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, - /// Model to use (default: "whisper-1"). + /// Explicit transcription API key (overrides provider-specific keys). + pub api_key: Option, + /// LLM API key (reuses LLM_API_KEY, used as fallback for chat_completions). + pub llm_api_key: Option, + /// Model to use (default depends on provider). pub model: String, /// Base URL override for the transcription API. pub base_url: Option, @@ -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> { 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)) } } diff --git a/src/llm/config.rs b/src/llm/config.rs index 8b7d41c3..413f80e2 100644 --- a/src/llm/config.rs +++ b/src/llm/config.rs @@ -138,6 +138,30 @@ pub struct LlmConfig { /// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that /// need more time for prompt evaluation on consumer hardware. pub request_timeout_secs: u64, + /// Generic cheap/fast model for lightweight tasks (heartbeat, routing, evaluation). + /// Works with any backend. Set via `LLM_CHEAP_MODEL` env var. + /// When set, takes priority over the NearAI-specific `NEARAI_CHEAP_MODEL`. + pub cheap_model: Option, + /// Enable cascade mode for smart routing (retry with primary if cheap model + /// response seems uncertain). Default: true. Set via `SMART_ROUTING_CASCADE`. + pub smart_routing_cascade: bool, +} + +impl LlmConfig { + /// Resolve the effective cheap model name. + /// + /// Resolution order: + /// 1. `LLM_CHEAP_MODEL` (generic, works with any backend) + /// 2. `NEARAI_CHEAP_MODEL` (NearAI-only, backward compatibility) + pub fn cheap_model_name(&self) -> Option<&str> { + self.cheap_model.as_deref().or_else(|| { + if self.backend == "nearai" { + self.nearai.cheap_model.as_deref() + } else { + None + } + }) + } } /// NEAR AI configuration. diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 51309bf3..77102e32 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -376,32 +376,61 @@ fn create_ollama_from_registry( /// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation). /// -/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider. -/// Currently only supports NEAR AI backend. +/// Resolution order: +/// 1. `LLM_CHEAP_MODEL` (generic, works with any backend) +/// 2. `NEARAI_CHEAP_MODEL` (NearAI-only, backward compatibility) +/// +/// Returns `None` if no cheap model is configured. pub fn create_cheap_llm_provider( config: &LlmConfig, session: Arc, ) -> Result>, LlmError> { - let Some(ref cheap_model) = config.nearai.cheap_model else { + let Some(cheap_model) = config.cheap_model_name() else { return Ok(None); }; - if config.backend != "nearai" { - tracing::warn!( - "NEARAI_CHEAP_MODEL is set but LLM_BACKEND is '{}', not nearai. \ - Cheap model setting will be ignored.", - config.backend - ); - return Ok(None); + create_cheap_provider_for_backend(config, session, cheap_model) +} + +/// Create a cheap provider for a specific backend. +/// +/// Handles backend-specific provider construction: +/// - `nearai` — clones NearAiConfig, swaps model, uses `create_llm_provider_with_config` +/// - `bedrock` — returns error (smart routing not yet supported) +/// - All others — clones `RegistryProviderConfig`, swaps model, uses `create_registry_provider` +fn create_cheap_provider_for_backend( + config: &LlmConfig, + session: Arc, + cheap_model: &str, +) -> Result>, LlmError> { + if config.backend == "nearai" { + let mut cheap_config = config.nearai.clone(); + cheap_config.model = cheap_model.to_string(); + let provider = + create_llm_provider_with_config(&cheap_config, session, config.request_timeout_secs)?; + return Ok(Some(provider)); } - let mut cheap_config = config.nearai.clone(); - cheap_config.model = cheap_model.clone(); + if config.backend == "bedrock" { + return Err(LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: "Smart routing with cheap model is not supported for Bedrock yet".to_string(), + }); + } - Ok(Some(Arc::new(NearAiChatProvider::new( - cheap_config, - session, - )?))) + // Registry-based provider: clone config and swap model + let reg_config = config.provider.as_ref().ok_or_else(|| LlmError::RequestFailed { + provider: config.backend.clone(), + reason: format!( + "Cannot create cheap provider for backend '{}': no registry provider config available", + config.backend + ), + })?; + + let mut cheap_reg_config = reg_config.clone(); + cheap_reg_config.model = cheap_model.to_string(); + let provider = create_registry_provider(&cheap_reg_config)?; + Ok(Some(provider)) } /// Build the full LLM provider chain with all configured wrappers. @@ -449,14 +478,15 @@ pub async fn build_provider_chain( }; // 2. Smart routing (cheap/primary split) - let llm: Arc = if let Some(ref cheap_model) = config.nearai.cheap_model { - let mut cheap_config = config.nearai.clone(); - cheap_config.model = cheap_model.clone(); - let cheap = create_llm_provider_with_config( - &cheap_config, - session.clone(), - config.request_timeout_secs, - )?; + let llm: Arc = if let Some(cheap_model) = config.cheap_model_name() { + let cheap = create_cheap_provider_for_backend(config, session.clone(), cheap_model)? + .ok_or_else(|| LlmError::RequestFailed { + provider: config.backend.clone(), + reason: format!( + "Failed to create cheap provider for model '{cheap_model}' on backend '{}'", + config.backend + ), + })?; let cheap: Arc = if retry_config.max_retries > 0 { Arc::new(RetryProvider::new(cheap, retry_config.clone())) } else { @@ -471,7 +501,7 @@ pub async fn build_provider_chain( llm, cheap, SmartRoutingConfig { - cascade_enabled: config.nearai.smart_routing_cascade, + cascade_enabled: config.smart_routing_cascade, ..SmartRoutingConfig::default() }, )) @@ -600,6 +630,8 @@ mod tests { provider: None, bedrock: None, request_timeout_secs: 120, + cheap_model: None, + smart_routing_cascade: true, } } @@ -614,7 +646,7 @@ mod tests { } #[test] - fn test_create_cheap_llm_provider_creates_provider_when_configured() { + fn test_create_cheap_llm_provider_creates_provider_with_nearai_cheap_model() { let mut config = test_llm_config(); config.nearai.cheap_model = Some("cheap-test-model".to_string()); @@ -628,7 +660,26 @@ mod tests { } #[test] - fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() { + fn test_create_cheap_llm_provider_generic_overrides_nearai() { + let mut config = test_llm_config(); + config.nearai.cheap_model = Some("nearai-cheap".to_string()); + config.cheap_model = Some("generic-cheap".to_string()); + + let session = Arc::new(SessionManager::new(SessionConfig::default())); + let result = create_cheap_llm_provider(&config, session); + + assert!(result.is_ok()); + let provider = result.unwrap(); + assert!(provider.is_some()); + assert_eq!( + provider.unwrap().model_name(), + "generic-cheap", + "LLM_CHEAP_MODEL should take priority over NEARAI_CHEAP_MODEL" + ); + } + + #[test] + fn test_create_cheap_llm_provider_nearai_cheap_ignored_for_non_nearai_backend() { let mut config = test_llm_config(); config.backend = "openai".to_string(); config.nearai.cheap_model = Some("cheap-test-model".to_string()); @@ -637,6 +688,48 @@ mod tests { let result = create_cheap_llm_provider(&config, session); assert!(result.is_ok()); - assert!(result.unwrap().is_none()); + assert!( + result.unwrap().is_none(), + "NEARAI_CHEAP_MODEL should be ignored when backend is not nearai" + ); + } + + #[test] + fn test_create_cheap_llm_provider_bedrock_returns_error() { + let mut config = test_llm_config(); + config.backend = "bedrock".to_string(); + config.cheap_model = Some("cheap-model".to_string()); + + let session = Arc::new(SessionManager::new(SessionConfig::default())); + let result = create_cheap_llm_provider(&config, session); + + assert!( + result.is_err(), + "Bedrock should return an error for cheap model" + ); + } + + #[test] + fn test_cheap_model_name_resolution() { + // Generic takes priority + let mut config = test_llm_config(); + config.cheap_model = Some("generic".to_string()); + config.nearai.cheap_model = Some("nearai".to_string()); + assert_eq!(config.cheap_model_name(), Some("generic")); + + // NearAI fallback when backend is nearai + let mut config = test_llm_config(); + config.nearai.cheap_model = Some("nearai".to_string()); + assert_eq!(config.cheap_model_name(), Some("nearai")); + + // NearAI ignored for non-nearai backend + let mut config = test_llm_config(); + config.backend = "openai".to_string(); + config.nearai.cheap_model = Some("nearai".to_string()); + assert_eq!(config.cheap_model_name(), None); + + // None when nothing configured + let config = test_llm_config(); + assert_eq!(config.cheap_model_name(), None); } } diff --git a/src/orchestrator/mod.rs b/src/orchestrator/mod.rs index 5e750ddf..b72f90ee 100644 --- a/src/orchestrator/mod.rs +++ b/src/orchestrator/mod.rs @@ -51,6 +51,15 @@ use crate::db::Database; use crate::llm::LlmProvider; use crate::secrets::SecretsStore; +/// Resolve the orchestrator port from the `ORCHESTRATOR_PORT` environment +/// variable, falling back to 50051. +fn resolve_orchestrator_port() -> u16 { + std::env::var("ORCHESTRATOR_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(50051) +} + /// Result of orchestrator setup, containing all handles needed by the agent. pub struct OrchestratorSetup { pub container_job_manager: Option>, @@ -101,11 +110,12 @@ pub async fn setup_orchestrator( let job_event_tx = Some(tx); let token_store = TokenStore::new(); + let orchestrator_port = resolve_orchestrator_port(); let job_config = ContainerJobConfig { image: config.sandbox.image.clone(), memory_limit_mb: config.sandbox.memory_limit_mb, cpu_shares: config.sandbox.cpu_shares, - orchestrator_port: 50051, + orchestrator_port, claude_code_api_key: std::env::var("ANTHROPIC_API_KEY").ok(), claude_code_oauth_token: crate::config::ClaudeCodeConfig::extract_oauth_token(), claude_code_model: config.claude_code.model.clone(), @@ -127,7 +137,7 @@ pub async fn setup_orchestrator( }; tokio::spawn(async move { - if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await { + if let Err(e) = OrchestratorApi::start(orchestrator_state, orchestrator_port).await { tracing::error!("Orchestrator API failed: {}", e); } }); @@ -151,3 +161,40 @@ pub async fn setup_orchestrator( docker_status, } } + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + + /// Serialize access to `ORCHESTRATOR_PORT` env var across test threads. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn resolve_orchestrator_port_from_env() { + let _guard = ENV_LOCK.lock().unwrap(); + + // Safety: env-var mutation requires unsafe in edition 2024; + // ENV_LOCK serializes concurrent access from other test threads. + + // Absent env var → default 50051 + unsafe { std::env::remove_var("ORCHESTRATOR_PORT") }; + assert_eq!(resolve_orchestrator_port(), 50051); + + // Valid custom port + unsafe { std::env::set_var("ORCHESTRATOR_PORT", "50052") }; + assert_eq!(resolve_orchestrator_port(), 50052); + + // Non-numeric value → fallback to default + unsafe { std::env::set_var("ORCHESTRATOR_PORT", "not_a_port") }; + assert_eq!(resolve_orchestrator_port(), 50051); + + // Out of u16 range → fallback to default + unsafe { std::env::set_var("ORCHESTRATOR_PORT", "99999") }; + assert_eq!(resolve_orchestrator_port(), 50051); + + // Cleanup + unsafe { std::env::remove_var("ORCHESTRATOR_PORT") }; + } +} diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 23494d12..d2f773d2 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -3099,6 +3099,60 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa /// Mask an API key for display: show first 6 + last 4 chars. /// /// Uses char-based indexing to avoid panicking on multi-byte UTF-8. +/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models. +/// +/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated +/// via Cloud API key (option 4) don't get re-prompted during model selection. +fn build_nearai_model_fetch_config() -> crate::config::LlmConfig { + // If the user authenticated via API key (option 4), the key is stored + // as an env var. Pass it through so `resolve_bearer_token()` doesn't + // re-trigger the interactive auth prompt. + let api_key = std::env::var("NEARAI_API_KEY") + .ok() + .filter(|k| !k.is_empty()) + .map(secrecy::SecretString::from); + + // Match the same base_url logic as LlmConfig::resolve(): use cloud-api + // when an API key is present, private.near.ai for session-token auth. + let default_base = if api_key.is_some() { + "https://cloud-api.near.ai" + } else { + "https://private.near.ai" + }; + let base_url = std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string()); + let auth_base_url = + std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string()); + + crate::config::LlmConfig { + backend: "nearai".to_string(), + session: crate::llm::session::SessionConfig { + auth_base_url, + session_path: crate::config::llm::default_session_path(), + }, + nearai: crate::config::NearAiConfig { + model: "dummy".to_string(), + cheap_model: None, + base_url, + api_key, + fallback_model: None, + max_retries: 3, + circuit_breaker_threshold: None, + circuit_breaker_recovery_secs: 30, + response_cache_enabled: false, + response_cache_ttl_secs: 3600, + response_cache_max_entries: 1000, + failover_cooldown_secs: 300, + failover_cooldown_threshold: 3, + smart_routing_cascade: true, + }, + provider: None, + bedrock: None, + request_timeout_secs: 120, + cheap_model: None, + smart_routing_cascade: true, + } +} + fn mask_api_key(key: &str) -> String { let chars: Vec = key.chars().collect(); if chars.len() < 12 { diff --git a/src/transcription/chat_completions.rs b/src/transcription/chat_completions.rs new file mode 100644 index 00000000..e23818aa --- /dev/null +++ b/src/transcription/chat_completions.rs @@ -0,0 +1,179 @@ +//! Chat Completions-based transcription provider. +//! +//! Uses the `/v1/chat/completions` endpoint with `input_audio` content type +//! to transcribe audio. Compatible with OpenRouter, OpenAI GPT-4o-audio, and +//! any provider that supports audio input via the Chat Completions API. + +use async_trait::async_trait; +use base64::Engine; +use secrecy::{ExposeSecret, SecretString}; + +use super::{AudioFormat, TranscriptionError, TranscriptionProvider}; + +/// Transcription provider that sends audio via the Chat Completions API. +/// +/// Unlike the Whisper provider (which uses `/v1/audio/transcriptions` with +/// multipart upload), this provider sends base64-encoded audio as an +/// `input_audio` content part in a chat message, enabling use with +/// OpenRouter and other providers that only expose audio through the +/// Chat Completions API. +pub struct ChatCompletionsTranscriptionProvider { + client: reqwest::Client, + api_key: SecretString, + model: String, + base_url: String, +} + +impl ChatCompletionsTranscriptionProvider { + /// Create a new provider with the given API key. + pub fn new(api_key: SecretString) -> Self { + Self { + client: match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + { + Ok(c) => c, + Err(e) => { + tracing::error!( + "Failed to build HTTP client with timeout, falling back to default: {e}" + ); + reqwest::Client::default() + } + }, + api_key, + model: "google/gemini-2.0-flash-001".to_string(), + base_url: "https://openrouter.ai/api".to_string(), + } + } + + /// Override the base URL. + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into().trim_end_matches('/').to_string(); + self + } + + /// Override the model name. + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = model.into(); + self + } +} + +/// Map [`AudioFormat`] to the format string expected by the Chat Completions API. +fn audio_format_str(format: AudioFormat) -> &'static str { + match format { + AudioFormat::Ogg => "ogg", + AudioFormat::Mp3 => "mp3", + AudioFormat::Mp4 => "mp4", + AudioFormat::Wav => "wav", + AudioFormat::Webm => "webm", + AudioFormat::Flac => "flac", + AudioFormat::M4a => "m4a", + } +} + +#[async_trait] +impl TranscriptionProvider for ChatCompletionsTranscriptionProvider { + async fn transcribe( + &self, + audio_data: &[u8], + format: AudioFormat, + ) -> Result { + if audio_data.is_empty() { + return Err(TranscriptionError::EmptyAudio); + } + + let b64 = base64::engine::general_purpose::STANDARD.encode(audio_data); + + let body = serde_json::json!({ + "model": self.model, + "messages": [{ + "role": "user", + "content": [ + { + "type": "text", + "text": "Transcribe this audio. Return only the transcript text, nothing else." + }, + { + "type": "input_audio", + "input_audio": { + "data": b64, + "format": audio_format_str(format) + } + } + ] + }] + }); + + let url = format!("{}/v1/chat/completions", self.base_url); + + let response = self + .client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", self.api_key.expose_secret()), + ) + .json(&body) + .send() + .await + .map_err(|e| TranscriptionError::RequestFailed(e.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "unknown error".to_string()); + return Err(TranscriptionError::RequestFailed(format!( + "HTTP {}: {}", + status, body + ))); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| TranscriptionError::RequestFailed(e.to_string()))?; + + // Extract text from the standard Chat Completions response format: + // { "choices": [{ "message": { "content": "..." } }] } + let text = json + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .ok_or_else(|| { + TranscriptionError::RequestFailed( + "unexpected response format: missing choices[0].message.content".to_string(), + ) + })?; + + Ok(text.trim().to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audio_format_str_maps_all_variants() { + assert_eq!(audio_format_str(AudioFormat::Ogg), "ogg"); + assert_eq!(audio_format_str(AudioFormat::Mp3), "mp3"); + assert_eq!(audio_format_str(AudioFormat::Mp4), "mp4"); + assert_eq!(audio_format_str(AudioFormat::Wav), "wav"); + assert_eq!(audio_format_str(AudioFormat::Webm), "webm"); + assert_eq!(audio_format_str(AudioFormat::Flac), "flac"); + assert_eq!(audio_format_str(AudioFormat::M4a), "m4a"); + } + + #[tokio::test] + async fn rejects_empty_audio() { + let provider = + ChatCompletionsTranscriptionProvider::new(SecretString::from("test-key".to_string())); + let result = provider.transcribe(&[], AudioFormat::Ogg).await; + assert!(matches!(result, Err(TranscriptionError::EmptyAudio))); + } +} diff --git a/src/transcription/mod.rs b/src/transcription/mod.rs index d0a7d31c..ab2e43f9 100644 --- a/src/transcription/mod.rs +++ b/src/transcription/mod.rs @@ -4,8 +4,10 @@ //! backends and a [`TranscriptionMiddleware`] that detects audio attachments //! on incoming messages and replaces them with transcribed text. +mod chat_completions; mod openai; +pub use self::chat_completions::ChatCompletionsTranscriptionProvider; pub use self::openai::OpenAiWhisperProvider; use async_trait::async_trait;