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