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