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 pub fn into_options_and_context(self) -> (crate::providers::TranscriptionOptions, OpContext) {
153 let ctx = self.operation.into_op_context();
154 let options = crate::providers::TranscriptionOptions {
155 model: self.model,
156 language: self.language,
157 timestamps: self.timestamps,
158 cancel: Some(ctx.cancel.clone()),
159 op: Some(ctx.clone()),
160 };
161 (options, ctx)
162 }
163}
164
165#[derive(Debug, Clone)]
167pub struct SynthesisRequest {
168 pub model: String,
169 pub voice: String,
170 pub language: String,
171 pub speaking_rate: f32,
172 pub operation: OperationOptions,
173}
174
175impl SynthesisRequest {
176 pub fn new(model: impl Into<String>, voice: impl Into<String>) -> Self {
177 Self {
178 model: model.into(),
179 voice: voice.into(),
180 language: "en".into(),
181 speaking_rate: 1.0,
182 operation: OperationOptions::new(),
183 }
184 }
185
186 pub fn language(mut self, language: impl Into<String>) -> Self {
187 self.language = language.into();
188 self
189 }
190
191 pub fn speaking_rate(mut self, rate: f32) -> Self {
192 self.speaking_rate = rate;
193 self
194 }
195
196 pub fn operation(mut self, op: OperationOptions) -> Self {
197 self.operation = op;
198 self
199 }
200
201 pub fn validate(&self) -> Result<()> {
202 if self.model.trim().is_empty() {
203 return Err(UserError::InvalidConfig {
204 reason: "synthesis request model must be non-empty".into(),
205 }
206 .into());
207 }
208 if self.voice.trim().is_empty() {
209 return Err(UserError::InvalidConfig {
210 reason: "synthesis request voice must be non-empty".into(),
211 }
212 .into());
213 }
214 if !self.speaking_rate.is_finite() || self.speaking_rate <= 0.0 || self.speaking_rate > 4.0
215 {
216 return Err(UserError::InvalidConfig {
217 reason: format!(
218 "speaking_rate {} is out of range (0, 4]",
219 self.speaking_rate
220 ),
221 }
222 .into());
223 }
224 Ok(())
225 }
226
227 #[cfg(feature = "tts")]
229 pub fn into_options_and_context(self) -> (crate::tts::provider::SynthesisOptions, OpContext) {
230 let ctx = self.operation.into_op_context();
231 let options = crate::tts::provider::SynthesisOptions {
232 model: self.model,
233 voice: self.voice,
234 language: self.language,
235 speaking_rate: self.speaking_rate,
236 cancel: Some(ctx.cancel.clone()),
237 op: Some(ctx.clone()),
238 ..Default::default()
239 };
240 (options, ctx)
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250pub struct RuntimeConfig {
251 pub local_only: bool,
253 pub cache_dir: PathBuf,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
258pub struct SttConfig {
259 pub provider: String,
260 pub model: String,
261 pub language: String,
262 pub timestamps: bool,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267pub struct CleanupConfig {
268 pub style: String,
269 pub provider: String,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub model: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
276pub struct TtsConfig {
277 pub provider: String,
278 pub model: String,
279 pub voice: String,
280 pub language: String,
281 pub speaking_rate: f32,
282 pub max_chars: usize,
283 pub timeout_ms: u64,
284 #[serde(default)]
285 pub allow_unverified: bool,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
290pub struct ProviderProfiles {
291 pub openrouter_configured: bool,
292 pub openai_configured: bool,
293 pub elevenlabs_configured: bool,
294 pub xai_configured: bool,
295}
296
297#[derive(Debug, Clone)]
302pub struct AurumConfig {
303 pub runtime: RuntimeConfig,
304 pub stt: SttConfig,
305 #[cfg(feature = "tts")]
306 pub tts: TtsConfig,
307 pub cleanup: CleanupConfig,
308 pub providers: ProviderProfiles,
309 validated: ValidatedConfig,
311}
312
313impl AurumConfig {
314 pub fn load() -> Result<Self> {
316 Self::try_from_config(Config::load()?)
317 }
318
319 pub fn try_from_config(cfg: Config) -> Result<Self> {
321 let validated = ValidatedConfig::try_from_config(cfg.clone())?;
322 Ok(Self::from_validated(validated))
323 }
324
325 pub fn from_validated(validated: ValidatedConfig) -> Self {
327 let c = validated.as_config();
328 Self {
329 runtime: RuntimeConfig {
330 local_only: c.local_only,
331 cache_dir: c.cache_dir.clone(),
332 },
333 stt: SttConfig {
334 provider: c.provider.clone(),
335 model: c
336 .model
337 .clone()
338 .unwrap_or_else(|| c.resolve_model_or_default()),
339 language: c.language.clone(),
340 timestamps: c.timestamps,
341 },
342 #[cfg(feature = "tts")]
343 tts: TtsConfig {
344 provider: c.tts_provider.clone(),
345 model: c.tts_model.clone(),
346 voice: c.tts_voice.clone(),
347 language: c.tts_language.clone(),
348 speaking_rate: c.tts_speaking_rate,
349 max_chars: c.tts_max_chars,
350 timeout_ms: c.tts_timeout_ms,
351 allow_unverified: c.tts_allow_unverified,
352 },
353 cleanup: CleanupConfig {
354 style: c.cleanup_style.clone(),
355 provider: c.cleanup_provider.clone(),
356 model: c.cleanup_openrouter_model.clone(),
357 },
358 providers: ProviderProfiles {
359 openrouter_configured: c.openrouter_api_key.is_some(),
360 openai_configured: c.providers.openai.api_key.is_some(),
361 elevenlabs_configured: c.providers.elevenlabs.api_key.is_some(),
362 xai_configured: c.providers.xai.api_key.is_some(),
363 },
364 validated,
365 }
366 }
367
368 pub fn try_from_file(file: ConfigFile) -> Result<Self> {
370 let _ = file;
374 Err(UserError::InvalidConfig {
375 reason: "use AurumConfig::load or try_from_config(Config::load_from(path)?)".into(),
376 }
377 .into())
378 }
379
380 pub fn validated(&self) -> &ValidatedConfig {
381 &self.validated
382 }
383
384 pub fn into_validated(self) -> ValidatedConfig {
385 self.validated
386 }
387
388 pub fn with_local_only(self, local_only: bool) -> Result<Self> {
390 let v = self.validated.with_local_only(local_only)?;
391 Ok(Self::from_validated(v))
392 }
393}
394
395trait ResolveModelOrDefault {
397 fn resolve_model_or_default(&self) -> String;
398}
399
400impl ResolveModelOrDefault for Config {
401 fn resolve_model_or_default(&self) -> String {
402 self.model
403 .clone()
404 .unwrap_or_else(|| crate::config::DEFAULT_LOCAL_MODEL.into())
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn transcription_request_validation() {
414 assert!(TranscriptionRequest::new("base").validate().is_ok());
415 assert!(TranscriptionRequest::new("").validate().is_err());
416 }
417
418 #[test]
419 fn synthesis_request_rate_bounds() {
420 let ok = SynthesisRequest::new("kitten-nano-int8", "Luna");
421 assert!(ok.validate().is_ok());
422 let bad = SynthesisRequest::new("m", "v").speaking_rate(0.0);
423 assert!(bad.validate().is_err());
424 let nan = SynthesisRequest::new("m", "v").speaking_rate(f32::NAN);
425 assert!(nan.validate().is_err());
426 }
427
428 #[test]
429 fn transcription_request_into_options_preserves_cancel() {
430 let cancel = CancelFlag::new();
431 cancel.cancel();
432 let req = TranscriptionRequest::new("base")
433 .language("en")
434 .timestamps(true)
435 .operation(OperationOptions::new().with_cancel(cancel));
436 let (opts, ctx) = req.into_options_and_context();
437 assert_eq!(opts.model, "base");
438 assert_eq!(opts.language, "en");
439 assert!(opts.timestamps);
440 assert!(opts.cancel.as_ref().unwrap().is_cancelled());
441 assert!(ctx.cancel.is_cancelled());
442 }
443
444 #[test]
445 fn operation_options_into_op_context() {
446 let cancel = CancelFlag::new();
447 cancel.cancel();
448 let op = OperationOptions::new()
449 .with_cancel(cancel)
450 .with_timeout_from_now(Duration::from_secs(5))
451 .with_request_id(42);
452 let ctx = op.into_op_context();
453 assert!(ctx.cancel.is_cancelled());
454 assert_eq!(ctx.request_id, 42);
455 assert!(ctx.deadline().is_some() || ctx.remaining().is_some());
456 }
457
458 #[test]
459 fn aurum_config_from_defaults() {
460 let cfg = Config {
461 provider: "local".into(),
462 model: Some("base".into()),
463 language: "en".into(),
464 output: "txt".into(),
465 output_file: None,
466 timestamps: false,
467 verbose: false,
468 openrouter_api_key: None,
469 openrouter_base_url: crate::config::DEFAULT_OPENROUTER_BASE_URL.into(),
470 openrouter_default_model: crate::config::DEFAULT_OPENROUTER_MODEL.into(),
471 openrouter_allow_custom_endpoint: false,
472 openrouter_stt_mode: "auto".into(),
473 openrouter_use_system_proxy: false,
474 providers: Default::default(),
475 cleanup_style: "raw".into(),
476 cleanup_provider: "rules".into(),
477 cleanup_openrouter_model: None,
478 tts_provider: "local".into(),
479 tts_model: "kitten-nano-int8".into(),
480 tts_voice: "Luna".into(),
481 tts_language: "en".into(),
482 tts_speaking_rate: 1.0,
483 tts_max_chars: 5000,
484 tts_timeout_ms: 120_000,
485 tts_pack_dir: None,
486 tts_allow_unverified: false,
487 tts_custom_models: vec![],
488 local_only: false,
489 config_path: None,
490 cache_dir: std::env::temp_dir().join("aurum-sdk-test-cache"),
491 };
492 let ac = AurumConfig::try_from_config(cfg).unwrap();
493 assert_eq!(ac.stt.provider, "local");
494 assert_eq!(ac.stt.model, "base");
495 assert!(!ac.runtime.local_only);
496 assert!(!ac.providers.openrouter_configured);
497 }
498}