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/// Detect old format: any [models.X] with api_key or base_url, or no config_version.
383pub fn needs_migration(text: &str) -> bool {
384    let Ok(doc) = text.parse::<toml_edit::DocumentMut>() else {
385        return false;
386    };
387    if doc.get("config_version").is_some() {
388        return false;
389    }
390    if let Some(models) = doc.get("models").and_then(|m| m.as_table()) {
391        for (_, entry) in models {
392            if entry.get("api_key").is_some() || entry.get("base_url").is_some() {
393                return true;
394            }
395            // provider field is a type string (openai/openai-compat/anthropic/codex)
396            if let Some(p) = entry.get("provider").and_then(|v| v.as_str()) {
397                if matches!(p, "openai" | "openai-compat" | "anthropic" | "codex") {
398                    return true;
399                }
400            }
401        }
402    }
403    false
404}
405
406/// Migrate v1 config to v2 format. Returns migrated text or None on failure.
407///
408/// - Groups models by (provider_type, api_key, base_url) into [providers.X]
409/// - Removes api_key/base_url from [models.X]
410/// - Updates provider field to reference provider name
411/// - Adds config_version = 2
412pub fn migrate_config(text: &str) -> Option<String> {
413    let mut doc = text.parse::<toml_edit::DocumentMut>().ok()?;
414
415    // Collect models that need migration, grouped by (type, key, url)
416    let models = doc.get("models")?.as_table()?;
417    let mut groups: std::collections::BTreeMap<(String, String, String), Vec<String>> =
418        std::collections::BTreeMap::new();
419
420    for (name, entry) in models.iter() {
421        let api_key = entry
422            .get("api_key")
423            .and_then(|v| v.as_str())
424            .unwrap_or("")
425            .to_string();
426        let base_url = entry
427            .get("base_url")
428            .and_then(|v| v.as_str())
429            .unwrap_or("")
430            .to_string();
431        let ptype = entry
432            .get("provider")
433            .and_then(|v| v.as_str())
434            .unwrap_or("openai-compat")
435            .to_string();
436
437        if !api_key.is_empty()
438            || !base_url.is_empty()
439            || matches!(
440                ptype.as_str(),
441                "openai" | "openai-compat" | "anthropic" | "codex"
442            )
443        {
444            groups
445                .entry((ptype, api_key, base_url))
446                .or_default()
447                .push(name.to_string());
448        }
449    }
450
451    if groups.is_empty() {
452        return None;
453    }
454
455    let mut used_names: std::collections::HashSet<String> = std::collections::HashSet::new();
456
457    for ((ptype, api_key, base_url), model_names) in &groups {
458        let provider_name = pick_provider_name(ptype, base_url, &mut used_names);
459        used_names.insert(provider_name.clone());
460
461        // Create [providers.{name}] section
462        let mut table = toml_edit::Table::new();
463        table.insert("kind", toml_edit::value(ptype.clone()));
464        if !api_key.is_empty() {
465            table.insert("api_key", toml_edit::value(api_key.clone()));
466        }
467        if !base_url.is_empty() {
468            table.insert("base_url", toml_edit::value(base_url.clone()));
469        }
470        table.insert("enabled", toml_edit::value(true));
471
472        // Ensure [providers] table exists
473        if doc.get("providers").is_none() {
474            doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
475        }
476        if let Some(providers) = doc.get_mut("providers").and_then(|p| p.as_table_mut()) {
477            providers.insert(&provider_name, toml_edit::Item::Table(table));
478        }
479
480        // Update each model's provider field and remove api_key/base_url
481        for model_name in model_names {
482            if let Some(model) = doc
483                .get_mut("models")
484                .and_then(|m| m.as_table_mut())
485                .and_then(|t| t.get_mut(model_name.as_str()))
486                .and_then(|e| e.as_table_mut())
487            {
488                model.insert("provider", toml_edit::value(&provider_name));
489                model.remove("api_key");
490                model.remove("base_url");
491            }
492        }
493    }
494
495    // Add config_version = 2 at the top level
496    doc.insert("config_version", toml_edit::value(2i64));
497
498    Some(doc.to_string())
499}
500
501fn pick_provider_name(
502    ptype: &str,
503    base_url: &str,
504    used: &mut std::collections::HashSet<String>,
505) -> String {
506    // Try to match base_url against PROVIDER_PRESETS
507    for preset in PROVIDER_PRESETS {
508        if !base_url.is_empty() && preset.base_url == base_url {
509            let name = preset.name.to_lowercase();
510            if !used.contains(&name) {
511                return name;
512            }
513        }
514    }
515    // Fall back to provider type, with suffix for duplicates
516    let base = ptype.to_string();
517    if !used.contains(&base) {
518        return base;
519    }
520    for i in 2.. {
521        let candidate = format!("{base}-{i}");
522        if !used.contains(&candidate) {
523            return candidate;
524        }
525    }
526    unreachable!()
527}
528
529/// Run migration if needed. Returns true if migration was performed.
530pub fn run_migration_if_needed() -> bool {
531    let Ok(text) = read_config_toml().ok_or(()) else {
532        return false;
533    };
534    if !needs_migration(&text) {
535        return false;
536    }
537    let Some(migrated) = migrate_config(&text) else {
538        return false;
539    };
540    // Backup original
541    if let Ok(dir) = crate::storage::config_dir() {
542        let _ = std::fs::write(dir.join("config.toml.bak"), &text);
543    }
544    if write_config_toml(&migrated).is_err() {
545        return false;
546    }
547    crate::notify!(
548        info,
549        "config.toml migrated to v2 format (backup at config.toml.bak)"
550    );
551    true
552}
553
554// Alias CRUD (writes config.toml)
555
556pub fn read_config_toml_pub() -> Option<String> {
557    read_config_toml()
558}
559
560fn read_config_toml() -> Option<String> {
561    let path = crate::storage::config_dir().ok()?.join("config.toml");
562    std::fs::read_to_string(&path).ok()
563}
564
565fn write_config_toml(text: &str) -> anyhow::Result<()> {
566    let dir = crate::storage::config_dir().map_err(|e| anyhow::anyhow!("config dir: {e}"))?;
567    std::fs::create_dir_all(&dir)?;
568    let path = dir.join("config.toml");
569    let tmp = dir.join(".config.toml.tmp");
570    std::fs::write(&tmp, text)?;
571    std::fs::rename(&tmp, &path)?;
572    Ok(())
573}
574
575fn reload_from_text(text: &str) {
576    let Ok(raw) = toml::from_str::<toml::Value>(text) else {
577        return;
578    };
579    let mut guard = MODEL_CONFIG.write().unwrap();
580    let mut cfg = guard.take().unwrap_or_default();
581
582    // Update aliases from config.toml — preserve all other state
583    // (discovered models, config-defined models).
584    cfg.aliases.clear();
585    if let Some(aliases) = raw.get("alias").and_then(|a| a.as_table()) {
586        for (name, entry) in aliases {
587            if let Some(model) = entry.get("model").and_then(|m| m.as_str()) {
588                cfg.aliases.insert(
589                    name.clone(),
590                    AliasEntry {
591                        model: model.to_string(),
592                    },
593                );
594            }
595        }
596    }
597
598    // Update providers from config.toml.
599    cfg.providers.clear();
600    if let Some(providers) = raw.get("providers").and_then(|p| p.as_table()) {
601        for (key, entry) in providers {
602            let name = entry
603                .get("name")
604                .and_then(|v| v.as_str())
605                .map(String::from)
606                .unwrap_or_else(|| key.clone());
607            let kind = entry
608                .get("kind")
609                .and_then(|v| v.as_str())
610                .map(String::from)
611                .unwrap_or_default();
612            let api_key = entry
613                .get("api_key")
614                .and_then(|v| v.as_str())
615                .map(String::from);
616            let api_key_env = entry
617                .get("api_key_env")
618                .and_then(|v| v.as_str())
619                .map(String::from);
620            let base_url = entry
621                .get("base_url")
622                .and_then(|v| v.as_str())
623                .map(String::from);
624            let max_tokens = entry
625                .get("max_tokens")
626                .and_then(|v| v.as_integer())
627                .map(|n| n as u32);
628            let enabled = entry.get("enabled").and_then(|v| v.as_bool());
629            cfg.providers.insert(
630                key.clone(),
631                ProviderEntry {
632                    name,
633                    kind,
634                    api_key,
635                    api_key_env,
636                    base_url,
637                    max_tokens,
638                    enabled,
639                },
640            );
641        }
642    }
643
644    // Update config-defined models — only update existing keys or add
645    // new ones; never remove entries that aren't in config.toml.
646    if let Some(models) = raw.get("models").and_then(|m| m.as_table()) {
647        for (name, entry) in models {
648            let provider = entry
649                .get("provider")
650                .and_then(|v| v.as_str())
651                .map(String::from);
652            let context_budget = entry
653                .get("context_budget")
654                .and_then(|v| v.as_integer())
655                .map(|n| n as u64);
656            let thinking = entry.get("thinking").and_then(|v| v.as_bool());
657            let max_tokens = entry
658                .get("max_tokens")
659                .and_then(|v| v.as_integer())
660                .map(|n| n as u32);
661            let model = entry
662                .get("model")
663                .and_then(|v| v.as_str())
664                .map(String::from);
665            cfg.models.insert(
666                name.clone(),
667                ModelEntry {
668                    model: model.unwrap_or_default(),
669                    provider,
670                    context_budget,
671                    compact_threshold_ratio: None,
672                    thinking,
673                    max_tokens,
674                    enabled: None,
675                    discovered: false,
676                },
677            );
678        }
679    }
680
681    *guard = Some(cfg);
682}
683
684/// Unified config parser — parses `[providers.X]`, `[models.X]`, and `[alias.X]`
685/// sections from a TOML string into a [ProviderConfig].
686///
687/// Replaces the per-crate `parse_model_config` functions in CLI and daemon.
688pub fn parse_config(text: &str) -> Option<ProviderConfig> {
689    #[derive(serde::Deserialize, Default)]
690    struct RawProvider {
691        #[serde(default)]
692        name: Option<String>,
693        #[serde(default)]
694        kind: Option<String>,
695        #[serde(default)]
696        api_key: Option<String>,
697        #[serde(default)]
698        api_key_env: Option<String>,
699        #[serde(default)]
700        base_url: Option<String>,
701        #[serde(default)]
702        max_tokens: Option<u32>,
703        #[serde(default)]
704        enabled: Option<bool>,
705    }
706
707    #[derive(serde::Deserialize, Default)]
708    struct RawModel {
709        #[serde(default)]
710        model: Option<String>,
711        #[serde(default)]
712        provider: Option<String>,
713        #[serde(default)]
714        context_budget: Option<u64>,
715        #[serde(default)]
716        compact_threshold_ratio: Option<f64>,
717        #[serde(default)]
718        thinking: Option<bool>,
719        #[serde(default)]
720        max_tokens: Option<u32>,
721        #[serde(default)]
722        enabled: Option<bool>,
723        #[serde(default)]
724        discovered: bool,
725    }
726
727    #[derive(serde::Deserialize, Default)]
728    struct RawAlias {
729        model: String,
730    }
731
732    #[derive(serde::Deserialize, Default)]
733    struct RawFile {
734        #[serde(default)]
735        providers: std::collections::HashMap<String, RawProvider>,
736        #[serde(default)]
737        models: std::collections::HashMap<String, RawModel>,
738        #[serde(default)]
739        alias: std::collections::HashMap<String, RawAlias>,
740    }
741
742    let raw: RawFile = toml::from_str(text).ok()?;
743    let mut cfg = ProviderConfig::default();
744
745    for (key, p) in raw.providers {
746        cfg.providers.insert(
747            key.clone(),
748            ProviderEntry {
749                name: p.name.unwrap_or(key),
750                kind: p.kind.unwrap_or_default(),
751                api_key: p.api_key,
752                api_key_env: p.api_key_env,
753                base_url: p.base_url,
754                max_tokens: p.max_tokens,
755                enabled: p.enabled,
756            },
757        );
758    }
759
760    for (name, m) in raw.models {
761        cfg.models.insert(
762            name,
763            ModelEntry {
764                model: m.model.unwrap_or_default(),
765                provider: m.provider,
766                context_budget: m.context_budget,
767                compact_threshold_ratio: m.compact_threshold_ratio,
768                thinking: m.thinking,
769                max_tokens: m.max_tokens,
770                enabled: m.enabled,
771                discovered: m.discovered,
772            },
773        );
774    }
775
776    for (name, a) in raw.alias {
777        cfg.aliases.insert(name, AliasEntry { model: a.model });
778    }
779
780    if cfg.providers.is_empty() && cfg.models.is_empty() && cfg.aliases.is_empty() {
781        return None;
782    }
783    Some(cfg)
784}
785
786pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
787    let text = read_config_toml().unwrap_or_default();
788    let new_text = upsert_alias_comment_preserving(&text, alias, model);
789    write_config_toml(&new_text)?;
790    reload_from_text(&new_text);
791    Ok(())
792}
793
794fn upsert_alias_comment_preserving(text: &str, alias: &str, model: &str) -> String {
795    let section_start = format!("[alias.{alias}]");
796    let model_line = format!("model = {model:?}");
797    let mut lines: Vec<String> = text.lines().map(String::from).collect();
798
799    if let Some(section) = lines
800        .iter()
801        .position(|l| l.trim().starts_with(&format!("[alias.{alias}")) && l.trim().ends_with(']'))
802    {
803        let mut inserted = false;
804        for line in lines.iter_mut().skip(section + 1) {
805            let t = line.trim();
806            if (t.starts_with("[alias.") || t == "[alias]") && t.ends_with(']') {
807                break;
808            }
809            if t.starts_with("model") && t.contains('=') {
810                *line = model_line.clone();
811                inserted = true;
812                break;
813            }
814        }
815        if !inserted {
816            lines.insert(section + 1, model_line);
817        }
818    } else {
819        while lines.last().is_some_and(|l| l.trim().is_empty()) {
820            lines.pop();
821        }
822        if !lines.is_empty() {
823            lines.push(String::new());
824        }
825        lines.push(section_start);
826        lines.push(model_line);
827    }
828
829    lines.join("\n")
830}
831
832pub fn upsert_provider_config(
833    name: &str,
834    kind: &str,
835    api_key: Option<&str>,
836    api_key_env: Option<&str>,
837    base_url: Option<&str>,
838    max_tokens: Option<u32>,
839    enabled: bool,
840) -> anyhow::Result<()> {
841    let text = read_config_toml().unwrap_or_default();
842    let mut doc: toml_edit::DocumentMut = if text.trim().is_empty() {
843        toml_edit::DocumentMut::new()
844    } else {
845        text.parse()
846            .map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
847    };
848
849    if doc.get("providers").is_none() {
850        doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
851    }
852    let providers = doc
853        .get_mut("providers")
854        .and_then(|p| p.as_table_mut())
855        .ok_or_else(|| anyhow::anyhow!("providers is not a table"))?;
856
857    let mut entry = toml_edit::Table::new();
858    entry.insert("kind", toml_edit::value(kind));
859    if let Some(key) = api_key {
860        if !key.is_empty() {
861            entry.insert("api_key", toml_edit::value(key));
862        }
863    }
864    if let Some(env) = api_key_env {
865        if !env.is_empty() {
866            entry.insert("api_key_env", toml_edit::value(env));
867        }
868    }
869    if let Some(url) = base_url {
870        if !url.is_empty() {
871            entry.insert("base_url", toml_edit::value(url));
872        }
873    }
874    if let Some(mt) = max_tokens {
875        entry.insert("max_tokens", toml_edit::value(mt as i64));
876    }
877    entry.insert("enabled", toml_edit::value(enabled));
878    providers.insert(name, toml_edit::Item::Table(entry));
879
880    let new_text = doc.to_string();
881    write_config_toml(&new_text)?;
882    reload_from_text(&new_text);
883    Ok(())
884}
885
886#[derive(Debug, Clone)]
887pub struct ModelConfigUpdate<'a> {
888    pub old_name: Option<&'a str>,
889    pub name: &'a str,
890    pub model: &'a str,
891    pub provider: Option<&'a str>,
892    pub context_budget: u64,
893    pub thinking: bool,
894    pub max_tokens: Option<u32>,
895    pub enabled: bool,
896}
897
898fn apply_model_config_update(
899    doc: &mut toml_edit::DocumentMut,
900    update: ModelConfigUpdate<'_>,
901) -> anyhow::Result<()> {
902    if doc.get("models").is_none() {
903        doc.insert("models", toml_edit::Item::Table(toml_edit::Table::new()));
904    }
905    {
906        let models = doc
907            .get_mut("models")
908            .and_then(|item| item.as_table_mut())
909            .ok_or_else(|| anyhow::anyhow!("models is not a table"))?;
910        if let Some(old_name) = update.old_name.filter(|old| *old != update.name) {
911            models.remove(old_name);
912        }
913        let entry = models
914            .entry(update.name)
915            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
916            .as_table_mut()
917            .ok_or_else(|| anyhow::anyhow!("model entry is not a table"))?;
918        entry.insert("model", toml_edit::value(update.model));
919        if let Some(provider) = update.provider {
920            entry.insert("provider", toml_edit::value(provider));
921        } else {
922            entry.remove("provider");
923        }
924        entry.insert(
925            "context_budget",
926            toml_edit::value(update.context_budget as i64),
927        );
928        entry.insert("thinking", toml_edit::value(update.thinking));
929        if let Some(max_tokens) = update.max_tokens {
930            entry.insert("max_tokens", toml_edit::value(max_tokens as i64));
931        } else {
932            entry.remove("max_tokens");
933        }
934        entry.insert("enabled", toml_edit::value(update.enabled));
935    }
936
937    if let Some(old_name) = update.old_name.filter(|old| *old != update.name)
938        && let Some(aliases) = doc.get_mut("alias").and_then(|item| item.as_table_mut())
939    {
940        for (_, alias) in aliases.iter_mut() {
941            if let Some(table) = alias.as_table_mut() {
942                if table.get("model").and_then(|item| item.as_str()) == Some(old_name) {
943                    table.insert("model", toml_edit::value(update.name));
944                }
945            } else if let Some(inline) = alias.as_inline_table_mut()
946                && inline.get("model").and_then(|value| value.as_str()) == Some(old_name)
947            {
948                inline.insert("model", toml_edit::Value::from(update.name));
949            }
950        }
951    }
952    Ok(())
953}
954
955pub fn upsert_model_config(update: ModelConfigUpdate<'_>) -> anyhow::Result<()> {
956    let text = read_config_toml().unwrap_or_default();
957    let mut doc: toml_edit::DocumentMut = if text.trim().is_empty() {
958        toml_edit::DocumentMut::new()
959    } else {
960        text.parse()
961            .map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
962    };
963    apply_model_config_update(&mut doc, update)?;
964
965    let new_text = doc.to_string();
966    write_config_toml(&new_text)?;
967    reload_from_text(&new_text);
968    Ok(())
969}
970
971pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
972    let text = read_config_toml().unwrap_or_default();
973    let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
974    if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
975        table.remove(alias);
976    }
977    let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
978    write_config_toml(&new_text)?;
979    reload_from_text(&new_text);
980    Ok(())
981}
982
983pub fn update_alias_in_config(
984    old_alias: &str,
985    new_alias: &str,
986    new_model: &str,
987) -> anyhow::Result<()> {
988    let text = read_config_toml().unwrap_or_default();
989    let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
990    if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
991        table.remove(old_alias);
992        let mut entry = toml::value::Table::new();
993        entry.insert(
994            "model".to_string(),
995            toml::Value::String(new_model.to_string()),
996        );
997        table.insert(new_alias.to_string(), toml::Value::Table(entry));
998    }
999    let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
1000    write_config_toml(&new_text)?;
1001    reload_from_text(&new_text);
1002    Ok(())
1003}
1004
1005// Provider presets + first-run detection
1006
1007pub struct ProviderPreset {
1008    pub name: &'static str,
1009    pub description: &'static str,
1010    pub base_url: &'static str,
1011    pub provider_type: &'static str,
1012    pub models: &'static [ProviderPresetModel],
1013    pub key_url: Option<&'static str>,
1014    pub needs_api_key: bool,
1015}
1016
1017pub struct ProviderPresetModel {
1018    pub id: &'static str,
1019    pub description: &'static str,
1020    pub context_budget: u64,
1021    pub thinking: bool,
1022}
1023
1024pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
1025    ProviderPreset {
1026        name: "DeepSeek",
1027        description: "Recommended — cheap, smart, supports thinking",
1028        base_url: "https://api.deepseek.com",
1029        provider_type: "openai-compat",
1030        models: &[
1031            ProviderPresetModel {
1032                id: "deepseek-v4-flash",
1033                description: "Fast & capable",
1034                context_budget: 1000000,
1035                thinking: false,
1036            },
1037            ProviderPresetModel {
1038                id: "deepseek-v4-pro",
1039                description: "Thinking mode",
1040                context_budget: 1000000,
1041                thinking: true,
1042            },
1043        ],
1044        key_url: Some("https://platform.deepseek.com"),
1045        needs_api_key: true,
1046    },
1047    ProviderPreset {
1048        name: "OpenAI",
1049        description: "GPT-4o / GPT-4o-mini",
1050        base_url: "https://api.openai.com/v1",
1051        provider_type: "openai",
1052        models: &[
1053            ProviderPresetModel {
1054                id: "gpt-4o",
1055                description: "Most capable",
1056                context_budget: 128000,
1057                thinking: false,
1058            },
1059            ProviderPresetModel {
1060                id: "gpt-4o-mini",
1061                description: "Fast & cheap",
1062                context_budget: 128000,
1063                thinking: false,
1064            },
1065        ],
1066        key_url: Some("https://platform.openai.com/api-keys"),
1067        needs_api_key: true,
1068    },
1069    ProviderPreset {
1070        name: "Anthropic",
1071        description: "Claude models",
1072        base_url: "https://api.anthropic.com",
1073        provider_type: "anthropic",
1074        models: &[ProviderPresetModel {
1075            id: "claude-sonnet-4-20250514",
1076            description: "Claude Sonnet 4",
1077            context_budget: 200000,
1078            thinking: true,
1079        }],
1080        key_url: Some("https://console.anthropic.com/settings/keys"),
1081        needs_api_key: true,
1082    },
1083    ProviderPreset {
1084        name: "ZhipuAI",
1085        description: "GLM models",
1086        base_url: "https://open.bigmodel.cn/api/paas/v4",
1087        provider_type: "openai-compat",
1088        models: &[ProviderPresetModel {
1089            id: "glm-5.2",
1090            description: "GLM 5.2",
1091            context_budget: 1000000,
1092            thinking: true,
1093        }],
1094        key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
1095        needs_api_key: true,
1096    },
1097    ProviderPreset {
1098        name: "Ollama",
1099        description: "Local models, no API key needed",
1100        base_url: "http://localhost:11434/v1",
1101        provider_type: "openai-compat",
1102        models: &[],
1103        key_url: None,
1104        needs_api_key: false,
1105    },
1106    ProviderPreset {
1107        name: "Codex",
1108        description: "ChatGPT Plus/Pro OAuth",
1109        base_url: "https://chatgpt.com/backend-api/codex",
1110        provider_type: "codex",
1111        models: &[],
1112        key_url: None,
1113        needs_api_key: false,
1114    },
1115];
1116
1117pub fn is_first_run() -> bool {
1118    let providers = all_provider_entries();
1119    let models = all_model_entries();
1120    let config_configured = providers.iter().any(|(_, e)| {
1121        e.api_key.as_deref().is_some_and(|k| !k.is_empty())
1122            || e.api_key_env
1123                .as_deref()
1124                .is_some_and(|env| std::env::var(env).is_ok_and(|v| !v.trim().is_empty()))
1125    });
1126    let env_configured =
1127        std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
1128    let auth_configured = AuthStore::load()
1129        .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
1130    let smart_resolves = {
1131        let resolved = resolve_alias("smart");
1132        resolved != "smart" && models.iter().any(|(n, _)| *n == resolved)
1133    };
1134    !(config_configured || env_configured || auth_configured) || !smart_resolves
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140    use std::sync::Mutex as StdMutex;
1141
1142    /// Tests that mutate MODEL_CONFIG must hold this lock to avoid races
1143    /// when cargo test runs them in parallel.
1144    static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
1145
1146    #[test]
1147    fn unregistered_model_returns_zero_budget() {
1148        let _lock = TEST_CFG_LOCK.lock().unwrap();
1149        *MODEL_CONFIG.write().unwrap() = None;
1150        assert_eq!(model_info("mystery-model").context_budget, 0);
1151        assert_eq!(model_info("").context_budget, 0);
1152    }
1153
1154    #[test]
1155    fn threshold_is_eighty_percent() {
1156        let _lock = TEST_CFG_LOCK.lock().unwrap();
1157        let mut cfg = ModelConfig::default();
1158        cfg.models.insert(
1159            "claude-opus-4.7".into(),
1160            ModelEntry {
1161                model: "claude-opus-4.7".into(),
1162                context_budget: Some(200_000),
1163                compact_threshold_ratio: Some(0.8),
1164                thinking: None,
1165                ..Default::default()
1166            },
1167        );
1168        set_model_config(cfg);
1169        let info = model_info("claude-opus-4.7");
1170        assert_eq!(info.compact_threshold_tokens(), 160_000);
1171    }
1172
1173    #[test]
1174    fn compaction_trigger_is_near_budget_top() {
1175        let _lock = TEST_CFG_LOCK.lock().unwrap();
1176        let mut cfg = ModelConfig::default();
1177        cfg.models.insert(
1178            "claude-opus-4.7".into(),
1179            ModelEntry {
1180                model: "claude-opus-4.7".into(),
1181                context_budget: Some(200_000),
1182                compact_threshold_ratio: Some(0.8),
1183                thinking: None,
1184                ..Default::default()
1185            },
1186        );
1187        set_model_config(cfg);
1188        let info = model_info("claude-opus-4.7");
1189        let trigger = info.compaction_trigger_threshold();
1190        assert!(
1191            trigger > 150_000 && trigger <= 190_000,
1192            "trigger should be near the top of the budget, got {trigger}"
1193        );
1194    }
1195
1196    #[test]
1197    fn compaction_target_is_lower_than_trigger() {
1198        let _lock = TEST_CFG_LOCK.lock().unwrap();
1199        let mut cfg = ModelConfig::default();
1200        cfg.models.insert(
1201            "claude-opus-4.7".into(),
1202            ModelEntry {
1203                model: "claude-opus-4.7".into(),
1204                context_budget: Some(200_000),
1205                compact_threshold_ratio: Some(0.8),
1206                thinking: None,
1207                ..Default::default()
1208            },
1209        );
1210        set_model_config(cfg);
1211        let info = model_info("claude-opus-4.7");
1212        let trigger = info.compaction_trigger_threshold();
1213        let target = info.compaction_target_after();
1214        assert!(
1215            target < trigger,
1216            "target {target} should be less than trigger {trigger}"
1217        );
1218        assert_eq!(target, 50_000, "compaction target should be 25% of context");
1219    }
1220
1221    #[test]
1222    fn alias_resolves_to_real_model() {
1223        let _lock = TEST_CFG_LOCK.lock().unwrap();
1224        let mut cfg = ModelConfig::default();
1225        cfg.models.insert(
1226            "claude-opus-4.7".into(),
1227            ModelEntry {
1228                model: "claude-opus-4.7".into(),
1229                context_budget: Some(200_000),
1230                ..Default::default()
1231            },
1232        );
1233        cfg.aliases.insert(
1234            "smart".into(),
1235            AliasEntry {
1236                model: "claude-opus-4.7".into(),
1237            },
1238        );
1239        set_model_config(cfg);
1240        let info = model_info("smart");
1241        assert_eq!(info.context_budget, 200_000);
1242        assert_eq!(info.name, "claude-opus-4.7");
1243    }
1244
1245    #[test]
1246    fn custom_model_overrides_budget() {
1247        let _lock = TEST_CFG_LOCK.lock().unwrap();
1248        let mut cfg = ModelConfig::default();
1249        cfg.models.insert(
1250            "my-local-model".into(),
1251            ModelEntry {
1252                model: "my-local-model".into(),
1253                context_budget: Some(8192),
1254                compact_threshold_ratio: Some(0.9),
1255                thinking: None,
1256                ..Default::default()
1257            },
1258        );
1259        set_model_config(cfg);
1260        let info = model_info("my-local-model");
1261        assert_eq!(info.context_budget, 8192);
1262        assert_eq!(info.compact_threshold_ratio, 0.9);
1263    }
1264
1265    #[test]
1266    fn compact_threshold_reserves_configured_output_tokens() {
1267        let _lock = TEST_CFG_LOCK.lock().unwrap();
1268        let mut cfg = ModelConfig::default();
1269        cfg.models.insert(
1270            "large-output".into(),
1271            ModelEntry {
1272                model: "large-output".into(),
1273                context_budget: Some(1_000_000),
1274                compact_threshold_ratio: Some(0.8),
1275                thinking: None,
1276                max_tokens: Some(400_000),
1277                ..Default::default()
1278            },
1279        );
1280        set_model_config(cfg);
1281        let info = model_info("large-output");
1282        assert_eq!(info.compact_threshold_tokens(), 480_000);
1283        let trigger = info.compaction_trigger_threshold();
1284        assert!(
1285            trigger > 700_000,
1286            "trigger with capped output reserve should be > 700K, got {trigger}"
1287        );
1288    }
1289
1290    #[test]
1291    fn alias_chains_through_custom_model() {
1292        let _lock = TEST_CFG_LOCK.lock().unwrap();
1293        let mut cfg = ModelConfig::default();
1294        cfg.aliases.insert(
1295            "default".into(),
1296            AliasEntry {
1297                model: "my-model".into(),
1298            },
1299        );
1300        cfg.models.insert(
1301            "my-model".into(),
1302            ModelEntry {
1303                model: "my-model".into(),
1304                context_budget: Some(65_536),
1305                compact_threshold_ratio: None,
1306                thinking: None,
1307                ..Default::default()
1308            },
1309        );
1310        set_model_config(cfg);
1311        let info = model_info("default");
1312        assert_eq!(info.name, "my-model");
1313        assert_eq!(info.context_budget, 65_536);
1314    }
1315
1316    #[test]
1317    fn discovered_models_survive_set_model_config() {
1318        let _lock = TEST_CFG_LOCK.lock().unwrap();
1319        // Register discovered models first.
1320        register_discovered(
1321            "pid-abc",
1322            "Codex",
1323            &[crate::provider::DiscoveredModel {
1324                slug: "codex/gpt-5".to_string(),
1325                context_budget: Some(128_000),
1326                thinking: true,
1327            }],
1328        );
1329        assert!(model_entry("Codex:codex/gpt-5").is_some());
1330
1331        // Simulate config reload from config.toml.
1332        let mut cfg = ModelConfig::default();
1333        cfg.aliases.insert(
1334            "cheap".into(),
1335            AliasEntry {
1336                model: "claude-opus-4.7".into(),
1337            },
1338        );
1339        set_model_config(cfg);
1340
1341        // Discovered models should still be there.
1342        assert!(
1343            model_entry("Codex:codex/gpt-5").is_some(),
1344            "discovered models should survive set_model_config"
1345        );
1346        // Alias from config should work.
1347        assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
1348    }
1349
1350    #[test]
1351    fn discovered_models_survive_reload_from_text_alias_crud() {
1352        let _lock = TEST_CFG_LOCK.lock().unwrap();
1353        register_discovered(
1354            "pid-abc",
1355            "Codex",
1356            &[crate::provider::DiscoveredModel {
1357                slug: "codex/gpt-5".to_string(),
1358                context_budget: Some(128_000),
1359                thinking: true,
1360            }],
1361        );
1362
1363        // Simulate alias add → reload_from_text
1364        let toml = r#"
1365[alias]
1366smart = { model = "Codex:codex/gpt-5" }
1367"#;
1368        reload_from_text(toml);
1369
1370        assert!(
1371            model_entry("Codex:codex/gpt-5").is_some(),
1372            "discovered models should survive alias CRUD"
1373        );
1374        assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
1375    }
1376
1377    #[test]
1378    fn add_alias_preserves_comments() {
1379        let toml = "# top comment\n[alias]\n# smart line comment\nsmart = { model = \"claude\" }\n";
1380        let out = upsert_alias_comment_preserving(toml, "smart", "claude-opus-4.7");
1381        assert!(out.contains("# top comment"));
1382        assert!(out.contains("# smart line comment"));
1383        assert!(out.contains("model = \"claude-opus-4.7\""));
1384    }
1385
1386    #[test]
1387    fn model_config_update_replaces_name_and_preserves_other_sections() {
1388        let mut doc = r#"
1389# keep this comment
1390[providers.openai]
1391kind = "openai"
1392
1393[alias]
1394smart = { model = "old-name" }
1395cheap = { model = "other-name" }
1396
1397[alias.deep]
1398model = "old-name"
1399
1400[models.old-name]
1401model = "old-id"
1402provider = "openai"
1403enabled = true
1404"#
1405        .parse::<toml_edit::DocumentMut>()
1406        .unwrap();
1407
1408        apply_model_config_update(
1409            &mut doc,
1410            ModelConfigUpdate {
1411                old_name: Some("old-name"),
1412                name: "new-name",
1413                model: "new-id",
1414                provider: Some("openai"),
1415                context_budget: 128_000,
1416                thinking: true,
1417                max_tokens: Some(4096),
1418                enabled: false,
1419            },
1420        )
1421        .unwrap();
1422
1423        let out = doc.to_string();
1424        assert!(out.contains("# keep this comment"));
1425        assert!(out.contains("[providers.openai]"));
1426        assert!(out.contains("[alias]"));
1427        assert!(out.contains("smart = { model = \"new-name\" }"));
1428        assert!(out.contains("cheap = { model = \"other-name\" }"));
1429        assert!(out.contains("[alias.deep]"));
1430        assert!(out.contains("model = \"new-name\""));
1431        assert!(out.contains("[models.new-name]"));
1432        assert!(!out.contains("[models.old-name]"));
1433        assert!(out.contains("model = \"new-id\""));
1434        assert!(out.contains("context_budget = 128000"));
1435        assert!(out.contains("thinking = true"));
1436        assert!(out.contains("max_tokens = 4096"));
1437        assert!(out.contains("enabled = false"));
1438    }
1439}