1use crate::cancel::CancelFlag;
13use crate::config::{Config, ConfigFile, ValidatedConfig};
14use crate::error::{Result, UserError};
15use crate::runtime::{OpContext, ProgressSink};
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18use std::time::{Duration, Instant};
19
20#[derive(Clone, Default)]
25pub struct OperationOptions {
26 cancel: CancelFlag,
27 deadline: Option<Instant>,
28 progress: Option<ProgressSink>,
29 request_id: Option<u64>,
30}
31
32impl std::fmt::Debug for OperationOptions {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.debug_struct("OperationOptions")
35 .field("cancelled", &self.cancel.is_cancelled())
36 .field("has_deadline", &self.deadline.is_some())
37 .field("has_progress", &self.progress.is_some())
38 .field("request_id", &self.request_id)
39 .finish()
40 }
41}
42
43impl OperationOptions {
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn with_cancel(mut self, cancel: CancelFlag) -> Self {
49 self.cancel = cancel;
50 self
51 }
52
53 pub fn with_deadline(mut self, deadline: Instant) -> Self {
54 self.deadline = Some(deadline);
55 self
56 }
57
58 pub fn with_timeout_from_now(mut self, timeout: Duration) -> Self {
59 self.deadline = Some(Instant::now() + timeout);
60 self
61 }
62
63 pub fn with_progress(mut self, sink: ProgressSink) -> Self {
64 self.progress = Some(sink);
65 self
66 }
67
68 pub fn with_request_id(mut self, id: u64) -> Self {
69 self.request_id = Some(id);
70 self
71 }
72
73 pub fn cancel(&self) -> &CancelFlag {
74 &self.cancel
75 }
76
77 pub fn deadline(&self) -> Option<Instant> {
78 self.deadline
79 }
80
81 pub fn request_id(&self) -> Option<u64> {
82 self.request_id
83 }
84
85 pub fn into_op_context(self) -> OpContext {
87 let mut ctx = OpContext::with_cancel(self.cancel);
88 if let Some(d) = self.deadline {
89 ctx = ctx.with_absolute_deadline(d);
90 }
91 if let Some(p) = self.progress {
92 ctx = ctx.with_progress(p);
93 }
94 if let Some(id) = self.request_id {
95 ctx = ctx.with_request_id(id);
96 }
97 ctx
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct TranscriptionRequest {
104 pub model: String,
105 pub language: String,
106 pub timestamps: bool,
107 pub operation: OperationOptions,
108}
109
110impl TranscriptionRequest {
111 pub fn new(model: impl Into<String>) -> Self {
112 Self {
113 model: model.into(),
114 language: "auto".into(),
115 timestamps: false,
116 operation: OperationOptions::new(),
117 }
118 }
119
120 pub fn language(mut self, language: impl Into<String>) -> Self {
121 self.language = language.into();
122 self
123 }
124
125 pub fn timestamps(mut self, on: bool) -> Self {
126 self.timestamps = on;
127 self
128 }
129
130 pub fn operation(mut self, op: OperationOptions) -> Self {
131 self.operation = op;
132 self
133 }
134
135 pub fn validate(&self) -> Result<()> {
136 if self.model.trim().is_empty() {
137 return Err(UserError::InvalidConfig {
138 reason: "transcription request model must be non-empty".into(),
139 }
140 .into());
141 }
142 if self.model.len() > 256 {
143 return Err(UserError::InvalidConfig {
144 reason: "transcription request model id exceeds 256 characters".into(),
145 }
146 .into());
147 }
148 Ok(())
149 }
150}
151
152#[derive(Debug, Clone)]
154pub struct SynthesisRequest {
155 pub model: String,
156 pub voice: String,
157 pub language: String,
158 pub speaking_rate: f32,
159 pub operation: OperationOptions,
160}
161
162impl SynthesisRequest {
163 pub fn new(model: impl Into<String>, voice: impl Into<String>) -> Self {
164 Self {
165 model: model.into(),
166 voice: voice.into(),
167 language: "en".into(),
168 speaking_rate: 1.0,
169 operation: OperationOptions::new(),
170 }
171 }
172
173 pub fn language(mut self, language: impl Into<String>) -> Self {
174 self.language = language.into();
175 self
176 }
177
178 pub fn speaking_rate(mut self, rate: f32) -> Self {
179 self.speaking_rate = rate;
180 self
181 }
182
183 pub fn operation(mut self, op: OperationOptions) -> Self {
184 self.operation = op;
185 self
186 }
187
188 pub fn validate(&self) -> Result<()> {
189 if self.model.trim().is_empty() {
190 return Err(UserError::InvalidConfig {
191 reason: "synthesis request model must be non-empty".into(),
192 }
193 .into());
194 }
195 if self.voice.trim().is_empty() {
196 return Err(UserError::InvalidConfig {
197 reason: "synthesis request voice must be non-empty".into(),
198 }
199 .into());
200 }
201 if !self.speaking_rate.is_finite() || self.speaking_rate <= 0.0 || self.speaking_rate > 4.0
202 {
203 return Err(UserError::InvalidConfig {
204 reason: format!(
205 "speaking_rate {} is out of range (0, 4]",
206 self.speaking_rate
207 ),
208 }
209 .into());
210 }
211 Ok(())
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
221pub struct RuntimeConfig {
222 pub local_only: bool,
224 pub cache_dir: PathBuf,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229pub struct SttConfig {
230 pub provider: String,
231 pub model: String,
232 pub language: String,
233 pub timestamps: bool,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238pub struct CleanupConfig {
239 pub style: String,
240 pub provider: String,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub model: Option<String>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
247pub struct TtsConfig {
248 pub provider: String,
249 pub model: String,
250 pub voice: String,
251 pub language: String,
252 pub speaking_rate: f32,
253 pub max_chars: usize,
254 pub timeout_ms: u64,
255 #[serde(default)]
256 pub allow_unverified: bool,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
261pub struct ProviderProfiles {
262 pub openrouter_configured: bool,
263 pub openai_configured: bool,
264 pub elevenlabs_configured: bool,
265 pub xai_configured: bool,
266}
267
268#[derive(Debug, Clone)]
273pub struct AurumConfig {
274 pub runtime: RuntimeConfig,
275 pub stt: SttConfig,
276 #[cfg(feature = "tts")]
277 pub tts: TtsConfig,
278 pub cleanup: CleanupConfig,
279 pub providers: ProviderProfiles,
280 validated: ValidatedConfig,
282}
283
284impl AurumConfig {
285 pub fn load() -> Result<Self> {
287 Self::try_from_config(Config::load()?)
288 }
289
290 pub fn try_from_config(cfg: Config) -> Result<Self> {
292 let validated = ValidatedConfig::try_from_config(cfg.clone())?;
293 Ok(Self::from_validated(validated))
294 }
295
296 pub fn from_validated(validated: ValidatedConfig) -> Self {
298 let c = validated.as_config();
299 Self {
300 runtime: RuntimeConfig {
301 local_only: c.local_only,
302 cache_dir: c.cache_dir.clone(),
303 },
304 stt: SttConfig {
305 provider: c.provider.clone(),
306 model: c
307 .model
308 .clone()
309 .unwrap_or_else(|| c.resolve_model_or_default()),
310 language: c.language.clone(),
311 timestamps: c.timestamps,
312 },
313 #[cfg(feature = "tts")]
314 tts: TtsConfig {
315 provider: c.tts_provider.clone(),
316 model: c.tts_model.clone(),
317 voice: c.tts_voice.clone(),
318 language: c.tts_language.clone(),
319 speaking_rate: c.tts_speaking_rate,
320 max_chars: c.tts_max_chars,
321 timeout_ms: c.tts_timeout_ms,
322 allow_unverified: c.tts_allow_unverified,
323 },
324 cleanup: CleanupConfig {
325 style: c.cleanup_style.clone(),
326 provider: c.cleanup_provider.clone(),
327 model: c.cleanup_openrouter_model.clone(),
328 },
329 providers: ProviderProfiles {
330 openrouter_configured: c.openrouter_api_key.is_some(),
331 openai_configured: c.providers.openai.api_key.is_some(),
332 elevenlabs_configured: c.providers.elevenlabs.api_key.is_some(),
333 xai_configured: c.providers.xai.api_key.is_some(),
334 },
335 validated,
336 }
337 }
338
339 pub fn try_from_file(file: ConfigFile) -> Result<Self> {
341 let _ = file;
345 Err(UserError::InvalidConfig {
346 reason: "use AurumConfig::load or try_from_config(Config::load_from(path)?)".into(),
347 }
348 .into())
349 }
350
351 pub fn validated(&self) -> &ValidatedConfig {
352 &self.validated
353 }
354
355 pub fn into_validated(self) -> ValidatedConfig {
356 self.validated
357 }
358
359 pub fn with_local_only(self, local_only: bool) -> Result<Self> {
361 let v = self.validated.with_local_only(local_only)?;
362 Ok(Self::from_validated(v))
363 }
364}
365
366trait ResolveModelOrDefault {
368 fn resolve_model_or_default(&self) -> String;
369}
370
371impl ResolveModelOrDefault for Config {
372 fn resolve_model_or_default(&self) -> String {
373 self.model
374 .clone()
375 .unwrap_or_else(|| crate::config::DEFAULT_LOCAL_MODEL.into())
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn transcription_request_validation() {
385 assert!(TranscriptionRequest::new("base").validate().is_ok());
386 assert!(TranscriptionRequest::new("").validate().is_err());
387 }
388
389 #[test]
390 fn synthesis_request_rate_bounds() {
391 let ok = SynthesisRequest::new("kitten-nano-int8", "Luna");
392 assert!(ok.validate().is_ok());
393 let bad = SynthesisRequest::new("m", "v").speaking_rate(0.0);
394 assert!(bad.validate().is_err());
395 let nan = SynthesisRequest::new("m", "v").speaking_rate(f32::NAN);
396 assert!(nan.validate().is_err());
397 }
398
399 #[test]
400 fn operation_options_into_op_context() {
401 let cancel = CancelFlag::new();
402 cancel.cancel();
403 let op = OperationOptions::new()
404 .with_cancel(cancel)
405 .with_timeout_from_now(Duration::from_secs(5))
406 .with_request_id(42);
407 let ctx = op.into_op_context();
408 assert!(ctx.cancel.is_cancelled());
409 assert_eq!(ctx.request_id, 42);
410 assert!(ctx.deadline().is_some() || ctx.remaining().is_some());
411 }
412
413 #[test]
414 fn aurum_config_from_defaults() {
415 let cfg = Config {
416 provider: "local".into(),
417 model: Some("base".into()),
418 language: "en".into(),
419 output: "txt".into(),
420 output_file: None,
421 timestamps: false,
422 verbose: false,
423 openrouter_api_key: None,
424 openrouter_base_url: crate::config::DEFAULT_OPENROUTER_BASE_URL.into(),
425 openrouter_default_model: crate::config::DEFAULT_OPENROUTER_MODEL.into(),
426 openrouter_allow_custom_endpoint: false,
427 openrouter_stt_mode: "auto".into(),
428 openrouter_use_system_proxy: false,
429 providers: Default::default(),
430 cleanup_style: "raw".into(),
431 cleanup_provider: "rules".into(),
432 cleanup_openrouter_model: None,
433 tts_provider: "local".into(),
434 tts_model: "kitten-nano-int8".into(),
435 tts_voice: "Luna".into(),
436 tts_language: "en".into(),
437 tts_speaking_rate: 1.0,
438 tts_max_chars: 5000,
439 tts_timeout_ms: 120_000,
440 tts_pack_dir: None,
441 tts_allow_unverified: false,
442 tts_custom_models: vec![],
443 local_only: false,
444 config_path: None,
445 cache_dir: std::env::temp_dir().join("aurum-sdk-test-cache"),
446 };
447 let ac = AurumConfig::try_from_config(cfg).unwrap();
448 assert_eq!(ac.stt.provider, "local");
449 assert_eq!(ac.stt.model, "base");
450 assert!(!ac.runtime.local_only);
451 assert!(!ac.providers.openrouter_configured);
452 }
453}