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 ProviderEntry {
35    pub name: String,
36    pub kind: String,
37    pub api_key: Option<String>,
38    pub api_key_env: Option<String>,
39    pub base_url: Option<String>,
40    pub max_tokens: Option<u32>,
41    pub enabled: Option<bool>,
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct ModelEntry {
46    pub model: String,
47    pub provider: Option<String>,
48    pub context_budget: Option<u64>,
49    pub compact_threshold_ratio: Option<f64>,
50    pub thinking: Option<bool>,
51    pub max_tokens: Option<u32>,
52    pub enabled: Option<bool>,
53    #[allow(dead_code)]
54    pub discovered: bool,
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct AliasEntry {
59    pub model: String,
60}
61
62#[derive(Debug, Clone, Default)]
63pub struct ProviderConfig {
64    pub providers: HashMap<String, ProviderEntry>,
65    pub models: HashMap<String, ModelEntry>,
66    pub aliases: HashMap<String, AliasEntry>,
67}
68
69/// Backwards-compatible alias — ProviderConfig is the canonical name.
70pub type ModelConfig = ProviderConfig;
71
72static MODEL_CONFIG: RwLock<Option<ProviderConfig>> = RwLock::new(None);
73
74/// Serializes tests that mutate the global model registry.
75///
76/// This stays available in integration tests so they can avoid racing the
77/// shared `MODEL_CONFIG` state.
78pub static MODEL_CONFIG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
79
80/// Model IDs discovered from OAuth providers (e.g. Codex).
81static DISCOVERED_MODELS: RwLock<Vec<String>> = RwLock::new(Vec::new());
82
83pub fn set_discovered_models(models: Vec<String>) {
84    *DISCOVERED_MODELS.write().unwrap() = models;
85}
86
87pub fn discovered_models() -> Vec<String> {
88    DISCOVERED_MODELS.read().unwrap().clone()
89}
90
91/// Set the base model configuration (from config.toml).
92/// Preserves previously registered discovered models.
93pub fn set_provider_config(mut cfg: ProviderConfig) {
94    let mut guard = MODEL_CONFIG.write().unwrap();
95    if let Some(old) = guard.take() {
96        // Preserve discovered models from old config.
97        for (name, entry) in old.models {
98            if entry.discovered {
99                cfg.models.entry(name).or_insert(entry);
100            }
101        }
102        // Preserve aliases from old config that aren't in the new one.
103        // config.toml aliases are authoritative; only preserve
104        // non-config-sourced aliases (currently none exist).
105    }
106    *guard = Some(cfg);
107}
108
109/// Backwards-compatible alias for [set_provider_config].
110pub fn set_model_config(cfg: ModelConfig) {
111    set_provider_config(cfg);
112}
113
114/// Register additional model entries without clobbering existing ones.
115pub fn register_model_entries(entries: Vec<(String, ModelEntry)>) {
116    let mut guard = MODEL_CONFIG.write().unwrap();
117    let mut cfg = guard.take().unwrap_or_default();
118    for (name, entry) in entries {
119        cfg.models.entry(name).or_insert(entry);
120    }
121    *guard = Some(cfg);
122}
123
124/// Register additional provider entries without clobbering existing ones.
125pub fn register_provider_entries(entries: Vec<(String, ProviderEntry)>) {
126    let mut guard = MODEL_CONFIG.write().unwrap();
127    let mut cfg = guard.take().unwrap_or_default();
128    for (name, entry) in entries {
129        cfg.providers.entry(name).or_insert(entry);
130    }
131    *guard = Some(cfg);
132}
133
134/// Build ModelEntry values from discovered models and register them.
135/// Models are keyed as `<provider_name>:<slug>` (e.g. `Codex:codex/gpt-5.5`).
136pub fn register_discovered(
137    _provider_id: &str,
138    provider_name: &str,
139    models: &[crate::provider::DiscoveredModel],
140) {
141    let entries: Vec<(String, ModelEntry)> = models
142        .iter()
143        .map(|m| {
144            let name = format!("{provider_name}:{}", m.slug);
145            let entry = ModelEntry {
146                model: name.clone(),
147                provider: Some(provider_name.to_string()),
148                context_budget: m.context_budget,
149                thinking: Some(m.thinking),
150                enabled: None,
151                discovered: true,
152                ..Default::default()
153            };
154            (name, entry)
155        })
156        .collect();
157    let slugs: Vec<String> = entries.iter().map(|(name, _)| name.clone()).collect();
158    register_model_entries(entries);
159    set_discovered_models(slugs);
160}
161
162#[derive(Debug, Clone)]
163pub struct ModelRow {
164    pub slug: String,
165    pub provider_name: String,
166    pub context_budget: u64,
167    pub max_output_tokens: Option<u32>,
168    pub thinking: bool,
169}
170
171#[derive(Debug, Clone)]
172pub struct ProviderGroup {
173    pub provider_name: String,
174    pub models: Vec<ModelRow>,
175}
176
177/// Return all models grouped by provider, with complete metadata.
178/// Single canonical source for UI — no manual union of all_model_entries +
179/// discovered_models + aliases.
180pub fn all_provider_groups() -> Vec<ProviderGroup> {
181    provider_groups(false)
182}
183
184/// Return enabled configured providers even when no model has been registered yet.
185pub fn all_provider_groups_with_empty() -> Vec<ProviderGroup> {
186    provider_groups(true)
187}
188
189fn provider_groups(include_empty: bool) -> Vec<ProviderGroup> {
190    let entries = all_model_entries();
191    let mut groups: std::collections::BTreeMap<String, Vec<ModelRow>> =
192        std::collections::BTreeMap::new();
193    for (name, entry) in entries {
194        let info = model_info(&name);
195        let provider = entry.provider.unwrap_or_else(|| "unknown".to_string());
196        let row = ModelRow {
197            slug: name,
198            provider_name: provider.clone(),
199            context_budget: info.context_budget,
200            max_output_tokens: info.max_output_tokens,
201            thinking: info.thinking_enabled(),
202        };
203        groups.entry(provider).or_default().push(row);
204    }
205    if include_empty {
206        for (provider, entry) in all_provider_entries() {
207            if entry.enabled.unwrap_or(true) {
208                groups.entry(provider).or_default();
209            }
210        }
211    }
212    groups
213        .into_iter()
214        .map(|(provider_name, models)| ProviderGroup {
215            provider_name,
216            models,
217        })
218        .collect()
219}
220
221pub fn resolve_alias(name: &str) -> String {
222    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
223        let mut current = name.to_string();
224        let mut seen = std::collections::HashSet::new();
225        while let Some(entry) = cfg.aliases.get(&current) {
226            if !seen.insert(current.clone()) {
227                break;
228            }
229            current = entry.model.clone();
230        }
231        return current;
232    }
233    name.to_string()
234}
235
236pub fn model_entry(name: &str) -> Option<ModelEntry> {
237    let resolved = resolve_alias(name);
238    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
239        return cfg.models.get(&resolved).cloned();
240    }
241    None
242}
243
244pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
245    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
246        return cfg
247            .models
248            .iter()
249            .map(|(k, v)| (k.clone(), v.clone()))
250            .collect();
251    }
252    Vec::new()
253}
254
255pub fn is_provider_enabled(name: &str) -> bool {
256    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
257        if let Some(entry) = cfg.providers.get(name) {
258            return entry.enabled.unwrap_or(true);
259        }
260    }
261    true
262}
263
264pub fn all_provider_entries() -> Vec<(String, ProviderEntry)> {
265    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
266        return cfg
267            .providers
268            .iter()
269            .map(|(k, v)| (k.clone(), v.clone()))
270            .collect();
271    }
272    Vec::new()
273}
274
275pub fn all_aliases() -> Vec<(String, String)> {
276    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
277        return cfg
278            .aliases
279            .iter()
280            .map(|(k, v)| (k.clone(), v.model.clone()))
281            .collect();
282    }
283    Vec::new()
284}
285
286pub fn model_info(name: &str) -> ModelInfo {
287    let resolved = resolve_alias(name);
288    if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
289        if let Some(entry) = cfg.models.get(&resolved) {
290            let enabled = entry.enabled.unwrap_or(true);
291            return ModelInfo {
292                name: resolved.clone(),
293                context_budget: if enabled {
294                    entry.context_budget.unwrap_or(0)
295                } else {
296                    0
297                },
298                compact_threshold_ratio: entry.compact_threshold_ratio.unwrap_or(0.8),
299                thinking_enabled: entry.thinking.unwrap_or(false),
300                max_output_tokens: entry.max_tokens,
301            };
302        }
303    }
304    ModelInfo {
305        name: resolved,
306        context_budget: 0,
307        compact_threshold_ratio: 0.8,
308        thinking_enabled: false,
309        max_output_tokens: None,
310    }
311}
312
313impl ModelInfo {
314    pub fn compact_threshold_tokens(&self) -> u64 {
315        let reserved = self.max_output_tokens.unwrap_or(0) as u64;
316        let available = self.context_budget.saturating_sub(reserved);
317        (available as f64 * self.compact_threshold_ratio) as u64
318    }
319
320    pub fn compaction_trigger_threshold(&self) -> u64 {
321        if self.context_budget == 0 {
322            return u64::MAX;
323        }
324        let budget = self.context_budget;
325
326        let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
327        let output_cap = (budget as f64 * 0.20) as u64;
328        let output_reserve = configured_output.min(output_cap).max(8_000);
329
330        let safety = (budget as f64 * 0.05) as u64;
331        let safety_margin = safety.max(4_000);
332
333        let trigger = budget
334            .saturating_sub(output_reserve)
335            .saturating_sub(safety_margin);
336        let floor = (budget as f64 * 0.50) as u64;
337        let ceiling = (budget as f64 * 0.95) as u64;
338        trigger.clamp(floor, ceiling)
339    }
340
341    pub fn compaction_target_after(&self) -> u64 {
342        let trigger = self.compaction_trigger_threshold();
343        let budget_cap = (self.context_budget as f64 * 0.25) as u64;
344        let trigger_cap = (trigger as f64 * 0.75) as u64;
345        budget_cap.min(trigger_cap)
346    }
347
348    pub fn thinking_enabled(&self) -> bool {
349        self.thinking_enabled
350    }
351}
352
353// Known models table (supplements API discovery)
354
355pub use crate::known_models::{KNOWN_MODELS, lookup_known_model};
356/// Register preset models for a provider whose base_url matches a PROVIDER_PRESETS entry.
357/// Models are marked `discovered = true` so they survive `set_model_config` reloads
358/// but are never written to config.toml. User-defined models in config.toml take priority.
359pub fn register_preset_models_for(provider_name: &str, base_url: &str) {
360    for preset in PROVIDER_PRESETS {
361        if preset.base_url == base_url && !preset.models.is_empty() {
362            let entries: Vec<(String, ModelEntry)> = preset
363                .models
364                .iter()
365                .map(|m| {
366                    let name = m.id.to_string();
367                    let entry = ModelEntry {
368                        model: m.id.to_string(),
369                        provider: Some(provider_name.to_string()),
370                        context_budget: Some(m.context_budget),
371                        thinking: Some(m.thinking),
372                        enabled: None,
373                        discovered: true,
374                        ..Default::default()
375                    };
376                    (name, entry)
377                })
378                .collect();
379            register_model_entries(entries);
380            return;
381        }
382    }
383}
384
385/// Register preset models for all config providers that match a PROVIDER_PRESETS entry.
386/// Called at bootstrap after `register_providers_from_config`.
387pub fn register_all_preset_models() {
388    let providers = all_provider_entries();
389    for (name, entry) in &providers {
390        if let Some(base_url) = &entry.base_url {
391            register_preset_models_for(name, base_url);
392        }
393    }
394}
395
396// Config migration (v1 to v2)
397
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub enum ModelMigrationOutcome {
400    NotNeeded,
401    Migrated { backup: std::path::PathBuf },
402}
403
404pub fn migrate_config_if_needed(
405    text: &str,
406) -> Result<Option<String>, crate::config_hub::ConfigError> {
407    let mut doc = text.parse::<toml_edit::DocumentMut>()?;
408    let legacy = has_legacy_model_fields(&doc);
409    match doc.get("config_version") {
410        None if !legacy => return Ok(None),
411        None => {}
412        Some(version) => match version.as_integer() {
413            Some(1) if legacy => {}
414            Some(1) | Some(2) => return Ok(None),
415            Some(value) => {
416                return Err(crate::config_hub::ConfigError::Invalid(format!(
417                    "unsupported config_version {value}"
418                )));
419            }
420            None => {
421                return Err(crate::config_hub::ConfigError::Invalid(
422                    "config_version must be an integer".into(),
423                ));
424            }
425        },
426    }
427
428    let provider_names: std::collections::HashSet<String> = doc
429        .get("providers")
430        .and_then(|item| item.as_table())
431        .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
432        .unwrap_or_default();
433    let models = doc
434        .get("models")
435        .and_then(|item| item.as_table())
436        .ok_or_else(|| crate::config_hub::ConfigError::Invalid("models is not a table".into()))?;
437    let mut groups: std::collections::BTreeMap<(String, String, String), Vec<String>> =
438        std::collections::BTreeMap::new();
439
440    for (name, entry) in models.iter() {
441        let api_key = entry
442            .get("api_key")
443            .and_then(|v| v.as_str())
444            .unwrap_or("")
445            .to_string();
446        let base_url = entry
447            .get("base_url")
448            .and_then(|v| v.as_str())
449            .unwrap_or("")
450            .to_string();
451        let ptype = entry
452            .get("provider")
453            .and_then(|v| v.as_str())
454            .unwrap_or("openai-compat")
455            .to_string();
456
457        if !api_key.is_empty()
458            || !base_url.is_empty()
459            || (matches!(
460                ptype.as_str(),
461                "openai" | "openai-compat" | "anthropic" | "codex"
462            ) && !provider_names.contains(&ptype))
463        {
464            groups
465                .entry((ptype, api_key, base_url))
466                .or_default()
467                .push(name.to_string());
468        }
469    }
470
471    if groups.is_empty() {
472        return Ok(None);
473    }
474
475    let mut used_names: std::collections::HashSet<String> = doc
476        .get("providers")
477        .and_then(|item| item.as_table())
478        .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
479        .unwrap_or_default();
480
481    for ((ptype, api_key, base_url), model_names) in &groups {
482        let provider_name = pick_provider_name(ptype, base_url, &mut used_names);
483        used_names.insert(provider_name.clone());
484
485        // Create [providers.{name}] section
486        let mut table = toml_edit::Table::new();
487        table.insert("kind", toml_edit::value(ptype.clone()));
488        if !api_key.is_empty() {
489            table.insert("api_key", toml_edit::value(api_key.clone()));
490        }
491        if !base_url.is_empty() {
492            table.insert("base_url", toml_edit::value(base_url.clone()));
493        }
494        table.insert("enabled", toml_edit::value(true));
495
496        // Ensure [providers] table exists
497        if doc.get("providers").is_none() {
498            doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
499        }
500        if let Some(providers) = doc.get_mut("providers").and_then(|p| p.as_table_mut()) {
501            providers.insert(&provider_name, toml_edit::Item::Table(table));
502        }
503
504        // Update each model's provider field and remove api_key/base_url
505        for model_name in model_names {
506            if let Some(model) = doc
507                .get_mut("models")
508                .and_then(|m| m.as_table_mut())
509                .and_then(|t| t.get_mut(model_name.as_str()))
510                .and_then(|e| e.as_table_mut())
511            {
512                model.insert("provider", toml_edit::value(&provider_name));
513                model.remove("api_key");
514                model.remove("base_url");
515            }
516        }
517    }
518
519    doc.insert("config_version", toml_edit::value(2i64));
520    let migrated = doc.to_string();
521    parse_config(&migrated).ok_or_else(|| {
522        crate::config_hub::ConfigError::Invalid("validate migrated config.toml".into())
523    })?;
524    Ok(Some(migrated))
525}
526
527fn has_legacy_model_fields(doc: &toml_edit::DocumentMut) -> bool {
528    let provider_names: std::collections::HashSet<&str> = doc
529        .get("providers")
530        .and_then(|item| item.as_table())
531        .map(|providers| providers.iter().map(|(name, _)| name).collect())
532        .unwrap_or_default();
533    doc.get("models")
534        .and_then(|item| item.as_table())
535        .is_some_and(|models| {
536            models.iter().any(|(_, entry)| {
537                entry.get("api_key").is_some()
538                    || entry.get("base_url").is_some()
539                    || entry
540                        .get("provider")
541                        .and_then(|value| value.as_str())
542                        .is_some_and(|provider| {
543                            matches!(provider, "openai" | "openai-compat" | "anthropic" | "codex")
544                                && !provider_names.contains(provider)
545                        })
546            })
547        })
548}
549
550fn pick_provider_name(
551    ptype: &str,
552    base_url: &str,
553    used: &mut std::collections::HashSet<String>,
554) -> String {
555    // Try to match base_url against PROVIDER_PRESETS
556    for preset in PROVIDER_PRESETS {
557        if !base_url.is_empty() && preset.base_url == base_url {
558            let name = preset.name.to_lowercase();
559            if !used.contains(&name) {
560                return name;
561            }
562        }
563    }
564    // Fall back to provider type, with suffix for duplicates
565    let base = ptype.to_string();
566    if !used.contains(&base) {
567        return base;
568    }
569    for i in 2.. {
570        let candidate = format!("{base}-{i}");
571        if !used.contains(&candidate) {
572            return candidate;
573        }
574    }
575    unreachable!()
576}
577
578// Alias CRUD (writes config.toml)
579
580pub fn read_config_toml_pub() -> Option<String> {
581    crate::config_hub::ConfigHub::global()
582        .ok()?
583        .read_config_toml()
584        .ok()
585}
586
587pub(crate) fn reload_from_text(text: &str) -> anyhow::Result<()> {
588    if !text.trim().is_empty() {
589        toml::from_str::<toml::Value>(text)
590            .map_err(|error| anyhow::anyhow!("parse config.toml: {error}"))?;
591    }
592    set_provider_config(parse_config(text).unwrap_or_default());
593    Ok(())
594}
595
596/// Unified config parser — parses `[providers.X]`, `[models.X]`, and `[alias.X]`
597/// sections from a TOML string into a [ProviderConfig].
598///
599/// Replaces the per-crate `parse_model_config` functions in CLI and daemon.
600pub fn parse_config(text: &str) -> Option<ProviderConfig> {
601    #[derive(serde::Deserialize, Default)]
602    struct RawProvider {
603        #[serde(default)]
604        name: Option<String>,
605        #[serde(default)]
606        kind: Option<String>,
607        #[serde(default)]
608        api_key: Option<String>,
609        #[serde(default)]
610        api_key_env: Option<String>,
611        #[serde(default)]
612        base_url: Option<String>,
613        #[serde(default)]
614        max_tokens: Option<u32>,
615        #[serde(default)]
616        enabled: Option<bool>,
617    }
618
619    #[derive(serde::Deserialize, Default)]
620    struct RawModel {
621        #[serde(default)]
622        model: Option<String>,
623        #[serde(default)]
624        provider: Option<String>,
625        #[serde(default)]
626        context_budget: Option<u64>,
627        #[serde(default)]
628        compact_threshold_ratio: Option<f64>,
629        #[serde(default)]
630        thinking: Option<bool>,
631        #[serde(default)]
632        max_tokens: Option<u32>,
633        #[serde(default)]
634        enabled: Option<bool>,
635        #[serde(default)]
636        discovered: bool,
637    }
638
639    #[derive(serde::Deserialize, Default)]
640    struct RawAlias {
641        model: String,
642    }
643
644    #[derive(serde::Deserialize, Default)]
645    struct RawFile {
646        #[serde(default)]
647        providers: std::collections::HashMap<String, RawProvider>,
648        #[serde(default)]
649        models: std::collections::HashMap<String, RawModel>,
650        #[serde(default)]
651        alias: std::collections::HashMap<String, RawAlias>,
652    }
653
654    let raw: RawFile = toml::from_str(text).ok()?;
655    let mut cfg = ProviderConfig::default();
656
657    for (key, p) in raw.providers {
658        cfg.providers.insert(
659            key.clone(),
660            ProviderEntry {
661                name: p.name.unwrap_or(key),
662                kind: p.kind.unwrap_or_default(),
663                api_key: p.api_key,
664                api_key_env: p.api_key_env,
665                base_url: p.base_url,
666                max_tokens: p.max_tokens,
667                enabled: p.enabled,
668            },
669        );
670    }
671
672    for (name, m) in raw.models {
673        cfg.models.insert(
674            name,
675            ModelEntry {
676                model: m.model.unwrap_or_default(),
677                provider: m.provider,
678                context_budget: m.context_budget,
679                compact_threshold_ratio: m.compact_threshold_ratio,
680                thinking: m.thinking,
681                max_tokens: m.max_tokens,
682                enabled: m.enabled,
683                discovered: m.discovered,
684            },
685        );
686    }
687
688    for (name, a) in raw.alias {
689        cfg.aliases.insert(name, AliasEntry { model: a.model });
690    }
691
692    if cfg.providers.is_empty() && cfg.models.is_empty() && cfg.aliases.is_empty() {
693        return None;
694    }
695    Some(cfg)
696}
697
698pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
699    crate::config_hub::ConfigHub::global()?
700        .add_alias(alias, model)
701        .map_err(Into::into)
702}
703
704pub fn upsert_provider_config(
705    name: &str,
706    kind: &str,
707    api_key: Option<&str>,
708    api_key_env: Option<&str>,
709    base_url: Option<&str>,
710    max_tokens: Option<u32>,
711    enabled: bool,
712) -> anyhow::Result<()> {
713    crate::config_hub::ConfigHub::global()?
714        .upsert_provider(crate::config_hub::ProviderConfigUpdate {
715            name,
716            kind,
717            api_key,
718            api_key_env,
719            base_url,
720            max_tokens,
721            enabled,
722        })
723        .map_err(Into::into)
724}
725
726#[derive(Debug, Clone)]
727pub struct ModelConfigUpdate<'a> {
728    pub old_name: Option<&'a str>,
729    pub name: &'a str,
730    pub model: &'a str,
731    pub provider: Option<&'a str>,
732    pub context_budget: u64,
733    pub thinking: bool,
734    pub max_tokens: Option<u32>,
735    pub enabled: bool,
736}
737
738pub(crate) fn apply_model_config_update(
739    doc: &mut toml_edit::DocumentMut,
740    update: ModelConfigUpdate<'_>,
741) -> anyhow::Result<()> {
742    if doc.get("models").is_none() {
743        doc.insert("models", toml_edit::Item::Table(toml_edit::Table::new()));
744    }
745    {
746        let models = doc
747            .get_mut("models")
748            .and_then(|item| item.as_table_mut())
749            .ok_or_else(|| anyhow::anyhow!("models is not a table"))?;
750        if let Some(old_name) = update.old_name.filter(|old| *old != update.name) {
751            models.remove(old_name);
752        }
753        let entry = models
754            .entry(update.name)
755            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
756            .as_table_mut()
757            .ok_or_else(|| anyhow::anyhow!("model entry is not a table"))?;
758        entry.insert("model", toml_edit::value(update.model));
759        if let Some(provider) = update.provider {
760            entry.insert("provider", toml_edit::value(provider));
761        } else {
762            entry.remove("provider");
763        }
764        entry.insert(
765            "context_budget",
766            toml_edit::value(update.context_budget as i64),
767        );
768        entry.insert("thinking", toml_edit::value(update.thinking));
769        if let Some(max_tokens) = update.max_tokens {
770            entry.insert("max_tokens", toml_edit::value(max_tokens as i64));
771        } else {
772            entry.remove("max_tokens");
773        }
774        entry.insert("enabled", toml_edit::value(update.enabled));
775    }
776
777    if let Some(old_name) = update.old_name.filter(|old| *old != update.name)
778        && let Some(aliases) = doc.get_mut("alias").and_then(|item| item.as_table_mut())
779    {
780        for (_, alias) in aliases.iter_mut() {
781            if let Some(table) = alias.as_table_mut() {
782                if table.get("model").and_then(|item| item.as_str()) == Some(old_name) {
783                    table.insert("model", toml_edit::value(update.name));
784                }
785            } else if let Some(inline) = alias.as_inline_table_mut()
786                && inline.get("model").and_then(|value| value.as_str()) == Some(old_name)
787            {
788                inline.insert("model", toml_edit::Value::from(update.name));
789            }
790        }
791    }
792    Ok(())
793}
794
795pub fn upsert_model_config(update: ModelConfigUpdate<'_>) -> anyhow::Result<()> {
796    crate::config_hub::ConfigHub::global()?
797        .upsert_model(update)
798        .map_err(Into::into)
799}
800
801pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
802    crate::config_hub::ConfigHub::global()?
803        .remove_alias(alias)
804        .map_err(Into::into)
805}
806
807pub fn update_alias_in_config(
808    old_alias: &str,
809    new_alias: &str,
810    new_model: &str,
811) -> anyhow::Result<()> {
812    crate::config_hub::ConfigHub::global()?
813        .update_alias(Some(old_alias), new_alias, new_model)
814        .map_err(Into::into)
815}
816
817// Provider presets + first-run detection
818
819pub struct ProviderPreset {
820    pub name: &'static str,
821    pub description: &'static str,
822    pub base_url: &'static str,
823    pub provider_type: &'static str,
824    pub models: &'static [ProviderPresetModel],
825    pub key_url: Option<&'static str>,
826    pub needs_api_key: bool,
827}
828
829pub struct ProviderPresetModel {
830    pub id: &'static str,
831    pub description: &'static str,
832    pub context_budget: u64,
833    pub thinking: bool,
834}
835
836pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
837    ProviderPreset {
838        name: "DeepSeek",
839        description: "Recommended — cheap, smart, supports thinking",
840        base_url: "https://api.deepseek.com",
841        provider_type: "openai-compat",
842        models: &[
843            ProviderPresetModel {
844                id: "deepseek-v4-flash",
845                description: "Fast & capable",
846                context_budget: 1000000,
847                thinking: false,
848            },
849            ProviderPresetModel {
850                id: "deepseek-v4-pro",
851                description: "Thinking mode",
852                context_budget: 1000000,
853                thinking: true,
854            },
855        ],
856        key_url: Some("https://platform.deepseek.com"),
857        needs_api_key: true,
858    },
859    ProviderPreset {
860        name: "OpenAI",
861        description: "GPT-4o / GPT-4o-mini",
862        base_url: "https://api.openai.com/v1",
863        provider_type: "openai",
864        models: &[
865            ProviderPresetModel {
866                id: "gpt-4o",
867                description: "Most capable",
868                context_budget: 128000,
869                thinking: false,
870            },
871            ProviderPresetModel {
872                id: "gpt-4o-mini",
873                description: "Fast & cheap",
874                context_budget: 128000,
875                thinking: false,
876            },
877        ],
878        key_url: Some("https://platform.openai.com/api-keys"),
879        needs_api_key: true,
880    },
881    ProviderPreset {
882        name: "Anthropic",
883        description: "Claude models",
884        base_url: "https://api.anthropic.com",
885        provider_type: "anthropic",
886        models: &[ProviderPresetModel {
887            id: "claude-sonnet-4-20250514",
888            description: "Claude Sonnet 4",
889            context_budget: 200000,
890            thinking: true,
891        }],
892        key_url: Some("https://console.anthropic.com/settings/keys"),
893        needs_api_key: true,
894    },
895    ProviderPreset {
896        name: "ZhipuAI",
897        description: "GLM models",
898        base_url: "https://open.bigmodel.cn/api/paas/v4",
899        provider_type: "openai-compat",
900        models: &[ProviderPresetModel {
901            id: "glm-5.2",
902            description: "GLM 5.2",
903            context_budget: 1000000,
904            thinking: true,
905        }],
906        key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
907        needs_api_key: true,
908    },
909    ProviderPreset {
910        name: "Ollama",
911        description: "Local models, no API key needed",
912        base_url: "http://localhost:11434/v1",
913        provider_type: "openai-compat",
914        models: &[],
915        key_url: None,
916        needs_api_key: false,
917    },
918    ProviderPreset {
919        name: "Codex",
920        description: "ChatGPT Plus/Pro OAuth",
921        base_url: "https://chatgpt.com/backend-api/codex",
922        provider_type: "codex",
923        models: &[],
924        key_url: None,
925        needs_api_key: false,
926    },
927];
928
929pub fn is_first_run() -> bool {
930    let providers = all_provider_entries();
931    let models = all_model_entries();
932    let config_configured = providers.iter().any(|(_, e)| {
933        e.api_key.as_deref().is_some_and(|k| !k.is_empty())
934            || e.api_key_env
935                .as_deref()
936                .is_some_and(|env| std::env::var(env).is_ok_and(|v| !v.trim().is_empty()))
937    });
938    let env_configured =
939        std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
940    let auth_configured = AuthStore::load()
941        .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
942    let smart_resolves = {
943        let resolved = resolve_alias("smart");
944        resolved != "smart" && models.iter().any(|(n, _)| *n == resolved)
945    };
946    !(config_configured || env_configured || auth_configured) || !smart_resolves
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952    use std::sync::Mutex as StdMutex;
953
954    /// Tests that mutate MODEL_CONFIG must hold this lock to avoid races
955    /// when cargo test runs them in parallel.
956    static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
957
958    #[test]
959    fn unregistered_model_returns_zero_budget() {
960        let _lock = TEST_CFG_LOCK.lock().unwrap();
961        *MODEL_CONFIG.write().unwrap() = None;
962        assert_eq!(model_info("mystery-model").context_budget, 0);
963        assert_eq!(model_info("").context_budget, 0);
964    }
965
966    #[test]
967    fn threshold_is_eighty_percent() {
968        let _lock = TEST_CFG_LOCK.lock().unwrap();
969        let mut cfg = ModelConfig::default();
970        cfg.models.insert(
971            "claude-opus-4.7".into(),
972            ModelEntry {
973                model: "claude-opus-4.7".into(),
974                context_budget: Some(200_000),
975                compact_threshold_ratio: Some(0.8),
976                thinking: None,
977                ..Default::default()
978            },
979        );
980        set_model_config(cfg);
981        let info = model_info("claude-opus-4.7");
982        assert_eq!(info.compact_threshold_tokens(), 160_000);
983    }
984
985    #[test]
986    fn compaction_trigger_is_near_budget_top() {
987        let _lock = TEST_CFG_LOCK.lock().unwrap();
988        let mut cfg = ModelConfig::default();
989        cfg.models.insert(
990            "claude-opus-4.7".into(),
991            ModelEntry {
992                model: "claude-opus-4.7".into(),
993                context_budget: Some(200_000),
994                compact_threshold_ratio: Some(0.8),
995                thinking: None,
996                ..Default::default()
997            },
998        );
999        set_model_config(cfg);
1000        let info = model_info("claude-opus-4.7");
1001        let trigger = info.compaction_trigger_threshold();
1002        assert!(
1003            trigger > 150_000 && trigger <= 190_000,
1004            "trigger should be near the top of the budget, got {trigger}"
1005        );
1006    }
1007
1008    #[test]
1009    fn compaction_target_is_lower_than_trigger() {
1010        let _lock = TEST_CFG_LOCK.lock().unwrap();
1011        let mut cfg = ModelConfig::default();
1012        cfg.models.insert(
1013            "claude-opus-4.7".into(),
1014            ModelEntry {
1015                model: "claude-opus-4.7".into(),
1016                context_budget: Some(200_000),
1017                compact_threshold_ratio: Some(0.8),
1018                thinking: None,
1019                ..Default::default()
1020            },
1021        );
1022        set_model_config(cfg);
1023        let info = model_info("claude-opus-4.7");
1024        let trigger = info.compaction_trigger_threshold();
1025        let target = info.compaction_target_after();
1026        assert!(
1027            target < trigger,
1028            "target {target} should be less than trigger {trigger}"
1029        );
1030        assert_eq!(target, 50_000, "compaction target should be 25% of context");
1031    }
1032
1033    #[test]
1034    fn alias_resolves_to_real_model() {
1035        let _lock = TEST_CFG_LOCK.lock().unwrap();
1036        let mut cfg = ModelConfig::default();
1037        cfg.models.insert(
1038            "claude-opus-4.7".into(),
1039            ModelEntry {
1040                model: "claude-opus-4.7".into(),
1041                context_budget: Some(200_000),
1042                ..Default::default()
1043            },
1044        );
1045        cfg.aliases.insert(
1046            "smart".into(),
1047            AliasEntry {
1048                model: "claude-opus-4.7".into(),
1049            },
1050        );
1051        set_model_config(cfg);
1052        let info = model_info("smart");
1053        assert_eq!(info.context_budget, 200_000);
1054        assert_eq!(info.name, "claude-opus-4.7");
1055    }
1056
1057    #[test]
1058    fn custom_model_overrides_budget() {
1059        let _lock = TEST_CFG_LOCK.lock().unwrap();
1060        let mut cfg = ModelConfig::default();
1061        cfg.models.insert(
1062            "my-local-model".into(),
1063            ModelEntry {
1064                model: "my-local-model".into(),
1065                context_budget: Some(8192),
1066                compact_threshold_ratio: Some(0.9),
1067                thinking: None,
1068                ..Default::default()
1069            },
1070        );
1071        set_model_config(cfg);
1072        let info = model_info("my-local-model");
1073        assert_eq!(info.context_budget, 8192);
1074        assert_eq!(info.compact_threshold_ratio, 0.9);
1075    }
1076
1077    #[test]
1078    fn compact_threshold_reserves_configured_output_tokens() {
1079        let _lock = TEST_CFG_LOCK.lock().unwrap();
1080        let mut cfg = ModelConfig::default();
1081        cfg.models.insert(
1082            "large-output".into(),
1083            ModelEntry {
1084                model: "large-output".into(),
1085                context_budget: Some(1_000_000),
1086                compact_threshold_ratio: Some(0.8),
1087                thinking: None,
1088                max_tokens: Some(400_000),
1089                ..Default::default()
1090            },
1091        );
1092        set_model_config(cfg);
1093        let info = model_info("large-output");
1094        assert_eq!(info.compact_threshold_tokens(), 480_000);
1095        let trigger = info.compaction_trigger_threshold();
1096        assert!(
1097            trigger > 700_000,
1098            "trigger with capped output reserve should be > 700K, got {trigger}"
1099        );
1100    }
1101
1102    #[test]
1103    fn alias_chains_through_custom_model() {
1104        let _lock = TEST_CFG_LOCK.lock().unwrap();
1105        let mut cfg = ModelConfig::default();
1106        cfg.aliases.insert(
1107            "default".into(),
1108            AliasEntry {
1109                model: "my-model".into(),
1110            },
1111        );
1112        cfg.models.insert(
1113            "my-model".into(),
1114            ModelEntry {
1115                model: "my-model".into(),
1116                context_budget: Some(65_536),
1117                compact_threshold_ratio: None,
1118                thinking: None,
1119                ..Default::default()
1120            },
1121        );
1122        set_model_config(cfg);
1123        let info = model_info("default");
1124        assert_eq!(info.name, "my-model");
1125        assert_eq!(info.context_budget, 65_536);
1126    }
1127
1128    #[test]
1129    fn discovered_models_survive_set_model_config() {
1130        let _lock = TEST_CFG_LOCK.lock().unwrap();
1131        // Register discovered models first.
1132        register_discovered(
1133            "pid-abc",
1134            "Codex",
1135            &[crate::provider::DiscoveredModel {
1136                slug: "codex/gpt-5".to_string(),
1137                context_budget: Some(128_000),
1138                thinking: true,
1139            }],
1140        );
1141        assert!(model_entry("Codex:codex/gpt-5").is_some());
1142
1143        // Simulate config reload from config.toml.
1144        let mut cfg = ModelConfig::default();
1145        cfg.aliases.insert(
1146            "cheap".into(),
1147            AliasEntry {
1148                model: "claude-opus-4.7".into(),
1149            },
1150        );
1151        set_model_config(cfg);
1152
1153        // Discovered models should still be there.
1154        assert!(
1155            model_entry("Codex:codex/gpt-5").is_some(),
1156            "discovered models should survive set_model_config"
1157        );
1158        // Alias from config should work.
1159        assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
1160    }
1161
1162    #[test]
1163    fn reload_replaces_config_models_and_preserves_discovered_models() {
1164        let _lock = TEST_CFG_LOCK.lock().unwrap();
1165        let mut initial = ProviderConfig::default();
1166        initial.models.insert(
1167            "old-config".into(),
1168            ModelEntry {
1169                model: "provider/old".into(),
1170                discovered: false,
1171                ..Default::default()
1172            },
1173        );
1174        initial.models.insert(
1175            "dynamic".into(),
1176            ModelEntry {
1177                model: "provider/dynamic".into(),
1178                discovered: true,
1179                ..Default::default()
1180            },
1181        );
1182        set_provider_config(initial);
1183
1184        reload_from_text(
1185            r#"
1186[models.new-config]
1187model = "provider/new"
1188"#,
1189        )
1190        .unwrap();
1191
1192        assert!(model_entry("old-config").is_none());
1193        assert!(model_entry("new-config").is_some());
1194        assert!(model_entry("dynamic").is_some());
1195    }
1196
1197    #[test]
1198    fn discovered_models_survive_reload_from_text_alias_crud() {
1199        let _lock = TEST_CFG_LOCK.lock().unwrap();
1200        register_discovered(
1201            "pid-abc",
1202            "Codex",
1203            &[crate::provider::DiscoveredModel {
1204                slug: "codex/gpt-5".to_string(),
1205                context_budget: Some(128_000),
1206                thinking: true,
1207            }],
1208        );
1209
1210        let toml = r#"
1211[alias]
1212smart = { model = "Codex:codex/gpt-5" }
1213"#;
1214        reload_from_text(toml).unwrap();
1215
1216        assert!(
1217            model_entry("Codex:codex/gpt-5").is_some(),
1218            "discovered models should survive alias CRUD"
1219        );
1220        assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
1221    }
1222
1223    #[test]
1224    fn model_config_update_replaces_name_and_preserves_other_sections() {
1225        let mut doc = r#"
1226# keep this comment
1227[providers.openai]
1228kind = "openai"
1229
1230[alias]
1231smart = { model = "old-name" }
1232cheap = { model = "other-name" }
1233
1234[alias.deep]
1235model = "old-name"
1236
1237[models.old-name]
1238model = "old-id"
1239provider = "openai"
1240enabled = true
1241"#
1242        .parse::<toml_edit::DocumentMut>()
1243        .unwrap();
1244
1245        apply_model_config_update(
1246            &mut doc,
1247            ModelConfigUpdate {
1248                old_name: Some("old-name"),
1249                name: "new-name",
1250                model: "new-id",
1251                provider: Some("openai"),
1252                context_budget: 128_000,
1253                thinking: true,
1254                max_tokens: Some(4096),
1255                enabled: false,
1256            },
1257        )
1258        .unwrap();
1259
1260        let out = doc.to_string();
1261        assert!(out.contains("# keep this comment"));
1262        assert!(out.contains("[providers.openai]"));
1263        assert!(out.contains("[alias]"));
1264        assert!(out.contains("smart = { model = \"new-name\" }"));
1265        assert!(out.contains("cheap = { model = \"other-name\" }"));
1266        assert!(out.contains("[alias.deep]"));
1267        assert!(out.contains("model = \"new-name\""));
1268        assert!(out.contains("[models.new-name]"));
1269        assert!(!out.contains("[models.old-name]"));
1270        assert!(out.contains("model = \"new-id\""));
1271        assert!(out.contains("context_budget = 128000"));
1272        assert!(out.contains("thinking = true"));
1273        assert!(out.contains("max_tokens = 4096"));
1274        assert!(out.contains("enabled = false"));
1275    }
1276}