Skip to main content

aurum_core/
sdk.rs

1//! Typed library configuration and operation contracts (JOE-2221).
2//!
3//! This module is the intentional host-facing shape for 0.0.22+:
4//! * [`AurumConfig`] — direction-oriented validated runtime config
5//! * [`OperationOptions`] — shared cancel / deadline / progress / request id
6//! * [`TranscriptionRequest`] / [`SynthesisRequest`] — direction-specific requests
7//!
8//! [`crate::config::ConfigFile`] remains the serializable file schema.
9//! [`crate::config::Config`] remains the CLI-oriented flat runtime bag; convert
10//! once into [`AurumConfig`] via [`AurumConfig::try_from_config`].
11
12use 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/// Shared operation control for STT, TTS, and cleanup.
21///
22/// One absolute deadline propagates through nested stages. Cancel tokens and
23/// progress sinks are never recreated silently by the engine when supplied here.
24#[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    /// Convert into the engine [`OpContext`] (preserves cancel and deadline).
86    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/// STT request with shared operation control.
102#[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    /// Split into legacy provider options and a full [`OpContext`] (P1c).
152    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/// TTS request with shared operation control.
166#[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    /// Split into legacy provider options and a full [`OpContext`] (P1c).
228    #[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// ---------------------------------------------------------------------------
245// AurumConfig — direction-oriented library config
246// ---------------------------------------------------------------------------
247
248/// Runtime policy applied consistently to both STT and TTS directions.
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250pub struct RuntimeConfig {
251    /// When true, reject remote providers before encoding or request construction.
252    pub local_only: bool,
253    pub cache_dir: PathBuf,
254}
255
256/// STT direction settings (no CLI presentation fields).
257#[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/// Cleanup direction settings.
266#[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/// TTS direction settings.
275#[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/// Redacted provider profile presence (secrets stay in validated Config only).
289#[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/// Library-facing typed configuration (JOE-2221).
298///
299/// Distinct from [`ConfigFile`] (on-disk schema) and CLI presentation fields.
300/// Construct via [`AurumConfig::try_from_config`] or [`AurumConfig::load`].
301#[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    /// Opaque validated bag used to construct the engine (includes secrets).
310    validated: ValidatedConfig,
311}
312
313impl AurumConfig {
314    /// Load file/env defaults, validate, and project into typed sections.
315    pub fn load() -> Result<Self> {
316        Self::try_from_config(Config::load()?)
317    }
318
319    /// Convert a flat runtime [`Config`] once into the typed library model.
320    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    /// Project an already-validated config.
326    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    /// Build from a serializable file schema (plus env merge via Config).
369    pub fn try_from_file(file: ConfigFile) -> Result<Self> {
370        // Config::from_parts is private; round-trip via TOML is avoided — use load paths.
371        // Hosts should prefer ValidatedConfig / Config::load. This path validates the
372        // file shape by converting through the standard loader helpers.
373        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    /// Apply local-only policy consistently (re-validates).
389    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
395// Helper on Config for model default without falling through CLI.
396trait 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}