Skip to main content

aurum_core/tts/
custom.rs

1//! Validated custom TTS catalogue registration (JOE-1620).
2
3use super::adapter::{lookup_adapter, ModelPackManifest, TrustMode};
4use super::pack::{custom_pack_cache_dir, load_pack_dir};
5use crate::error::{Result, UserError};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Maximum custom catalogue entries.
10pub const MAX_CUSTOM_MODELS: usize = 32;
11
12/// Config schema: `[[tts.custom_models]]`.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct CustomTtsModelEntry {
15    pub id: String,
16    pub adapter: String,
17    /// Local pack directory (preferred for v0.0.3).
18    #[serde(default)]
19    pub pack_dir: Option<String>,
20    pub trust: String,
21    #[serde(default)]
22    pub license: Option<String>,
23    #[serde(default)]
24    pub notes: Option<String>,
25}
26
27/// Validated in-memory custom model.
28#[derive(Debug, Clone)]
29pub struct CustomTtsModel {
30    pub id: String,
31    pub adapter: String,
32    pub pack_dir: PathBuf,
33    pub trust: TrustMode,
34    pub license: String,
35    pub notes: String,
36    pub manifest: ModelPackManifest,
37}
38
39/// Parse and validate custom model entries from config.
40pub fn validate_custom_models(entries: &[CustomTtsModelEntry]) -> Result<Vec<CustomTtsModel>> {
41    if entries.len() > MAX_CUSTOM_MODELS {
42        return Err(UserError::InvalidConfig {
43            reason: format!(
44                "too many [[tts.custom_models]] entries ({} > {MAX_CUSTOM_MODELS})",
45                entries.len()
46            ),
47        }
48        .into());
49    }
50    let mut out = Vec::with_capacity(entries.len());
51    let mut ids = std::collections::HashSet::new();
52    for e in entries {
53        let id = e.id.trim().to_string();
54        if id.is_empty() {
55            return Err(UserError::InvalidConfig {
56                reason: "custom TTS model id must be non-empty".into(),
57            }
58            .into());
59        }
60        // Reserved built-in namespace.
61        if id == super::catalogue::DEFAULT_TTS_MODEL
62            || id == super::catalogue::PLACEHOLDER_ADAPTER_MODEL
63            || super::catalogue::lookup_model(&id).is_ok()
64                && super::catalogue::lookup_model(&id)
65                    .map(|m| m.shipped)
66                    .unwrap_or(false)
67        {
68            // Only reject if it collides with a shipped built-in id.
69            if let Ok(m) = super::catalogue::lookup_model(&id) {
70                if m.shipped {
71                    return Err(UserError::InvalidConfig {
72                        reason: format!(
73                            "custom model id '{id}' collides with built-in catalogue entry"
74                        ),
75                    }
76                    .into());
77                }
78            }
79        }
80        if !ids.insert(id.clone()) {
81            return Err(UserError::InvalidConfig {
82                reason: format!("duplicate custom TTS model id '{id}'"),
83            }
84            .into());
85        }
86        let _adapter = lookup_adapter(&e.adapter)?;
87        let trust = TrustMode::parse(&e.trust)?;
88        if matches!(trust, TrustMode::Builtin) {
89            return Err(UserError::InvalidConfig {
90                reason: "custom models cannot use trust=builtin".into(),
91            }
92            .into());
93        }
94        let pack_dir = e.pack_dir.as_ref().ok_or_else(|| UserError::InvalidConfig {
95            reason: format!(
96                "custom model '{id}' requires pack_dir (remote custom packs are not enabled in v0.0.3)"
97            ),
98        })?;
99        let path = PathBuf::from(pack_dir);
100        let allow_unverified = matches!(trust, TrustMode::LocalUnverified);
101        let (_root, manifest) = load_pack_dir(&path, allow_unverified)?;
102        if manifest.adapter_id != e.adapter {
103            return Err(UserError::InvalidConfig {
104                reason: format!(
105                    "custom model '{id}' adapter mismatch: config={} pack={}",
106                    e.adapter, manifest.adapter_id
107                ),
108            }
109            .into());
110        }
111        out.push(CustomTtsModel {
112            id,
113            adapter: e.adapter.clone(),
114            pack_dir: path,
115            trust,
116            license: e
117                .license
118                .clone()
119                .unwrap_or_else(|| manifest.license.clone()),
120            notes: e.notes.clone().unwrap_or_default(),
121            manifest,
122        });
123    }
124    Ok(out)
125}
126
127/// Status row for listings.
128#[derive(Debug, Clone, Serialize)]
129pub struct CustomModelStatus {
130    pub id: String,
131    pub adapter: String,
132    pub trust: String,
133    pub license: String,
134    pub pack_dir: String,
135    pub provenance: String,
136}
137
138pub fn list_custom_status(models: &[CustomTtsModel]) -> Vec<CustomModelStatus> {
139    models
140        .iter()
141        .map(|m| CustomModelStatus {
142            id: m.id.clone(),
143            adapter: m.adapter.clone(),
144            trust: m.trust.as_str().into(),
145            license: m.license.clone(),
146            pack_dir: m.pack_dir.display().to_string(),
147            provenance: "custom".into(),
148        })
149        .collect()
150}
151
152pub fn format_custom_list(models: &[CustomTtsModel]) -> String {
153    if models.is_empty() {
154        return "No custom TTS models configured.\n".into();
155    }
156    let mut out = String::from("Custom TTS models:\n");
157    for m in models {
158        out.push_str(&format!(
159            "  {}  adapter={}  trust={}  license={}\n    pack: {}\n",
160            m.id,
161            m.adapter,
162            m.trust.as_str(),
163            m.license,
164            m.pack_dir.display()
165        ));
166    }
167    out.push_str("\nBuilt-in models are never shadowed. Custom models are never default.\n");
168    let _ = custom_pack_cache_dir(Path::new("."));
169    out
170}
171
172#[cfg(test)]
173mod tests {
174    use super::super::pack::write_fake_sine_pack;
175    use super::*;
176    use tempfile::tempdir;
177
178    #[test]
179    fn accepts_valid_custom() {
180        let dir = tempdir().unwrap();
181        let pack = dir.path().join("p");
182        write_fake_sine_pack(&pack, "my-fake").unwrap();
183        let entries = vec![CustomTtsModelEntry {
184            id: "my-fake".into(),
185            adapter: "fake-sine-v1".into(),
186            pack_dir: Some(pack.display().to_string()),
187            trust: "verified".into(),
188            license: Some("CC0".into()),
189            notes: None,
190        }];
191        let v = validate_custom_models(&entries).unwrap();
192        assert_eq!(v.len(), 1);
193    }
194
195    #[test]
196    fn rejects_builtin_collision() {
197        let dir = tempdir().unwrap();
198        let pack = dir.path().join("p");
199        write_fake_sine_pack(&pack, "x").unwrap();
200        let entries = vec![CustomTtsModelEntry {
201            id: super::super::catalogue::DEFAULT_TTS_MODEL.into(),
202            adapter: "fake-sine-v1".into(),
203            pack_dir: Some(pack.display().to_string()),
204            trust: "verified".into(),
205            license: None,
206            notes: None,
207        }];
208        assert!(validate_custom_models(&entries).is_err());
209    }
210}