Skip to main content

atman_runtime/
model_registry.rs

1use std::collections::HashMap;
2use std::sync::RwLock;
3
4#[derive(Debug, Clone)]
5pub struct ModelInfo {
6    pub name: String,
7    pub context_budget: u64,
8    pub compact_threshold_ratio: f64,
9    pub thinking_enabled: bool,
10    pub max_output_tokens: Option<u32>,
11}
12
13#[derive(Debug, Clone, Default)]
14pub struct ModelEntry {
15    pub model: String,
16    pub provider: Option<String>,
17    pub api_key: Option<String>,
18    pub base_url: Option<String>,
19    pub context_budget: Option<u64>,
20    pub compact_threshold_ratio: Option<f64>,
21    pub thinking: Option<bool>,
22    pub max_tokens: Option<u32>,
23    /// True for models registered via discover (OAuth providers),
24    /// false for config.toml-defined models.
25    #[allow(dead_code)]
26    pub discovered: bool,
27}
28
29#[derive(Debug, Clone, Default)]
30pub struct AliasEntry {
31    pub model: String,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct ModelConfig {
36    pub models: HashMap<String, ModelEntry>,
37    pub aliases: HashMap<String, AliasEntry>,
38}
39
40static MODEL_CONFIG: RwLock<Option<ModelConfig>> = RwLock::new(None);
41
42/// Model IDs discovered from OAuth providers (e.g. Codex).
43static DISCOVERED_MODELS: RwLock<Vec<String>> = RwLock::new(Vec::new());
44
45pub fn set_discovered_models(models: Vec<String>) {
46    *DISCOVERED_MODELS.write().unwrap() = models;
47}
48
49pub fn discovered_models() -> Vec<String> {
50    DISCOVERED_MODELS.read().unwrap().clone()
51}
52
53/// Set the base model configuration (from config.toml).
54/// Preserves previously registered discovered models.
55pub fn set_model_config(mut cfg: ModelConfig) {
56    let mut guard = MODEL_CONFIG.write().unwrap();
57    if let Some(old) = guard.take() {
58        // Preserve discovered models from old config.
59        for (name, entry) in old.models {
60            if entry.discovered {
61                cfg.models.entry(name).or_insert(entry);
62            }
63        }
64        // Preserve aliases from old config that aren't in the new one.
65        // config.toml aliases are authoritative; only preserve
66        // non-config-sourced aliases (currently none exist).
67    }
68    *guard = Some(cfg);
69}
70
71/// Register additional model entries without clobbering existing ones.
72pub fn register_model_entries(entries: Vec<(String, ModelEntry)>) {
73    let mut guard = MODEL_CONFIG.write().unwrap();
74    let mut cfg = guard.take().unwrap_or_default();
75    for (name, entry) in entries {
76        cfg.models.entry(name).or_insert(entry);
77    }
78    *guard = Some(cfg);
79}
80
81/// Build ModelEntry values from discovered models and register them.
82/// Models are keyed as `<provider_name>:<slug>` (e.g. `Codex:codex/gpt-5.5`).
83pub fn register_discovered(
84    _provider_id: &str,
85    provider_name: &str,
86    models: &[crate::provider::DiscoveredModel],
87) {
88    let entries: Vec<(String, ModelEntry)> = models
89        .iter()
90        .map(|m| {
91            let name = format!("{provider_name}:{}", m.slug);
92            let entry = ModelEntry {
93                model: name.clone(),
94                provider: Some(provider_name.to_string()),
95                context_budget: m.context_budget,
96                thinking: Some(m.thinking),
97                discovered: true,
98                ..Default::default()
99            };
100            (name, entry)
101        })
102        .collect();
103    let slugs: Vec<String> = entries.iter().map(|(name, _)| name.clone()).collect();
104    register_model_entries(entries);
105    set_discovered_models(slugs);
106}
107
108#[derive(Debug, Clone)]
109pub struct ModelRow {
110    pub slug: String,
111    pub provider_name: String,
112    pub context_budget: u64,
113    pub max_output_tokens: Option<u32>,
114    pub thinking: bool,
115}
116
117#[derive(Debug, Clone)]
118pub struct ProviderGroup {
119    pub provider_name: String,
120    pub models: Vec<ModelRow>,
121}
122
123/// Return all models grouped by provider, with complete metadata.
124/// Single canonical source for UI — no manual union of all_model_entries +
125/// discovered_models + aliases.
126pub fn all_provider_groups() -> Vec<ProviderGroup> {
127    let entries = all_model_entries();
128    let mut groups: std::collections::BTreeMap<String, Vec<ModelRow>> =
129        std::collections::BTreeMap::new();
130    for (name, entry) in entries {
131        let info = model_info(&name);
132        let provider = entry.provider.unwrap_or_else(|| "unknown".to_string());
133        let row = ModelRow {
134            slug: name,
135            provider_name: provider.clone(),
136            context_budget: info.context_budget,
137            max_output_tokens: info.max_output_tokens,
138            thinking: info.thinking_enabled(),
139        };
140        groups.entry(provider).or_default().push(row);
141    }
142    groups
143        .into_iter()
144        .map(|(provider_name, models)| ProviderGroup {
145            provider_name,
146            models,
147        })
148        .collect()
149}
150
151pub fn resolve_alias(name: &str) -> String {
152    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
153        if let Some(entry) = cfg.aliases.get(name) {
154            return entry.model.clone();
155        }
156    }
157    name.to_string()
158}
159
160pub fn model_entry(name: &str) -> Option<ModelEntry> {
161    let resolved = resolve_alias(name);
162    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
163        return cfg.models.get(&resolved).cloned();
164    }
165    None
166}
167
168pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
169    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
170        return cfg
171            .models
172            .iter()
173            .map(|(k, v)| (k.clone(), v.clone()))
174            .collect();
175    }
176    Vec::new()
177}
178
179pub fn all_aliases() -> Vec<(String, String)> {
180    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
181        return cfg
182            .aliases
183            .iter()
184            .map(|(k, v)| (k.clone(), v.model.clone()))
185            .collect();
186    }
187    Vec::new()
188}
189
190pub fn model_info(name: &str) -> ModelInfo {
191    let resolved = resolve_alias(name);
192    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
193        if let Some(entry) = cfg.models.get(&resolved) {
194            let (budget, ratio) = builtin_budget(&resolved);
195            return ModelInfo {
196                name: resolved.clone(),
197                context_budget: entry.context_budget.unwrap_or(budget),
198                compact_threshold_ratio: entry.compact_threshold_ratio.unwrap_or(ratio),
199                thinking_enabled: entry.thinking.unwrap_or(false),
200                max_output_tokens: entry.max_tokens,
201            };
202        }
203    }
204    let (budget, ratio) = builtin_budget(&resolved);
205    ModelInfo {
206        name: resolved,
207        context_budget: budget,
208        compact_threshold_ratio: ratio,
209        thinking_enabled: false,
210        max_output_tokens: None,
211    }
212}
213
214fn builtin_budget(name: &str) -> (u64, f64) {
215    let bare = match name.split_once('/') {
216        Some((_, rest)) => rest,
217        None => name.split_once(':').map(|(_, r)| r).unwrap_or(name),
218    };
219    match bare {
220        n if n.starts_with("codex-") => (272_000, 0.8),
221        n if n.starts_with("claude-opus") => (200_000, 0.8),
222        n if n.starts_with("claude-sonnet") => (200_000, 0.8),
223        n if n.starts_with("claude-haiku") => (200_000, 0.8),
224        n if n.starts_with("claude-") => (200_000, 0.8),
225        n if n.starts_with("gpt-5") => (128_000, 0.8),
226        n if n.starts_with("gpt-4o-mini") => (128_000, 0.8),
227        n if n.starts_with("gpt-4o") => (128_000, 0.8),
228        n if n.starts_with("gpt-4-turbo") => (128_000, 0.8),
229        n if n.starts_with("gpt-4") => (32_000, 0.8),
230        n if n.starts_with("gpt-3.5") => (16_000, 0.8),
231        n if n.starts_with("o1") => (128_000, 0.8),
232        n if n.starts_with("o3") => (128_000, 0.8),
233        n if n.starts_with("glm-5") => (128_000, 0.8),
234        n if n.starts_with("glm-4.5") => (128_000, 0.8),
235        n if n.starts_with("glm-4") => (128_000, 0.8),
236        n if n.starts_with("glm-") => (128_000, 0.8),
237        n if n.starts_with("deepseek-v4") => (1_000_000, 0.8),
238        n if n.starts_with("deepseek-v3") => (128_000, 0.8),
239        n if n.starts_with("deepseek-r1") => (128_000, 0.8),
240        n if n.starts_with("deepseek") => (64_000, 0.8),
241        n if n.starts_with("qwen3") => (128_000, 0.8),
242        n if n.starts_with("qwen-max") => (128_000, 0.8),
243        n if n.starts_with("qwen") => (32_000, 0.8),
244        n if n.starts_with("llama") => (8_000, 0.8),
245        _ => (32_000, 0.8),
246    }
247}
248
249impl ModelInfo {
250    pub fn compact_threshold_tokens(&self) -> u64 {
251        let reserved = self.max_output_tokens.unwrap_or(0) as u64;
252        let available = self.context_budget.saturating_sub(reserved);
253        (available as f64 * self.compact_threshold_ratio) as u64
254    }
255
256    pub fn compaction_trigger_threshold(&self) -> u64 {
257        let budget = self.context_budget;
258
259        let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
260        let output_cap = (budget as f64 * 0.20) as u64;
261        let output_reserve = configured_output.min(output_cap).max(8_000);
262
263        let safety = (budget as f64 * 0.05) as u64;
264        let safety_margin = safety.max(4_000);
265
266        let trigger = budget
267            .saturating_sub(output_reserve)
268            .saturating_sub(safety_margin);
269        let floor = (budget as f64 * 0.50) as u64;
270        let ceiling = (budget as f64 * 0.95) as u64;
271        trigger.clamp(floor, ceiling)
272    }
273
274    pub fn compaction_target_after(&self) -> u64 {
275        let trigger = self.compaction_trigger_threshold();
276        let budget_cap = (self.context_budget as f64 * 0.60) as u64;
277        let trigger_cap = (trigger as f64 * 0.75) as u64;
278        budget_cap.min(trigger_cap)
279    }
280
281    pub fn thinking_enabled(&self) -> bool {
282        self.thinking_enabled
283    }
284}
285
286// ── Alias CRUD (writes config.toml) ──
287
288fn read_config_toml() -> Option<String> {
289    let path = crate::storage::config_dir().ok()?.join("config.toml");
290    std::fs::read_to_string(&path).ok()
291}
292
293fn write_config_toml(text: &str) -> anyhow::Result<()> {
294    let dir = crate::storage::config_dir().map_err(|e| anyhow::anyhow!("config dir: {e}"))?;
295    std::fs::create_dir_all(&dir)?;
296    let path = dir.join("config.toml");
297    std::fs::write(&path, text)?;
298    Ok(())
299}
300
301fn reload_from_text(text: &str) {
302    let Ok(raw) = toml::from_str::<toml::Value>(text) else {
303        return;
304    };
305    let mut guard = MODEL_CONFIG.write().unwrap();
306    let mut cfg = guard.take().unwrap_or_default();
307
308    // Update aliases from config.toml — preserve all other state
309    // (discovered models, config-defined models).
310    cfg.aliases.clear();
311    if let Some(aliases) = raw.get("alias").and_then(|a| a.as_table()) {
312        for (name, entry) in aliases {
313            if let Some(model) = entry.get("model").and_then(|m| m.as_str()) {
314                cfg.aliases.insert(
315                    name.clone(),
316                    AliasEntry {
317                        model: model.to_string(),
318                    },
319                );
320            }
321        }
322    }
323
324    // Update config-defined models — only update existing keys or add
325    // new ones; never remove entries that aren't in config.toml.
326    if let Some(models) = raw.get("models").and_then(|m| m.as_table()) {
327        for (name, entry) in models {
328            let provider = entry
329                .get("provider")
330                .and_then(|v| v.as_str())
331                .map(String::from);
332            let api_key = entry
333                .get("api_key")
334                .and_then(|v| v.as_str())
335                .map(String::from);
336            let base_url = entry
337                .get("base_url")
338                .and_then(|v| v.as_str())
339                .map(String::from);
340            let context_budget = entry
341                .get("context_budget")
342                .and_then(|v| v.as_integer())
343                .map(|n| n as u64);
344            let thinking = entry.get("thinking").and_then(|v| v.as_bool());
345            let max_tokens = entry
346                .get("max_tokens")
347                .and_then(|v| v.as_integer())
348                .map(|n| n as u32);
349            let model = entry
350                .get("model")
351                .and_then(|v| v.as_str())
352                .map(String::from);
353            cfg.models.insert(
354                name.clone(),
355                ModelEntry {
356                    model: model.unwrap_or_default(),
357                    provider,
358                    api_key,
359                    base_url,
360                    context_budget,
361                    compact_threshold_ratio: None,
362                    thinking,
363                    max_tokens,
364                    discovered: false,
365                },
366            );
367        }
368    }
369
370    *guard = Some(cfg);
371}
372
373pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
374    let text = read_config_toml().unwrap_or_default();
375    let mut raw: toml::Value = if text.trim().is_empty() {
376        toml::Value::Table(toml::value::Table::new())
377    } else {
378        toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
379    };
380    let aliases = raw
381        .as_table_mut()
382        .ok_or_else(|| anyhow::anyhow!("config.toml is not a table"))?
383        .entry("alias")
384        .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
385    if let Some(table) = aliases.as_table_mut() {
386        let mut entry = toml::value::Table::new();
387        entry.insert("model".to_string(), toml::Value::String(model.to_string()));
388        table.insert(alias.to_string(), toml::Value::Table(entry));
389    }
390    let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
391    write_config_toml(&new_text)?;
392    reload_from_text(&new_text);
393    Ok(())
394}
395
396pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
397    let text = read_config_toml().unwrap_or_default();
398    let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
399    if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
400        table.remove(alias);
401    }
402    let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
403    write_config_toml(&new_text)?;
404    reload_from_text(&new_text);
405    Ok(())
406}
407
408pub fn update_alias_in_config(
409    old_alias: &str,
410    new_alias: &str,
411    new_model: &str,
412) -> anyhow::Result<()> {
413    let text = read_config_toml().unwrap_or_default();
414    let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
415    if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
416        table.remove(old_alias);
417        let mut entry = toml::value::Table::new();
418        entry.insert(
419            "model".to_string(),
420            toml::Value::String(new_model.to_string()),
421        );
422        table.insert(new_alias.to_string(), toml::Value::Table(entry));
423    }
424    let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
425    write_config_toml(&new_text)?;
426    reload_from_text(&new_text);
427    Ok(())
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use std::sync::Mutex as StdMutex;
434
435    /// Tests that mutate MODEL_CONFIG must hold this lock to avoid races
436    /// when cargo test runs them in parallel.
437    static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
438
439    #[test]
440    fn claude_opus_returns_200k() {
441        assert_eq!(model_info("claude-opus-4.7").context_budget, 200_000);
442    }
443
444    #[test]
445    fn gpt_4o_returns_128k() {
446        assert_eq!(model_info("gpt-4o-mini").context_budget, 128_000);
447        assert_eq!(model_info("gpt-4o-2024-08-06").context_budget, 128_000);
448    }
449
450    #[test]
451    fn unknown_model_falls_back_to_32k() {
452        assert_eq!(model_info("mystery-model").context_budget, 32_000);
453        assert_eq!(model_info("").context_budget, 32_000);
454    }
455
456    #[test]
457    fn threshold_is_eighty_percent() {
458        let info = model_info("claude-opus-4.7");
459        assert_eq!(info.compact_threshold_tokens(), 160_000);
460    }
461
462    #[test]
463    fn compaction_trigger_is_near_budget_top() {
464        let info = model_info("claude-opus-4.7");
465        let trigger = info.compaction_trigger_threshold();
466        assert!(
467            trigger > 150_000 && trigger <= 190_000,
468            "trigger should be near the top of the budget, got {trigger}"
469        );
470    }
471
472    #[test]
473    fn compaction_target_is_lower_than_trigger() {
474        let info = model_info("claude-opus-4.7");
475        let trigger = info.compaction_trigger_threshold();
476        let target = info.compaction_target_after();
477        assert!(
478            target < trigger,
479            "target {target} should be less than trigger {trigger}"
480        );
481    }
482
483    #[test]
484    fn alias_resolves_to_real_model() {
485        let _lock = TEST_CFG_LOCK.lock().unwrap();
486        let mut cfg = ModelConfig::default();
487        cfg.aliases.insert(
488            "smart".into(),
489            AliasEntry {
490                model: "claude-opus-4.7".into(),
491            },
492        );
493        set_model_config(cfg);
494        let info = model_info("smart");
495        assert_eq!(info.context_budget, 200_000);
496        assert_eq!(info.name, "claude-opus-4.7");
497    }
498
499    #[test]
500    fn custom_model_overrides_budget() {
501        let _lock = TEST_CFG_LOCK.lock().unwrap();
502        let mut cfg = ModelConfig::default();
503        cfg.models.insert(
504            "my-local-model".into(),
505            ModelEntry {
506                model: "my-local-model".into(),
507                context_budget: Some(8192),
508                compact_threshold_ratio: Some(0.9),
509                thinking: None,
510                ..Default::default()
511            },
512        );
513        set_model_config(cfg);
514        let info = model_info("my-local-model");
515        assert_eq!(info.context_budget, 8192);
516        assert_eq!(info.compact_threshold_ratio, 0.9);
517    }
518
519    #[test]
520    fn compact_threshold_reserves_configured_output_tokens() {
521        let _lock = TEST_CFG_LOCK.lock().unwrap();
522        let mut cfg = ModelConfig::default();
523        cfg.models.insert(
524            "large-output".into(),
525            ModelEntry {
526                model: "large-output".into(),
527                context_budget: Some(1_000_000),
528                compact_threshold_ratio: Some(0.8),
529                thinking: None,
530                max_tokens: Some(400_000),
531                ..Default::default()
532            },
533        );
534        set_model_config(cfg);
535        let info = model_info("large-output");
536        assert_eq!(info.compact_threshold_tokens(), 480_000);
537        let trigger = info.compaction_trigger_threshold();
538        assert!(
539            trigger > 700_000,
540            "trigger with capped output reserve should be > 700K, got {trigger}"
541        );
542    }
543
544    #[test]
545    fn alias_chains_through_custom_model() {
546        let _lock = TEST_CFG_LOCK.lock().unwrap();
547        let mut cfg = ModelConfig::default();
548        cfg.aliases.insert(
549            "default".into(),
550            AliasEntry {
551                model: "my-model".into(),
552            },
553        );
554        cfg.models.insert(
555            "my-model".into(),
556            ModelEntry {
557                model: "my-model".into(),
558                context_budget: Some(65_536),
559                compact_threshold_ratio: None,
560                thinking: None,
561                ..Default::default()
562            },
563        );
564        set_model_config(cfg);
565        let info = model_info("default");
566        assert_eq!(info.name, "my-model");
567        assert_eq!(info.context_budget, 65_536);
568    }
569
570    #[test]
571    fn discovered_models_survive_set_model_config() {
572        let _lock = TEST_CFG_LOCK.lock().unwrap();
573        // Register discovered models first.
574        register_discovered(
575            "pid-abc",
576            "Codex",
577            &[crate::provider::DiscoveredModel {
578                slug: "codex/gpt-5".to_string(),
579                context_budget: Some(128_000),
580                thinking: true,
581            }],
582        );
583        assert!(model_entry("Codex:codex/gpt-5").is_some());
584
585        // Simulate config reload from config.toml.
586        let mut cfg = ModelConfig::default();
587        cfg.aliases.insert(
588            "cheap".into(),
589            AliasEntry {
590                model: "claude-opus-4.7".into(),
591            },
592        );
593        set_model_config(cfg);
594
595        // Discovered models should still be there.
596        assert!(
597            model_entry("Codex:codex/gpt-5").is_some(),
598            "discovered models should survive set_model_config"
599        );
600        // Alias from config should work.
601        assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
602    }
603
604    #[test]
605    fn discovered_models_survive_reload_from_text_alias_crud() {
606        let _lock = TEST_CFG_LOCK.lock().unwrap();
607        register_discovered(
608            "pid-abc",
609            "Codex",
610            &[crate::provider::DiscoveredModel {
611                slug: "codex/gpt-5".to_string(),
612                context_budget: Some(128_000),
613                thinking: true,
614            }],
615        );
616
617        // Simulate alias add → reload_from_text
618        let toml = r#"
619[alias]
620smart = { model = "Codex:codex/gpt-5" }
621"#;
622        reload_from_text(toml);
623
624        assert!(
625            model_entry("Codex:codex/gpt-5").is_some(),
626            "discovered models should survive alias CRUD"
627        );
628        assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
629    }
630}