Skip to main content

microclaw_engine/config/
media.rs

1use super::*;
2
3/// Configuration for multimedia tools (image generation, vision, TTS, STT).
4///
5/// All four tools are **disabled by default** — operators opt in per-tool.
6/// Credential resolution order for each tool (first non-empty wins):
7/// 1. `media.api_key` (plaintext in config; discouraged but supported)
8/// 2. Environment variable `MICROCLAW_OPENAI_API_KEY`
9/// 3. Environment variable `OPENAI_API_KEY`
10/// 4. `config.openai_api_key` (existing top-level field; used by transcribe)
11#[derive(Clone, Debug, Serialize, Deserialize, Default)]
12pub struct MediaConfig {
13    /// Optional explicit API key. Prefer env vars (`MICROCLAW_OPENAI_API_KEY`
14    /// or `OPENAI_API_KEY`) over plaintext here.
15    #[serde(default)]
16    pub api_key: Option<String>,
17    /// Optional per-module base URL override. Falls back to `openai_base_url`
18    /// then to `https://api.openai.com/v1`.
19    #[serde(default)]
20    pub base_url: Option<String>,
21    /// Extra directories that `describe_image` / `transcribe_audio` may read
22    /// from, beyond the working_dir default. Absolute paths only. Empty by
23    /// default — matches the previous working-dir-only behavior.
24    #[serde(default)]
25    pub allowed_read_dirs: Vec<String>,
26    #[serde(default)]
27    pub image_gen: ImageGenConfig,
28    #[serde(default)]
29    pub vision: VisionConfig,
30    #[serde(default)]
31    pub tts: TtsConfig,
32    #[serde(default)]
33    pub stt: SttConfig,
34    #[serde(default)]
35    pub book: BookConfig,
36    #[serde(default)]
37    pub podcast: PodcastConfig,
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct ImageGenConfig {
42    #[serde(default)]
43    pub enabled: bool,
44    #[serde(default = "default_image_model")]
45    pub model: String,
46    #[serde(default = "default_image_size")]
47    pub default_size: String,
48}
49
50impl Default for ImageGenConfig {
51    fn default() -> Self {
52        Self {
53            enabled: false,
54            model: default_image_model(),
55            default_size: default_image_size(),
56        }
57    }
58}
59
60#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct VisionConfig {
62    #[serde(default)]
63    pub enabled: bool,
64    #[serde(default = "default_vision_model")]
65    pub model: String,
66    #[serde(default = "default_vision_max_tokens")]
67    pub max_tokens: u32,
68}
69
70impl Default for VisionConfig {
71    fn default() -> Self {
72        Self {
73            enabled: false,
74            model: default_vision_model(),
75            max_tokens: default_vision_max_tokens(),
76        }
77    }
78}
79
80#[derive(Clone, Debug, Serialize, Deserialize)]
81pub struct TtsConfig {
82    #[serde(default)]
83    pub enabled: bool,
84    #[serde(default = "default_tts_model")]
85    pub model: String,
86    #[serde(default = "default_tts_voice")]
87    pub default_voice: String,
88    #[serde(default = "default_tts_format")]
89    pub default_format: String,
90    /// Optional TTS-specific endpoint override, e.g. a dedicated speech host.
91    /// Falls back to `media.base_url`, then `openai_base_url`, then OpenAI.
92    #[serde(default)]
93    pub base_url: Option<String>,
94    /// Optional TTS-specific API key. Falls back to `media.api_key`, then
95    /// `MICROCLAW_OPENAI_API_KEY` / `OPENAI_API_KEY` / `openai_api_key`.
96    #[serde(default)]
97    pub api_key: Option<String>,
98}
99
100impl Default for TtsConfig {
101    fn default() -> Self {
102        Self {
103            enabled: false,
104            model: default_tts_model(),
105            default_voice: default_tts_voice(),
106            default_format: default_tts_format(),
107            base_url: None,
108            api_key: None,
109        }
110    }
111}
112
113#[derive(Clone, Debug, Serialize, Deserialize)]
114pub struct SttConfig {
115    #[serde(default)]
116    pub enabled: bool,
117    #[serde(default = "default_stt_model")]
118    pub model: String,
119    #[serde(default)]
120    pub language: Option<String>,
121}
122
123impl Default for SttConfig {
124    fn default() -> Self {
125        Self {
126            enabled: false,
127            model: default_stt_model(),
128            language: None,
129        }
130    }
131}
132
133/// Configuration for the native `render_pdf` ("generate a book") tool.
134///
135/// Disabled by default — operators opt in via `media.book.enabled`. PDF
136/// rendering is fully self-contained (pure-Rust `genpdf`), so no external
137/// binaries are required.
138#[derive(Clone, Debug, Serialize, Deserialize)]
139pub struct BookConfig {
140    #[serde(default)]
141    pub enabled: bool,
142    /// Page size for rendered documents. Currently "A4" or "Letter".
143    #[serde(default = "default_book_page_size")]
144    pub page_size: String,
145    /// Path to a single-face TrueType (`.ttf`) font used to render text.
146    /// When unset, a usable system font is auto-detected (e.g. Arial Unicode
147    /// on macOS, DejaVu/Liberation on Linux). For CJK output, point this at a
148    /// CJK-capable TrueType font.
149    #[serde(default)]
150    pub font_path: Option<String>,
151}
152
153impl Default for BookConfig {
154    fn default() -> Self {
155        Self {
156            enabled: false,
157            page_size: default_book_page_size(),
158            font_path: None,
159        }
160    }
161}
162
163/// Configuration for the native `generate_podcast` tool.
164///
165/// Disabled by default — operators opt in via `media.podcast.enabled`.
166/// Per-segment speech uses the OpenAI-compatible `/audio/speech` endpoint with
167/// the `media.tts` model; credentials/endpoint default to `media.tts` then the
168/// shared `media` settings, but can be overridden here. Segments are stitched
169/// into a single file by shelling out to `ffmpeg`.
170#[derive(Clone, Debug, Serialize, Deserialize)]
171pub struct PodcastConfig {
172    #[serde(default)]
173    pub enabled: bool,
174    /// Path to (or name of) the `ffmpeg` binary used to concatenate segments.
175    #[serde(default = "default_ffmpeg_path")]
176    pub ffmpeg_path: String,
177    /// Default voice when a segment does not specify one.
178    #[serde(default = "default_tts_voice")]
179    pub default_voice: String,
180    /// Silence inserted between segments, in milliseconds.
181    #[serde(default = "default_segment_pause_ms")]
182    pub segment_pause_ms: u32,
183    /// Optional podcast-specific endpoint override. Falls back to
184    /// `media.tts.base_url`, then `media.base_url`, then `openai_base_url`.
185    #[serde(default)]
186    pub base_url: Option<String>,
187    /// Optional podcast-specific API key. Falls back to `media.tts.api_key`,
188    /// then `media.api_key` / `MICROCLAW_OPENAI_API_KEY` / `OPENAI_API_KEY`.
189    #[serde(default)]
190    pub api_key: Option<String>,
191}
192
193impl Default for PodcastConfig {
194    fn default() -> Self {
195        Self {
196            enabled: false,
197            ffmpeg_path: default_ffmpeg_path(),
198            default_voice: default_tts_voice(),
199            segment_pause_ms: default_segment_pause_ms(),
200            base_url: None,
201            api_key: None,
202        }
203    }
204}
205
206pub(crate) fn default_image_model() -> String {
207    "gpt-image-1".into()
208}
209
210pub(crate) fn default_image_size() -> String {
211    "1024x1024".into()
212}
213
214pub(crate) fn default_vision_model() -> String {
215    "gpt-4o-mini".into()
216}
217
218pub(crate) fn default_vision_max_tokens() -> u32 {
219    1024
220}
221
222pub(crate) fn default_tts_model() -> String {
223    "tts-1".into()
224}
225
226pub(crate) fn default_tts_voice() -> String {
227    "alloy".into()
228}
229
230pub(crate) fn default_tts_format() -> String {
231    "mp3".into()
232}
233
234pub(crate) fn default_stt_model() -> String {
235    "whisper-1".into()
236}
237
238pub(crate) fn default_book_page_size() -> String {
239    "A4".into()
240}
241
242pub(crate) fn default_ffmpeg_path() -> String {
243    "ffmpeg".into()
244}
245
246pub(crate) fn default_segment_pause_ms() -> u32 {
247    500
248}
249
250impl MediaConfig {
251    /// Resolve the API key using the documented priority order. Returns
252    /// `None` if no source is configured. Never logs the value.
253    pub fn resolve_api_key(&self, fallback_openai_key: Option<&str>) -> Option<String> {
254        if let Some(k) = self.api_key.as_deref().filter(|s| !s.trim().is_empty()) {
255            return Some(k.to_string());
256        }
257        if let Ok(k) = std::env::var("MICROCLAW_OPENAI_API_KEY") {
258            if !k.trim().is_empty() {
259                return Some(k);
260            }
261        }
262        if let Ok(k) = std::env::var("OPENAI_API_KEY") {
263            if !k.trim().is_empty() {
264                return Some(k);
265            }
266        }
267        fallback_openai_key
268            .filter(|s| !s.trim().is_empty())
269            .map(|s| s.to_string())
270    }
271
272    /// Resolve the base URL using the documented priority order. Always
273    /// returns a non-empty value (defaults to OpenAI).
274    pub fn resolve_base_url(&self, fallback: Option<&str>) -> String {
275        if let Some(u) = self.base_url.as_deref().filter(|s| !s.trim().is_empty()) {
276            return u.trim_end_matches('/').to_string();
277        }
278        if let Some(u) = fallback.filter(|s| !s.trim().is_empty()) {
279            return u.trim_end_matches('/').to_string();
280        }
281        "https://api.openai.com/v1".to_string()
282    }
283}