1use 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
9pub const MAX_CUSTOM_MODELS: usize = 32;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct CustomTtsModelEntry {
15 pub id: String,
16 pub adapter: String,
17 #[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#[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
39pub 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 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 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#[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}