Merge branch 'main' into fix/resolve-conflicts

Resolved merge conflicts in 5 files:

1. src/agent/job_monitor.rs - Used is_internal flag approach (HEAD) for safe internal message marking. Removed metadata-based approach which could be spoofed by external channels.

2. src/agent/agent_loop.rs - Used is_internal check (HEAD) for routing internal messages, consistent with security model where is_internal field cannot be spoofed.

3. src/agent/dispatcher.rs - Included notify_metadata in job context (main), needed for job routing through JobMonitorRoute.

4. src/setup/wizard.rs - Added build_nearai_model_fetch_config() function (main) for model selection during setup.

5. src/tools/builtin/job.rs - Used both comments from HEAD (clarifying notify_channel and notify_user logic) while removing metadata field from JobMonitorRoute (consistent with job_monitor.rs).

All conflicts resolved with security-first approach: use is_internal boolean field for internal message marking (cannot be spoofed), while passing routing metadata through context.

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
Nick Pismenkov
2026-03-16 14:47:07 -07:00
co-authored by Claude Haiku 4.5
12 changed files with 509 additions and 46 deletions
+1 -1
View File
@@ -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": [
+1 -1
View File
@@ -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": [
+1 -1
View File
@@ -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": [
+1
View File
@@ -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
+12
View File
@@ -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,
})
}
+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))
}
}
+24
View File
@@ -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<String>,
/// 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.
+121 -28
View File
@@ -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<SessionManager>,
) -> Result<Option<Arc<dyn LlmProvider>>, 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<SessionManager>,
cheap_model: &str,
) -> Result<Option<Arc<dyn LlmProvider>>, 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<dyn LlmProvider> = 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<dyn LlmProvider> = 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<dyn LlmProvider> = 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);
}
}
+49 -2
View File
@@ -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<Arc<ContainerJobManager>>,
@@ -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") };
}
}
+54
View File
@@ -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<char> = key.chars().collect();
if chars.len() < 12 {
+179
View File
@@ -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<String>) -> 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<String>) -> 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<String, TranscriptionError> {
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)));
}
}
+2
View File
@@ -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;