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
152/// TTS request with shared operation control.
153#[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// ---------------------------------------------------------------------------
216// AurumConfig — direction-oriented library config
217// ---------------------------------------------------------------------------
218
219/// Runtime policy applied consistently to both STT and TTS directions.
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
221pub struct RuntimeConfig {
222    /// When true, reject remote providers before encoding or request construction.
223    pub local_only: bool,
224    pub cache_dir: PathBuf,
225}
226
227/// STT direction settings (no CLI presentation fields).
228#[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/// Cleanup direction settings.
237#[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/// TTS direction settings.
246#[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/// Redacted provider profile presence (secrets stay in validated Config only).
260#[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/// Library-facing typed configuration (JOE-2221).
269///
270/// Distinct from [`ConfigFile`] (on-disk schema) and CLI presentation fields.
271/// Construct via [`AurumConfig::try_from_config`] or [`AurumConfig::load`].
272#[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    /// Opaque validated bag used to construct the engine (includes secrets).
281    validated: ValidatedConfig,
282}
283
284impl AurumConfig {
285    /// Load file/env defaults, validate, and project into typed sections.
286    pub fn load() -> Result<Self> {
287        Self::try_from_config(Config::load()?)
288    }
289
290    /// Convert a flat runtime [`Config`] once into the typed library model.
291    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    /// Project an already-validated config.
297    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    /// Build from a serializable file schema (plus env merge via Config).
340    pub fn try_from_file(file: ConfigFile) -> Result<Self> {
341        // Config::from_parts is private; round-trip via TOML is avoided — use load paths.
342        // Hosts should prefer ValidatedConfig / Config::load. This path validates the
343        // file shape by converting through the standard loader helpers.
344        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    /// Apply local-only policy consistently (re-validates).
360    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
366// Helper on Config for model default without falling through CLI.
367trait 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}