Skip to main content

atman_runtime/
model_registry.rs

1use std::collections::HashMap;
2use std::sync::RwLock;
3
4use crate::auth_store::AuthStore;
5
6#[derive(Debug, Clone)]
7pub struct ModelInfo {
8    pub name: String,
9    pub context_budget: u64,
10    pub compact_threshold_ratio: f64,
11    pub thinking_enabled: bool,
12    pub max_output_tokens: Option<u32>,
13}
14
15pub const DEFAULT_CONFIG_PROVIDER_TYPE: &str = "openai-compat";
16
17pub fn config_provider_types() -> Vec<&'static str> {
18    let mut types = Vec::new();
19    for preset in PROVIDER_PRESETS {
20        if preset.provider_type == "codex" {
21            continue;
22        }
23        if !types.contains(&preset.provider_type) {
24            types.push(preset.provider_type);
25        }
26    }
27    if types.is_empty() {
28        types.push(DEFAULT_CONFIG_PROVIDER_TYPE);
29    }
30    types
31}
32
33#[derive(Debug, Clone, Default)]
34pub struct ModelEntry {
35    pub model: String,
36    pub provider: Option<String>,
37    pub api_key: Option<String>,
38    pub base_url: Option<String>,
39    pub context_budget: Option<u64>,
40    pub compact_threshold_ratio: Option<f64>,
41    pub thinking: Option<bool>,
42    pub max_tokens: Option<u32>,
43    pub enabled: Option<bool>,
44    #[allow(dead_code)]
45    pub discovered: bool,
46}
47
48#[derive(Debug, Clone, Default)]
49pub struct AliasEntry {
50    pub model: String,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct ModelConfig {
55    pub models: HashMap<String, ModelEntry>,
56    pub aliases: HashMap<String, AliasEntry>,
57}
58
59static MODEL_CONFIG: RwLock<Option<ModelConfig>> = RwLock::new(None);
60
61/// Serializes tests that mutate the global model registry.
62///
63/// This stays available in integration tests so they can avoid racing the
64/// shared `MODEL_CONFIG` state.
65pub static MODEL_CONFIG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
66
67/// Model IDs discovered from OAuth providers (e.g. Codex).
68static DISCOVERED_MODELS: RwLock<Vec<String>> = RwLock::new(Vec::new());
69
70pub fn set_discovered_models(models: Vec<String>) {
71    *DISCOVERED_MODELS.write().unwrap() = models;
72}
73
74pub fn discovered_models() -> Vec<String> {
75    DISCOVERED_MODELS.read().unwrap().clone()
76}
77
78/// Set the base model configuration (from config.toml).
79/// Preserves previously registered discovered models.
80pub fn set_model_config(mut cfg: ModelConfig) {
81    let mut guard = MODEL_CONFIG.write().unwrap();
82    if let Some(old) = guard.take() {
83        // Preserve discovered models from old config.
84        for (name, entry) in old.models {
85            if entry.discovered {
86                cfg.models.entry(name).or_insert(entry);
87            }
88        }
89        // Preserve aliases from old config that aren't in the new one.
90        // config.toml aliases are authoritative; only preserve
91        // non-config-sourced aliases (currently none exist).
92    }
93    *guard = Some(cfg);
94}
95
96/// Register additional model entries without clobbering existing ones.
97pub fn register_model_entries(entries: Vec<(String, ModelEntry)>) {
98    let mut guard = MODEL_CONFIG.write().unwrap();
99    let mut cfg = guard.take().unwrap_or_default();
100    for (name, entry) in entries {
101        cfg.models.entry(name).or_insert(entry);
102    }
103    *guard = Some(cfg);
104}
105
106/// Build ModelEntry values from discovered models and register them.
107/// Models are keyed as `<provider_name>:<slug>` (e.g. `Codex:codex/gpt-5.5`).
108pub fn register_discovered(
109    _provider_id: &str,
110    provider_name: &str,
111    models: &[crate::provider::DiscoveredModel],
112) {
113    let entries: Vec<(String, ModelEntry)> = models
114        .iter()
115        .map(|m| {
116            let name = format!("{provider_name}:{}", m.slug);
117            let entry = ModelEntry {
118                model: name.clone(),
119                provider: Some(provider_name.to_string()),
120                context_budget: m.context_budget,
121                thinking: Some(m.thinking),
122                enabled: None,
123                discovered: true,
124                ..Default::default()
125            };
126            (name, entry)
127        })
128        .collect();
129    let slugs: Vec<String> = entries.iter().map(|(name, _)| name.clone()).collect();
130    register_model_entries(entries);
131    set_discovered_models(slugs);
132}
133
134#[derive(Debug, Clone)]
135pub struct ModelRow {
136    pub slug: String,
137    pub provider_name: String,
138    pub context_budget: u64,
139    pub max_output_tokens: Option<u32>,
140    pub thinking: bool,
141}
142
143#[derive(Debug, Clone)]
144pub struct ProviderGroup {
145    pub provider_name: String,
146    pub models: Vec<ModelRow>,
147}
148
149/// Return all models grouped by provider, with complete metadata.
150/// Single canonical source for UI — no manual union of all_model_entries +
151/// discovered_models + aliases.
152pub fn all_provider_groups() -> Vec<ProviderGroup> {
153    let entries = all_model_entries();
154    let mut groups: std::collections::BTreeMap<String, Vec<ModelRow>> =
155        std::collections::BTreeMap::new();
156    for (name, entry) in entries {
157        let info = model_info(&name);
158        let provider = entry.provider.unwrap_or_else(|| "unknown".to_string());
159        let row = ModelRow {
160            slug: name,
161            provider_name: provider.clone(),
162            context_budget: info.context_budget,
163            max_output_tokens: info.max_output_tokens,
164            thinking: info.thinking_enabled(),
165        };
166        groups.entry(provider).or_default().push(row);
167    }
168    groups
169        .into_iter()
170        .map(|(provider_name, models)| ProviderGroup {
171            provider_name,
172            models,
173        })
174        .collect()
175}
176
177pub fn resolve_alias(name: &str) -> String {
178    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
179        let mut current = name.to_string();
180        let mut seen = std::collections::HashSet::new();
181        while let Some(entry) = cfg.aliases.get(&current) {
182            if !seen.insert(current.clone()) {
183                break;
184            }
185            current = entry.model.clone();
186        }
187        return current;
188    }
189    name.to_string()
190}
191
192pub fn model_entry(name: &str) -> Option<ModelEntry> {
193    let resolved = resolve_alias(name);
194    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
195        return cfg.models.get(&resolved).cloned();
196    }
197    None
198}
199
200pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
201    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
202        return cfg
203            .models
204            .iter()
205            .map(|(k, v)| (k.clone(), v.clone()))
206            .collect();
207    }
208    Vec::new()
209}
210
211pub fn all_aliases() -> Vec<(String, String)> {
212    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
213        return cfg
214            .aliases
215            .iter()
216            .map(|(k, v)| (k.clone(), v.model.clone()))
217            .collect();
218    }
219    Vec::new()
220}
221
222pub fn model_info(name: &str) -> ModelInfo {
223    let resolved = resolve_alias(name);
224    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
225        if let Some(entry) = cfg.models.get(&resolved) {
226            return ModelInfo {
227                name: resolved.clone(),
228                context_budget: entry.context_budget.unwrap_or(0),
229                compact_threshold_ratio: entry.compact_threshold_ratio.unwrap_or(0.8),
230                thinking_enabled: entry.thinking.unwrap_or(false),
231                max_output_tokens: entry.max_tokens,
232            };
233        }
234    }
235    ModelInfo {
236        name: resolved,
237        context_budget: 0,
238        compact_threshold_ratio: 0.8,
239        thinking_enabled: false,
240        max_output_tokens: None,
241    }
242}
243
244impl ModelInfo {
245    pub fn compact_threshold_tokens(&self) -> u64 {
246        let reserved = self.max_output_tokens.unwrap_or(0) as u64;
247        let available = self.context_budget.saturating_sub(reserved);
248        (available as f64 * self.compact_threshold_ratio) as u64
249    }
250
251    pub fn compaction_trigger_threshold(&self) -> u64 {
252        let budget = self.context_budget;
253
254        let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
255        let output_cap = (budget as f64 * 0.20) as u64;
256        let output_reserve = configured_output.min(output_cap).max(8_000);
257
258        let safety = (budget as f64 * 0.05) as u64;
259        let safety_margin = safety.max(4_000);
260
261        let trigger = budget
262            .saturating_sub(output_reserve)
263            .saturating_sub(safety_margin);
264        let floor = (budget as f64 * 0.50) as u64;
265        let ceiling = (budget as f64 * 0.95) as u64;
266        trigger.clamp(floor, ceiling)
267    }
268
269    pub fn compaction_target_after(&self) -> u64 {
270        let trigger = self.compaction_trigger_threshold();
271        let budget_cap = (self.context_budget as f64 * 0.60) as u64;
272        let trigger_cap = (trigger as f64 * 0.75) as u64;
273        budget_cap.min(trigger_cap)
274    }
275
276    pub fn thinking_enabled(&self) -> bool {
277        self.thinking_enabled
278    }
279}
280
281// ── Alias CRUD (writes config.toml) ──
282
283fn read_config_toml() -> Option<String> {
284    let path = crate::storage::config_dir().ok()?.join("config.toml");
285    std::fs::read_to_string(&path).ok()
286}
287
288fn write_config_toml(text: &str) -> anyhow::Result<()> {
289    let dir = crate::storage::config_dir().map_err(|e| anyhow::anyhow!("config dir: {e}"))?;
290    std::fs::create_dir_all(&dir)?;
291    let path = dir.join("config.toml");
292    let tmp = dir.join(".config.toml.tmp");
293    std::fs::write(&tmp, text)?;
294    std::fs::rename(&tmp, &path)?;
295    Ok(())
296}
297
298fn reload_from_text(text: &str) {
299    let Ok(raw) = toml::from_str::<toml::Value>(text) else {
300        return;
301    };
302    let mut guard = MODEL_CONFIG.write().unwrap();
303    let mut cfg = guard.take().unwrap_or_default();
304
305    // Update aliases from config.toml — preserve all other state
306    // (discovered models, config-defined models).
307    cfg.aliases.clear();
308    if let Some(aliases) = raw.get("alias").and_then(|a| a.as_table()) {
309        for (name, entry) in aliases {
310            if let Some(model) = entry.get("model").and_then(|m| m.as_str()) {
311                cfg.aliases.insert(
312                    name.clone(),
313                    AliasEntry {
314                        model: model.to_string(),
315                    },
316                );
317            }
318        }
319    }
320
321    // Update config-defined models — only update existing keys or add
322    // new ones; never remove entries that aren't in config.toml.
323    if let Some(models) = raw.get("models").and_then(|m| m.as_table()) {
324        for (name, entry) in models {
325            let provider = entry
326                .get("provider")
327                .and_then(|v| v.as_str())
328                .map(String::from);
329            let api_key = entry
330                .get("api_key")
331                .and_then(|v| v.as_str())
332                .map(String::from);
333            let base_url = entry
334                .get("base_url")
335                .and_then(|v| v.as_str())
336                .map(String::from);
337            let context_budget = entry
338                .get("context_budget")
339                .and_then(|v| v.as_integer())
340                .map(|n| n as u64);
341            let thinking = entry.get("thinking").and_then(|v| v.as_bool());
342            let max_tokens = entry
343                .get("max_tokens")
344                .and_then(|v| v.as_integer())
345                .map(|n| n as u32);
346            let model = entry
347                .get("model")
348                .and_then(|v| v.as_str())
349                .map(String::from);
350            cfg.models.insert(
351                name.clone(),
352                ModelEntry {
353                    model: model.unwrap_or_default(),
354                    provider,
355                    api_key,
356                    base_url,
357                    context_budget,
358                    compact_threshold_ratio: None,
359                    thinking,
360                    max_tokens,
361                    enabled: None,
362                    discovered: false,
363                },
364            );
365        }
366    }
367
368    *guard = Some(cfg);
369}
370
371pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
372    let text = read_config_toml().unwrap_or_default();
373    let new_text = upsert_alias_comment_preserving(&text, alias, model);
374    write_config_toml(&new_text)?;
375    reload_from_text(&new_text);
376    Ok(())
377}
378
379fn upsert_alias_comment_preserving(text: &str, alias: &str, model: &str) -> String {
380    let section_start = format!("[alias.{alias}]");
381    let model_line = format!("model = {model:?}");
382    let mut lines: Vec<String> = text.lines().map(String::from).collect();
383
384    if let Some(section) = lines
385        .iter()
386        .position(|l| l.trim().starts_with(&format!("[alias.{alias}")) && l.trim().ends_with(']'))
387    {
388        let mut inserted = false;
389        for line in lines.iter_mut().skip(section + 1) {
390            let t = line.trim();
391            if (t.starts_with("[alias.") || t == "[alias]") && t.ends_with(']') {
392                break;
393            }
394            if t.starts_with("model") && t.contains('=') {
395                *line = model_line.clone();
396                inserted = true;
397                break;
398            }
399        }
400        if !inserted {
401            lines.insert(section + 1, model_line);
402        }
403    } else {
404        while lines.last().is_some_and(|l| l.trim().is_empty()) {
405            lines.pop();
406        }
407        if !lines.is_empty() {
408            lines.push(String::new());
409        }
410        lines.push(section_start);
411        lines.push(model_line);
412    }
413
414    lines.join("\n")
415}
416
417pub fn upsert_model_config(
418    name: &str,
419    provider: &str,
420    api_key: Option<&str>,
421    base_url: Option<&str>,
422    context_budget: u64,
423    thinking: bool,
424) -> anyhow::Result<()> {
425    let text = read_config_toml().unwrap_or_default();
426    let mut raw: toml::Value = if text.trim().is_empty() {
427        toml::Value::Table(toml::value::Table::new())
428    } else {
429        toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
430    };
431    let models = raw
432        .as_table_mut()
433        .ok_or_else(|| anyhow::anyhow!("config.toml is not a table"))?
434        .entry("models")
435        .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
436    if let Some(table) = models.as_table_mut() {
437        let mut entry = toml::value::Table::new();
438        entry.insert(
439            "provider".to_string(),
440            toml::Value::String(provider.to_string()),
441        );
442        if let Some(key) = api_key {
443            entry.insert("api_key".to_string(), toml::Value::String(key.to_string()));
444        }
445        if let Some(url) = base_url {
446            entry.insert("base_url".to_string(), toml::Value::String(url.to_string()));
447        }
448        entry.insert(
449            "context_budget".to_string(),
450            toml::Value::Integer(context_budget as i64),
451        );
452        if thinking {
453            entry.insert("thinking".to_string(), toml::Value::Boolean(true));
454        }
455        entry.insert("enabled".to_string(), toml::Value::Boolean(true));
456        table.insert(name.to_string(), toml::Value::Table(entry));
457    }
458    let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
459    write_config_toml(&new_text)?;
460    reload_from_text(&new_text);
461    Ok(())
462}
463
464pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
465    let text = read_config_toml().unwrap_or_default();
466    let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
467    if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
468        table.remove(alias);
469    }
470    let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
471    write_config_toml(&new_text)?;
472    reload_from_text(&new_text);
473    Ok(())
474}
475
476pub fn update_alias_in_config(
477    old_alias: &str,
478    new_alias: &str,
479    new_model: &str,
480) -> anyhow::Result<()> {
481    let text = read_config_toml().unwrap_or_default();
482    let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
483    if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
484        table.remove(old_alias);
485        let mut entry = toml::value::Table::new();
486        entry.insert(
487            "model".to_string(),
488            toml::Value::String(new_model.to_string()),
489        );
490        table.insert(new_alias.to_string(), toml::Value::Table(entry));
491    }
492    let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
493    write_config_toml(&new_text)?;
494    reload_from_text(&new_text);
495    Ok(())
496}
497
498// ── Provider presets + first-run detection ──
499
500pub struct ProviderPreset {
501    pub name: &'static str,
502    pub description: &'static str,
503    pub base_url: &'static str,
504    pub provider_type: &'static str,
505    pub models: &'static [ProviderPresetModel],
506    pub key_url: Option<&'static str>,
507    pub needs_api_key: bool,
508}
509
510pub struct ProviderPresetModel {
511    pub id: &'static str,
512    pub description: &'static str,
513    pub context_budget: u64,
514    pub thinking: bool,
515}
516
517pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
518    ProviderPreset {
519        name: "DeepSeek",
520        description: "Recommended — cheap, smart, supports thinking",
521        base_url: "https://api.deepseek.com",
522        provider_type: "openai-compat",
523        models: &[
524            ProviderPresetModel {
525                id: "deepseek-v4-flash",
526                description: "Fast & capable",
527                context_budget: 1000000,
528                thinking: false,
529            },
530            ProviderPresetModel {
531                id: "deepseek-v4-pro",
532                description: "Thinking mode",
533                context_budget: 1000000,
534                thinking: true,
535            },
536        ],
537        key_url: Some("https://platform.deepseek.com"),
538        needs_api_key: true,
539    },
540    ProviderPreset {
541        name: "OpenAI",
542        description: "GPT-4o / GPT-4o-mini",
543        base_url: "https://api.openai.com/v1",
544        provider_type: "openai",
545        models: &[
546            ProviderPresetModel {
547                id: "gpt-4o",
548                description: "Most capable",
549                context_budget: 128000,
550                thinking: false,
551            },
552            ProviderPresetModel {
553                id: "gpt-4o-mini",
554                description: "Fast & cheap",
555                context_budget: 128000,
556                thinking: false,
557            },
558        ],
559        key_url: Some("https://platform.openai.com/api-keys"),
560        needs_api_key: true,
561    },
562    ProviderPreset {
563        name: "Anthropic",
564        description: "Claude models",
565        base_url: "https://api.anthropic.com",
566        provider_type: "anthropic",
567        models: &[ProviderPresetModel {
568            id: "claude-sonnet-4-20250514",
569            description: "Claude Sonnet 4",
570            context_budget: 200000,
571            thinking: true,
572        }],
573        key_url: Some("https://console.anthropic.com/settings/keys"),
574        needs_api_key: true,
575    },
576    ProviderPreset {
577        name: "ZhipuAI",
578        description: "GLM models",
579        base_url: "https://open.bigmodel.cn/api/paas/v4",
580        provider_type: "openai-compat",
581        models: &[ProviderPresetModel {
582            id: "glm-5.2",
583            description: "GLM 5.2",
584            context_budget: 1000000,
585            thinking: true,
586        }],
587        key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
588        needs_api_key: true,
589    },
590    ProviderPreset {
591        name: "Ollama",
592        description: "Local models, no API key needed",
593        base_url: "http://localhost:11434/v1",
594        provider_type: "openai-compat",
595        models: &[],
596        key_url: None,
597        needs_api_key: false,
598    },
599    ProviderPreset {
600        name: "Codex",
601        description: "ChatGPT Plus/Pro OAuth",
602        base_url: "https://chatgpt.com/backend-api/codex",
603        provider_type: "codex",
604        models: &[],
605        key_url: None,
606        needs_api_key: false,
607    },
608];
609
610pub fn is_first_run() -> bool {
611    let models = all_model_entries();
612    let config_configured = models.iter().any(|(_, e)| {
613        e.api_key.as_deref().is_some_and(|k| !k.is_empty())
614            && e.provider.is_some()
615            && e.context_budget.unwrap_or(0) > 0
616    });
617    let env_configured =
618        std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
619    let auth_configured = AuthStore::load()
620        .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
621    let smart_resolves = {
622        let resolved = resolve_alias("smart");
623        resolved != "smart" && models.iter().any(|(n, _)| *n == resolved)
624    };
625    !(config_configured || env_configured || auth_configured) || !smart_resolves
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use std::sync::Mutex as StdMutex;
632
633    /// Tests that mutate MODEL_CONFIG must hold this lock to avoid races
634    /// when cargo test runs them in parallel.
635    static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
636
637    #[test]
638    fn unregistered_model_returns_zero_budget() {
639        let _lock = TEST_CFG_LOCK.lock().unwrap();
640        *MODEL_CONFIG.write().unwrap() = None;
641        assert_eq!(model_info("mystery-model").context_budget, 0);
642        assert_eq!(model_info("").context_budget, 0);
643    }
644
645    #[test]
646    fn threshold_is_eighty_percent() {
647        let _lock = TEST_CFG_LOCK.lock().unwrap();
648        let mut cfg = ModelConfig::default();
649        cfg.models.insert(
650            "claude-opus-4.7".into(),
651            ModelEntry {
652                model: "claude-opus-4.7".into(),
653                context_budget: Some(200_000),
654                compact_threshold_ratio: Some(0.8),
655                thinking: None,
656                ..Default::default()
657            },
658        );
659        set_model_config(cfg);
660        let info = model_info("claude-opus-4.7");
661        assert_eq!(info.compact_threshold_tokens(), 160_000);
662    }
663
664    #[test]
665    fn compaction_trigger_is_near_budget_top() {
666        let _lock = TEST_CFG_LOCK.lock().unwrap();
667        let mut cfg = ModelConfig::default();
668        cfg.models.insert(
669            "claude-opus-4.7".into(),
670            ModelEntry {
671                model: "claude-opus-4.7".into(),
672                context_budget: Some(200_000),
673                compact_threshold_ratio: Some(0.8),
674                thinking: None,
675                ..Default::default()
676            },
677        );
678        set_model_config(cfg);
679        let info = model_info("claude-opus-4.7");
680        let trigger = info.compaction_trigger_threshold();
681        assert!(
682            trigger > 150_000 && trigger <= 190_000,
683            "trigger should be near the top of the budget, got {trigger}"
684        );
685    }
686
687    #[test]
688    fn compaction_target_is_lower_than_trigger() {
689        let _lock = TEST_CFG_LOCK.lock().unwrap();
690        let mut cfg = ModelConfig::default();
691        cfg.models.insert(
692            "claude-opus-4.7".into(),
693            ModelEntry {
694                model: "claude-opus-4.7".into(),
695                context_budget: Some(200_000),
696                compact_threshold_ratio: Some(0.8),
697                thinking: None,
698                ..Default::default()
699            },
700        );
701        set_model_config(cfg);
702        let info = model_info("claude-opus-4.7");
703        let trigger = info.compaction_trigger_threshold();
704        let target = info.compaction_target_after();
705        assert!(
706            target < trigger,
707            "target {target} should be less than trigger {trigger}"
708        );
709    }
710
711    #[test]
712    fn alias_resolves_to_real_model() {
713        let _lock = TEST_CFG_LOCK.lock().unwrap();
714        let mut cfg = ModelConfig::default();
715        cfg.models.insert(
716            "claude-opus-4.7".into(),
717            ModelEntry {
718                model: "claude-opus-4.7".into(),
719                context_budget: Some(200_000),
720                ..Default::default()
721            },
722        );
723        cfg.aliases.insert(
724            "smart".into(),
725            AliasEntry {
726                model: "claude-opus-4.7".into(),
727            },
728        );
729        set_model_config(cfg);
730        let info = model_info("smart");
731        assert_eq!(info.context_budget, 200_000);
732        assert_eq!(info.name, "claude-opus-4.7");
733    }
734
735    #[test]
736    fn custom_model_overrides_budget() {
737        let _lock = TEST_CFG_LOCK.lock().unwrap();
738        let mut cfg = ModelConfig::default();
739        cfg.models.insert(
740            "my-local-model".into(),
741            ModelEntry {
742                model: "my-local-model".into(),
743                context_budget: Some(8192),
744                compact_threshold_ratio: Some(0.9),
745                thinking: None,
746                ..Default::default()
747            },
748        );
749        set_model_config(cfg);
750        let info = model_info("my-local-model");
751        assert_eq!(info.context_budget, 8192);
752        assert_eq!(info.compact_threshold_ratio, 0.9);
753    }
754
755    #[test]
756    fn compact_threshold_reserves_configured_output_tokens() {
757        let _lock = TEST_CFG_LOCK.lock().unwrap();
758        let mut cfg = ModelConfig::default();
759        cfg.models.insert(
760            "large-output".into(),
761            ModelEntry {
762                model: "large-output".into(),
763                context_budget: Some(1_000_000),
764                compact_threshold_ratio: Some(0.8),
765                thinking: None,
766                max_tokens: Some(400_000),
767                ..Default::default()
768            },
769        );
770        set_model_config(cfg);
771        let info = model_info("large-output");
772        assert_eq!(info.compact_threshold_tokens(), 480_000);
773        let trigger = info.compaction_trigger_threshold();
774        assert!(
775            trigger > 700_000,
776            "trigger with capped output reserve should be > 700K, got {trigger}"
777        );
778    }
779
780    #[test]
781    fn alias_chains_through_custom_model() {
782        let _lock = TEST_CFG_LOCK.lock().unwrap();
783        let mut cfg = ModelConfig::default();
784        cfg.aliases.insert(
785            "default".into(),
786            AliasEntry {
787                model: "my-model".into(),
788            },
789        );
790        cfg.models.insert(
791            "my-model".into(),
792            ModelEntry {
793                model: "my-model".into(),
794                context_budget: Some(65_536),
795                compact_threshold_ratio: None,
796                thinking: None,
797                ..Default::default()
798            },
799        );
800        set_model_config(cfg);
801        let info = model_info("default");
802        assert_eq!(info.name, "my-model");
803        assert_eq!(info.context_budget, 65_536);
804    }
805
806    #[test]
807    fn discovered_models_survive_set_model_config() {
808        let _lock = TEST_CFG_LOCK.lock().unwrap();
809        // Register discovered models first.
810        register_discovered(
811            "pid-abc",
812            "Codex",
813            &[crate::provider::DiscoveredModel {
814                slug: "codex/gpt-5".to_string(),
815                context_budget: Some(128_000),
816                thinking: true,
817            }],
818        );
819        assert!(model_entry("Codex:codex/gpt-5").is_some());
820
821        // Simulate config reload from config.toml.
822        let mut cfg = ModelConfig::default();
823        cfg.aliases.insert(
824            "cheap".into(),
825            AliasEntry {
826                model: "claude-opus-4.7".into(),
827            },
828        );
829        set_model_config(cfg);
830
831        // Discovered models should still be there.
832        assert!(
833            model_entry("Codex:codex/gpt-5").is_some(),
834            "discovered models should survive set_model_config"
835        );
836        // Alias from config should work.
837        assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
838    }
839
840    #[test]
841    fn discovered_models_survive_reload_from_text_alias_crud() {
842        let _lock = TEST_CFG_LOCK.lock().unwrap();
843        register_discovered(
844            "pid-abc",
845            "Codex",
846            &[crate::provider::DiscoveredModel {
847                slug: "codex/gpt-5".to_string(),
848                context_budget: Some(128_000),
849                thinking: true,
850            }],
851        );
852
853        // Simulate alias add → reload_from_text
854        let toml = r#"
855[alias]
856smart = { model = "Codex:codex/gpt-5" }
857"#;
858        reload_from_text(toml);
859
860        assert!(
861            model_entry("Codex:codex/gpt-5").is_some(),
862            "discovered models should survive alias CRUD"
863        );
864        assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
865    }
866
867    #[test]
868    fn add_alias_preserves_comments() {
869        let toml = "# top comment\n[alias]\n# smart line comment\nsmart = { model = \"claude\" }\n";
870        let out = upsert_alias_comment_preserving(toml, "smart", "claude-opus-4.7");
871        assert!(out.contains("# top comment"));
872        assert!(out.contains("# smart line comment"));
873        assert!(out.contains("model = \"claude-opus-4.7\""));
874    }
875}