Skip to main content

atman_runtime/
model_registry.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::sync::{LazyLock, RwLock};
3
4use crate::auth_store::AuthStore;
5use crate::provider::{
6    CapabilityKnowledge, ImageDetail, InputModality, ModelCapabilities, ReasoningEffort,
7    ReasoningExecutionMode, ReasoningSelection, ReasoningWireProfile,
8};
9
10#[derive(Debug, Clone)]
11pub struct ModelInfo {
12    pub name: String,
13    pub context_budget: u64,
14    pub compact_threshold_ratio: f64,
15    pub reasoning: ReasoningSelection,
16    pub capabilities: ModelCapabilities,
17    pub image_detail: ImageDetail,
18    pub max_output_tokens: Option<u32>,
19}
20
21pub const DEFAULT_CONFIG_PROVIDER_TYPE: &str = "openai-compat";
22
23pub fn config_provider_types() -> Vec<&'static str> {
24    let mut types = Vec::new();
25    for preset in PROVIDER_PRESETS {
26        if preset.provider_type == "codex" {
27            continue;
28        }
29        if !types.contains(&preset.provider_type) {
30            types.push(preset.provider_type);
31        }
32    }
33    if types.is_empty() {
34        types.push(DEFAULT_CONFIG_PROVIDER_TYPE);
35    }
36    types
37}
38
39#[derive(Debug, Clone, Default)]
40pub struct ProviderEntry {
41    pub name: String,
42    pub kind: String,
43    pub api_key: Option<String>,
44    pub api_key_env: Option<String>,
45    pub base_url: Option<String>,
46    pub max_tokens: Option<u32>,
47    pub reasoning_format: Option<crate::providers::openai::OpenAiReasoningFormat>,
48    pub prompt_cache_key: Option<bool>,
49    pub enabled: Option<bool>,
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct ModelEntry {
54    pub model: String,
55    pub provider: Option<String>,
56    pub context_budget: Option<u64>,
57    pub compact_threshold_ratio: Option<f64>,
58    pub thinking: Option<bool>,
59    pub reasoning: Option<String>,
60    pub reasoning_mode: Option<String>,
61    pub reasoning_budget_tokens: Option<u32>,
62    pub reasoning_efforts: Vec<ReasoningEffort>,
63    pub default_reasoning_effort: Option<ReasoningEffort>,
64    pub reasoning_modes: Vec<ReasoningExecutionMode>,
65    pub default_reasoning_mode: Option<ReasoningExecutionMode>,
66    pub input_modalities: Vec<InputModality>,
67    pub image_detail: Option<ImageDetail>,
68    pub max_tokens: Option<u32>,
69    pub enabled: Option<bool>,
70    #[allow(dead_code)]
71    pub discovered: bool,
72}
73
74#[derive(Debug, Clone, Default)]
75pub struct AliasEntry {
76    pub model: String,
77}
78
79#[derive(Debug, Clone, Default)]
80pub struct ProviderConfig {
81    pub providers: HashMap<String, ProviderEntry>,
82    pub models: HashMap<String, ModelEntry>,
83    pub aliases: HashMap<String, AliasEntry>,
84}
85
86/// Backwards-compatible alias — ProviderConfig is the canonical name.
87pub type ModelConfig = ProviderConfig;
88
89#[derive(Debug, Clone, Default)]
90struct CapabilityDeclarations {
91    reasoning_efforts: bool,
92    reasoning_modes: bool,
93    input_modalities: bool,
94}
95
96impl CapabilityDeclarations {
97    fn inferred(entry: &ModelEntry) -> Self {
98        Self {
99            reasoning_efforts: !entry.reasoning_efforts.is_empty(),
100            reasoning_modes: !entry.reasoning_modes.is_empty(),
101            input_modalities: !entry.input_modalities.is_empty(),
102        }
103    }
104}
105
106#[derive(Debug, Clone, Default)]
107struct ConfigLayer {
108    values: ProviderConfig,
109    capabilities: HashMap<String, CapabilityDeclarations>,
110}
111
112impl ConfigLayer {
113    fn inferred(values: ProviderConfig) -> Self {
114        let capabilities = values
115            .models
116            .iter()
117            .map(|(name, entry)| (name.clone(), CapabilityDeclarations::inferred(entry)))
118            .collect();
119        Self {
120            values,
121            capabilities,
122        }
123    }
124
125    fn declarations(&self, name: &str) -> CapabilityDeclarations {
126        self.capabilities.get(name).cloned().unwrap_or_default()
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
131struct ModelIdentity {
132    provider_key: String,
133    api_model: String,
134}
135
136impl ModelIdentity {
137    fn new(provider_key: impl Into<String>, api_model: impl Into<String>) -> Self {
138        Self {
139            provider_key: provider_key.into(),
140            api_model: api_model.into(),
141        }
142    }
143}
144
145#[derive(Debug, Clone)]
146struct PresetModelEntry {
147    registry_key: String,
148    entry: ModelEntry,
149}
150
151#[derive(Debug, Clone)]
152struct CatalogModelEntry {
153    registry_key: String,
154    api_model: String,
155    context_budget: Option<u64>,
156    capability_knowledge: CapabilityKnowledge,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ProviderDescriptor {
161    pub provider_key: String,
162    pub provider_name: String,
163    pub namespace: String,
164    pub wire_profile: ReasoningWireProfile,
165}
166
167#[derive(Debug, Clone)]
168struct ProviderCatalog {
169    descriptor: ProviderDescriptor,
170    models: BTreeMap<String, CatalogModelEntry>,
171}
172
173#[derive(Debug, Default)]
174struct RegistryState {
175    config: ConfigLayer,
176    preset_models: BTreeMap<ModelIdentity, PresetModelEntry>,
177    catalogs: BTreeMap<String, ProviderCatalog>,
178    legacy_models: BTreeMap<String, ModelEntry>,
179    legacy_discovered_models: Vec<String>,
180    catalog_revision: u64,
181}
182
183static REGISTRY_STATE: LazyLock<RwLock<RegistryState>> =
184    LazyLock::new(|| RwLock::new(RegistryState::default()));
185static CATALOG_REVISION: LazyLock<tokio::sync::watch::Sender<u64>> =
186    LazyLock::new(|| tokio::sync::watch::channel(0).0);
187static REGISTRY_TRANSACTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
188
189/// Serializes tests that mutate the global model registry.
190///
191/// This stays available in integration tests so they can avoid racing the
192/// shared model registry state.
193pub static MODEL_CONFIG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
194
195pub fn set_discovered_models(models: Vec<String>) {
196    REGISTRY_STATE.write().unwrap().legacy_discovered_models = models;
197}
198
199pub fn discovered_models() -> Vec<String> {
200    let state = REGISTRY_STATE.read().unwrap();
201    let mut models = state.legacy_discovered_models.clone();
202    let mut seen: BTreeSet<String> = models.iter().cloned().collect();
203    for catalog in state.catalogs.values() {
204        for model in catalog.models.values() {
205            if seen.insert(model.registry_key.clone()) {
206                models.push(model.registry_key.clone());
207            }
208        }
209    }
210    models
211}
212
213/// Set the base model configuration (from config.toml).
214/// Dynamic provider catalogs remain in their own layer.
215pub fn set_provider_config(cfg: ProviderConfig) {
216    if let Err(error) = install_config_layer(ConfigLayer::inferred(cfg)) {
217        crate::notify!(error, "model config update rejected: {error}");
218    }
219}
220
221/// Backwards-compatible alias for [set_provider_config].
222pub fn set_model_config(cfg: ModelConfig) {
223    set_provider_config(cfg);
224}
225
226/// Register additional model entries without clobbering existing ones.
227pub fn register_model_entries(entries: Vec<(String, ModelEntry)>) {
228    let mut state = REGISTRY_STATE.write().unwrap();
229    for (name, entry) in entries {
230        if state.config.values.models.contains_key(&name) || state.legacy_models.contains_key(&name)
231        {
232            continue;
233        }
234        if entry.discovered {
235            state.legacy_models.insert(name, entry);
236        } else {
237            let declarations = CapabilityDeclarations::inferred(&entry);
238            state.config.capabilities.insert(name.clone(), declarations);
239            state.config.values.models.insert(name, entry);
240        }
241    }
242}
243
244/// Register additional provider entries without clobbering existing ones.
245pub fn register_provider_entries(entries: Vec<(String, ProviderEntry)>) {
246    let mut config = REGISTRY_STATE.read().unwrap().config.clone();
247    for (name, entry) in entries {
248        config.values.providers.entry(name).or_insert(entry);
249    }
250    if let Err(error) = install_config_layer(config) {
251        crate::notify!(error, "provider config update rejected: {error}");
252    }
253}
254
255/// Register a legacy catalog using `<provider_name>:<api_model>` keys.
256pub fn register_discovered(
257    _provider_id: &str,
258    provider_name: &str,
259    models: &[crate::provider::DiscoveredModel],
260) {
261    register_legacy_discovered_entries(provider_name, provider_name, models, true);
262}
263
264pub fn register_discovered_details(
265    provider_id: &str,
266    provider_name: &str,
267    models: &[crate::provider::DiscoveredModelDetails],
268) -> Result<CatalogDelta, CatalogError> {
269    let prepared = prepare_discovered_details(provider_id, provider_name, models)?;
270    Ok(commit_prepared_provider_catalog(prepared))
271}
272
273pub fn prepare_discovered_details(
274    provider_id: &str,
275    provider_name: &str,
276    models: &[crate::provider::DiscoveredModelDetails],
277) -> Result<PreparedProviderCatalog, CatalogError> {
278    let profile = reasoning_wire_profile_for_provider(provider_name);
279    prepare_discovered_details_with_global_auth(provider_id, provider_name, profile, models)
280}
281
282pub fn register_discovered_for_provider(
283    provider_key: &str,
284    provider_name: &str,
285    models: &[crate::provider::DiscoveredModel],
286) {
287    if models.is_empty() {
288        return;
289    }
290    let provider_ids = AuthStore::load()
291        .map(|auth| {
292            auth.providers
293                .into_iter()
294                .map(|provider| provider.id)
295                .collect::<Vec<_>>()
296        })
297        .unwrap_or_else(|_| vec![provider_key.to_string()]);
298    let short_id = shortest_unique_provider_id(provider_key, &provider_ids);
299    let model_namespace = format!("{short_id}@{provider_name}");
300    register_legacy_discovered_entries(&model_namespace, provider_key, models, false);
301}
302
303fn register_legacy_discovered_entries(
304    model_namespace: &str,
305    provider_key: &str,
306    models: &[crate::provider::DiscoveredModel],
307    api_model_uses_registry_key: bool,
308) {
309    if models.is_empty() {
310        return;
311    }
312    let entries: Vec<_> = models
313        .iter()
314        .map(|model| {
315            let name = format!("{model_namespace}:{}", model.slug);
316            (
317                name.clone(),
318                ModelEntry {
319                    model: if api_model_uses_registry_key {
320                        name.clone()
321                    } else {
322                        model.slug.clone()
323                    },
324                    provider: Some(provider_key.to_string()),
325                    context_budget: model.context_budget,
326                    thinking: Some(model.thinking),
327                    discovered: true,
328                    ..Default::default()
329                },
330            )
331        })
332        .collect();
333    let names = entries.iter().map(|(name, _)| name.clone()).collect();
334    register_model_entries(entries);
335    set_discovered_models(names);
336}
337
338pub fn register_discovered_details_for_provider(
339    provider_key: &str,
340    provider_name: &str,
341    models: &[crate::provider::DiscoveredModelDetails],
342) -> Result<CatalogDelta, CatalogError> {
343    let prepared = prepare_discovered_details_for_provider(provider_key, provider_name, models)?;
344    Ok(commit_prepared_provider_catalog(prepared))
345}
346
347pub fn prepare_discovered_details_for_provider(
348    provider_key: &str,
349    provider_name: &str,
350    models: &[crate::provider::DiscoveredModelDetails],
351) -> Result<PreparedProviderCatalog, CatalogError> {
352    prepare_discovered_details_with_global_auth(
353        provider_key,
354        provider_name,
355        ReasoningWireProfile::CodexResponses,
356        models,
357    )
358}
359
360fn prepare_discovered_details_with_global_auth(
361    provider_key: &str,
362    provider_name: &str,
363    wire_profile: ReasoningWireProfile,
364    models: &[crate::provider::DiscoveredModelDetails],
365) -> Result<PreparedProviderCatalog, CatalogError> {
366    let auth = AuthStore::load().map_err(|error| CatalogError::NamespaceStore {
367        message: error.to_string(),
368    })?;
369    let persisted_namespace = crate::auth_store::load_provider_model_namespace(provider_key)
370        .map_err(|error| CatalogError::NamespaceStore {
371            message: error.to_string(),
372        })?;
373    prepare_discovered_details_for_provider_with_auth(
374        provider_key,
375        provider_name,
376        &auth,
377        persisted_namespace.as_deref(),
378        wire_profile,
379        models,
380    )
381}
382
383pub fn prepare_discovered_details_for_provider_with_auth(
384    provider_key: &str,
385    provider_name: &str,
386    auth: &AuthStore,
387    persisted_namespace: Option<&str>,
388    wire_profile: ReasoningWireProfile,
389    models: &[crate::provider::DiscoveredModelDetails],
390) -> Result<PreparedProviderCatalog, CatalogError> {
391    prepare_discovered_details_with_profile(
392        provider_key,
393        provider_name,
394        wire_profile,
395        auth,
396        persisted_namespace,
397        models,
398    )
399}
400
401fn prepare_discovered_details_with_profile(
402    provider_key: &str,
403    provider_name: &str,
404    wire_profile: ReasoningWireProfile,
405    auth: &AuthStore,
406    persisted_namespace: Option<&str>,
407    models: &[crate::provider::DiscoveredModelDetails],
408) -> Result<PreparedProviderCatalog, CatalogError> {
409    let in_memory_namespace = REGISTRY_STATE
410        .read()
411        .unwrap()
412        .catalogs
413        .get(provider_key)
414        .map(|catalog| catalog.descriptor.namespace.clone());
415    if let (Some(current), Some(persisted)) = (&in_memory_namespace, persisted_namespace)
416        && current != persisted
417    {
418        return Err(CatalogError::NamespaceChanged {
419            provider_key: provider_key.to_string(),
420            current: current.clone(),
421            requested: persisted.to_string(),
422        });
423    }
424    let model_namespace = if let Some(namespace) = in_memory_namespace {
425        namespace
426    } else if let Some(namespace) = persisted_namespace {
427        namespace.to_string()
428    } else {
429        let provider_ids = auth
430            .providers
431            .iter()
432            .map(|provider| provider.id.clone())
433            .collect::<Vec<_>>();
434        let short_id = shortest_unique_provider_id(provider_key, &provider_ids);
435        format!("{short_id}@{provider_name}")
436    };
437    let descriptor = ProviderDescriptor {
438        provider_key: provider_key.to_string(),
439        provider_name: provider_name.to_string(),
440        namespace: model_namespace,
441        wire_profile,
442    };
443    prepare_provider_catalog(descriptor, models)
444}
445
446pub(crate) fn shortest_unique_provider_id(provider_id: &str, provider_ids: &[String]) -> String {
447    let normalized: String = provider_id
448        .chars()
449        .filter(|ch| ch.is_ascii_alphanumeric())
450        .collect();
451    let peers: Vec<(&str, String)> = provider_ids
452        .iter()
453        .map(|id| {
454            (
455                id.as_str(),
456                id.chars().filter(|ch| ch.is_ascii_alphanumeric()).collect(),
457            )
458        })
459        .collect();
460    if normalized.is_empty() {
461        return "provider".to_string();
462    }
463    if peers
464        .iter()
465        .any(|(id, peer)| *id != provider_id && peer == &normalized)
466    {
467        return provider_id.to_string();
468    }
469    let mut len = normalized.len().min(6);
470    while len < normalized.len()
471        && peers
472            .iter()
473            .filter(|(id, _)| *id != provider_id)
474            .any(|(_, peer)| peer.starts_with(&normalized[..len]))
475    {
476        len += 1;
477    }
478    normalized[..len].to_string()
479}
480
481#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
482pub struct CatalogDelta {
483    pub added: usize,
484    pub updated: usize,
485    pub removed: usize,
486    pub total: usize,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
490#[non_exhaustive]
491pub enum CatalogError {
492    #[error("provider catalog {field} must not be empty")]
493    EmptyDescriptorField { field: &'static str },
494    #[error("provider catalog contains an empty model id")]
495    EmptyModel,
496    #[error("provider catalog contains duplicate model `{model}`")]
497    DuplicateModel { model: String },
498    #[error(
499        "provider `{provider_key}` namespace is already `{current}` and cannot change to `{requested}`"
500    )]
501    NamespaceChanged {
502        provider_key: String,
503        current: String,
504        requested: String,
505    },
506    #[error("provider namespace `{namespace}` is already used by `{provider_key}`")]
507    NamespaceInUse {
508        namespace: String,
509        provider_key: String,
510    },
511    #[error("model registry key `{registry_key}` is already used by `{provider_key}`")]
512    RegistryKeyInUse {
513        registry_key: String,
514        provider_key: String,
515    },
516    #[error("load provider model namespace: {message}")]
517    NamespaceStore { message: String },
518}
519
520pub struct PreparedProviderCatalog {
521    _transaction: std::sync::MutexGuard<'static, ()>,
522    catalog: ProviderCatalog,
523}
524
525impl PreparedProviderCatalog {
526    pub fn namespace(&self) -> &str {
527        &self.catalog.descriptor.namespace
528    }
529}
530
531pub(crate) struct PreparedConfigLayer {
532    _transaction: std::sync::MutexGuard<'static, ()>,
533    config: ConfigLayer,
534    presets: BTreeMap<ModelIdentity, PresetModelEntry>,
535}
536
537impl PreparedConfigLayer {
538    pub(crate) fn snapshot(&self) -> ProviderConfig {
539        self.config.values.clone()
540    }
541}
542
543fn prepare_config_layer(config: ConfigLayer) -> Result<PreparedConfigLayer, CatalogError> {
544    let presets = build_preset_models(&config);
545    let transaction = REGISTRY_TRANSACTION_LOCK.lock().unwrap();
546    let state = REGISTRY_STATE.read().unwrap();
547    for (preset_identity, preset) in &presets {
548        if let Some(catalog) = state.catalogs.values().find(|catalog| {
549            catalog.models.values().any(|model| {
550                model.registry_key == preset.registry_key
551                    && ModelIdentity::new(&catalog.descriptor.provider_key, &model.api_model)
552                        != *preset_identity
553            })
554        }) {
555            return Err(CatalogError::RegistryKeyInUse {
556                registry_key: preset.registry_key.clone(),
557                provider_key: catalog.descriptor.provider_key.clone(),
558            });
559        }
560    }
561    drop(state);
562    Ok(PreparedConfigLayer {
563        _transaction: transaction,
564        config,
565        presets,
566    })
567}
568
569pub(crate) fn prepare_config_text(text: &str) -> anyhow::Result<PreparedConfigLayer> {
570    let config = parse_config_layer(text)
571        .map_err(|error| anyhow::anyhow!("parse config.toml: {error}"))?
572        .unwrap_or_default();
573    prepare_config_layer(config).map_err(Into::into)
574}
575
576pub(crate) fn commit_prepared_config(prepared: PreparedConfigLayer) {
577    let PreparedConfigLayer {
578        _transaction,
579        config,
580        presets,
581    } = prepared;
582    let mut state = REGISTRY_STATE.write().unwrap();
583    state.config = config;
584    state.preset_models = presets;
585}
586
587fn install_config_layer(config: ConfigLayer) -> Result<(), CatalogError> {
588    let prepared = prepare_config_layer(config)?;
589    commit_prepared_config(prepared);
590    Ok(())
591}
592
593pub fn replace_provider_catalog(
594    descriptor: ProviderDescriptor,
595    models: &[crate::provider::DiscoveredModelDetails],
596) -> Result<CatalogDelta, CatalogError> {
597    let prepared = prepare_provider_catalog(descriptor, models)?;
598    Ok(commit_prepared_provider_catalog(prepared))
599}
600
601pub fn prepare_provider_catalog(
602    descriptor: ProviderDescriptor,
603    models: &[crate::provider::DiscoveredModelDetails],
604) -> Result<PreparedProviderCatalog, CatalogError> {
605    for (field, value) in [
606        ("provider_key", descriptor.provider_key.as_str()),
607        ("provider_name", descriptor.provider_name.as_str()),
608        ("namespace", descriptor.namespace.as_str()),
609    ] {
610        if value.trim().is_empty() {
611            return Err(CatalogError::EmptyDescriptorField { field });
612        }
613    }
614    let mut next_models = BTreeMap::new();
615    for model in models {
616        if model.slug.trim().is_empty() {
617            return Err(CatalogError::EmptyModel);
618        }
619        let entry = CatalogModelEntry {
620            registry_key: qualified_model_key(&descriptor.namespace, &model.slug),
621            api_model: model.slug.clone(),
622            context_budget: model.context_budget,
623            capability_knowledge: model.capability_knowledge.clone(),
624        };
625        if next_models.insert(model.slug.clone(), entry).is_some() {
626            return Err(CatalogError::DuplicateModel {
627                model: model.slug.clone(),
628            });
629        }
630    }
631
632    let transaction = REGISTRY_TRANSACTION_LOCK
633        .lock()
634        .unwrap_or_else(std::sync::PoisonError::into_inner);
635    let state = REGISTRY_STATE
636        .read()
637        .unwrap_or_else(std::sync::PoisonError::into_inner);
638    let previous = state.catalogs.get(&descriptor.provider_key);
639    if let Some(previous) = previous
640        && previous.descriptor.namespace != descriptor.namespace
641    {
642        return Err(CatalogError::NamespaceChanged {
643            provider_key: descriptor.provider_key,
644            current: previous.descriptor.namespace.clone(),
645            requested: descriptor.namespace,
646        });
647    }
648    if let Some((provider_key, _)) = state.catalogs.iter().find(|(provider_key, catalog)| {
649        provider_key.as_str() != descriptor.provider_key
650            && catalog.descriptor.namespace == descriptor.namespace
651    }) {
652        return Err(CatalogError::NamespaceInUse {
653            namespace: descriptor.namespace,
654            provider_key: provider_key.clone(),
655        });
656    }
657    for model in next_models.values() {
658        if let Some((provider_key, _)) = state
659            .catalogs
660            .iter()
661            .filter(|(provider_key, _)| provider_key.as_str() != descriptor.provider_key)
662            .find(|(_, catalog)| {
663                catalog
664                    .models
665                    .values()
666                    .any(|existing| existing.registry_key == model.registry_key)
667            })
668        {
669            return Err(CatalogError::RegistryKeyInUse {
670                registry_key: model.registry_key.clone(),
671                provider_key: provider_key.clone(),
672            });
673        }
674        let identity = ModelIdentity::new(&descriptor.provider_key, &model.api_model);
675        if let Some((preset_identity, _)) =
676            state
677                .preset_models
678                .iter()
679                .find(|(preset_identity, preset)| {
680                    preset.registry_key == model.registry_key && **preset_identity != identity
681                })
682        {
683            return Err(CatalogError::RegistryKeyInUse {
684                registry_key: model.registry_key.clone(),
685                provider_key: preset_identity.provider_key.clone(),
686            });
687        }
688    }
689    drop(state);
690    Ok(PreparedProviderCatalog {
691        _transaction: transaction,
692        catalog: ProviderCatalog {
693            descriptor,
694            models: next_models,
695        },
696    })
697}
698
699pub fn commit_prepared_provider_catalog(prepared: PreparedProviderCatalog) -> CatalogDelta {
700    let PreparedProviderCatalog {
701        _transaction,
702        catalog,
703    } = prepared;
704    let ProviderCatalog {
705        descriptor,
706        models: next_models,
707    } = catalog;
708    let mut state = REGISTRY_STATE
709        .write()
710        .unwrap_or_else(std::sync::PoisonError::into_inner);
711    let previous = state.catalogs.get(&descriptor.provider_key);
712    let added = next_models
713        .keys()
714        .filter(|key| previous.is_none_or(|catalog| !catalog.models.contains_key(*key)))
715        .count();
716    let updated = next_models
717        .iter()
718        .filter(|(key, next)| {
719            previous
720                .and_then(|catalog| catalog.models.get(*key))
721                .is_some_and(|old| !catalog_model_eq(old, next))
722        })
723        .count();
724    let removed = previous
725        .map(|catalog| {
726            catalog
727                .models
728                .keys()
729                .filter(|key| !next_models.contains_key(*key))
730                .count()
731        })
732        .unwrap_or(0);
733    let total = next_models.len();
734    let changed = previous.is_none_or(|catalog| {
735        catalog.descriptor != descriptor
736            || catalog.models.len() != next_models.len()
737            || catalog.models.iter().any(|(model, entry)| {
738                next_models
739                    .get(model)
740                    .is_none_or(|next| !catalog_model_eq(entry, next))
741            })
742    });
743    state.catalogs.insert(
744        descriptor.provider_key.clone(),
745        ProviderCatalog {
746            descriptor,
747            models: next_models,
748        },
749    );
750    if changed {
751        state.catalog_revision = state.catalog_revision.wrapping_add(1);
752        CATALOG_REVISION.send_replace(state.catalog_revision);
753    }
754    CatalogDelta {
755        added,
756        updated,
757        removed,
758        total,
759    }
760}
761
762pub fn remove_provider_catalog(provider_key: &str) -> bool {
763    let _transaction = REGISTRY_TRANSACTION_LOCK.lock().unwrap();
764    let mut state = REGISTRY_STATE.write().unwrap();
765    let removed = state.catalogs.remove(provider_key).is_some();
766    if removed {
767        state.catalog_revision = state.catalog_revision.wrapping_add(1);
768        CATALOG_REVISION.send_replace(state.catalog_revision);
769    }
770    removed
771}
772
773pub(crate) fn provider_catalog_namespace(provider_key: &str) -> Option<String> {
774    REGISTRY_STATE
775        .read()
776        .unwrap()
777        .catalogs
778        .get(provider_key)
779        .map(|catalog| catalog.descriptor.namespace.clone())
780}
781
782pub fn model_catalog_revision() -> u64 {
783    REGISTRY_STATE.read().unwrap().catalog_revision
784}
785
786pub fn subscribe_model_catalog() -> tokio::sync::watch::Receiver<u64> {
787    CATALOG_REVISION.subscribe()
788}
789
790fn catalog_model_eq(left: &CatalogModelEntry, right: &CatalogModelEntry) -> bool {
791    left.registry_key == right.registry_key
792        && left.api_model == right.api_model
793        && left.context_budget == right.context_budget
794        && left.capability_knowledge == right.capability_knowledge
795}
796
797fn qualified_model_key(namespace: &str, api_model: &str) -> String {
798    let escaped_namespace = namespace.replace('%', "%25").replace(':', "%3A");
799    format!("{escaped_namespace}:{api_model}")
800}
801
802fn build_preset_models(config: &ConfigLayer) -> BTreeMap<ModelIdentity, PresetModelEntry> {
803    let providers: BTreeMap<String, ProviderEntry> = config
804        .values
805        .providers
806        .iter()
807        .map(|(name, entry)| (name.clone(), entry.clone()))
808        .collect();
809    let mut candidates = Vec::new();
810    for (provider_name, provider) in providers {
811        let Some(base_url) = provider.base_url.as_deref() else {
812            continue;
813        };
814        let Some(preset) = PROVIDER_PRESETS
815            .iter()
816            .find(|preset| preset.base_url == base_url)
817        else {
818            continue;
819        };
820        for model in preset.models {
821            candidates.push((
822                ModelIdentity::new(&provider_name, model.id),
823                ModelEntry {
824                    model: model.id.to_string(),
825                    provider: Some(provider_name.clone()),
826                    context_budget: Some(model.context_budget),
827                    thinking: Some(model.thinking),
828                    discovered: true,
829                    ..Default::default()
830                },
831            ));
832        }
833    }
834    candidates
835        .into_iter()
836        .map(|(identity, entry)| {
837            let registry_key = qualified_model_key(&identity.provider_key, &identity.api_model);
838            (
839                identity,
840                PresetModelEntry {
841                    registry_key,
842                    entry,
843                },
844            )
845        })
846        .collect()
847}
848
849#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
850enum CapabilityFieldKnowledge {
851    #[default]
852    Unknown,
853    Legacy,
854    Advertised,
855}
856
857#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
858struct ResolvedCapabilityKnowledge {
859    reasoning_efforts: CapabilityFieldKnowledge,
860    reasoning_modes: CapabilityFieldKnowledge,
861    input_modalities: CapabilityFieldKnowledge,
862}
863
864#[derive(Debug, Clone)]
865struct ResolvedModel {
866    key: String,
867    identity: Option<ModelIdentity>,
868    entry: ModelEntry,
869    capability_knowledge: ResolvedCapabilityKnowledge,
870    wire_profile: ReasoningWireProfile,
871}
872
873fn profile_from_provider_entry(entry: &ProviderEntry) -> ReasoningWireProfile {
874    match entry.kind.as_str() {
875        "openai" | "openai-compat" => match entry.reasoning_format.unwrap_or_else(|| {
876            crate::providers::openai::OpenAiReasoningFormat::for_provider_kind(&entry.kind)
877        }) {
878            crate::providers::openai::OpenAiReasoningFormat::Official => {
879                ReasoningWireProfile::OpenAiOfficial
880            }
881            crate::providers::openai::OpenAiReasoningFormat::CompatibleThinking => {
882                ReasoningWireProfile::CompatibleThinking
883            }
884        },
885        "anthropic" => ReasoningWireProfile::AnthropicMessages,
886        "codex" => ReasoningWireProfile::CodexResponses,
887        _ => ReasoningWireProfile::Unknown,
888    }
889}
890
891fn fallback_profile(provider: &str) -> ReasoningWireProfile {
892    match provider {
893        "openai" => ReasoningWireProfile::OpenAiOfficial,
894        "openai-compat" => ReasoningWireProfile::CompatibleThinking,
895        "anthropic" => ReasoningWireProfile::AnthropicMessages,
896        "codex" => ReasoningWireProfile::CodexResponses,
897        _ => ReasoningWireProfile::Unknown,
898    }
899}
900
901fn provider_entries_in_state(state: &RegistryState) -> BTreeMap<String, ProviderEntry> {
902    state
903        .config
904        .values
905        .providers
906        .iter()
907        .map(|(name, entry)| (name.clone(), entry.clone()))
908        .collect()
909}
910
911fn provider_profile_in_state(state: &RegistryState, provider: &str) -> ReasoningWireProfile {
912    if let Some(catalog) = state.catalogs.get(provider) {
913        return catalog.descriptor.wire_profile;
914    }
915    provider_entries_in_state(state)
916        .get(provider)
917        .map(profile_from_provider_entry)
918        .unwrap_or_else(|| fallback_profile(provider))
919}
920
921fn catalog_model_to_resolved(
922    catalog: &ProviderCatalog,
923    model: &CatalogModelEntry,
924) -> ResolvedModel {
925    let capabilities = model
926        .capability_knowledge
927        .advertised()
928        .cloned()
929        .unwrap_or_default();
930    let thinking = match &model.capability_knowledge {
931        CapabilityKnowledge::Legacy { thinking } => Some(*thinking),
932        CapabilityKnowledge::Advertised(_) => None,
933    };
934    let field_knowledge = match &model.capability_knowledge {
935        CapabilityKnowledge::Legacy { .. } => CapabilityFieldKnowledge::Legacy,
936        CapabilityKnowledge::Advertised(_) => CapabilityFieldKnowledge::Advertised,
937    };
938    ResolvedModel {
939        key: model.registry_key.clone(),
940        identity: Some(ModelIdentity::new(
941            &catalog.descriptor.provider_key,
942            &model.api_model,
943        )),
944        entry: ModelEntry {
945            model: model.api_model.clone(),
946            provider: Some(catalog.descriptor.provider_key.clone()),
947            context_budget: model.context_budget,
948            thinking,
949            reasoning_efforts: capabilities.reasoning_efforts,
950            default_reasoning_effort: capabilities.default_reasoning_effort,
951            reasoning_modes: capabilities.reasoning_modes,
952            default_reasoning_mode: capabilities.default_reasoning_mode,
953            input_modalities: capabilities.input_modalities,
954            discovered: true,
955            ..Default::default()
956        },
957        capability_knowledge: ResolvedCapabilityKnowledge {
958            reasoning_efforts: field_knowledge,
959            reasoning_modes: field_knowledge,
960            input_modalities: field_knowledge,
961        },
962        wire_profile: catalog.descriptor.wire_profile,
963    }
964}
965
966fn model_identity(entry: &ModelEntry) -> Option<ModelIdentity> {
967    let provider = entry.provider.as_deref()?;
968    (!entry.model.is_empty()).then(|| ModelIdentity::new(provider, &entry.model))
969}
970
971fn model_entry_to_resolved(
972    state: &RegistryState,
973    key: String,
974    entry: ModelEntry,
975    declarations: &CapabilityDeclarations,
976    legacy_capabilities: bool,
977) -> ResolvedModel {
978    let wire_profile = entry
979        .provider
980        .as_deref()
981        .map(|provider| provider_profile_in_state(state, provider))
982        .unwrap_or(ReasoningWireProfile::Unknown);
983    let unknown_or_legacy = if legacy_capabilities {
984        CapabilityFieldKnowledge::Legacy
985    } else {
986        CapabilityFieldKnowledge::Unknown
987    };
988    ResolvedModel {
989        key,
990        identity: model_identity(&entry),
991        entry,
992        capability_knowledge: ResolvedCapabilityKnowledge {
993            reasoning_efforts: if declarations.reasoning_efforts {
994                CapabilityFieldKnowledge::Advertised
995            } else {
996                unknown_or_legacy
997            },
998            reasoning_modes: if declarations.reasoning_modes {
999                CapabilityFieldKnowledge::Advertised
1000            } else {
1001                unknown_or_legacy
1002            },
1003            input_modalities: if declarations.input_modalities {
1004                CapabilityFieldKnowledge::Advertised
1005            } else {
1006                unknown_or_legacy
1007            },
1008        },
1009        wire_profile,
1010    }
1011}
1012
1013fn entries_have_compatible_identity(base: &ModelEntry, overlay: &ModelEntry) -> bool {
1014    !overlay.model.is_empty()
1015        && overlay.model == base.model
1016        && overlay
1017            .provider
1018            .as_ref()
1019            .is_none_or(|provider| base.provider.as_ref() == Some(provider))
1020}
1021
1022fn overlay_model_entry(
1023    state: &RegistryState,
1024    key: String,
1025    base: &ResolvedModel,
1026    overlay: &ModelEntry,
1027    declarations: &CapabilityDeclarations,
1028) -> ResolvedModel {
1029    let mut entry = base.entry.clone();
1030    if !overlay.model.is_empty() {
1031        entry.model = overlay.model.clone();
1032    }
1033    if overlay.provider.is_some() {
1034        entry.provider = overlay.provider.clone();
1035    }
1036    entry.context_budget = overlay.context_budget.or(entry.context_budget);
1037    entry.compact_threshold_ratio = overlay
1038        .compact_threshold_ratio
1039        .or(entry.compact_threshold_ratio);
1040    entry.thinking = overlay.thinking.or(entry.thinking);
1041    entry.reasoning = overlay.reasoning.clone().or(entry.reasoning);
1042    entry.reasoning_mode = overlay.reasoning_mode.clone().or(entry.reasoning_mode);
1043    entry.reasoning_budget_tokens = overlay
1044        .reasoning_budget_tokens
1045        .or(entry.reasoning_budget_tokens);
1046    if declarations.reasoning_efforts {
1047        entry.reasoning_efforts = overlay.reasoning_efforts.clone();
1048    }
1049    entry.default_reasoning_effort = overlay
1050        .default_reasoning_effort
1051        .clone()
1052        .or(entry.default_reasoning_effort);
1053    if declarations.reasoning_modes {
1054        entry.reasoning_modes = overlay.reasoning_modes.clone();
1055    }
1056    entry.default_reasoning_mode = overlay
1057        .default_reasoning_mode
1058        .clone()
1059        .or(entry.default_reasoning_mode);
1060    if declarations.input_modalities {
1061        entry.input_modalities = overlay.input_modalities.clone();
1062    }
1063    entry.image_detail = overlay.image_detail.or(entry.image_detail);
1064    entry.max_tokens = overlay.max_tokens.or(entry.max_tokens);
1065    entry.enabled = overlay.enabled.or(entry.enabled);
1066    entry.discovered |= overlay.discovered;
1067
1068    let mut capability_knowledge = base.capability_knowledge;
1069    if declarations.reasoning_efforts {
1070        capability_knowledge.reasoning_efforts = CapabilityFieldKnowledge::Advertised;
1071    }
1072    if declarations.reasoning_modes {
1073        capability_knowledge.reasoning_modes = CapabilityFieldKnowledge::Advertised;
1074    }
1075    if declarations.input_modalities {
1076        capability_knowledge.input_modalities = CapabilityFieldKnowledge::Advertised;
1077    }
1078    let wire_profile = entry
1079        .provider
1080        .as_deref()
1081        .map(|provider| provider_profile_in_state(state, provider))
1082        .filter(|profile| *profile != ReasoningWireProfile::Unknown)
1083        .unwrap_or(base.wire_profile);
1084    ResolvedModel {
1085        key,
1086        identity: base.identity.clone(),
1087        entry,
1088        capability_knowledge,
1089        wire_profile,
1090    }
1091}
1092
1093fn config_matches_base(config_key: &str, config: &ModelEntry, base: &ResolvedModel) -> bool {
1094    model_identity(config)
1095        .zip(base.identity.clone())
1096        .is_some_and(|(config, base)| config == base)
1097        || (config_key == base.key && entries_have_compatible_identity(&base.entry, config))
1098}
1099
1100fn resolved_models_in_state(state: &RegistryState) -> BTreeMap<String, ResolvedModel> {
1101    let mut bases = BTreeMap::new();
1102    for (identity, preset) in &state.preset_models {
1103        let model = model_entry_to_resolved(
1104            state,
1105            preset.registry_key.clone(),
1106            preset.entry.clone(),
1107            &CapabilityDeclarations::default(),
1108            true,
1109        );
1110        bases.insert(identity.clone(), model);
1111    }
1112    for catalog in state.catalogs.values() {
1113        for model in catalog.models.values() {
1114            let model = catalog_model_to_resolved(catalog, model);
1115            if let Some(identity) = model.identity.clone() {
1116                bases.insert(identity, model);
1117            }
1118        }
1119    }
1120
1121    let mut configs: Vec<(&String, &ModelEntry)> = state.config.values.models.iter().collect();
1122    configs.sort_by(|left, right| left.0.cmp(right.0));
1123    let associations: BTreeMap<String, ResolvedModel> = configs
1124        .iter()
1125        .filter_map(|(key, entry)| {
1126            let base = model_identity(entry)
1127                .and_then(|identity| bases.get(&identity))
1128                .or_else(|| {
1129                    bases
1130                        .values()
1131                        .find(|base| config_matches_base(key, entry, base))
1132                })?;
1133            Some(((*key).clone(), base.clone()))
1134        })
1135        .collect();
1136    let suppressed: BTreeSet<String> = associations.values().map(|base| base.key.clone()).collect();
1137    let mut resolved: BTreeMap<String, ResolvedModel> = bases
1138        .into_values()
1139        .filter(|base| !suppressed.contains(&base.key))
1140        .map(|base| (base.key.clone(), base))
1141        .collect();
1142
1143    for (key, entry) in &state.legacy_models {
1144        resolved.entry(key.clone()).or_insert_with(|| {
1145            model_entry_to_resolved(
1146                state,
1147                key.clone(),
1148                entry.clone(),
1149                &CapabilityDeclarations::inferred(entry),
1150                false,
1151            )
1152        });
1153    }
1154
1155    for (key, entry) in configs {
1156        let key = key.clone();
1157        let declarations = state.config.declarations(&key);
1158        if let Some(base) = associations.get(&key) {
1159            resolved.insert(
1160                key.clone(),
1161                overlay_model_entry(state, key, base, entry, &declarations),
1162            );
1163        } else {
1164            resolved.insert(
1165                key.clone(),
1166                model_entry_to_resolved(state, key, entry.clone(), &declarations, false),
1167            );
1168        }
1169    }
1170    resolved
1171}
1172
1173fn resolve_alias_in_state(state: &RegistryState, name: &str) -> String {
1174    let mut current = name.to_string();
1175    let mut seen = BTreeSet::new();
1176    while let Some(entry) = state.config.values.aliases.get(&current) {
1177        if !seen.insert(current.clone()) {
1178            break;
1179        }
1180        current = entry.model.clone();
1181    }
1182    current
1183}
1184
1185fn resolved_model_in_state(state: &RegistryState, name: &str) -> Option<ResolvedModel> {
1186    let resolved = resolve_alias_in_state(state, name);
1187    let mut models = resolved_models_in_state(state);
1188    if let Some(model) = models.remove(&resolved) {
1189        return Some(model);
1190    }
1191
1192    // Preserve aliases written before preset model keys became provider-qualified.
1193    // A bare API model is only safe when it identifies exactly one model.
1194    let mut matches = models.into_values().filter(|model| {
1195        model.entry.model == resolved
1196            && model
1197                .identity
1198                .as_ref()
1199                .is_some_and(|identity| state.preset_models.contains_key(identity))
1200    });
1201    let model = matches.next()?;
1202    matches.next().is_none().then_some(model)
1203}
1204
1205#[derive(Debug, Clone)]
1206pub struct ModelRow {
1207    pub slug: String,
1208    pub provider_name: String,
1209    pub context_budget: u64,
1210    pub max_output_tokens: Option<u32>,
1211    pub thinking: bool,
1212    pub reasoning: ReasoningSelection,
1213    pub capabilities: ModelCapabilities,
1214    pub image_detail: ImageDetail,
1215}
1216
1217#[derive(Debug, Clone)]
1218pub struct ProviderGroup {
1219    pub provider_name: String,
1220    pub models: Vec<ModelRow>,
1221}
1222
1223/// Return all models grouped by provider, with complete metadata.
1224/// Single canonical source for UI — no manual union of all_model_entries +
1225/// discovered_models + aliases.
1226pub fn all_provider_groups() -> Vec<ProviderGroup> {
1227    provider_groups(false)
1228}
1229
1230pub fn enabled_provider_names() -> std::collections::HashSet<String> {
1231    let auth = AuthStore::load().unwrap_or_default();
1232    enabled_provider_names_from_auth(&auth)
1233}
1234
1235fn enabled_provider_names_from_auth(auth: &AuthStore) -> std::collections::HashSet<String> {
1236    let mut names: std::collections::HashSet<String> = all_provider_entries()
1237        .into_iter()
1238        .filter(|(_, entry)| entry.enabled.unwrap_or(true))
1239        .map(|(name, _)| name)
1240        .collect();
1241    names.extend(
1242        auth.providers
1243            .iter()
1244            .filter(|provider| provider.enabled)
1245            .map(|provider| provider.id.clone()),
1246    );
1247    names
1248}
1249
1250/// Return enabled configured providers even when no model has been registered yet.
1251pub fn all_provider_groups_with_empty() -> Vec<ProviderGroup> {
1252    provider_groups(true)
1253}
1254
1255fn provider_groups(include_empty: bool) -> Vec<ProviderGroup> {
1256    let state = REGISTRY_STATE.read().unwrap();
1257    let entries = resolved_models_in_state(&state);
1258    let mut groups: std::collections::BTreeMap<String, Vec<ModelRow>> =
1259        std::collections::BTreeMap::new();
1260    for (name, resolved) in entries {
1261        let info = model_info_from_resolved(&resolved);
1262        let provider = resolved
1263            .entry
1264            .provider
1265            .clone()
1266            .unwrap_or_else(|| "unknown".to_string());
1267        let row = ModelRow {
1268            slug: name,
1269            provider_name: provider.clone(),
1270            context_budget: info.context_budget,
1271            max_output_tokens: info.max_output_tokens,
1272            thinking: info.thinking_enabled(),
1273            reasoning: info.reasoning.clone(),
1274            capabilities: info.capabilities.clone(),
1275            image_detail: info.image_detail,
1276        };
1277        groups.entry(provider).or_default().push(row);
1278    }
1279    if include_empty {
1280        for (provider, entry) in provider_entries_in_state(&state) {
1281            if entry.enabled.unwrap_or(true) {
1282                groups.entry(provider).or_default();
1283            }
1284        }
1285        for catalog in state.catalogs.values() {
1286            groups
1287                .entry(catalog.descriptor.provider_key.clone())
1288                .or_default();
1289        }
1290    }
1291    groups
1292        .into_iter()
1293        .map(|(provider_name, models)| ProviderGroup {
1294            provider_name,
1295            models,
1296        })
1297        .collect()
1298}
1299
1300pub fn resolve_alias(name: &str) -> String {
1301    resolve_alias_in_state(&REGISTRY_STATE.read().unwrap(), name)
1302}
1303
1304pub fn model_entry(name: &str) -> Option<ModelEntry> {
1305    resolved_model_in_state(&REGISTRY_STATE.read().unwrap(), name).map(|model| model.entry)
1306}
1307
1308pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
1309    resolved_models_in_state(&REGISTRY_STATE.read().unwrap())
1310        .into_iter()
1311        .map(|(name, model)| (name, model.entry))
1312        .collect()
1313}
1314
1315pub fn provider_display_name(provider_key: &str) -> String {
1316    AuthStore::load()
1317        .ok()
1318        .and_then(|auth| {
1319            auth.providers
1320                .into_iter()
1321                .find(|provider| provider.id == provider_key)
1322        })
1323        .map(|provider| match provider.account {
1324            Some(account) if !account.is_empty() => format!("{} · {account}", provider.name),
1325            _ => provider.name,
1326        })
1327        .unwrap_or_else(|| provider_key.to_string())
1328}
1329
1330pub fn is_provider_enabled(name: &str) -> bool {
1331    let state = REGISTRY_STATE.read().unwrap();
1332    if let Some(entry) = provider_entries_in_state(&state).get(name) {
1333        return entry.enabled.unwrap_or(true);
1334    }
1335    let auth_provider = AuthStore::load().ok().and_then(|auth| {
1336        auth.providers
1337            .into_iter()
1338            .find(|provider| provider.id == name)
1339    });
1340    if state.catalogs.contains_key(name) {
1341        return auth_provider.is_some_and(|provider| provider.enabled);
1342    }
1343    auth_provider.is_none_or(|provider| provider.enabled)
1344}
1345
1346pub fn all_provider_entries() -> Vec<(String, ProviderEntry)> {
1347    provider_entries_in_state(&REGISTRY_STATE.read().unwrap())
1348        .into_iter()
1349        .collect()
1350}
1351
1352pub fn all_aliases() -> Vec<(String, String)> {
1353    let state = REGISTRY_STATE.read().unwrap();
1354    let mut aliases: Vec<(String, String)> = state
1355        .config
1356        .values
1357        .aliases
1358        .iter()
1359        .map(|(name, entry)| (name.clone(), entry.model.clone()))
1360        .collect();
1361    aliases.sort_by(|left, right| left.0.cmp(&right.0));
1362    aliases
1363}
1364
1365pub fn model_info(name: &str) -> ModelInfo {
1366    let state = REGISTRY_STATE.read().unwrap();
1367    if let Some(model) = resolved_model_in_state(&state, name) {
1368        return model_info_from_resolved(&model);
1369    }
1370    ModelInfo {
1371        name: resolve_alias_in_state(&state, name),
1372        context_budget: 0,
1373        compact_threshold_ratio: 0.8,
1374        reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1375        capabilities: ModelCapabilities::default(),
1376        image_detail: ImageDetail::Auto,
1377        max_output_tokens: None,
1378    }
1379}
1380
1381fn model_info_from_resolved(model: &ResolvedModel) -> ModelInfo {
1382    let enabled = model.entry.enabled.unwrap_or(true);
1383    ModelInfo {
1384        name: model.key.clone(),
1385        context_budget: if enabled {
1386            model.entry.context_budget.unwrap_or(0)
1387        } else {
1388            0
1389        },
1390        compact_threshold_ratio: model.entry.compact_threshold_ratio.unwrap_or(0.8),
1391        reasoning: reasoning_selection(&model.entry),
1392        capabilities: model_capabilities(&model.entry),
1393        image_detail: model.entry.image_detail.unwrap_or_default(),
1394        max_output_tokens: model.entry.max_tokens,
1395    }
1396}
1397
1398pub fn reasoning_wire_profile_for_provider(provider: &str) -> ReasoningWireProfile {
1399    provider_profile_in_state(&REGISTRY_STATE.read().unwrap(), provider)
1400}
1401
1402pub fn reasoning_wire_profile_for_model(model: &str) -> ReasoningWireProfile {
1403    resolved_model_in_state(&REGISTRY_STATE.read().unwrap(), model)
1404        .map(|model| model.wire_profile)
1405        .unwrap_or(ReasoningWireProfile::Unknown)
1406}
1407
1408pub fn resolve_reasoning_for_model(
1409    model: &str,
1410    selection: &ReasoningSelection,
1411) -> Result<ReasoningSelection, String> {
1412    let state = REGISTRY_STATE.read().unwrap();
1413    let Some(model) = resolved_model_in_state(&state, model) else {
1414        return ReasoningWireProfile::Unknown
1415            .validate(selection, None)
1416            .map(|()| selection.clone());
1417    };
1418    resolve_reasoning_for_resolved(&model, selection)
1419}
1420
1421/// Return whether a wire-valid selection still requires capability fields that are unknown.
1422pub fn reasoning_selection_uses_legacy_capabilities(
1423    model: &str,
1424    selection: &ReasoningSelection,
1425) -> bool {
1426    let state = REGISTRY_STATE.read().unwrap();
1427    let Some(model) = resolved_model_in_state(&state, model) else {
1428        return false;
1429    };
1430    if model
1431        .wire_profile
1432        .validate(selection, model.entry.max_tokens)
1433        .is_err()
1434    {
1435        return false;
1436    }
1437    let checks_effort_metadata = matches!(
1438        selection,
1439        ReasoningSelection::Effort { effort, .. } if !matches!(effort, ReasoningEffort::None)
1440    ) || matches!(selection, ReasoningSelection::BudgetTokens { .. });
1441    (checks_effort_metadata
1442        && matches!(
1443            model.capability_knowledge.reasoning_efforts,
1444            CapabilityFieldKnowledge::Legacy
1445        ))
1446        || (selection.execution_mode().is_some()
1447            && matches!(
1448                model.capability_knowledge.reasoning_modes,
1449                CapabilityFieldKnowledge::Legacy
1450            ))
1451}
1452
1453fn resolve_reasoning_for_resolved(
1454    model: &ResolvedModel,
1455    selection: &ReasoningSelection,
1456) -> Result<ReasoningSelection, String> {
1457    model
1458        .wire_profile
1459        .validate(selection, model.entry.max_tokens)?;
1460    if let ReasoningSelection::Effort { effort, .. } = selection
1461        && !matches!(effort, ReasoningEffort::None)
1462    {
1463        match model.capability_knowledge.reasoning_efforts {
1464            CapabilityFieldKnowledge::Legacy => {
1465                return Err(format!(
1466                    "model `{}` has legacy reasoning metadata; use `auto` until its catalog is refreshed",
1467                    model.key
1468                ));
1469            }
1470            CapabilityFieldKnowledge::Advertised
1471                if !model.entry.reasoning_efforts.contains(effort) =>
1472            {
1473                let available = model
1474                    .entry
1475                    .reasoning_efforts
1476                    .iter()
1477                    .map(ToString::to_string)
1478                    .collect::<Vec<_>>()
1479                    .join(", ");
1480                return Err(if available.is_empty() {
1481                    format!(
1482                        "model `{}` does not advertise exact reasoning efforts",
1483                        model.key
1484                    )
1485                } else {
1486                    format!(
1487                        "reasoning effort `{effort}` is not supported by model `{}`; available: {available}",
1488                        model.key
1489                    )
1490                });
1491            }
1492            _ => {}
1493        }
1494    }
1495    if matches!(selection, ReasoningSelection::BudgetTokens { .. })
1496        && matches!(
1497            model.capability_knowledge.reasoning_efforts,
1498            CapabilityFieldKnowledge::Legacy
1499        )
1500    {
1501        return Err(format!(
1502            "model `{}` has legacy reasoning metadata; use `auto` until its catalog is refreshed",
1503            model.key
1504        ));
1505    }
1506    if let Some(mode) = selection.execution_mode() {
1507        match model.capability_knowledge.reasoning_modes {
1508            CapabilityFieldKnowledge::Legacy => {
1509                return Err(format!(
1510                    "model `{}` has legacy reasoning metadata and cannot validate mode `{mode}`",
1511                    model.key
1512                ));
1513            }
1514            CapabilityFieldKnowledge::Advertised if !model.entry.reasoning_modes.contains(mode) => {
1515                let available = model
1516                    .entry
1517                    .reasoning_modes
1518                    .iter()
1519                    .map(ToString::to_string)
1520                    .collect::<Vec<_>>()
1521                    .join(", ");
1522                return Err(if available.is_empty() {
1523                    format!("model `{}` does not advertise reasoning modes", model.key)
1524                } else {
1525                    format!(
1526                        "reasoning mode `{mode}` is not supported by model `{}`; available: {available}",
1527                        model.key
1528                    )
1529                });
1530            }
1531            _ => {}
1532        }
1533    }
1534    let info = model_info_from_resolved(model);
1535    let profile = model.wire_profile;
1536    let resolved = resolve_reasoning(selection, &info.capabilities)?;
1537    let resolved = if matches!(selection, ReasoningSelection::Auto { .. })
1538        && matches!(
1539            profile,
1540            ReasoningWireProfile::CompatibleThinking
1541                | ReasoningWireProfile::CodexResponses
1542                | ReasoningWireProfile::AnthropicMessages
1543        ) {
1544        ReasoningSelection::Auto {
1545            execution_mode: resolved.execution_mode().cloned(),
1546        }
1547    } else {
1548        resolved
1549    };
1550    profile.validate(&resolved, info.max_output_tokens)?;
1551    Ok(resolved)
1552}
1553
1554pub fn reasoning_selections_for_provider(
1555    provider: &str,
1556    capabilities: &ModelCapabilities,
1557) -> Vec<ReasoningSelection> {
1558    let profile = reasoning_wire_profile_for_provider(provider);
1559    reasoning_selections(profile, capabilities, true)
1560}
1561
1562fn reasoning_selections(
1563    profile: ReasoningWireProfile,
1564    capabilities: &ModelCapabilities,
1565    use_profile_fallback: bool,
1566) -> Vec<ReasoningSelection> {
1567    let mut choices = vec![
1568        ReasoningSelection::ProviderDefault,
1569        ReasoningSelection::Disabled,
1570        ReasoningSelection::Auto {
1571            execution_mode: None,
1572        },
1573    ];
1574    let efforts = if use_profile_fallback && capabilities.reasoning_efforts.is_empty() {
1575        profile.fallback_efforts()
1576    } else {
1577        capabilities.reasoning_efforts.as_slice()
1578    };
1579    for effort in efforts {
1580        let selection = ReasoningSelection::Effort {
1581            effort: effort.clone(),
1582            execution_mode: None,
1583        };
1584        if profile.validate(&selection, None).is_ok() && !choices.contains(&selection) {
1585            choices.push(selection);
1586        }
1587    }
1588    if profile.supports_token_budget() {
1589        choices.push(ReasoningSelection::BudgetTokens { tokens: 4096 });
1590    }
1591    choices
1592}
1593
1594pub fn reasoning_selections_for_model(model: &str) -> Vec<ReasoningSelection> {
1595    let state = REGISTRY_STATE.read().unwrap();
1596    let Some(model) = resolved_model_in_state(&state, model) else {
1597        return vec![
1598            ReasoningSelection::ProviderDefault,
1599            ReasoningSelection::Disabled,
1600            ReasoningSelection::Auto {
1601                execution_mode: None,
1602            },
1603        ];
1604    };
1605    let info = model_info_from_resolved(&model);
1606    let mut choices = match model.capability_knowledge.reasoning_efforts {
1607        CapabilityFieldKnowledge::Legacy => vec![
1608            ReasoningSelection::ProviderDefault,
1609            ReasoningSelection::Disabled,
1610            ReasoningSelection::Auto {
1611                execution_mode: None,
1612            },
1613        ],
1614        CapabilityFieldKnowledge::Advertised => {
1615            reasoning_selections(model.wire_profile, &info.capabilities, false)
1616        }
1617        CapabilityFieldKnowledge::Unknown => {
1618            reasoning_selections(model.wire_profile, &info.capabilities, true)
1619        }
1620    };
1621    choices.retain(|selection| resolve_reasoning_for_resolved(&model, selection).is_ok());
1622    choices
1623}
1624
1625pub fn effective_reasoning_for_model(
1626    model: &str,
1627    input_selection: Option<&ReasoningSelection>,
1628) -> Result<Option<ReasoningSelection>, String> {
1629    let state = REGISTRY_STATE.read().unwrap();
1630    let Some(model) = resolved_model_in_state(&state, model) else {
1631        return Ok(None);
1632    };
1633    let info = model_info_from_resolved(&model);
1634    let requested = input_selection.unwrap_or(&info.reasoning);
1635    let provider_supports_reasoning = model.wire_profile != ReasoningWireProfile::Unknown;
1636    let should_display = input_selection.is_some()
1637        || !matches!(&info.reasoning, ReasoningSelection::ProviderDefault)
1638        || !info.capabilities.reasoning_efforts.is_empty()
1639        || info.capabilities.default_reasoning_effort.is_some()
1640        || provider_supports_reasoning;
1641    if !should_display {
1642        return Ok(None);
1643    }
1644    resolve_reasoning_for_resolved(&model, requested).map(Some)
1645}
1646
1647impl ModelInfo {
1648    pub fn compact_threshold_tokens(&self) -> u64 {
1649        let reserved = self.max_output_tokens.unwrap_or(0) as u64;
1650        let available = self.context_budget.saturating_sub(reserved);
1651        (available as f64 * self.compact_threshold_ratio) as u64
1652    }
1653
1654    pub fn compaction_trigger_threshold(&self) -> u64 {
1655        if self.context_budget == 0 {
1656            return u64::MAX;
1657        }
1658        let budget = self.context_budget;
1659
1660        let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
1661        let output_cap = (budget as f64 * 0.20) as u64;
1662        let output_reserve = configured_output.min(output_cap).max(8_000);
1663
1664        let safety = (budget as f64 * 0.05) as u64;
1665        let safety_margin = safety.max(4_000);
1666
1667        let trigger = budget
1668            .saturating_sub(output_reserve)
1669            .saturating_sub(safety_margin);
1670        let floor = (budget as f64 * 0.50) as u64;
1671        let ceiling = (budget as f64 * 0.95) as u64;
1672        trigger.clamp(floor, ceiling)
1673    }
1674
1675    pub fn compaction_target_after(&self) -> u64 {
1676        let trigger = self.compaction_trigger_threshold();
1677        let budget_cap = (self.context_budget as f64 * 0.25) as u64;
1678        let trigger_cap = (trigger as f64 * 0.75) as u64;
1679        budget_cap.min(trigger_cap)
1680    }
1681
1682    pub fn thinking_enabled(&self) -> bool {
1683        self.reasoning.enabled()
1684    }
1685}
1686
1687fn reasoning_selection(entry: &ModelEntry) -> ReasoningSelection {
1688    if let Some(tokens) = entry.reasoning_budget_tokens {
1689        return ReasoningSelection::BudgetTokens { tokens };
1690    }
1691    let execution_mode = entry
1692        .reasoning_mode
1693        .as_deref()
1694        .and_then(|value| value.parse().ok());
1695    if let Some(value) = entry.reasoning.as_deref() {
1696        return match value.trim().to_ascii_lowercase().as_str() {
1697            "default" | "provider_default" => ReasoningSelection::ProviderDefault,
1698            "off" | "none" | "disabled" => ReasoningSelection::Disabled,
1699            "auto" => ReasoningSelection::Auto { execution_mode },
1700            _ => value
1701                .parse()
1702                .map(|effort| ReasoningSelection::Effort {
1703                    effort,
1704                    execution_mode,
1705                })
1706                .unwrap_or_default(),
1707        };
1708    }
1709    match entry.thinking {
1710        Some(true) => ReasoningSelection::Auto { execution_mode },
1711        Some(false) => ReasoningSelection::Disabled,
1712        None => ReasoningSelection::ProviderDefault,
1713    }
1714}
1715
1716fn model_capabilities(entry: &ModelEntry) -> ModelCapabilities {
1717    ModelCapabilities {
1718        reasoning_efforts: entry.reasoning_efforts.clone(),
1719        default_reasoning_effort: entry.default_reasoning_effort.clone(),
1720        reasoning_modes: entry.reasoning_modes.clone(),
1721        default_reasoning_mode: entry.default_reasoning_mode.clone(),
1722        input_modalities: entry.input_modalities.clone(),
1723    }
1724}
1725
1726pub fn resolve_reasoning(
1727    selection: &ReasoningSelection,
1728    capabilities: &ModelCapabilities,
1729) -> Result<ReasoningSelection, String> {
1730    let validate_mode = |mode: &Option<ReasoningExecutionMode>| -> Result<(), String> {
1731        if let Some(mode) = mode
1732            && !capabilities.reasoning_modes.is_empty()
1733            && !capabilities.reasoning_modes.contains(mode)
1734        {
1735            return Err(format!(
1736                "reasoning mode `{mode}` is not supported; available: {}",
1737                capabilities
1738                    .reasoning_modes
1739                    .iter()
1740                    .map(ToString::to_string)
1741                    .collect::<Vec<_>>()
1742                    .join(", ")
1743            ));
1744        }
1745        Ok(())
1746    };
1747
1748    match selection {
1749        ReasoningSelection::ProviderDefault | ReasoningSelection::Disabled => Ok(selection.clone()),
1750        ReasoningSelection::Auto { execution_mode } => {
1751            validate_mode(execution_mode)?;
1752            let mode = execution_mode
1753                .clone()
1754                .or_else(|| capabilities.default_reasoning_mode.clone());
1755            if let Some(effort) = capabilities.default_reasoning_effort.clone() {
1756                Ok(ReasoningSelection::Effort {
1757                    effort,
1758                    execution_mode: mode,
1759                })
1760            } else {
1761                Ok(ReasoningSelection::Auto {
1762                    execution_mode: mode,
1763                })
1764            }
1765        }
1766        ReasoningSelection::Effort {
1767            effort: ReasoningEffort::None,
1768            ..
1769        } => Ok(ReasoningSelection::Disabled),
1770        ReasoningSelection::Effort {
1771            effort,
1772            execution_mode,
1773        } => {
1774            validate_mode(execution_mode)?;
1775            if !capabilities.reasoning_efforts.is_empty()
1776                && !capabilities.reasoning_efforts.contains(effort)
1777            {
1778                return Err(format!(
1779                    "reasoning effort `{effort}` is not supported; available: {}",
1780                    capabilities
1781                        .reasoning_efforts
1782                        .iter()
1783                        .map(ToString::to_string)
1784                        .collect::<Vec<_>>()
1785                        .join(", ")
1786                ));
1787            }
1788            Ok(selection.clone())
1789        }
1790        ReasoningSelection::BudgetTokens { .. } => {
1791            if capabilities.reasoning_efforts.is_empty() {
1792                Ok(selection.clone())
1793            } else {
1794                Err("token-budget reasoning is not supported by this model".into())
1795            }
1796        }
1797    }
1798}
1799
1800pub use crate::known_models::{KNOWN_MODELS, lookup_known_model};
1801/// Register derived preset models for a provider with a matching base URL.
1802/// User configuration overlays take priority over this layer.
1803pub fn register_preset_models_for(provider_name: &str, base_url: &str) {
1804    let config = {
1805        let state = REGISTRY_STATE.read().unwrap();
1806        let configured = state
1807            .config
1808            .values
1809            .providers
1810            .get(provider_name)
1811            .and_then(|provider| provider.base_url.as_deref());
1812        if configured != Some(base_url) {
1813            return;
1814        }
1815        state.config.clone()
1816    };
1817    if let Err(error) = install_config_layer(config) {
1818        crate::notify!(error, "preset model update rejected: {error}");
1819    }
1820}
1821
1822/// Register preset models for all configured providers with matching base URLs.
1823pub fn register_all_preset_models() {
1824    let config = REGISTRY_STATE.read().unwrap().config.clone();
1825    if let Err(error) = install_config_layer(config) {
1826        crate::notify!(error, "preset model update rejected: {error}");
1827    }
1828}
1829
1830// Config migration (v1 to v2)
1831
1832#[derive(Debug, Clone, PartialEq, Eq)]
1833pub enum ModelMigrationOutcome {
1834    NotNeeded,
1835    Migrated { backup: std::path::PathBuf },
1836}
1837
1838pub fn migrate_config_if_needed(
1839    text: &str,
1840) -> Result<Option<String>, crate::config_hub::ConfigError> {
1841    let mut doc = text.parse::<toml_edit::DocumentMut>()?;
1842    let legacy = has_legacy_model_fields(&doc);
1843    match doc.get("config_version") {
1844        None if !legacy => return Ok(None),
1845        None => {}
1846        Some(version) => match version.as_integer() {
1847            Some(1) if legacy => {}
1848            Some(1) | Some(2) => return Ok(None),
1849            Some(value) => {
1850                return Err(crate::config_hub::ConfigError::Invalid(format!(
1851                    "unsupported config_version {value}"
1852                )));
1853            }
1854            None => {
1855                return Err(crate::config_hub::ConfigError::Invalid(
1856                    "config_version must be an integer".into(),
1857                ));
1858            }
1859        },
1860    }
1861
1862    let provider_names: std::collections::HashSet<String> = doc
1863        .get("providers")
1864        .and_then(|item| item.as_table())
1865        .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
1866        .unwrap_or_default();
1867    let models = doc
1868        .get("models")
1869        .and_then(|item| item.as_table())
1870        .ok_or_else(|| crate::config_hub::ConfigError::Invalid("models is not a table".into()))?;
1871    let mut groups: std::collections::BTreeMap<(String, String, String), Vec<String>> =
1872        std::collections::BTreeMap::new();
1873
1874    for (name, entry) in models.iter() {
1875        let api_key = entry
1876            .get("api_key")
1877            .and_then(|v| v.as_str())
1878            .unwrap_or("")
1879            .to_string();
1880        let base_url = entry
1881            .get("base_url")
1882            .and_then(|v| v.as_str())
1883            .unwrap_or("")
1884            .to_string();
1885        let ptype = entry
1886            .get("provider")
1887            .and_then(|v| v.as_str())
1888            .unwrap_or("openai-compat")
1889            .to_string();
1890
1891        if !api_key.is_empty()
1892            || !base_url.is_empty()
1893            || (matches!(
1894                ptype.as_str(),
1895                "openai" | "openai-compat" | "anthropic" | "codex"
1896            ) && !provider_names.contains(&ptype))
1897        {
1898            groups
1899                .entry((ptype, api_key, base_url))
1900                .or_default()
1901                .push(name.to_string());
1902        }
1903    }
1904
1905    if groups.is_empty() {
1906        return Ok(None);
1907    }
1908
1909    let mut used_names: std::collections::HashSet<String> = doc
1910        .get("providers")
1911        .and_then(|item| item.as_table())
1912        .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
1913        .unwrap_or_default();
1914
1915    for ((ptype, api_key, base_url), model_names) in &groups {
1916        let provider_name = pick_provider_name(ptype, base_url, &mut used_names);
1917        used_names.insert(provider_name.clone());
1918
1919        // Create [providers.{name}] section
1920        let mut table = toml_edit::Table::new();
1921        table.insert("kind", toml_edit::value(ptype.clone()));
1922        if !api_key.is_empty() {
1923            table.insert("api_key", toml_edit::value(api_key.clone()));
1924        }
1925        if !base_url.is_empty() {
1926            table.insert("base_url", toml_edit::value(base_url.clone()));
1927        }
1928        table.insert("enabled", toml_edit::value(true));
1929
1930        // Ensure [providers] table exists
1931        if doc.get("providers").is_none() {
1932            doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
1933        }
1934        if let Some(providers) = doc.get_mut("providers").and_then(|p| p.as_table_mut()) {
1935            providers.insert(&provider_name, toml_edit::Item::Table(table));
1936        }
1937
1938        // Update each model's provider field and remove api_key/base_url
1939        for model_name in model_names {
1940            if let Some(model) = doc
1941                .get_mut("models")
1942                .and_then(|m| m.as_table_mut())
1943                .and_then(|t| t.get_mut(model_name.as_str()))
1944                .and_then(|e| e.as_table_mut())
1945            {
1946                model.insert("provider", toml_edit::value(&provider_name));
1947                model.remove("api_key");
1948                model.remove("base_url");
1949            }
1950        }
1951    }
1952
1953    doc.insert("config_version", toml_edit::value(2i64));
1954    let migrated = doc.to_string();
1955    parse_config(&migrated).ok_or_else(|| {
1956        crate::config_hub::ConfigError::Invalid("validate migrated config.toml".into())
1957    })?;
1958    Ok(Some(migrated))
1959}
1960
1961fn has_legacy_model_fields(doc: &toml_edit::DocumentMut) -> bool {
1962    let provider_names: std::collections::HashSet<&str> = doc
1963        .get("providers")
1964        .and_then(|item| item.as_table())
1965        .map(|providers| providers.iter().map(|(name, _)| name).collect())
1966        .unwrap_or_default();
1967    doc.get("models")
1968        .and_then(|item| item.as_table())
1969        .is_some_and(|models| {
1970            models.iter().any(|(_, entry)| {
1971                entry.get("api_key").is_some()
1972                    || entry.get("base_url").is_some()
1973                    || entry
1974                        .get("provider")
1975                        .and_then(|value| value.as_str())
1976                        .is_some_and(|provider| {
1977                            matches!(provider, "openai" | "openai-compat" | "anthropic" | "codex")
1978                                && !provider_names.contains(provider)
1979                        })
1980            })
1981        })
1982}
1983
1984fn pick_provider_name(
1985    ptype: &str,
1986    base_url: &str,
1987    used: &mut std::collections::HashSet<String>,
1988) -> String {
1989    // Try to match base_url against PROVIDER_PRESETS
1990    for preset in PROVIDER_PRESETS {
1991        if !base_url.is_empty() && preset.base_url == base_url {
1992            let name = preset.name.to_lowercase();
1993            if !used.contains(&name) {
1994                return name;
1995            }
1996        }
1997    }
1998    // Fall back to provider type, with suffix for duplicates
1999    let base = ptype.to_string();
2000    if !used.contains(&base) {
2001        return base;
2002    }
2003    for i in 2.. {
2004        let candidate = format!("{base}-{i}");
2005        if !used.contains(&candidate) {
2006            return candidate;
2007        }
2008    }
2009    unreachable!()
2010}
2011
2012// Alias CRUD (writes config.toml)
2013
2014pub fn read_config_toml_pub() -> Option<String> {
2015    crate::config_hub::ConfigHub::global()
2016        .ok()?
2017        .read_config_toml()
2018        .ok()
2019}
2020
2021#[cfg(test)]
2022pub(crate) fn reload_from_text(text: &str) -> anyhow::Result<()> {
2023    let prepared = prepare_config_text(text)?;
2024    commit_prepared_config(prepared);
2025    Ok(())
2026}
2027
2028/// Unified config parser — parses `[providers.X]`, `[models.X]`, and `[alias.X]`
2029/// sections from a TOML string into a [ProviderConfig].
2030pub fn parse_config(text: &str) -> Option<ProviderConfig> {
2031    parse_config_layer(text)
2032        .ok()
2033        .flatten()
2034        .map(|layer| layer.values)
2035}
2036
2037fn parse_config_layer(text: &str) -> Result<Option<ConfigLayer>, toml::de::Error> {
2038    #[derive(serde::Deserialize, Default)]
2039    struct RawProvider {
2040        #[serde(default)]
2041        name: Option<String>,
2042        #[serde(default)]
2043        kind: Option<String>,
2044        #[serde(default)]
2045        api_key: Option<String>,
2046        #[serde(default)]
2047        api_key_env: Option<String>,
2048        #[serde(default)]
2049        base_url: Option<String>,
2050        #[serde(default)]
2051        max_tokens: Option<u32>,
2052        #[serde(default)]
2053        reasoning_format: Option<crate::providers::openai::OpenAiReasoningFormat>,
2054        #[serde(default)]
2055        prompt_cache_key: Option<bool>,
2056        #[serde(default)]
2057        enabled: Option<bool>,
2058    }
2059
2060    #[derive(serde::Deserialize, Default)]
2061    struct RawModel {
2062        #[serde(default)]
2063        model: Option<String>,
2064        #[serde(default)]
2065        provider: Option<String>,
2066        #[serde(default)]
2067        context_budget: Option<u64>,
2068        #[serde(default)]
2069        compact_threshold_ratio: Option<f64>,
2070        #[serde(default)]
2071        thinking: Option<bool>,
2072        #[serde(default)]
2073        reasoning: Option<String>,
2074        #[serde(default)]
2075        reasoning_mode: Option<String>,
2076        #[serde(default)]
2077        reasoning_budget_tokens: Option<u32>,
2078        #[serde(default)]
2079        reasoning_efforts: Option<Vec<ReasoningEffort>>,
2080        #[serde(default)]
2081        default_reasoning_effort: Option<ReasoningEffort>,
2082        #[serde(default)]
2083        reasoning_modes: Option<Vec<ReasoningExecutionMode>>,
2084        #[serde(default)]
2085        default_reasoning_mode: Option<ReasoningExecutionMode>,
2086        #[serde(default)]
2087        input_modalities: Option<Vec<InputModality>>,
2088        #[serde(default)]
2089        image_detail: Option<ImageDetail>,
2090        #[serde(default)]
2091        max_tokens: Option<u32>,
2092        #[serde(default)]
2093        enabled: Option<bool>,
2094        #[serde(default)]
2095        discovered: bool,
2096    }
2097
2098    #[derive(serde::Deserialize, Default)]
2099    struct RawAlias {
2100        model: String,
2101    }
2102
2103    #[derive(serde::Deserialize, Default)]
2104    struct RawFile {
2105        #[serde(default)]
2106        providers: std::collections::HashMap<String, RawProvider>,
2107        #[serde(default)]
2108        models: std::collections::HashMap<String, RawModel>,
2109        #[serde(default)]
2110        alias: std::collections::HashMap<String, RawAlias>,
2111    }
2112
2113    let raw: RawFile = toml::from_str(text)?;
2114    let mut cfg = ProviderConfig::default();
2115    let mut declarations = HashMap::new();
2116
2117    for (key, p) in raw.providers {
2118        cfg.providers.insert(
2119            key.clone(),
2120            ProviderEntry {
2121                name: p.name.unwrap_or(key),
2122                kind: p.kind.unwrap_or_default(),
2123                api_key: p.api_key,
2124                api_key_env: p.api_key_env,
2125                base_url: p.base_url,
2126                max_tokens: p.max_tokens,
2127                reasoning_format: p.reasoning_format,
2128                prompt_cache_key: p.prompt_cache_key,
2129                enabled: p.enabled,
2130            },
2131        );
2132    }
2133
2134    for (name, m) in raw.models {
2135        declarations.insert(
2136            name.clone(),
2137            CapabilityDeclarations {
2138                reasoning_efforts: m.reasoning_efforts.is_some(),
2139                reasoning_modes: m.reasoning_modes.is_some(),
2140                input_modalities: m.input_modalities.is_some(),
2141            },
2142        );
2143        cfg.models.insert(
2144            name,
2145            ModelEntry {
2146                model: m.model.unwrap_or_default(),
2147                provider: m.provider,
2148                context_budget: m.context_budget,
2149                compact_threshold_ratio: m.compact_threshold_ratio,
2150                thinking: m.thinking,
2151                reasoning: m.reasoning,
2152                reasoning_mode: m.reasoning_mode,
2153                reasoning_budget_tokens: m.reasoning_budget_tokens,
2154                reasoning_efforts: m.reasoning_efforts.unwrap_or_default(),
2155                default_reasoning_effort: m.default_reasoning_effort,
2156                reasoning_modes: m.reasoning_modes.unwrap_or_default(),
2157                default_reasoning_mode: m.default_reasoning_mode,
2158                input_modalities: m.input_modalities.unwrap_or_default(),
2159                image_detail: m.image_detail,
2160                max_tokens: m.max_tokens,
2161                enabled: m.enabled,
2162                discovered: m.discovered,
2163            },
2164        );
2165    }
2166
2167    for (name, a) in raw.alias {
2168        cfg.aliases.insert(name, AliasEntry { model: a.model });
2169    }
2170
2171    if cfg.providers.is_empty() && cfg.models.is_empty() && cfg.aliases.is_empty() {
2172        return Ok(None);
2173    }
2174    Ok(Some(ConfigLayer {
2175        values: cfg,
2176        capabilities: declarations,
2177    }))
2178}
2179
2180pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
2181    crate::config_hub::ConfigHub::global()?
2182        .add_alias(alias, model)
2183        .map_err(Into::into)
2184}
2185
2186pub fn upsert_provider_config(
2187    name: &str,
2188    kind: &str,
2189    api_key: Option<&str>,
2190    api_key_env: Option<&str>,
2191    base_url: Option<&str>,
2192    max_tokens: Option<u32>,
2193    enabled: bool,
2194) -> anyhow::Result<()> {
2195    crate::config_hub::ConfigHub::global()?
2196        .upsert_provider(crate::config_hub::ProviderConfigUpdate {
2197            name,
2198            kind,
2199            api_key,
2200            api_key_env,
2201            base_url,
2202            max_tokens,
2203            reasoning_format: None,
2204            prompt_cache_key: None,
2205            enabled,
2206        })
2207        .map_err(Into::into)
2208}
2209
2210#[derive(Debug, Clone)]
2211pub struct ModelConfigUpdate<'a> {
2212    pub old_name: Option<&'a str>,
2213    pub name: &'a str,
2214    pub model: &'a str,
2215    pub provider: Option<&'a str>,
2216    pub context_budget: u64,
2217    pub reasoning: ReasoningSelection,
2218    pub capabilities: Option<ModelCapabilities>,
2219    pub image_detail: Option<ImageDetail>,
2220    pub max_tokens: Option<u32>,
2221    pub enabled: bool,
2222}
2223
2224pub(crate) fn apply_model_config_update(
2225    doc: &mut toml_edit::DocumentMut,
2226    update: ModelConfigUpdate<'_>,
2227) -> anyhow::Result<()> {
2228    if doc.get("models").is_none() {
2229        doc.insert("models", toml_edit::Item::Table(toml_edit::Table::new()));
2230    }
2231    {
2232        let models = doc
2233            .get_mut("models")
2234            .and_then(|item| item.as_table_mut())
2235            .ok_or_else(|| anyhow::anyhow!("models is not a table"))?;
2236        if let Some(old_name) = update.old_name.filter(|old| *old != update.name) {
2237            models.remove(old_name);
2238        }
2239        let entry = models
2240            .entry(update.name)
2241            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
2242            .as_table_mut()
2243            .ok_or_else(|| anyhow::anyhow!("model entry is not a table"))?;
2244        entry.insert("model", toml_edit::value(update.model));
2245        if let Some(provider) = update.provider {
2246            entry.insert("provider", toml_edit::value(provider));
2247        } else {
2248            entry.remove("provider");
2249        }
2250        entry.insert(
2251            "context_budget",
2252            toml_edit::value(update.context_budget as i64),
2253        );
2254        entry.remove("thinking");
2255        entry.remove("reasoning");
2256        entry.remove("reasoning_mode");
2257        entry.remove("reasoning_budget_tokens");
2258        match &update.reasoning {
2259            ReasoningSelection::ProviderDefault => {}
2260            ReasoningSelection::Disabled => {
2261                entry.insert("reasoning", toml_edit::value("off"));
2262            }
2263            ReasoningSelection::Auto { execution_mode } => {
2264                entry.insert("reasoning", toml_edit::value("auto"));
2265                if let Some(mode) = execution_mode {
2266                    entry.insert("reasoning_mode", toml_edit::value(mode.to_string()));
2267                }
2268            }
2269            ReasoningSelection::Effort {
2270                effort,
2271                execution_mode,
2272            } => {
2273                entry.insert("reasoning", toml_edit::value(effort.to_string()));
2274                if let Some(mode) = execution_mode {
2275                    entry.insert("reasoning_mode", toml_edit::value(mode.to_string()));
2276                }
2277            }
2278            ReasoningSelection::BudgetTokens { tokens } => {
2279                entry.insert(
2280                    "reasoning_budget_tokens",
2281                    toml_edit::value(i64::from(*tokens)),
2282                );
2283            }
2284        }
2285        if let Some(capabilities) = update.capabilities {
2286            insert_string_array(
2287                entry,
2288                "reasoning_efforts",
2289                capabilities
2290                    .reasoning_efforts
2291                    .iter()
2292                    .map(ToString::to_string),
2293            );
2294            if let Some(default) = capabilities.default_reasoning_effort {
2295                entry.insert(
2296                    "default_reasoning_effort",
2297                    toml_edit::value(default.to_string()),
2298                );
2299            } else {
2300                entry.remove("default_reasoning_effort");
2301            }
2302            insert_string_array(
2303                entry,
2304                "reasoning_modes",
2305                capabilities.reasoning_modes.iter().map(ToString::to_string),
2306            );
2307            if let Some(default) = capabilities.default_reasoning_mode {
2308                entry.insert(
2309                    "default_reasoning_mode",
2310                    toml_edit::value(default.to_string()),
2311                );
2312            } else {
2313                entry.remove("default_reasoning_mode");
2314            }
2315            insert_string_array(
2316                entry,
2317                "input_modalities",
2318                capabilities
2319                    .input_modalities
2320                    .iter()
2321                    .map(|modality| match modality {
2322                        InputModality::Text => "text".to_string(),
2323                        InputModality::Image => "image".to_string(),
2324                        InputModality::Audio => "audio".to_string(),
2325                    }),
2326            );
2327        }
2328        if let Some(detail) = update.image_detail {
2329            let value = match detail {
2330                ImageDetail::Auto => "auto",
2331                ImageDetail::Low => "low",
2332                ImageDetail::High => "high",
2333                ImageDetail::Original => "original",
2334            };
2335            entry.insert("image_detail", toml_edit::value(value));
2336        }
2337        if let Some(max_tokens) = update.max_tokens {
2338            entry.insert("max_tokens", toml_edit::value(max_tokens as i64));
2339        } else {
2340            entry.remove("max_tokens");
2341        }
2342        entry.insert("enabled", toml_edit::value(update.enabled));
2343    }
2344
2345    if let Some(old_name) = update.old_name.filter(|old| *old != update.name)
2346        && let Some(aliases) = doc.get_mut("alias").and_then(|item| item.as_table_mut())
2347    {
2348        for (_, alias) in aliases.iter_mut() {
2349            if let Some(table) = alias.as_table_mut() {
2350                if table.get("model").and_then(|item| item.as_str()) == Some(old_name) {
2351                    table.insert("model", toml_edit::value(update.name));
2352                }
2353            } else if let Some(inline) = alias.as_inline_table_mut()
2354                && inline.get("model").and_then(|value| value.as_str()) == Some(old_name)
2355            {
2356                inline.insert("model", toml_edit::Value::from(update.name));
2357            }
2358        }
2359    }
2360    Ok(())
2361}
2362
2363fn insert_string_array(
2364    entry: &mut toml_edit::Table,
2365    key: &str,
2366    values: impl Iterator<Item = String>,
2367) {
2368    let mut array = toml_edit::Array::new();
2369    for value in values {
2370        array.push(value);
2371    }
2372    entry.insert(key, toml_edit::value(array));
2373}
2374
2375pub fn upsert_model_config(update: ModelConfigUpdate<'_>) -> anyhow::Result<()> {
2376    crate::config_hub::ConfigHub::global()?
2377        .upsert_model(update)
2378        .map_err(Into::into)
2379}
2380
2381pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
2382    crate::config_hub::ConfigHub::global()?
2383        .remove_alias(alias)
2384        .map_err(Into::into)
2385}
2386
2387pub fn update_alias_in_config(
2388    old_alias: &str,
2389    new_alias: &str,
2390    new_model: &str,
2391) -> anyhow::Result<()> {
2392    crate::config_hub::ConfigHub::global()?
2393        .update_alias(Some(old_alias), new_alias, new_model)
2394        .map_err(Into::into)
2395}
2396
2397// Provider presets + first-run detection
2398
2399pub struct ProviderPreset {
2400    pub name: &'static str,
2401    pub description: &'static str,
2402    pub base_url: &'static str,
2403    pub provider_type: &'static str,
2404    pub models: &'static [ProviderPresetModel],
2405    pub key_url: Option<&'static str>,
2406    pub needs_api_key: bool,
2407}
2408
2409pub struct ProviderPresetModel {
2410    pub id: &'static str,
2411    pub description: &'static str,
2412    pub context_budget: u64,
2413    pub thinking: bool,
2414}
2415
2416pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
2417    ProviderPreset {
2418        name: "DeepSeek",
2419        description: "Recommended — cheap, smart, supports thinking",
2420        base_url: "https://api.deepseek.com",
2421        provider_type: "openai-compat",
2422        models: &[
2423            ProviderPresetModel {
2424                id: "deepseek-v4-flash",
2425                description: "Fast & capable",
2426                context_budget: 1000000,
2427                thinking: false,
2428            },
2429            ProviderPresetModel {
2430                id: "deepseek-v4-pro",
2431                description: "Thinking mode",
2432                context_budget: 1000000,
2433                thinking: true,
2434            },
2435        ],
2436        key_url: Some("https://platform.deepseek.com"),
2437        needs_api_key: true,
2438    },
2439    ProviderPreset {
2440        name: "OpenAI",
2441        description: "GPT-4o / GPT-4o-mini",
2442        base_url: "https://api.openai.com/v1",
2443        provider_type: "openai",
2444        models: &[
2445            ProviderPresetModel {
2446                id: "gpt-4o",
2447                description: "Most capable",
2448                context_budget: 128000,
2449                thinking: false,
2450            },
2451            ProviderPresetModel {
2452                id: "gpt-4o-mini",
2453                description: "Fast & cheap",
2454                context_budget: 128000,
2455                thinking: false,
2456            },
2457        ],
2458        key_url: Some("https://platform.openai.com/api-keys"),
2459        needs_api_key: true,
2460    },
2461    ProviderPreset {
2462        name: "Anthropic",
2463        description: "Claude models",
2464        base_url: "https://api.anthropic.com",
2465        provider_type: "anthropic",
2466        models: &[ProviderPresetModel {
2467            id: "claude-sonnet-4-20250514",
2468            description: "Claude Sonnet 4",
2469            context_budget: 200000,
2470            thinking: true,
2471        }],
2472        key_url: Some("https://console.anthropic.com/settings/keys"),
2473        needs_api_key: true,
2474    },
2475    ProviderPreset {
2476        name: "ZhipuAI",
2477        description: "GLM models",
2478        base_url: "https://open.bigmodel.cn/api/paas/v4",
2479        provider_type: "openai-compat",
2480        models: &[ProviderPresetModel {
2481            id: "glm-5.2",
2482            description: "GLM 5.2",
2483            context_budget: 1000000,
2484            thinking: true,
2485        }],
2486        key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
2487        needs_api_key: true,
2488    },
2489    ProviderPreset {
2490        name: "Ollama",
2491        description: "Local models, no API key needed",
2492        base_url: "http://localhost:11434/v1",
2493        provider_type: "openai-compat",
2494        models: &[],
2495        key_url: None,
2496        needs_api_key: false,
2497    },
2498    ProviderPreset {
2499        name: "Codex",
2500        description: "ChatGPT Plus/Pro OAuth",
2501        base_url: "https://chatgpt.com/backend-api/codex",
2502        provider_type: "codex",
2503        models: &[],
2504        key_url: None,
2505        needs_api_key: false,
2506    },
2507];
2508
2509pub fn is_first_run() -> bool {
2510    let providers = all_provider_entries();
2511    let config_configured = providers.iter().any(|(_, e)| {
2512        e.api_key.as_deref().is_some_and(|k| !k.is_empty())
2513            || e.api_key_env
2514                .as_deref()
2515                .is_some_and(|env| std::env::var(env).is_ok_and(|v| !v.trim().is_empty()))
2516    });
2517    let env_configured =
2518        std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
2519    let auth_configured = AuthStore::load()
2520        .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
2521    let smart_resolves = {
2522        let resolved = resolve_alias("smart");
2523        resolved != "smart" && model_entry(&resolved).is_some()
2524    };
2525    !(config_configured || env_configured || auth_configured) || !smart_resolves
2526}
2527
2528#[cfg(test)]
2529mod tests {
2530    use super::*;
2531
2532    /// Tests that mutate the global registry must hold this lock to avoid races
2533    /// when cargo test runs them in parallel.
2534    static TEST_CFG_LOCK: &std::sync::Mutex<()> = &MODEL_CONFIG_LOCK;
2535
2536    struct IsolatedRegistry {
2537        _lock: std::sync::MutexGuard<'static, ()>,
2538    }
2539
2540    impl Drop for IsolatedRegistry {
2541        fn drop(&mut self) {
2542            *REGISTRY_STATE.write().unwrap() = RegistryState::default();
2543            CATALOG_REVISION.send_replace(0);
2544        }
2545    }
2546
2547    fn isolated_registry() -> IsolatedRegistry {
2548        let lock = TEST_CFG_LOCK
2549            .lock()
2550            .unwrap_or_else(std::sync::PoisonError::into_inner);
2551        *REGISTRY_STATE.write().unwrap() = RegistryState::default();
2552        CATALOG_REVISION.send_replace(0);
2553        IsolatedRegistry { _lock: lock }
2554    }
2555
2556    fn descriptor(
2557        provider_key: &str,
2558        namespace: &str,
2559        wire_profile: ReasoningWireProfile,
2560    ) -> ProviderDescriptor {
2561        ProviderDescriptor {
2562            provider_key: provider_key.to_string(),
2563            provider_name: "Test Provider".to_string(),
2564            namespace: namespace.to_string(),
2565            wire_profile,
2566        }
2567    }
2568
2569    fn advertised_model(
2570        slug: &str,
2571        context_budget: u64,
2572        reasoning_efforts: Vec<ReasoningEffort>,
2573    ) -> crate::provider::DiscoveredModelDetails {
2574        crate::provider::DiscoveredModelDetails {
2575            slug: slug.to_string(),
2576            context_budget: Some(context_budget),
2577            capability_knowledge: CapabilityKnowledge::Advertised(ModelCapabilities {
2578                reasoning_efforts,
2579                input_modalities: vec![InputModality::Text, InputModality::Image],
2580                ..Default::default()
2581            }),
2582        }
2583    }
2584
2585    #[test]
2586    fn enabled_auth_provider_names_match_discovered_model_groups() {
2587        let _registry = isolated_registry();
2588        let auth = AuthStore {
2589            providers: vec![
2590                crate::auth_store::StoredProvider {
2591                    id: "disabled-id".into(),
2592                    name: "Codex".into(),
2593                    kind: crate::auth_store::ProviderKind::Codex,
2594                    access_token: String::new(),
2595                    refresh_token: None,
2596                    expires_at: 0,
2597                    account: Some("old@example.com".into()),
2598                    enabled: false,
2599                    model_cache: None,
2600                },
2601                crate::auth_store::StoredProvider {
2602                    id: "enabled-id".into(),
2603                    name: "Codex".into(),
2604                    kind: crate::auth_store::ProviderKind::Codex,
2605                    access_token: String::new(),
2606                    refresh_token: None,
2607                    expires_at: 0,
2608                    account: Some("current@example.com".into()),
2609                    enabled: true,
2610                    model_cache: None,
2611                },
2612            ],
2613        };
2614
2615        let names = enabled_provider_names_from_auth(&auth);
2616        assert!(names.contains("enabled-id"));
2617        assert!(!names.contains("disabled-id"));
2618
2619        let models = vec![crate::provider::DiscoveredModelDetails {
2620            slug: "codex/gpt-test".into(),
2621            context_budget: Some(272_000),
2622            capability_knowledge: crate::provider::CapabilityKnowledge::Advertised(
2623                ModelCapabilities::default(),
2624            ),
2625        }];
2626        assert_eq!(
2627            shortest_unique_provider_id("1234567-account", &["1234567-account".into()]),
2628            "123456"
2629        );
2630        assert_eq!(
2631            shortest_unique_provider_id(
2632                "abcdef1-account",
2633                &["abcdef1-account".into(), "abcdef2-account".into()]
2634            ),
2635            "abcdef1"
2636        );
2637        register_discovered_details_for_provider("enabled-id", "Codex", &models).unwrap();
2638        let model_key = "enable@Codex:codex/gpt-test";
2639        let entry = model_entry(model_key).unwrap();
2640        assert_eq!(entry.provider.as_deref(), Some("enabled-id"));
2641        assert_eq!(entry.model, "codex/gpt-test");
2642    }
2643
2644    #[test]
2645    fn unregistered_model_returns_zero_budget() {
2646        let _registry = isolated_registry();
2647        *REGISTRY_STATE.write().unwrap() = RegistryState::default();
2648        assert_eq!(model_info("mystery-model").context_budget, 0);
2649        assert_eq!(model_info("").context_budget, 0);
2650    }
2651
2652    #[test]
2653    fn threshold_is_eighty_percent() {
2654        let _registry = isolated_registry();
2655        let mut cfg = ModelConfig::default();
2656        cfg.models.insert(
2657            "claude-opus-4.7".into(),
2658            ModelEntry {
2659                model: "claude-opus-4.7".into(),
2660                context_budget: Some(200_000),
2661                compact_threshold_ratio: Some(0.8),
2662                thinking: None,
2663                ..Default::default()
2664            },
2665        );
2666        set_model_config(cfg);
2667        let info = model_info("claude-opus-4.7");
2668        assert_eq!(info.compact_threshold_tokens(), 160_000);
2669    }
2670
2671    #[test]
2672    fn compaction_trigger_is_near_budget_top() {
2673        let _registry = isolated_registry();
2674        let mut cfg = ModelConfig::default();
2675        cfg.models.insert(
2676            "claude-opus-4.7".into(),
2677            ModelEntry {
2678                model: "claude-opus-4.7".into(),
2679                context_budget: Some(200_000),
2680                compact_threshold_ratio: Some(0.8),
2681                thinking: None,
2682                ..Default::default()
2683            },
2684        );
2685        set_model_config(cfg);
2686        let info = model_info("claude-opus-4.7");
2687        let trigger = info.compaction_trigger_threshold();
2688        assert!(
2689            trigger > 150_000 && trigger <= 190_000,
2690            "trigger should be near the top of the budget, got {trigger}"
2691        );
2692    }
2693
2694    #[test]
2695    fn compaction_target_is_lower_than_trigger() {
2696        let _registry = isolated_registry();
2697        let mut cfg = ModelConfig::default();
2698        cfg.models.insert(
2699            "claude-opus-4.7".into(),
2700            ModelEntry {
2701                model: "claude-opus-4.7".into(),
2702                context_budget: Some(200_000),
2703                compact_threshold_ratio: Some(0.8),
2704                thinking: None,
2705                ..Default::default()
2706            },
2707        );
2708        set_model_config(cfg);
2709        let info = model_info("claude-opus-4.7");
2710        let trigger = info.compaction_trigger_threshold();
2711        let target = info.compaction_target_after();
2712        assert!(
2713            target < trigger,
2714            "target {target} should be less than trigger {trigger}"
2715        );
2716        assert_eq!(target, 50_000, "compaction target should be 25% of context");
2717    }
2718
2719    #[test]
2720    fn alias_resolves_to_real_model() {
2721        let _registry = isolated_registry();
2722        let mut cfg = ModelConfig::default();
2723        cfg.models.insert(
2724            "claude-opus-4.7".into(),
2725            ModelEntry {
2726                model: "claude-opus-4.7".into(),
2727                context_budget: Some(200_000),
2728                ..Default::default()
2729            },
2730        );
2731        cfg.aliases.insert(
2732            "smart".into(),
2733            AliasEntry {
2734                model: "claude-opus-4.7".into(),
2735            },
2736        );
2737        set_model_config(cfg);
2738        let info = model_info("smart");
2739        assert_eq!(info.context_budget, 200_000);
2740        assert_eq!(info.name, "claude-opus-4.7");
2741    }
2742
2743    #[test]
2744    fn custom_model_overrides_budget() {
2745        let _registry = isolated_registry();
2746        let mut cfg = ModelConfig::default();
2747        cfg.models.insert(
2748            "my-local-model".into(),
2749            ModelEntry {
2750                model: "my-local-model".into(),
2751                context_budget: Some(8192),
2752                compact_threshold_ratio: Some(0.9),
2753                thinking: None,
2754                ..Default::default()
2755            },
2756        );
2757        set_model_config(cfg);
2758        let info = model_info("my-local-model");
2759        assert_eq!(info.context_budget, 8192);
2760        assert_eq!(info.compact_threshold_ratio, 0.9);
2761    }
2762
2763    #[test]
2764    fn compact_threshold_reserves_configured_output_tokens() {
2765        let _registry = isolated_registry();
2766        let mut cfg = ModelConfig::default();
2767        cfg.models.insert(
2768            "large-output".into(),
2769            ModelEntry {
2770                model: "large-output".into(),
2771                context_budget: Some(1_000_000),
2772                compact_threshold_ratio: Some(0.8),
2773                thinking: None,
2774                max_tokens: Some(400_000),
2775                ..Default::default()
2776            },
2777        );
2778        set_model_config(cfg);
2779        let info = model_info("large-output");
2780        assert_eq!(info.compact_threshold_tokens(), 480_000);
2781        let trigger = info.compaction_trigger_threshold();
2782        assert!(
2783            trigger > 700_000,
2784            "trigger with capped output reserve should be > 700K, got {trigger}"
2785        );
2786    }
2787
2788    #[test]
2789    fn alias_chains_through_custom_model() {
2790        let _registry = isolated_registry();
2791        let mut cfg = ModelConfig::default();
2792        cfg.aliases.insert(
2793            "default".into(),
2794            AliasEntry {
2795                model: "my-model".into(),
2796            },
2797        );
2798        cfg.models.insert(
2799            "my-model".into(),
2800            ModelEntry {
2801                model: "my-model".into(),
2802                context_budget: Some(65_536),
2803                compact_threshold_ratio: None,
2804                thinking: None,
2805                ..Default::default()
2806            },
2807        );
2808        set_model_config(cfg);
2809        let info = model_info("default");
2810        assert_eq!(info.name, "my-model");
2811        assert_eq!(info.context_budget, 65_536);
2812    }
2813
2814    #[test]
2815    fn discovered_models_survive_set_model_config() {
2816        let _registry = isolated_registry();
2817        set_discovered_models(vec!["z".into(), "a".into(), "z".into()]);
2818        assert_eq!(discovered_models(), ["z", "a", "z"]);
2819        register_discovered(
2820            "pid-abc",
2821            "Codex",
2822            &[crate::provider::DiscoveredModel {
2823                slug: "codex/gpt-5".to_string(),
2824                context_budget: Some(128_000),
2825                thinking: true,
2826            }],
2827        );
2828        assert!(model_entry("Codex:codex/gpt-5").is_some());
2829        assert_eq!(
2830            model_entry("Codex:codex/gpt-5").unwrap().model,
2831            "Codex:codex/gpt-5"
2832        );
2833        register_discovered("pid-abc", "Codex", &[]);
2834        assert!(model_entry("Codex:codex/gpt-5").is_some());
2835
2836        let mut cfg = ModelConfig::default();
2837        cfg.aliases.insert(
2838            "cheap".into(),
2839            AliasEntry {
2840                model: "claude-opus-4.7".into(),
2841            },
2842        );
2843        set_model_config(cfg);
2844
2845        assert!(
2846            model_entry("Codex:codex/gpt-5").is_some(),
2847            "discovered models should survive set_model_config"
2848        );
2849        assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
2850    }
2851
2852    #[test]
2853    fn reload_replaces_config_models_and_preserves_discovered_models() {
2854        let _registry = isolated_registry();
2855        let mut initial = ProviderConfig::default();
2856        initial.models.insert(
2857            "old-config".into(),
2858            ModelEntry {
2859                model: "provider/old".into(),
2860                discovered: false,
2861                ..Default::default()
2862            },
2863        );
2864        set_provider_config(initial);
2865        register_discovered_details(
2866            "dynamic-provider",
2867            "Dynamic",
2868            &[crate::provider::DiscoveredModelDetails {
2869                slug: "provider/dynamic".into(),
2870                context_budget: Some(64_000),
2871                capability_knowledge: CapabilityKnowledge::Advertised(ModelCapabilities::default()),
2872            }],
2873        )
2874        .unwrap();
2875
2876        reload_from_text(
2877            r#"
2878[models.new-config]
2879model = "provider/new"
2880"#,
2881        )
2882        .unwrap();
2883
2884        assert!(model_entry("old-config").is_none());
2885        assert!(model_entry("new-config").is_some());
2886        assert!(model_entry("dynami@Dynamic:provider/dynamic").is_some());
2887    }
2888
2889    #[test]
2890    fn discovered_models_survive_reload_from_text_alias_crud() {
2891        let _registry = isolated_registry();
2892        register_discovered(
2893            "pid-abc",
2894            "Codex",
2895            &[crate::provider::DiscoveredModel {
2896                slug: "codex/gpt-5".to_string(),
2897                context_budget: Some(128_000),
2898                thinking: true,
2899            }],
2900        );
2901
2902        let toml = r#"
2903[alias]
2904smart = { model = "Codex:codex/gpt-5" }
2905"#;
2906        reload_from_text(toml).unwrap();
2907
2908        assert!(
2909            model_entry("Codex:codex/gpt-5").is_some(),
2910            "discovered models should survive alias CRUD"
2911        );
2912        assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
2913    }
2914
2915    #[test]
2916    fn model_config_update_replaces_name_and_preserves_other_sections() {
2917        let mut doc = r#"
2918# keep this comment
2919[providers.openai]
2920kind = "openai"
2921
2922[alias]
2923smart = { model = "old-name" }
2924cheap = { model = "other-name" }
2925
2926[alias.deep]
2927model = "old-name"
2928
2929[models.old-name]
2930model = "old-id"
2931provider = "openai"
2932enabled = true
2933"#
2934        .parse::<toml_edit::DocumentMut>()
2935        .unwrap();
2936
2937        apply_model_config_update(
2938            &mut doc,
2939            ModelConfigUpdate {
2940                old_name: Some("old-name"),
2941                name: "new-name",
2942                model: "new-id",
2943                provider: Some("openai"),
2944                context_budget: 128_000,
2945                reasoning: ReasoningSelection::Effort {
2946                    effort: ReasoningEffort::High,
2947                    execution_mode: None,
2948                },
2949                capabilities: None,
2950                image_detail: None,
2951                max_tokens: Some(4096),
2952                enabled: false,
2953            },
2954        )
2955        .unwrap();
2956
2957        let out = doc.to_string();
2958        assert!(out.contains("# keep this comment"));
2959        assert!(out.contains("[providers.openai]"));
2960        assert!(out.contains("[alias]"));
2961        assert!(out.contains("smart = { model = \"new-name\" }"));
2962        assert!(out.contains("cheap = { model = \"other-name\" }"));
2963        assert!(out.contains("[alias.deep]"));
2964        assert!(out.contains("model = \"new-name\""));
2965        assert!(out.contains("[models.new-name]"));
2966        assert!(!out.contains("[models.old-name]"));
2967        assert!(out.contains("model = \"new-id\""));
2968        assert!(out.contains("context_budget = 128000"));
2969        assert!(out.contains("reasoning = \"high\""));
2970        assert!(out.contains("max_tokens = 4096"));
2971        assert!(out.contains("enabled = false"));
2972    }
2973
2974    #[test]
2975    fn model_config_update_preserves_explicit_empty_capability_knowledge() {
2976        let _registry = isolated_registry();
2977        let mut doc = r#"
2978[providers.official]
2979kind = "openai"
2980"#
2981        .parse::<toml_edit::DocumentMut>()
2982        .unwrap();
2983
2984        apply_model_config_update(
2985            &mut doc,
2986            ModelConfigUpdate {
2987                old_name: None,
2988                name: "known-empty",
2989                model: "api/model",
2990                provider: Some("official"),
2991                context_budget: 128_000,
2992                reasoning: ReasoningSelection::ProviderDefault,
2993                capabilities: Some(ModelCapabilities::default()),
2994                image_detail: None,
2995                max_tokens: None,
2996                enabled: true,
2997            },
2998        )
2999        .unwrap();
3000
3001        let out = doc.to_string();
3002        assert!(out.contains("reasoning_efforts = []"));
3003        assert!(out.contains("reasoning_modes = []"));
3004        assert!(out.contains("input_modalities = []"));
3005        reload_from_text(&out).unwrap();
3006        assert_eq!(
3007            reasoning_selections_for_model("known-empty")
3008                .into_iter()
3009                .map(|selection| selection.to_string())
3010                .collect::<Vec<_>>(),
3011            ["default", "off", "auto"]
3012        );
3013    }
3014
3015    #[test]
3016    fn model_config_reads_reasoning_capabilities_and_legacy_bool() {
3017        let _registry = isolated_registry();
3018        let cfg = parse_config(
3019            r#"
3020[models.modern]
3021model = "gpt-modern"
3022reasoning = "xhigh"
3023reasoning_mode = "pro"
3024reasoning_efforts = ["low", "high", "xhigh"]
3025reasoning_modes = ["standard", "pro"]
3026input_modalities = ["text", "image"]
3027image_detail = "high"
3028
3029[models.legacy]
3030model = "legacy"
3031thinking = true
3032"#,
3033        )
3034        .unwrap();
3035        set_model_config(cfg);
3036
3037        let modern = model_info("modern");
3038        assert_eq!(
3039            modern.reasoning,
3040            ReasoningSelection::Effort {
3041                effort: ReasoningEffort::XHigh,
3042                execution_mode: Some(ReasoningExecutionMode::Pro),
3043            }
3044        );
3045        assert_eq!(
3046            modern.capabilities.input_modalities,
3047            vec![InputModality::Text, InputModality::Image]
3048        );
3049        assert_eq!(modern.image_detail, ImageDetail::High);
3050        assert_eq!(
3051            model_info("legacy").reasoning,
3052            ReasoningSelection::Auto {
3053                execution_mode: None
3054            }
3055        );
3056    }
3057
3058    #[test]
3059    fn provider_config_reads_explicit_reasoning_wire_format() {
3060        let cfg = parse_config(
3061            r#"
3062[providers.openai-compatible]
3063kind = "openai-compat"
3064reasoning_format = "reasoning-effort"
3065"#,
3066        )
3067        .unwrap();
3068
3069        assert_eq!(
3070            cfg.providers["openai-compatible"].reasoning_format,
3071            Some(crate::providers::openai::OpenAiReasoningFormat::Official)
3072        );
3073    }
3074
3075    #[test]
3076    fn known_provider_profile_displays_default_reasoning_without_model_hints() {
3077        let _registry = isolated_registry();
3078        let cfg = parse_config(
3079            r#"
3080[providers.messages]
3081kind = "anthropic"
3082
3083[models.plain]
3084model = "claude-plain"
3085provider = "messages"
3086context_budget = 128000
3087
3088[models.disabled]
3089model = "claude-disabled"
3090provider = "messages"
3091context_budget = 128000
3092thinking = false
3093
3094[alias]
3095smart = { model = "plain" }
3096"#,
3097        )
3098        .unwrap();
3099        set_provider_config(cfg);
3100
3101        assert_eq!(
3102            effective_reasoning_for_model("smart", None).unwrap(),
3103            Some(ReasoningSelection::ProviderDefault)
3104        );
3105        assert_eq!(
3106            effective_reasoning_for_model("disabled", None).unwrap(),
3107            Some(ReasoningSelection::Disabled)
3108        );
3109    }
3110
3111    #[test]
3112    fn compatible_thinking_profile_limits_shared_model_choices() {
3113        let _registry = isolated_registry();
3114        let cfg = parse_config(
3115            r#"
3116[providers.openai-compatible]
3117kind = "openai-compat"
3118reasoning_format = "thinking-toggle"
3119
3120[models.custom-reasoning]
3121model = "vendor/reasoning-model"
3122provider = "openai-compatible"
3123context_budget = 128000
3124thinking = true
3125reasoning_efforts = ["high"]
3126default_reasoning_effort = "high"
3127"#,
3128        )
3129        .unwrap();
3130        set_provider_config(cfg);
3131
3132        assert_eq!(
3133            reasoning_wire_profile_for_model("custom-reasoning"),
3134            ReasoningWireProfile::CompatibleThinking
3135        );
3136        assert_eq!(
3137            reasoning_selections_for_model("custom-reasoning")
3138                .into_iter()
3139                .map(|selection| selection.to_string())
3140                .collect::<Vec<_>>(),
3141            ["default", "off", "auto"]
3142        );
3143        assert_eq!(
3144            resolve_reasoning_for_model(
3145                "custom-reasoning",
3146                &ReasoningSelection::Auto {
3147                    execution_mode: None,
3148                },
3149            )
3150            .unwrap(),
3151            ReasoningSelection::Auto {
3152                execution_mode: None,
3153            }
3154        );
3155        assert!(
3156            resolve_reasoning_for_model(
3157                "custom-reasoning",
3158                &ReasoningSelection::Effort {
3159                    effort: ReasoningEffort::High,
3160                    execution_mode: None,
3161                },
3162            )
3163            .unwrap_err()
3164            .contains("cannot represent effort `high`")
3165        );
3166    }
3167
3168    #[test]
3169    fn provider_catalog_replacement_is_atomic_and_tracks_api_identity() {
3170        let _registry = isolated_registry();
3171        let original = descriptor(
3172            "catalog-provider",
3173            "stable",
3174            ReasoningWireProfile::CodexResponses,
3175        );
3176        let first = vec![
3177            advertised_model("api/a", 64_000, vec![ReasoningEffort::Low]),
3178            advertised_model("api/b", 128_000, vec![ReasoningEffort::High]),
3179        ];
3180        assert_eq!(
3181            replace_provider_catalog(original.clone(), &first).unwrap(),
3182            CatalogDelta {
3183                added: 2,
3184                updated: 0,
3185                removed: 0,
3186                total: 2,
3187            }
3188        );
3189        let first_revision = model_catalog_revision();
3190        assert_eq!(model_entry("stable:api/a").unwrap().model, "api/a");
3191
3192        let colliding = descriptor(
3193            "other-provider",
3194            "stable",
3195            ReasoningWireProfile::OpenAiOfficial,
3196        );
3197        assert!(matches!(
3198            replace_provider_catalog(colliding, &[advertised_model("api/other", 32_000, vec![])]),
3199            Err(CatalogError::NamespaceInUse { .. })
3200        ));
3201        assert_eq!(model_catalog_revision(), first_revision);
3202        assert!(model_entry("stable:api/a").is_some());
3203
3204        replace_provider_catalog(
3205            descriptor("colon-left", "a", ReasoningWireProfile::OpenAiOfficial),
3206            &[advertised_model("b:c", 32_000, vec![])],
3207        )
3208        .unwrap();
3209        replace_provider_catalog(
3210            descriptor("colon-right", "a:b", ReasoningWireProfile::OpenAiOfficial),
3211            &[advertised_model("c", 32_000, vec![])],
3212        )
3213        .unwrap();
3214        assert_eq!(model_entry("a:b:c").unwrap().model, "b:c");
3215        assert_eq!(model_entry("a%3Ab:c").unwrap().model, "c");
3216
3217        let revision_after_colon_namespaces = model_catalog_revision();
3218        let duplicate = vec![first[0].clone(), first[0].clone()];
3219        assert!(matches!(
3220            replace_provider_catalog(original.clone(), &duplicate),
3221            Err(CatalogError::DuplicateModel { .. })
3222        ));
3223        assert_eq!(model_catalog_revision(), revision_after_colon_namespaces);
3224        assert!(model_entry("stable:api/b").is_some());
3225
3226        let second = vec![
3227            advertised_model("api/b", 256_000, vec![ReasoningEffort::XHigh]),
3228            advertised_model("api/c", 512_000, vec![ReasoningEffort::High]),
3229        ];
3230        assert_eq!(
3231            replace_provider_catalog(original.clone(), &second).unwrap(),
3232            CatalogDelta {
3233                added: 1,
3234                updated: 1,
3235                removed: 1,
3236                total: 2,
3237            }
3238        );
3239        assert!(model_entry("stable:api/a").is_none());
3240        assert_eq!(
3241            model_entry("stable:api/b").unwrap().context_budget,
3242            Some(256_000)
3243        );
3244        assert_eq!(model_entry("stable:api/c").unwrap().model, "api/c");
3245
3246        let renamed_namespace = descriptor(
3247            "catalog-provider",
3248            "changed",
3249            ReasoningWireProfile::CodexResponses,
3250        );
3251        assert!(matches!(
3252            replace_provider_catalog(renamed_namespace, &second),
3253            Err(CatalogError::NamespaceChanged { .. })
3254        ));
3255        assert!(model_entry("stable:api/b").is_some());
3256
3257        let empty = ProviderDescriptor {
3258            wire_profile: ReasoningWireProfile::OpenAiOfficial,
3259            ..original
3260        };
3261        assert_eq!(
3262            replace_provider_catalog(empty, &[]).unwrap(),
3263            CatalogDelta {
3264                added: 0,
3265                updated: 0,
3266                removed: 2,
3267                total: 0,
3268            }
3269        );
3270        assert!(model_entry("stable:api/b").is_none());
3271        assert_eq!(
3272            reasoning_wire_profile_for_provider("catalog-provider"),
3273            ReasoningWireProfile::OpenAiOfficial
3274        );
3275        assert!(
3276            all_provider_groups_with_empty()
3277                .iter()
3278                .any(|group| group.provider_name == "catalog-provider" && group.models.is_empty())
3279        );
3280    }
3281
3282    #[test]
3283    fn provider_namespace_prefixes_distinguish_peers_and_normalization_collisions() {
3284        assert_eq!(
3285            shortest_unique_provider_id(
3286                "abcdef-1111",
3287                &["abcdef-1111".into(), "abcdef-2222".into()],
3288            ),
3289            "abcdef1"
3290        );
3291        assert_eq!(
3292            shortest_unique_provider_id("abc-def", &["abc-def".into(), "abcdef".into()]),
3293            "abc-def"
3294        );
3295    }
3296
3297    #[test]
3298    fn config_overlay_uses_identity_and_explicit_empty_capabilities() {
3299        let _registry = isolated_registry();
3300        replace_provider_catalog(
3301            descriptor(
3302                "catalog-provider",
3303                "stable",
3304                ReasoningWireProfile::OpenAiOfficial,
3305            ),
3306            &[advertised_model(
3307                "api/reasoning",
3308                128_000,
3309                vec![ReasoningEffort::High],
3310            )],
3311        )
3312        .unwrap();
3313
3314        reload_from_text(
3315            r#"
3316[models.renamed]
3317model = "api/reasoning"
3318provider = "catalog-provider"
3319reasoning_efforts = []
3320"#,
3321        )
3322        .unwrap();
3323
3324        assert!(model_entry("stable:api/reasoning").is_none());
3325        let renamed = model_entry("renamed").unwrap();
3326        assert_eq!(renamed.model, "api/reasoning");
3327        assert!(renamed.reasoning_efforts.is_empty());
3328        assert_eq!(
3329            renamed.input_modalities,
3330            vec![InputModality::Text, InputModality::Image]
3331        );
3332        assert_eq!(
3333            reasoning_selections_for_model("renamed")
3334                .into_iter()
3335                .map(|selection| selection.to_string())
3336                .collect::<Vec<_>>(),
3337            ["default", "off", "auto"]
3338        );
3339        assert!(
3340            resolve_reasoning_for_model(
3341                "renamed",
3342                &ReasoningSelection::Effort {
3343                    effort: ReasoningEffort::High,
3344                    execution_mode: None,
3345                },
3346            )
3347            .unwrap_err()
3348            .contains("does not advertise exact reasoning efforts")
3349        );
3350
3351        assert!(reload_from_text("[models.broken]\ncontext_budget = \"large\"").is_err());
3352        assert!(model_entry("renamed").is_some());
3353        assert!(model_entry("broken").is_none());
3354
3355        assert!(remove_provider_catalog("catalog-provider"));
3356        let remaining = model_entry("renamed").unwrap();
3357        assert!(remaining.reasoning_efforts.is_empty());
3358        assert!(remaining.input_modalities.is_empty());
3359    }
3360
3361    #[test]
3362    fn same_key_config_requires_api_model_to_inherit_catalog_metadata() {
3363        let _registry = isolated_registry();
3364        replace_provider_catalog(
3365            descriptor(
3366                "catalog-provider",
3367                "stable",
3368                ReasoningWireProfile::OpenAiOfficial,
3369            ),
3370            &[advertised_model(
3371                "api/reasoning",
3372                128_000,
3373                vec![ReasoningEffort::High],
3374            )],
3375        )
3376        .unwrap();
3377
3378        reload_from_text(
3379            r#"
3380[models."stable:api/reasoning"]
3381model = "api/reasoning"
3382context_budget = 112000
3383"#,
3384        )
3385        .unwrap();
3386
3387        let bound = model_entry("stable:api/reasoning").unwrap();
3388        assert_eq!(bound.model, "api/reasoning");
3389        assert_eq!(bound.provider.as_deref(), Some("catalog-provider"));
3390        assert_eq!(bound.context_budget, Some(112_000));
3391        assert_eq!(bound.reasoning_efforts, vec![ReasoningEffort::High]);
3392        assert_eq!(
3393            bound.input_modalities,
3394            vec![InputModality::Text, InputModality::Image]
3395        );
3396        assert!(bound.discovered);
3397
3398        reload_from_text(
3399            r#"
3400[models."stable:api/reasoning"]
3401context_budget = 96000
3402"#,
3403        )
3404        .unwrap();
3405
3406        let model = model_entry("stable:api/reasoning").unwrap();
3407        assert!(model.model.is_empty());
3408        assert_eq!(model.provider, None);
3409        assert_eq!(model.context_budget, Some(96_000));
3410        assert!(model.reasoning_efforts.is_empty());
3411        assert!(model.input_modalities.is_empty());
3412        assert!(!model.discovered);
3413        assert_eq!(
3414            reasoning_selections_for_model("stable:api/reasoning")
3415                .into_iter()
3416                .map(|selection| selection.to_string())
3417                .collect::<Vec<_>>(),
3418            ["default", "off", "auto"]
3419        );
3420    }
3421
3422    #[test]
3423    fn input_capability_override_does_not_hide_reasoning_fallback() {
3424        let _registry = isolated_registry();
3425        reload_from_text(
3426            r#"
3427[providers.official]
3428kind = "openai"
3429
3430[models.vision-only]
3431model = "api/vision"
3432provider = "official"
3433input_modalities = ["text", "image"]
3434"#,
3435        )
3436        .unwrap();
3437
3438        let choices = reasoning_selections_for_model("vision-only")
3439            .into_iter()
3440            .map(|selection| selection.to_string())
3441            .collect::<Vec<_>>();
3442        assert!(choices.contains(&"high".to_string()));
3443        assert!(choices.contains(&"xhigh".to_string()));
3444        assert!(
3445            resolve_reasoning_for_model(
3446                "vision-only",
3447                &ReasoningSelection::Effort {
3448                    effort: ReasoningEffort::High,
3449                    execution_mode: None,
3450                },
3451            )
3452            .is_ok()
3453        );
3454    }
3455
3456    #[test]
3457    fn legacy_and_advertised_empty_catalogs_have_safe_reasoning_choices() {
3458        let _registry = isolated_registry();
3459        replace_provider_catalog(
3460            descriptor(
3461                "legacy-provider",
3462                "legacy",
3463                ReasoningWireProfile::CodexResponses,
3464            ),
3465            &[crate::provider::DiscoveredModelDetails {
3466                slug: "api/legacy".into(),
3467                context_budget: Some(64_000),
3468                capability_knowledge: CapabilityKnowledge::Legacy { thinking: false },
3469            }],
3470        )
3471        .unwrap();
3472        replace_provider_catalog(
3473            descriptor(
3474                "empty-provider",
3475                "empty",
3476                ReasoningWireProfile::CodexResponses,
3477            ),
3478            &[advertised_model("api/empty", 64_000, vec![])],
3479        )
3480        .unwrap();
3481
3482        for model in ["legacy:api/legacy", "empty:api/empty"] {
3483            assert_eq!(
3484                reasoning_selections_for_model(model)
3485                    .into_iter()
3486                    .map(|selection| selection.to_string())
3487                    .collect::<Vec<_>>(),
3488                ["default", "off", "auto"]
3489            );
3490            assert!(
3491                resolve_reasoning_for_model(
3492                    model,
3493                    &ReasoningSelection::Effort {
3494                        effort: ReasoningEffort::High,
3495                        execution_mode: None,
3496                    },
3497                )
3498                .is_err()
3499            );
3500        }
3501        assert_eq!(
3502            effective_reasoning_for_model("legacy:api/legacy", None).unwrap(),
3503            Some(ReasoningSelection::Disabled)
3504        );
3505        assert_eq!(
3506            effective_reasoning_for_model("empty:api/empty", None).unwrap(),
3507            Some(ReasoningSelection::ProviderDefault)
3508        );
3509    }
3510
3511    #[test]
3512    fn uuid_codex_catalog_uses_advertised_efforts_for_choices_and_validation() {
3513        let _registry = isolated_registry();
3514        replace_provider_catalog(
3515            descriptor(
3516                "018f0f2e-7b9a-7fd0-ae41-8c772ccae64b",
3517                "account",
3518                ReasoningWireProfile::CodexResponses,
3519            ),
3520            &[advertised_model(
3521                "gpt-5.6-sol",
3522                272_000,
3523                vec![ReasoningEffort::Low, ReasoningEffort::High],
3524            )],
3525        )
3526        .unwrap();
3527        let mut config = ProviderConfig::default();
3528        config.aliases.insert(
3529            "smart".into(),
3530            AliasEntry {
3531                model: "account:gpt-5.6-sol".into(),
3532            },
3533        );
3534        set_provider_config(config);
3535
3536        assert_eq!(
3537            reasoning_wire_profile_for_model("smart"),
3538            ReasoningWireProfile::CodexResponses
3539        );
3540        assert_eq!(
3541            reasoning_selections_for_model("smart")
3542                .into_iter()
3543                .map(|selection| selection.to_string())
3544                .collect::<Vec<_>>(),
3545            ["default", "off", "auto", "low", "high"]
3546        );
3547        assert!(
3548            resolve_reasoning_for_model(
3549                "smart",
3550                &ReasoningSelection::Effort {
3551                    effort: ReasoningEffort::High,
3552                    execution_mode: None,
3553                },
3554            )
3555            .is_ok()
3556        );
3557        let error = resolve_reasoning_for_model(
3558            "smart",
3559            &ReasoningSelection::Effort {
3560                effort: ReasoningEffort::XHigh,
3561                execution_mode: None,
3562            },
3563        )
3564        .unwrap_err();
3565        assert!(error.contains("available: low, high"));
3566    }
3567
3568    #[test]
3569    fn preset_and_catalog_overlays_do_not_duplicate_renamed_models() {
3570        let _registry = isolated_registry();
3571        let mut cfg = ProviderConfig::default();
3572        for provider in ["first", "second"] {
3573            cfg.providers.insert(
3574                provider.into(),
3575                ProviderEntry {
3576                    kind: "openai".into(),
3577                    base_url: Some("https://api.openai.com/v1".into()),
3578                    ..Default::default()
3579                },
3580            );
3581        }
3582        for name in ["renamed", "also-renamed"] {
3583            cfg.models.insert(
3584                name.into(),
3585                ModelEntry {
3586                    model: "gpt-4o".into(),
3587                    provider: Some("first".into()),
3588                    ..Default::default()
3589                },
3590            );
3591        }
3592        set_provider_config(cfg);
3593
3594        let names: BTreeSet<_> = all_model_entries()
3595            .into_iter()
3596            .map(|(name, _)| name)
3597            .collect();
3598        assert!(names.contains("renamed"));
3599        assert!(names.contains("also-renamed"));
3600        assert!(names.contains("second:gpt-4o"));
3601        assert!(!names.contains("first:gpt-4o"));
3602        assert!(!names.contains("gpt-4o"));
3603
3604        replace_provider_catalog(
3605            descriptor(
3606                "catalog-provider",
3607                "stable",
3608                ReasoningWireProfile::OpenAiOfficial,
3609            ),
3610            &[advertised_model(
3611                "api/reasoning",
3612                128_000,
3613                vec![ReasoningEffort::High],
3614            )],
3615        )
3616        .unwrap();
3617        let mut cfg = ProviderConfig::default();
3618        cfg.models.insert(
3619            "stable:api/reasoning".into(),
3620            ModelEntry {
3621                model: "api/different".into(),
3622                provider: Some("different-provider".into()),
3623                ..Default::default()
3624            },
3625        );
3626        set_provider_config(cfg);
3627        let shadow = model_entry("stable:api/reasoning").unwrap();
3628        assert_eq!(shadow.model, "api/different");
3629        assert!(shadow.reasoning_efforts.is_empty());
3630    }
3631
3632    #[test]
3633    fn catalog_rejects_a_registry_key_owned_by_a_different_preset_identity() {
3634        let _registry = isolated_registry();
3635        let mut cfg = ProviderConfig::default();
3636        cfg.providers.insert(
3637            "stable".into(),
3638            ProviderEntry {
3639                kind: "openai".into(),
3640                base_url: Some("https://api.openai.com/v1".into()),
3641                ..Default::default()
3642            },
3643        );
3644        set_provider_config(cfg);
3645
3646        assert!(matches!(
3647            replace_provider_catalog(
3648                descriptor(
3649                    "different-provider",
3650                    "stable",
3651                    ReasoningWireProfile::OpenAiOfficial,
3652                ),
3653                &[advertised_model("gpt-4o", 128_000, vec![])],
3654            ),
3655            Err(CatalogError::RegistryKeyInUse { .. })
3656        ));
3657        assert_eq!(model_entry("stable:gpt-4o").unwrap().model, "gpt-4o");
3658    }
3659
3660    #[test]
3661    fn config_reload_rejects_a_preset_key_owned_by_a_different_catalog_identity() {
3662        let _registry = isolated_registry();
3663        replace_provider_catalog(
3664            descriptor(
3665                "different-provider",
3666                "stable",
3667                ReasoningWireProfile::OpenAiOfficial,
3668            ),
3669            &[advertised_model("gpt-4o", 128_000, vec![])],
3670        )
3671        .unwrap();
3672
3673        let error = reload_from_text(
3674            r#"
3675[providers.stable]
3676kind = "openai"
3677base_url = "https://api.openai.com/v1"
3678"#,
3679        )
3680        .unwrap_err();
3681
3682        assert!(error.to_string().contains("already used"));
3683        assert!(all_provider_entries().is_empty());
3684        assert_eq!(
3685            model_entry("stable:gpt-4o").unwrap().provider.as_deref(),
3686            Some("different-provider")
3687        );
3688    }
3689
3690    #[test]
3691    fn preset_keys_and_canonical_aliases_do_not_drift_as_providers_change() {
3692        let _registry = isolated_registry();
3693        let provider = || ProviderEntry {
3694            kind: "openai".into(),
3695            base_url: Some("https://api.openai.com/v1".into()),
3696            ..Default::default()
3697        };
3698        let mut one = ProviderConfig::default();
3699        one.providers.insert("first".into(), provider());
3700        one.aliases.insert(
3701            "smart".into(),
3702            AliasEntry {
3703                model: "first:gpt-4o".into(),
3704            },
3705        );
3706        one.aliases.insert(
3707            "legacy".into(),
3708            AliasEntry {
3709                model: "gpt-4o".into(),
3710            },
3711        );
3712        set_provider_config(one.clone());
3713        assert!(model_entry("first:gpt-4o").is_some());
3714        assert_eq!(model_entry("smart").unwrap().model, "gpt-4o");
3715        assert_eq!(model_entry("legacy").unwrap().model, "gpt-4o");
3716
3717        replace_provider_catalog(
3718            descriptor(
3719                "dynamic-provider",
3720                "dynamic",
3721                ReasoningWireProfile::OpenAiOfficial,
3722            ),
3723            &[advertised_model("gpt-4o", 128_000, vec![])],
3724        )
3725        .unwrap();
3726        assert_eq!(
3727            model_entry("legacy").unwrap().provider.as_deref(),
3728            Some("first")
3729        );
3730
3731        let mut two = one.clone();
3732        two.providers.insert("second".into(), provider());
3733        set_provider_config(two);
3734        assert!(model_entry("first:gpt-4o").is_some());
3735        assert!(model_entry("second:gpt-4o").is_some());
3736        assert_eq!(model_entry("smart").unwrap().model, "gpt-4o");
3737        assert!(model_entry("legacy").is_none());
3738
3739        set_provider_config(one);
3740        assert!(model_entry("first:gpt-4o").is_some());
3741        assert!(model_entry("second:gpt-4o").is_none());
3742        assert_eq!(model_entry("smart").unwrap().model, "gpt-4o");
3743        assert_eq!(model_entry("legacy").unwrap().model, "gpt-4o");
3744    }
3745
3746    #[test]
3747    fn resolver_rejects_known_unsupported_effort() {
3748        let capabilities = ModelCapabilities {
3749            reasoning_efforts: vec![ReasoningEffort::Low, ReasoningEffort::High],
3750            ..Default::default()
3751        };
3752        let error = resolve_reasoning(
3753            &ReasoningSelection::Effort {
3754                effort: ReasoningEffort::XHigh,
3755                execution_mode: None,
3756            },
3757            &capabilities,
3758        )
3759        .unwrap_err();
3760        assert!(error.contains("available: low, high"));
3761    }
3762}