1use async_trait::async_trait;
2use reqwest::multipart;
3use serde_json::{json, Value};
4use std::path::PathBuf;
5
6use crate::internal::tool_runtime::media_client::{load_bytes_from_location, MediaClient};
7use microclaw_core::llm_types::ToolDefinition;
8
9use super::{schema_object, Tool, ToolResult};
10use crate::config::{Config, MediaConfig, SttConfig};
11
12pub struct TranscribeAudioTool {
18 working_dir: PathBuf,
19 allowed_read_dirs: Vec<PathBuf>,
20 cfg: SttConfig,
21 media: MediaConfig,
22 openai_api_key: Option<String>,
23 openai_base_url: Option<String>,
24 timeout_secs: u64,
25}
26
27impl TranscribeAudioTool {
28 pub fn new(config: &Config) -> Self {
29 Self {
30 working_dir: PathBuf::from(&config.working_dir),
31 allowed_read_dirs: config
32 .media
33 .allowed_read_dirs
34 .iter()
35 .map(PathBuf::from)
36 .collect(),
37 cfg: config.media.stt.clone(),
38 media: config.media.clone(),
39 openai_api_key: config.openai_api_key.clone(),
40 openai_base_url: config.openai_base_url.clone(),
41 timeout_secs: config.tool_timeout_secs("transcribe_audio", 120),
42 }
43 }
44
45 fn client(&self) -> Result<MediaClient, String> {
46 let key = self
47 .media
48 .resolve_api_key(self.openai_api_key.as_deref())
49 .ok_or_else(|| {
50 "transcribe_audio requires an API key (media.api_key, \
51 MICROCLAW_OPENAI_API_KEY, OPENAI_API_KEY, or top-level \
52 openai_api_key)."
53 .to_string()
54 })?;
55 let base = self.media.resolve_base_url(self.openai_base_url.as_deref());
56 MediaClient::new(base, key, self.timeout_secs)
57 }
58}
59
60fn filename_for_mime(mime: Option<&str>) -> (&'static str, &'static str) {
61 match mime.unwrap_or("") {
62 "audio/mpeg" => ("audio.mp3", "audio/mpeg"),
63 "audio/mp4" => ("audio.m4a", "audio/mp4"),
64 "audio/ogg" | "audio/opus" => ("audio.ogg", "audio/ogg"),
65 "audio/wav" | "audio/wave" | "audio/x-wav" => ("audio.wav", "audio/wav"),
66 "audio/flac" => ("audio.flac", "audio/flac"),
67 "audio/webm" => ("audio.webm", "audio/webm"),
68 _ => ("audio.ogg", "audio/ogg"),
69 }
70}
71
72#[async_trait]
73impl Tool for TranscribeAudioTool {
74 fn name(&self) -> &str {
75 "transcribe_audio"
76 }
77
78 fn definition(&self) -> ToolDefinition {
79 ToolDefinition {
80 name: self.name().into(),
81 description: "Transcribe an audio file to text via an OpenAI-compatible \
82 /audio/transcriptions endpoint (Whisper-style). Accepts a file path \
83 under the working directory, a public URL, or a `data:` URI. \
84 Returns the transcript. Disabled by default; requires operator \
85 opt-in via `media.stt.enabled`."
86 .into(),
87 input_schema: schema_object(
88 json!({
89 "audio": {
90 "type": "string",
91 "description": "Audio source: local file path (inside working_dir), https:// URL, or data: URI."
92 },
93 "language": {
94 "type": "string",
95 "description": "Optional ISO-639-1 hint (e.g. 'en', 'zh'). Defaults to media.stt.language (auto if unset)."
96 },
97 "prompt": {
98 "type": "string",
99 "description": "Optional biasing prompt (e.g. glossary of expected names/terms)."
100 },
101 "model": {
102 "type": "string",
103 "description": "Optional model override (e.g. whisper-1, gpt-4o-transcribe)."
104 }
105 }),
106 &["audio"],
107 ),
108 }
109 }
110
111 async fn execute(&self, input: Value) -> ToolResult {
112 if !self.cfg.enabled {
113 return ToolResult::error(
114 "transcribe_audio is disabled. Set media.stt.enabled=true to enable.".into(),
115 );
116 }
117 let audio = match input.get("audio").and_then(|v| v.as_str()) {
118 Some(v) if !v.trim().is_empty() => v.trim().to_string(),
119 _ => return ToolResult::error("Missing parameter: audio".into()),
120 };
121 let language = input
122 .get("language")
123 .and_then(|v| v.as_str())
124 .map(|s| s.to_string())
125 .or_else(|| self.cfg.language.clone());
126 let prompt = input
127 .get("prompt")
128 .and_then(|v| v.as_str())
129 .map(|s| s.to_string());
130 let model = input
131 .get("model")
132 .and_then(|v| v.as_str())
133 .map(|s| s.to_string())
134 .unwrap_or_else(|| self.cfg.model.clone());
135
136 let client = match self.client() {
137 Ok(c) => c,
138 Err(e) => return ToolResult::error(e),
139 };
140
141 let (bytes, mime) = match load_bytes_from_location(
142 client.client(),
143 &audio,
144 &self.working_dir,
145 &self.allowed_read_dirs,
146 )
147 .await
148 {
149 Ok(v) => v,
150 Err(e) => return ToolResult::error(format!("audio fetch failed: {e}")),
151 };
152 let (file_name, mime_str) = filename_for_mime(mime.as_deref());
153
154 let part = match multipart::Part::bytes(bytes)
155 .file_name(file_name)
156 .mime_str(mime_str)
157 {
158 Ok(p) => p,
159 Err(e) => return ToolResult::error(format!("invalid audio mime '{mime_str}': {e}")),
160 };
161 let mut form = multipart::Form::new()
162 .text("model", model.clone())
163 .part("file", part);
164 if let Some(lang) = language.as_deref().filter(|s| !s.is_empty()) {
165 form = form.text("language", lang.to_string());
166 }
167 if let Some(p) = prompt.as_deref().filter(|s| !s.is_empty()) {
168 form = form.text("prompt", p.to_string());
169 }
170
171 let resp = match client
172 .post_multipart("audio/transcriptions", form)
173 .send()
174 .await
175 {
176 Ok(r) => r,
177 Err(e) => return ToolResult::error(format!("transcriptions request failed: {e}")),
178 };
179 if !resp.status().is_success() {
180 let status = resp.status();
181 let text = resp.text().await.unwrap_or_default();
182 return ToolResult::error(format!("transcriptions HTTP {status}: {text}"));
183 }
184 let parsed: Value = match resp.json().await {
185 Ok(v) => v,
186 Err(e) => return ToolResult::error(format!("invalid JSON from transcriptions: {e}")),
187 };
188 match parsed.get("text").and_then(|v| v.as_str()) {
189 Some(t) => ToolResult::success(t.to_string()),
190 None => ToolResult::error("transcriptions response missing 'text' field".into()),
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn mime_mapping() {
201 assert_eq!(filename_for_mime(Some("audio/mpeg")).1, "audio/mpeg");
202 assert_eq!(filename_for_mime(Some("audio/wav")).1, "audio/wav");
203 assert_eq!(filename_for_mime(None).1, "audio/ogg");
204 }
205}