Skip to main content

qualia_client_core/
model_preferences.rs

1//! User-defined LLM load priority and conditional selection.
2
3use std::path::{Path, PathBuf};
4
5use qualia_core_db::resource_catalog::ResourceCatalog;
6use serde::{Deserialize, Serialize};
7use sysinfo::System;
8
9use crate::model_lifecycle::{self, load_install_manifest, models_dir, ActiveModelRecord};
10
11const PREFS_FILE: &str = "model_preferences.json";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum ModelTask {
16    #[default]
17    Always,
18    Chat,
19    Coding,
20    Vision,
21    LowRam,
22}
23
24impl ModelTask {
25    pub fn label(self) -> &'static str {
26        match self {
27            ModelTask::Always => "Whenever installed",
28            ModelTask::Chat => "General chat",
29            ModelTask::Coding => "Coding / reasoning",
30            ModelTask::Vision => "Vision / multimodal",
31            ModelTask::LowRam => "Low RAM only",
32        }
33    }
34
35    pub fn from_str_lossy(s: &str) -> Self {
36        match s.trim().to_lowercase().as_str() {
37            "chat" => ModelTask::Chat,
38            "coding" => ModelTask::Coding,
39            "vision" => ModelTask::Vision,
40            "low_ram" | "lowram" => ModelTask::LowRam,
41            _ => ModelTask::Always,
42        }
43    }
44
45    pub fn as_str(self) -> &'static str {
46        match self {
47            ModelTask::Always => "always",
48            ModelTask::Chat => "chat",
49            ModelTask::Coding => "coding",
50            ModelTask::Vision => "vision",
51            ModelTask::LowRam => "low_ram",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ModelLoadCondition {
58    #[serde(default = "default_true")]
59    pub require_installed: bool,
60    #[serde(default)]
61    pub task: ModelTask,
62    #[serde(default)]
63    pub min_ram_gb: Option<f64>,
64    #[serde(default = "default_true")]
65    pub respect_ram_estimate: bool,
66    #[serde(default)]
67    pub require_multimodal: bool,
68}
69
70impl Default for ModelLoadCondition {
71    fn default() -> Self {
72        Self {
73            require_installed: true,
74            task: ModelTask::Always,
75            min_ram_gb: None,
76            respect_ram_estimate: true,
77            require_multimodal: false,
78        }
79    }
80}
81
82fn default_true() -> bool {
83    true
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ModelPreferenceEntry {
88    pub model_id: String,
89    pub label: String,
90    pub priority: u32,
91    #[serde(default)]
92    pub when: ModelLoadCondition,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ModelPreferences {
97    #[serde(default)]
98    pub auto_select: bool,
99    #[serde(default)]
100    pub entries: Vec<ModelPreferenceEntry>,
101}
102
103impl Default for ModelPreferences {
104    fn default() -> Self {
105        Self {
106            auto_select: true,
107            entries: vec![],
108        }
109    }
110}
111
112#[derive(Debug, Clone, Serialize)]
113pub struct ResolvedModelPreference {
114    pub model_id: String,
115    pub label: String,
116    pub reason: String,
117    pub gguf_path: String,
118    pub priority: u32,
119    pub task: String,
120}
121
122pub fn preferences_path(storage_root: &Path) -> PathBuf {
123    storage_root.join("Meta").join(PREFS_FILE)
124}
125
126pub fn load_preferences(storage_root: &Path) -> ModelPreferences {
127    let path = preferences_path(storage_root);
128    let Ok(text) = std::fs::read_to_string(&path) else {
129        return ModelPreferences::default();
130    };
131    serde_json::from_str(&text).unwrap_or_default()
132}
133
134pub fn save_preferences(storage_root: &Path, prefs: &ModelPreferences) -> Result<(), String> {
135    let path = preferences_path(storage_root);
136    if let Some(parent) = path.parent() {
137        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
138    }
139    let json = serde_json::to_string_pretty(prefs).map_err(|e| e.to_string())?;
140    std::fs::write(path, json).map_err(|e| e.to_string())
141}
142
143pub fn list_installed_model_ids(storage_root: &Path) -> Vec<String> {
144    let models = models_dir(storage_root);
145    let Ok(entries) = std::fs::read_dir(&models) else {
146        return vec![];
147    };
148    let mut ids = Vec::new();
149    for entry in entries.filter_map(Result::ok) {
150        let path = entry.path();
151        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
152            continue;
153        };
154        if !name.ends_with(".install.json") {
155            continue;
156        }
157        let model_id = name.trim_end_matches(".install.json");
158        if load_install_manifest(storage_root, model_id).is_some() {
159            ids.push(model_id.to_string());
160        }
161    }
162    ids.sort();
163    ids
164}
165
166fn system_ram_gb() -> f64 {
167    let mut sys = System::new_all();
168    sys.refresh_memory();
169    sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0
170}
171
172fn catalog_ram_mb(catalog: &ResourceCatalog, model_id: &str) -> Option<u32> {
173    catalog
174        .find_llm(model_id)
175        .and_then(|m| m.ram_estimate_mb.or(m.size_mb))
176}
177
178fn catalog_is_multimodal(catalog: &ResourceCatalog, model_id: &str) -> bool {
179    catalog
180        .find_llm(model_id)
181        .map(|m| m.is_multimodal())
182        .unwrap_or(false)
183}
184
185fn task_matches(
186    condition_task: ModelTask,
187    request_task: ModelTask,
188    catalog: &ResourceCatalog,
189    model_id: &str,
190) -> bool {
191    if condition_task == ModelTask::Always {
192        return true;
193    }
194    if condition_task != request_task && request_task != ModelTask::Always {
195        return false;
196    }
197    let Some(llm) = catalog.find_llm(model_id) else {
198        return condition_task == request_task;
199    };
200    match condition_task {
201        ModelTask::Coding => {
202            llm.tags
203                .as_ref()
204                .map(|t| t.iter().any(|tag| tag == "coding" || tag == "reasoning"))
205                .unwrap_or(false)
206                || llm
207                    .recommended_for
208                    .as_ref()
209                    .map(|r| r.iter().any(|x| x == "coding"))
210                    .unwrap_or(false)
211        }
212        ModelTask::Vision => catalog_is_multimodal(catalog, model_id),
213        ModelTask::LowRam => {
214            let ram = catalog_ram_mb(catalog, model_id).unwrap_or(u32::MAX);
215            ram <= 2000
216                || llm
217                    .recommended_for
218                    .as_ref()
219                    .map(|r| {
220                        r.iter()
221                            .any(|x| x == "low_ram" || x == "very_low_ram" || x == "edge")
222                    })
223                    .unwrap_or(false)
224        }
225        ModelTask::Chat => true,
226        ModelTask::Always => true,
227    }
228}
229
230fn condition_passes(
231    entry: &ModelPreferenceEntry,
232    request_task: ModelTask,
233    ram_gb: f64,
234    catalog: &ResourceCatalog,
235    storage_root: &Path,
236) -> Option<String> {
237    let manifest = load_install_manifest(storage_root, &entry.model_id)?;
238    if entry.when.require_installed && !Path::new(&manifest.gguf_path).is_file() {
239        return None;
240    }
241    if entry.when.require_multimodal && manifest.modality != "multimodal" {
242        return None;
243    }
244    if let Some(min) = entry.when.min_ram_gb {
245        if ram_gb < min {
246            return None;
247        }
248    }
249    if entry.when.respect_ram_estimate {
250        if let Some(need_mb) = catalog_ram_mb(catalog, &entry.model_id) {
251            let need_gb = need_mb as f64 / 1024.0;
252            if ram_gb < need_gb * 0.85 {
253                return None;
254            }
255        }
256    }
257    if !task_matches(entry.when.task, request_task, catalog, &entry.model_id) {
258        return None;
259    }
260    let reason = format!(
261        "Priority #{} — {} ({})",
262        entry.priority,
263        entry.label,
264        entry.when.task.label()
265    );
266    Some(reason)
267}
268
269pub fn resolve_preference(
270    storage_root: &Path,
271    catalog: &ResourceCatalog,
272    prefs: &ModelPreferences,
273    request_task: ModelTask,
274) -> Option<ResolvedModelPreference> {
275    if prefs.entries.is_empty() {
276        return None;
277    }
278    let ram_gb = system_ram_gb();
279    let mut entries: Vec<_> = prefs.entries.iter().collect();
280    entries.sort_by_key(|e| e.priority);
281
282    for entry in entries {
283        let Some(reason) = condition_passes(entry, request_task, ram_gb, catalog, storage_root)
284        else {
285            continue;
286        };
287        let manifest = load_install_manifest(storage_root, &entry.model_id)?;
288        return Some(ResolvedModelPreference {
289            model_id: entry.model_id.clone(),
290            label: entry.label.clone(),
291            reason,
292            gguf_path: manifest.gguf_path,
293            priority: entry.priority,
294            task: entry.when.task.as_str().to_string(),
295        });
296    }
297    None
298}
299
300pub fn apply_preference(
301    storage_root: &Path,
302    catalog: &ResourceCatalog,
303    prefs: &ModelPreferences,
304    request_task: ModelTask,
305) -> Result<ActiveModelRecord, String> {
306    let resolved = resolve_preference(storage_root, catalog, prefs, request_task)
307        .ok_or_else(|| "No installed model matches your priority rules".to_string())?;
308    model_lifecycle::activate_model_for_id(&resolved.model_id, storage_root)
309        .map_err(|e| e.to_string())
310}
311
312pub fn default_preferences_from_catalog(catalog: &ResourceCatalog) -> ModelPreferences {
313    let mut llms: Vec<_> = catalog.llms.iter().collect();
314    llms.sort_by_key(|m| m.ram_estimate_mb.or(m.size_mb).unwrap_or(u32::MAX));
315
316    let entries: Vec<ModelPreferenceEntry> = llms
317        .iter()
318        .take(6)
319        .enumerate()
320        .map(|(i, m)| {
321            let task = if m.is_multimodal() {
322                ModelTask::Vision
323            } else if m
324                .tags
325                .as_ref()
326                .map(|t| t.iter().any(|tag| tag == "coding"))
327                .unwrap_or(false)
328            {
329                ModelTask::Coding
330            } else if m
331                .recommended_for
332                .as_ref()
333                .map(|r| r.iter().any(|x| x == "edge" || x == "low_ram"))
334                .unwrap_or(false)
335            {
336                ModelTask::LowRam
337            } else {
338                ModelTask::Chat
339            };
340            ModelPreferenceEntry {
341                model_id: m.id.clone(),
342                label: m.name.clone(),
343                priority: (i as u32) + 1,
344                when: ModelLoadCondition {
345                    task,
346                    ..ModelLoadCondition::default()
347                },
348            }
349        })
350        .collect();
351
352    ModelPreferences {
353        auto_select: true,
354        entries,
355    }
356}
357
358pub fn ensure_preferences(storage_root: &Path, catalog: &ResourceCatalog) -> ModelPreferences {
359    let mut prefs = load_preferences(storage_root);
360    if prefs.entries.is_empty() && !catalog.llms.is_empty() {
361        prefs = default_preferences_from_catalog(catalog);
362        let _ = save_preferences(storage_root, &prefs);
363    }
364    prefs
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use qualia_core_db::resource_catalog::ResourceCatalog;
371
372    #[test]
373    fn default_preferences_sorted_by_ram() {
374        let catalog = qualia_core_db::resource_catalog::load_default()
375            .unwrap_or_else(|_| ResourceCatalog::empty());
376        let prefs = default_preferences_from_catalog(&catalog);
377        assert!(!prefs.entries.is_empty());
378        assert!(prefs.auto_select);
379        for (i, entry) in prefs.entries.iter().enumerate() {
380            assert_eq!(entry.priority, (i as u32) + 1);
381        }
382    }
383
384    #[test]
385    fn task_from_str_maps_coding() {
386        assert_eq!(ModelTask::from_str_lossy("coding"), ModelTask::Coding);
387        assert_eq!(ModelTask::from_str_lossy("always"), ModelTask::Always);
388    }
389}