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 api_model_id(name: &str) -> String {
1309    model_entry(name)
1310        .and_then(|entry| (!entry.model.is_empty()).then_some(entry.model))
1311        .unwrap_or_else(|| name.to_string())
1312}
1313
1314pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
1315    resolved_models_in_state(&REGISTRY_STATE.read().unwrap())
1316        .into_iter()
1317        .map(|(name, model)| (name, model.entry))
1318        .collect()
1319}
1320
1321pub fn provider_display_name(provider_key: &str) -> String {
1322    AuthStore::load()
1323        .ok()
1324        .and_then(|auth| {
1325            auth.providers
1326                .into_iter()
1327                .find(|provider| provider.id == provider_key)
1328        })
1329        .map(|provider| match provider.account {
1330            Some(account) if !account.is_empty() => format!("{} · {account}", provider.name),
1331            _ => provider.name,
1332        })
1333        .unwrap_or_else(|| provider_key.to_string())
1334}
1335
1336pub fn is_provider_enabled(name: &str) -> bool {
1337    let state = REGISTRY_STATE.read().unwrap();
1338    if let Some(entry) = provider_entries_in_state(&state).get(name) {
1339        return entry.enabled.unwrap_or(true);
1340    }
1341    let auth_provider = AuthStore::load().ok().and_then(|auth| {
1342        auth.providers
1343            .into_iter()
1344            .find(|provider| provider.id == name)
1345    });
1346    if state.catalogs.contains_key(name) {
1347        return auth_provider.is_some_and(|provider| provider.enabled);
1348    }
1349    auth_provider.is_none_or(|provider| provider.enabled)
1350}
1351
1352pub fn all_provider_entries() -> Vec<(String, ProviderEntry)> {
1353    provider_entries_in_state(&REGISTRY_STATE.read().unwrap())
1354        .into_iter()
1355        .collect()
1356}
1357
1358pub fn all_aliases() -> Vec<(String, String)> {
1359    let state = REGISTRY_STATE.read().unwrap();
1360    let mut aliases: Vec<(String, String)> = state
1361        .config
1362        .values
1363        .aliases
1364        .iter()
1365        .map(|(name, entry)| (name.clone(), entry.model.clone()))
1366        .collect();
1367    aliases.sort_by(|left, right| left.0.cmp(&right.0));
1368    aliases
1369}
1370
1371pub fn model_info(name: &str) -> ModelInfo {
1372    let state = REGISTRY_STATE.read().unwrap();
1373    if let Some(model) = resolved_model_in_state(&state, name) {
1374        return model_info_from_resolved(&model);
1375    }
1376    ModelInfo {
1377        name: resolve_alias_in_state(&state, name),
1378        context_budget: 0,
1379        compact_threshold_ratio: 0.8,
1380        reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1381        capabilities: ModelCapabilities::default(),
1382        image_detail: ImageDetail::Auto,
1383        max_output_tokens: None,
1384    }
1385}
1386
1387fn model_info_from_resolved(model: &ResolvedModel) -> ModelInfo {
1388    let enabled = model.entry.enabled.unwrap_or(true);
1389    ModelInfo {
1390        name: model.key.clone(),
1391        context_budget: if enabled {
1392            model.entry.context_budget.unwrap_or(0)
1393        } else {
1394            0
1395        },
1396        compact_threshold_ratio: model.entry.compact_threshold_ratio.unwrap_or(0.8),
1397        reasoning: reasoning_selection(&model.entry),
1398        capabilities: model_capabilities(&model.entry),
1399        image_detail: model.entry.image_detail.unwrap_or_default(),
1400        max_output_tokens: model.entry.max_tokens,
1401    }
1402}
1403
1404pub fn reasoning_wire_profile_for_provider(provider: &str) -> ReasoningWireProfile {
1405    provider_profile_in_state(&REGISTRY_STATE.read().unwrap(), provider)
1406}
1407
1408pub fn reasoning_wire_profile_for_model(model: &str) -> ReasoningWireProfile {
1409    resolved_model_in_state(&REGISTRY_STATE.read().unwrap(), model)
1410        .map(|model| model.wire_profile)
1411        .unwrap_or(ReasoningWireProfile::Unknown)
1412}
1413
1414pub fn resolve_reasoning_for_model(
1415    model: &str,
1416    selection: &ReasoningSelection,
1417) -> Result<ReasoningSelection, String> {
1418    let state = REGISTRY_STATE.read().unwrap();
1419    let Some(model) = resolved_model_in_state(&state, model) else {
1420        return ReasoningWireProfile::Unknown
1421            .validate(selection, None)
1422            .map(|()| selection.clone());
1423    };
1424    resolve_reasoning_for_resolved(&model, selection)
1425}
1426
1427/// Return whether a wire-valid selection still requires capability fields that are unknown.
1428pub fn reasoning_selection_uses_legacy_capabilities(
1429    model: &str,
1430    selection: &ReasoningSelection,
1431) -> bool {
1432    let state = REGISTRY_STATE.read().unwrap();
1433    let Some(model) = resolved_model_in_state(&state, model) else {
1434        return false;
1435    };
1436    if model
1437        .wire_profile
1438        .validate(selection, model.entry.max_tokens)
1439        .is_err()
1440    {
1441        return false;
1442    }
1443    let checks_effort_metadata = matches!(
1444        selection,
1445        ReasoningSelection::Effort { effort, .. } if !matches!(effort, ReasoningEffort::None)
1446    ) || matches!(selection, ReasoningSelection::BudgetTokens { .. });
1447    (checks_effort_metadata
1448        && matches!(
1449            model.capability_knowledge.reasoning_efforts,
1450            CapabilityFieldKnowledge::Legacy
1451        ))
1452        || (selection.execution_mode().is_some()
1453            && matches!(
1454                model.capability_knowledge.reasoning_modes,
1455                CapabilityFieldKnowledge::Legacy
1456            ))
1457}
1458
1459fn resolve_reasoning_for_resolved(
1460    model: &ResolvedModel,
1461    selection: &ReasoningSelection,
1462) -> Result<ReasoningSelection, String> {
1463    model
1464        .wire_profile
1465        .validate(selection, model.entry.max_tokens)?;
1466    if let ReasoningSelection::Effort { effort, .. } = selection
1467        && !matches!(effort, ReasoningEffort::None)
1468    {
1469        match model.capability_knowledge.reasoning_efforts {
1470            CapabilityFieldKnowledge::Legacy => {
1471                return Err(format!(
1472                    "model `{}` has legacy reasoning metadata; use `auto` until its catalog is refreshed",
1473                    model.key
1474                ));
1475            }
1476            CapabilityFieldKnowledge::Advertised
1477                if !model.entry.reasoning_efforts.contains(effort) =>
1478            {
1479                let available = model
1480                    .entry
1481                    .reasoning_efforts
1482                    .iter()
1483                    .map(ToString::to_string)
1484                    .collect::<Vec<_>>()
1485                    .join(", ");
1486                return Err(if available.is_empty() {
1487                    format!(
1488                        "model `{}` does not advertise exact reasoning efforts",
1489                        model.key
1490                    )
1491                } else {
1492                    format!(
1493                        "reasoning effort `{effort}` is not supported by model `{}`; available: {available}",
1494                        model.key
1495                    )
1496                });
1497            }
1498            _ => {}
1499        }
1500    }
1501    if matches!(selection, ReasoningSelection::BudgetTokens { .. })
1502        && matches!(
1503            model.capability_knowledge.reasoning_efforts,
1504            CapabilityFieldKnowledge::Legacy
1505        )
1506    {
1507        return Err(format!(
1508            "model `{}` has legacy reasoning metadata; use `auto` until its catalog is refreshed",
1509            model.key
1510        ));
1511    }
1512    if let Some(mode) = selection.execution_mode() {
1513        match model.capability_knowledge.reasoning_modes {
1514            CapabilityFieldKnowledge::Legacy => {
1515                return Err(format!(
1516                    "model `{}` has legacy reasoning metadata and cannot validate mode `{mode}`",
1517                    model.key
1518                ));
1519            }
1520            CapabilityFieldKnowledge::Advertised if !model.entry.reasoning_modes.contains(mode) => {
1521                let available = model
1522                    .entry
1523                    .reasoning_modes
1524                    .iter()
1525                    .map(ToString::to_string)
1526                    .collect::<Vec<_>>()
1527                    .join(", ");
1528                return Err(if available.is_empty() {
1529                    format!("model `{}` does not advertise reasoning modes", model.key)
1530                } else {
1531                    format!(
1532                        "reasoning mode `{mode}` is not supported by model `{}`; available: {available}",
1533                        model.key
1534                    )
1535                });
1536            }
1537            _ => {}
1538        }
1539    }
1540    let info = model_info_from_resolved(model);
1541    let profile = model.wire_profile;
1542    let resolved = resolve_reasoning(selection, &info.capabilities)?;
1543    let resolved = if matches!(selection, ReasoningSelection::Auto { .. })
1544        && matches!(
1545            profile,
1546            ReasoningWireProfile::CompatibleThinking
1547                | ReasoningWireProfile::CodexResponses
1548                | ReasoningWireProfile::AnthropicMessages
1549        ) {
1550        ReasoningSelection::Auto {
1551            execution_mode: resolved.execution_mode().cloned(),
1552        }
1553    } else {
1554        resolved
1555    };
1556    profile.validate(&resolved, info.max_output_tokens)?;
1557    Ok(resolved)
1558}
1559
1560pub fn reasoning_selections_for_provider(
1561    provider: &str,
1562    capabilities: &ModelCapabilities,
1563) -> Vec<ReasoningSelection> {
1564    let profile = reasoning_wire_profile_for_provider(provider);
1565    reasoning_selections(profile, capabilities, true)
1566}
1567
1568fn reasoning_selections(
1569    profile: ReasoningWireProfile,
1570    capabilities: &ModelCapabilities,
1571    use_profile_fallback: bool,
1572) -> Vec<ReasoningSelection> {
1573    let mut choices = vec![
1574        ReasoningSelection::ProviderDefault,
1575        ReasoningSelection::Disabled,
1576        ReasoningSelection::Auto {
1577            execution_mode: None,
1578        },
1579    ];
1580    let efforts = if use_profile_fallback && capabilities.reasoning_efforts.is_empty() {
1581        profile.fallback_efforts()
1582    } else {
1583        capabilities.reasoning_efforts.as_slice()
1584    };
1585    for effort in efforts {
1586        let selection = ReasoningSelection::Effort {
1587            effort: effort.clone(),
1588            execution_mode: None,
1589        };
1590        if profile.validate(&selection, None).is_ok() && !choices.contains(&selection) {
1591            choices.push(selection);
1592        }
1593    }
1594    if profile.supports_token_budget() {
1595        choices.push(ReasoningSelection::BudgetTokens { tokens: 4096 });
1596    }
1597    choices
1598}
1599
1600pub fn reasoning_selections_for_model(model: &str) -> Vec<ReasoningSelection> {
1601    let state = REGISTRY_STATE.read().unwrap();
1602    let Some(model) = resolved_model_in_state(&state, model) else {
1603        return vec![
1604            ReasoningSelection::ProviderDefault,
1605            ReasoningSelection::Disabled,
1606            ReasoningSelection::Auto {
1607                execution_mode: None,
1608            },
1609        ];
1610    };
1611    let info = model_info_from_resolved(&model);
1612    let mut choices = match model.capability_knowledge.reasoning_efforts {
1613        CapabilityFieldKnowledge::Legacy => vec![
1614            ReasoningSelection::ProviderDefault,
1615            ReasoningSelection::Disabled,
1616            ReasoningSelection::Auto {
1617                execution_mode: None,
1618            },
1619        ],
1620        CapabilityFieldKnowledge::Advertised => {
1621            reasoning_selections(model.wire_profile, &info.capabilities, false)
1622        }
1623        CapabilityFieldKnowledge::Unknown => {
1624            reasoning_selections(model.wire_profile, &info.capabilities, true)
1625        }
1626    };
1627    choices.retain(|selection| resolve_reasoning_for_resolved(&model, selection).is_ok());
1628    choices
1629}
1630
1631pub fn effective_reasoning_for_model(
1632    model: &str,
1633    input_selection: Option<&ReasoningSelection>,
1634) -> Result<Option<ReasoningSelection>, String> {
1635    let state = REGISTRY_STATE.read().unwrap();
1636    let Some(model) = resolved_model_in_state(&state, model) else {
1637        return Ok(None);
1638    };
1639    let info = model_info_from_resolved(&model);
1640    let requested = input_selection.unwrap_or(&info.reasoning);
1641    let provider_supports_reasoning = model.wire_profile != ReasoningWireProfile::Unknown;
1642    let should_display = input_selection.is_some()
1643        || !matches!(&info.reasoning, ReasoningSelection::ProviderDefault)
1644        || !info.capabilities.reasoning_efforts.is_empty()
1645        || info.capabilities.default_reasoning_effort.is_some()
1646        || provider_supports_reasoning;
1647    if !should_display {
1648        return Ok(None);
1649    }
1650    resolve_reasoning_for_resolved(&model, requested).map(Some)
1651}
1652
1653impl ModelInfo {
1654    pub fn compact_threshold_tokens(&self) -> u64 {
1655        let reserved = self.max_output_tokens.unwrap_or(0) as u64;
1656        let available = self.context_budget.saturating_sub(reserved);
1657        (available as f64 * self.compact_threshold_ratio) as u64
1658    }
1659
1660    pub fn compaction_trigger_threshold(&self) -> u64 {
1661        if self.context_budget == 0 {
1662            return u64::MAX;
1663        }
1664        let budget = self.context_budget;
1665
1666        let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
1667        let output_cap = (budget as f64 * 0.20) as u64;
1668        let output_reserve = configured_output.min(output_cap).max(8_000);
1669
1670        let safety = (budget as f64 * 0.05) as u64;
1671        let safety_margin = safety.max(4_000);
1672
1673        let trigger = budget
1674            .saturating_sub(output_reserve)
1675            .saturating_sub(safety_margin);
1676        let floor = (budget as f64 * 0.50) as u64;
1677        let ceiling = (budget as f64 * 0.95) as u64;
1678        trigger.clamp(floor, ceiling)
1679    }
1680
1681    pub fn compaction_target_after(&self) -> u64 {
1682        let trigger = self.compaction_trigger_threshold();
1683        let budget_cap = (self.context_budget as f64 * 0.25) as u64;
1684        let trigger_cap = (trigger as f64 * 0.75) as u64;
1685        budget_cap.min(trigger_cap)
1686    }
1687
1688    pub fn thinking_enabled(&self) -> bool {
1689        self.reasoning.enabled()
1690    }
1691}
1692
1693fn reasoning_selection(entry: &ModelEntry) -> ReasoningSelection {
1694    if let Some(tokens) = entry.reasoning_budget_tokens {
1695        return ReasoningSelection::BudgetTokens { tokens };
1696    }
1697    let execution_mode = entry
1698        .reasoning_mode
1699        .as_deref()
1700        .and_then(|value| value.parse().ok());
1701    if let Some(value) = entry.reasoning.as_deref() {
1702        return match value.trim().to_ascii_lowercase().as_str() {
1703            "default" | "provider_default" => ReasoningSelection::ProviderDefault,
1704            "off" | "none" | "disabled" => ReasoningSelection::Disabled,
1705            "auto" => ReasoningSelection::Auto { execution_mode },
1706            _ => value
1707                .parse()
1708                .map(|effort| ReasoningSelection::Effort {
1709                    effort,
1710                    execution_mode,
1711                })
1712                .unwrap_or_default(),
1713        };
1714    }
1715    match entry.thinking {
1716        Some(true) => ReasoningSelection::Auto { execution_mode },
1717        Some(false) => ReasoningSelection::Disabled,
1718        None => ReasoningSelection::ProviderDefault,
1719    }
1720}
1721
1722fn model_capabilities(entry: &ModelEntry) -> ModelCapabilities {
1723    ModelCapabilities {
1724        reasoning_efforts: entry.reasoning_efforts.clone(),
1725        default_reasoning_effort: entry.default_reasoning_effort.clone(),
1726        reasoning_modes: entry.reasoning_modes.clone(),
1727        default_reasoning_mode: entry.default_reasoning_mode.clone(),
1728        input_modalities: entry.input_modalities.clone(),
1729    }
1730}
1731
1732pub fn resolve_reasoning(
1733    selection: &ReasoningSelection,
1734    capabilities: &ModelCapabilities,
1735) -> Result<ReasoningSelection, String> {
1736    let validate_mode = |mode: &Option<ReasoningExecutionMode>| -> Result<(), String> {
1737        if let Some(mode) = mode
1738            && !capabilities.reasoning_modes.is_empty()
1739            && !capabilities.reasoning_modes.contains(mode)
1740        {
1741            return Err(format!(
1742                "reasoning mode `{mode}` is not supported; available: {}",
1743                capabilities
1744                    .reasoning_modes
1745                    .iter()
1746                    .map(ToString::to_string)
1747                    .collect::<Vec<_>>()
1748                    .join(", ")
1749            ));
1750        }
1751        Ok(())
1752    };
1753
1754    match selection {
1755        ReasoningSelection::ProviderDefault | ReasoningSelection::Disabled => Ok(selection.clone()),
1756        ReasoningSelection::Auto { execution_mode } => {
1757            validate_mode(execution_mode)?;
1758            let mode = execution_mode
1759                .clone()
1760                .or_else(|| capabilities.default_reasoning_mode.clone());
1761            if let Some(effort) = capabilities.default_reasoning_effort.clone() {
1762                Ok(ReasoningSelection::Effort {
1763                    effort,
1764                    execution_mode: mode,
1765                })
1766            } else {
1767                Ok(ReasoningSelection::Auto {
1768                    execution_mode: mode,
1769                })
1770            }
1771        }
1772        ReasoningSelection::Effort {
1773            effort: ReasoningEffort::None,
1774            ..
1775        } => Ok(ReasoningSelection::Disabled),
1776        ReasoningSelection::Effort {
1777            effort,
1778            execution_mode,
1779        } => {
1780            validate_mode(execution_mode)?;
1781            if !capabilities.reasoning_efforts.is_empty()
1782                && !capabilities.reasoning_efforts.contains(effort)
1783            {
1784                return Err(format!(
1785                    "reasoning effort `{effort}` is not supported; available: {}",
1786                    capabilities
1787                        .reasoning_efforts
1788                        .iter()
1789                        .map(ToString::to_string)
1790                        .collect::<Vec<_>>()
1791                        .join(", ")
1792                ));
1793            }
1794            Ok(selection.clone())
1795        }
1796        ReasoningSelection::BudgetTokens { .. } => {
1797            if capabilities.reasoning_efforts.is_empty() {
1798                Ok(selection.clone())
1799            } else {
1800                Err("token-budget reasoning is not supported by this model".into())
1801            }
1802        }
1803    }
1804}
1805
1806pub use crate::known_models::{KNOWN_MODELS, lookup_known_model};
1807/// Register derived preset models for a provider with a matching base URL.
1808/// User configuration overlays take priority over this layer.
1809pub fn register_preset_models_for(provider_name: &str, base_url: &str) {
1810    let config = {
1811        let state = REGISTRY_STATE.read().unwrap();
1812        let configured = state
1813            .config
1814            .values
1815            .providers
1816            .get(provider_name)
1817            .and_then(|provider| provider.base_url.as_deref());
1818        if configured != Some(base_url) {
1819            return;
1820        }
1821        state.config.clone()
1822    };
1823    if let Err(error) = install_config_layer(config) {
1824        crate::notify!(error, "preset model update rejected: {error}");
1825    }
1826}
1827
1828/// Register preset models for all configured providers with matching base URLs.
1829pub fn register_all_preset_models() {
1830    let config = REGISTRY_STATE.read().unwrap().config.clone();
1831    if let Err(error) = install_config_layer(config) {
1832        crate::notify!(error, "preset model update rejected: {error}");
1833    }
1834}
1835
1836// Config migration (v1 to v2)
1837
1838#[derive(Debug, Clone, PartialEq, Eq)]
1839pub enum ModelMigrationOutcome {
1840    NotNeeded,
1841    Migrated { backup: std::path::PathBuf },
1842}
1843
1844pub fn migrate_config_if_needed(
1845    text: &str,
1846) -> Result<Option<String>, crate::config_hub::ConfigError> {
1847    let mut doc = text.parse::<toml_edit::DocumentMut>()?;
1848    let legacy = has_legacy_model_fields(&doc);
1849    match doc.get("config_version") {
1850        None if !legacy => return Ok(None),
1851        None => {}
1852        Some(version) => match version.as_integer() {
1853            Some(1) if legacy => {}
1854            Some(1) | Some(2) => return Ok(None),
1855            Some(value) => {
1856                return Err(crate::config_hub::ConfigError::Invalid(format!(
1857                    "unsupported config_version {value}"
1858                )));
1859            }
1860            None => {
1861                return Err(crate::config_hub::ConfigError::Invalid(
1862                    "config_version must be an integer".into(),
1863                ));
1864            }
1865        },
1866    }
1867
1868    let provider_names: std::collections::HashSet<String> = doc
1869        .get("providers")
1870        .and_then(|item| item.as_table())
1871        .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
1872        .unwrap_or_default();
1873    let models = doc
1874        .get("models")
1875        .and_then(|item| item.as_table())
1876        .ok_or_else(|| crate::config_hub::ConfigError::Invalid("models is not a table".into()))?;
1877    let mut groups: std::collections::BTreeMap<(String, String, String), Vec<String>> =
1878        std::collections::BTreeMap::new();
1879
1880    for (name, entry) in models.iter() {
1881        let api_key = entry
1882            .get("api_key")
1883            .and_then(|v| v.as_str())
1884            .unwrap_or("")
1885            .to_string();
1886        let base_url = entry
1887            .get("base_url")
1888            .and_then(|v| v.as_str())
1889            .unwrap_or("")
1890            .to_string();
1891        let ptype = entry
1892            .get("provider")
1893            .and_then(|v| v.as_str())
1894            .unwrap_or("openai-compat")
1895            .to_string();
1896
1897        if !api_key.is_empty()
1898            || !base_url.is_empty()
1899            || (matches!(
1900                ptype.as_str(),
1901                "openai" | "openai-compat" | "anthropic" | "codex"
1902            ) && !provider_names.contains(&ptype))
1903        {
1904            groups
1905                .entry((ptype, api_key, base_url))
1906                .or_default()
1907                .push(name.to_string());
1908        }
1909    }
1910
1911    if groups.is_empty() {
1912        return Ok(None);
1913    }
1914
1915    let mut used_names: std::collections::HashSet<String> = doc
1916        .get("providers")
1917        .and_then(|item| item.as_table())
1918        .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
1919        .unwrap_or_default();
1920
1921    for ((ptype, api_key, base_url), model_names) in &groups {
1922        let provider_name = pick_provider_name(ptype, base_url, &mut used_names);
1923        used_names.insert(provider_name.clone());
1924
1925        // Create [providers.{name}] section
1926        let mut table = toml_edit::Table::new();
1927        table.insert("kind", toml_edit::value(ptype.clone()));
1928        if !api_key.is_empty() {
1929            table.insert("api_key", toml_edit::value(api_key.clone()));
1930        }
1931        if !base_url.is_empty() {
1932            table.insert("base_url", toml_edit::value(base_url.clone()));
1933        }
1934        table.insert("enabled", toml_edit::value(true));
1935
1936        // Ensure [providers] table exists
1937        if doc.get("providers").is_none() {
1938            doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
1939        }
1940        if let Some(providers) = doc.get_mut("providers").and_then(|p| p.as_table_mut()) {
1941            providers.insert(&provider_name, toml_edit::Item::Table(table));
1942        }
1943
1944        // Update each model's provider field and remove api_key/base_url
1945        for model_name in model_names {
1946            if let Some(model) = doc
1947                .get_mut("models")
1948                .and_then(|m| m.as_table_mut())
1949                .and_then(|t| t.get_mut(model_name.as_str()))
1950                .and_then(|e| e.as_table_mut())
1951            {
1952                model.insert("provider", toml_edit::value(&provider_name));
1953                model.remove("api_key");
1954                model.remove("base_url");
1955            }
1956        }
1957    }
1958
1959    doc.insert("config_version", toml_edit::value(2i64));
1960    let migrated = doc.to_string();
1961    parse_config(&migrated).ok_or_else(|| {
1962        crate::config_hub::ConfigError::Invalid("validate migrated config.toml".into())
1963    })?;
1964    Ok(Some(migrated))
1965}
1966
1967fn has_legacy_model_fields(doc: &toml_edit::DocumentMut) -> bool {
1968    let provider_names: std::collections::HashSet<&str> = doc
1969        .get("providers")
1970        .and_then(|item| item.as_table())
1971        .map(|providers| providers.iter().map(|(name, _)| name).collect())
1972        .unwrap_or_default();
1973    doc.get("models")
1974        .and_then(|item| item.as_table())
1975        .is_some_and(|models| {
1976            models.iter().any(|(_, entry)| {
1977                entry.get("api_key").is_some()
1978                    || entry.get("base_url").is_some()
1979                    || entry
1980                        .get("provider")
1981                        .and_then(|value| value.as_str())
1982                        .is_some_and(|provider| {
1983                            matches!(provider, "openai" | "openai-compat" | "anthropic" | "codex")
1984                                && !provider_names.contains(provider)
1985                        })
1986            })
1987        })
1988}
1989
1990fn pick_provider_name(
1991    ptype: &str,
1992    base_url: &str,
1993    used: &mut std::collections::HashSet<String>,
1994) -> String {
1995    // Try to match base_url against PROVIDER_PRESETS
1996    for preset in PROVIDER_PRESETS {
1997        if !base_url.is_empty() && preset.base_url == base_url {
1998            let name = preset.name.to_lowercase();
1999            if !used.contains(&name) {
2000                return name;
2001            }
2002        }
2003    }
2004    // Fall back to provider type, with suffix for duplicates
2005    let base = ptype.to_string();
2006    if !used.contains(&base) {
2007        return base;
2008    }
2009    for i in 2.. {
2010        let candidate = format!("{base}-{i}");
2011        if !used.contains(&candidate) {
2012            return candidate;
2013        }
2014    }
2015    unreachable!()
2016}
2017
2018// Alias CRUD (writes config.toml)
2019
2020pub fn read_config_toml_pub() -> Option<String> {
2021    crate::config_hub::ConfigHub::global()
2022        .ok()?
2023        .read_config_toml()
2024        .ok()
2025}
2026
2027#[cfg(test)]
2028pub(crate) fn reload_from_text(text: &str) -> anyhow::Result<()> {
2029    let prepared = prepare_config_text(text)?;
2030    commit_prepared_config(prepared);
2031    Ok(())
2032}
2033
2034/// Unified config parser — parses `[providers.X]`, `[models.X]`, and `[alias.X]`
2035/// sections from a TOML string into a [ProviderConfig].
2036pub fn parse_config(text: &str) -> Option<ProviderConfig> {
2037    parse_config_layer(text)
2038        .ok()
2039        .flatten()
2040        .map(|layer| layer.values)
2041}
2042
2043fn parse_config_layer(text: &str) -> Result<Option<ConfigLayer>, toml::de::Error> {
2044    #[derive(serde::Deserialize, Default)]
2045    struct RawProvider {
2046        #[serde(default)]
2047        name: Option<String>,
2048        #[serde(default)]
2049        kind: Option<String>,
2050        #[serde(default)]
2051        api_key: Option<String>,
2052        #[serde(default)]
2053        api_key_env: Option<String>,
2054        #[serde(default)]
2055        base_url: Option<String>,
2056        #[serde(default)]
2057        max_tokens: Option<u32>,
2058        #[serde(default)]
2059        reasoning_format: Option<crate::providers::openai::OpenAiReasoningFormat>,
2060        #[serde(default)]
2061        prompt_cache_key: Option<bool>,
2062        #[serde(default)]
2063        enabled: Option<bool>,
2064    }
2065
2066    #[derive(serde::Deserialize, Default)]
2067    struct RawModel {
2068        #[serde(default)]
2069        model: Option<String>,
2070        #[serde(default)]
2071        provider: Option<String>,
2072        #[serde(default)]
2073        context_budget: Option<u64>,
2074        #[serde(default)]
2075        compact_threshold_ratio: Option<f64>,
2076        #[serde(default)]
2077        thinking: Option<bool>,
2078        #[serde(default)]
2079        reasoning: Option<String>,
2080        #[serde(default)]
2081        reasoning_mode: Option<String>,
2082        #[serde(default)]
2083        reasoning_budget_tokens: Option<u32>,
2084        #[serde(default)]
2085        reasoning_efforts: Option<Vec<ReasoningEffort>>,
2086        #[serde(default)]
2087        default_reasoning_effort: Option<ReasoningEffort>,
2088        #[serde(default)]
2089        reasoning_modes: Option<Vec<ReasoningExecutionMode>>,
2090        #[serde(default)]
2091        default_reasoning_mode: Option<ReasoningExecutionMode>,
2092        #[serde(default)]
2093        input_modalities: Option<Vec<InputModality>>,
2094        #[serde(default)]
2095        image_detail: Option<ImageDetail>,
2096        #[serde(default)]
2097        max_tokens: Option<u32>,
2098        #[serde(default)]
2099        enabled: Option<bool>,
2100        #[serde(default)]
2101        discovered: bool,
2102    }
2103
2104    #[derive(serde::Deserialize, Default)]
2105    struct RawAlias {
2106        model: String,
2107    }
2108
2109    #[derive(serde::Deserialize, Default)]
2110    struct RawFile {
2111        #[serde(default)]
2112        providers: std::collections::HashMap<String, RawProvider>,
2113        #[serde(default)]
2114        models: std::collections::HashMap<String, RawModel>,
2115        #[serde(default)]
2116        alias: std::collections::HashMap<String, RawAlias>,
2117    }
2118
2119    let raw: RawFile = toml::from_str(text)?;
2120    let mut cfg = ProviderConfig::default();
2121    let mut declarations = HashMap::new();
2122
2123    for (key, p) in raw.providers {
2124        cfg.providers.insert(
2125            key.clone(),
2126            ProviderEntry {
2127                name: p.name.unwrap_or(key),
2128                kind: p.kind.unwrap_or_default(),
2129                api_key: p.api_key,
2130                api_key_env: p.api_key_env,
2131                base_url: p.base_url,
2132                max_tokens: p.max_tokens,
2133                reasoning_format: p.reasoning_format,
2134                prompt_cache_key: p.prompt_cache_key,
2135                enabled: p.enabled,
2136            },
2137        );
2138    }
2139
2140    for (name, m) in raw.models {
2141        declarations.insert(
2142            name.clone(),
2143            CapabilityDeclarations {
2144                reasoning_efforts: m.reasoning_efforts.is_some(),
2145                reasoning_modes: m.reasoning_modes.is_some(),
2146                input_modalities: m.input_modalities.is_some(),
2147            },
2148        );
2149        cfg.models.insert(
2150            name,
2151            ModelEntry {
2152                model: m.model.unwrap_or_default(),
2153                provider: m.provider,
2154                context_budget: m.context_budget,
2155                compact_threshold_ratio: m.compact_threshold_ratio,
2156                thinking: m.thinking,
2157                reasoning: m.reasoning,
2158                reasoning_mode: m.reasoning_mode,
2159                reasoning_budget_tokens: m.reasoning_budget_tokens,
2160                reasoning_efforts: m.reasoning_efforts.unwrap_or_default(),
2161                default_reasoning_effort: m.default_reasoning_effort,
2162                reasoning_modes: m.reasoning_modes.unwrap_or_default(),
2163                default_reasoning_mode: m.default_reasoning_mode,
2164                input_modalities: m.input_modalities.unwrap_or_default(),
2165                image_detail: m.image_detail,
2166                max_tokens: m.max_tokens,
2167                enabled: m.enabled,
2168                discovered: m.discovered,
2169            },
2170        );
2171    }
2172
2173    for (name, a) in raw.alias {
2174        cfg.aliases.insert(name, AliasEntry { model: a.model });
2175    }
2176
2177    if cfg.providers.is_empty() && cfg.models.is_empty() && cfg.aliases.is_empty() {
2178        return Ok(None);
2179    }
2180    Ok(Some(ConfigLayer {
2181        values: cfg,
2182        capabilities: declarations,
2183    }))
2184}
2185
2186pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
2187    crate::config_hub::ConfigHub::global()?
2188        .add_alias(alias, model)
2189        .map_err(Into::into)
2190}
2191
2192pub fn upsert_provider_config(
2193    name: &str,
2194    kind: &str,
2195    api_key: Option<&str>,
2196    api_key_env: Option<&str>,
2197    base_url: Option<&str>,
2198    max_tokens: Option<u32>,
2199    enabled: bool,
2200) -> anyhow::Result<()> {
2201    crate::config_hub::ConfigHub::global()?
2202        .upsert_provider(crate::config_hub::ProviderConfigUpdate {
2203            name,
2204            kind,
2205            api_key,
2206            api_key_env,
2207            base_url,
2208            max_tokens,
2209            reasoning_format: None,
2210            prompt_cache_key: None,
2211            enabled,
2212        })
2213        .map_err(Into::into)
2214}
2215
2216#[derive(Debug, Clone)]
2217pub struct ModelConfigUpdate<'a> {
2218    pub old_name: Option<&'a str>,
2219    pub name: &'a str,
2220    pub model: &'a str,
2221    pub provider: Option<&'a str>,
2222    pub context_budget: u64,
2223    pub reasoning: ReasoningSelection,
2224    pub capabilities: Option<ModelCapabilities>,
2225    pub image_detail: Option<ImageDetail>,
2226    pub max_tokens: Option<u32>,
2227    pub enabled: bool,
2228}
2229
2230pub(crate) fn apply_model_config_update(
2231    doc: &mut toml_edit::DocumentMut,
2232    update: ModelConfigUpdate<'_>,
2233) -> anyhow::Result<()> {
2234    if doc.get("models").is_none() {
2235        doc.insert("models", toml_edit::Item::Table(toml_edit::Table::new()));
2236    }
2237    {
2238        let models = doc
2239            .get_mut("models")
2240            .and_then(|item| item.as_table_mut())
2241            .ok_or_else(|| anyhow::anyhow!("models is not a table"))?;
2242        if let Some(old_name) = update.old_name.filter(|old| *old != update.name) {
2243            models.remove(old_name);
2244        }
2245        let entry = models
2246            .entry(update.name)
2247            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
2248            .as_table_mut()
2249            .ok_or_else(|| anyhow::anyhow!("model entry is not a table"))?;
2250        entry.insert("model", toml_edit::value(update.model));
2251        if let Some(provider) = update.provider {
2252            entry.insert("provider", toml_edit::value(provider));
2253        } else {
2254            entry.remove("provider");
2255        }
2256        entry.insert(
2257            "context_budget",
2258            toml_edit::value(update.context_budget as i64),
2259        );
2260        entry.remove("thinking");
2261        entry.remove("reasoning");
2262        entry.remove("reasoning_mode");
2263        entry.remove("reasoning_budget_tokens");
2264        match &update.reasoning {
2265            ReasoningSelection::ProviderDefault => {}
2266            ReasoningSelection::Disabled => {
2267                entry.insert("reasoning", toml_edit::value("off"));
2268            }
2269            ReasoningSelection::Auto { execution_mode } => {
2270                entry.insert("reasoning", toml_edit::value("auto"));
2271                if let Some(mode) = execution_mode {
2272                    entry.insert("reasoning_mode", toml_edit::value(mode.to_string()));
2273                }
2274            }
2275            ReasoningSelection::Effort {
2276                effort,
2277                execution_mode,
2278            } => {
2279                entry.insert("reasoning", toml_edit::value(effort.to_string()));
2280                if let Some(mode) = execution_mode {
2281                    entry.insert("reasoning_mode", toml_edit::value(mode.to_string()));
2282                }
2283            }
2284            ReasoningSelection::BudgetTokens { tokens } => {
2285                entry.insert(
2286                    "reasoning_budget_tokens",
2287                    toml_edit::value(i64::from(*tokens)),
2288                );
2289            }
2290        }
2291        if let Some(capabilities) = update.capabilities {
2292            insert_string_array(
2293                entry,
2294                "reasoning_efforts",
2295                capabilities
2296                    .reasoning_efforts
2297                    .iter()
2298                    .map(ToString::to_string),
2299            );
2300            if let Some(default) = capabilities.default_reasoning_effort {
2301                entry.insert(
2302                    "default_reasoning_effort",
2303                    toml_edit::value(default.to_string()),
2304                );
2305            } else {
2306                entry.remove("default_reasoning_effort");
2307            }
2308            insert_string_array(
2309                entry,
2310                "reasoning_modes",
2311                capabilities.reasoning_modes.iter().map(ToString::to_string),
2312            );
2313            if let Some(default) = capabilities.default_reasoning_mode {
2314                entry.insert(
2315                    "default_reasoning_mode",
2316                    toml_edit::value(default.to_string()),
2317                );
2318            } else {
2319                entry.remove("default_reasoning_mode");
2320            }
2321            insert_string_array(
2322                entry,
2323                "input_modalities",
2324                capabilities
2325                    .input_modalities
2326                    .iter()
2327                    .map(|modality| match modality {
2328                        InputModality::Text => "text".to_string(),
2329                        InputModality::Image => "image".to_string(),
2330                        InputModality::Audio => "audio".to_string(),
2331                    }),
2332            );
2333        }
2334        if let Some(detail) = update.image_detail {
2335            let value = match detail {
2336                ImageDetail::Auto => "auto",
2337                ImageDetail::Low => "low",
2338                ImageDetail::High => "high",
2339                ImageDetail::Original => "original",
2340            };
2341            entry.insert("image_detail", toml_edit::value(value));
2342        }
2343        if let Some(max_tokens) = update.max_tokens {
2344            entry.insert("max_tokens", toml_edit::value(max_tokens as i64));
2345        } else {
2346            entry.remove("max_tokens");
2347        }
2348        entry.insert("enabled", toml_edit::value(update.enabled));
2349    }
2350
2351    if let Some(old_name) = update.old_name.filter(|old| *old != update.name)
2352        && let Some(aliases) = doc.get_mut("alias").and_then(|item| item.as_table_mut())
2353    {
2354        for (_, alias) in aliases.iter_mut() {
2355            if let Some(table) = alias.as_table_mut() {
2356                if table.get("model").and_then(|item| item.as_str()) == Some(old_name) {
2357                    table.insert("model", toml_edit::value(update.name));
2358                }
2359            } else if let Some(inline) = alias.as_inline_table_mut()
2360                && inline.get("model").and_then(|value| value.as_str()) == Some(old_name)
2361            {
2362                inline.insert("model", toml_edit::Value::from(update.name));
2363            }
2364        }
2365    }
2366    Ok(())
2367}
2368
2369fn insert_string_array(
2370    entry: &mut toml_edit::Table,
2371    key: &str,
2372    values: impl Iterator<Item = String>,
2373) {
2374    let mut array = toml_edit::Array::new();
2375    for value in values {
2376        array.push(value);
2377    }
2378    entry.insert(key, toml_edit::value(array));
2379}
2380
2381pub fn upsert_model_config(update: ModelConfigUpdate<'_>) -> anyhow::Result<()> {
2382    crate::config_hub::ConfigHub::global()?
2383        .upsert_model(update)
2384        .map_err(Into::into)
2385}
2386
2387pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
2388    crate::config_hub::ConfigHub::global()?
2389        .remove_alias(alias)
2390        .map_err(Into::into)
2391}
2392
2393pub fn update_alias_in_config(
2394    old_alias: &str,
2395    new_alias: &str,
2396    new_model: &str,
2397) -> anyhow::Result<()> {
2398    crate::config_hub::ConfigHub::global()?
2399        .update_alias(Some(old_alias), new_alias, new_model)
2400        .map_err(Into::into)
2401}
2402
2403// Provider presets + first-run detection
2404
2405pub struct ProviderPreset {
2406    pub name: &'static str,
2407    pub description: &'static str,
2408    pub base_url: &'static str,
2409    pub provider_type: &'static str,
2410    pub models: &'static [ProviderPresetModel],
2411    pub key_url: Option<&'static str>,
2412    pub needs_api_key: bool,
2413}
2414
2415pub struct ProviderPresetModel {
2416    pub id: &'static str,
2417    pub description: &'static str,
2418    pub context_budget: u64,
2419    pub thinking: bool,
2420}
2421
2422pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
2423    ProviderPreset {
2424        name: "DeepSeek",
2425        description: "Recommended — cheap, smart, supports thinking",
2426        base_url: "https://api.deepseek.com",
2427        provider_type: "openai-compat",
2428        models: &[
2429            ProviderPresetModel {
2430                id: "deepseek-v4-flash",
2431                description: "Fast & capable",
2432                context_budget: 1000000,
2433                thinking: false,
2434            },
2435            ProviderPresetModel {
2436                id: "deepseek-v4-pro",
2437                description: "Thinking mode",
2438                context_budget: 1000000,
2439                thinking: true,
2440            },
2441        ],
2442        key_url: Some("https://platform.deepseek.com"),
2443        needs_api_key: true,
2444    },
2445    ProviderPreset {
2446        name: "OpenAI",
2447        description: "GPT-4o / GPT-4o-mini",
2448        base_url: "https://api.openai.com/v1",
2449        provider_type: "openai",
2450        models: &[
2451            ProviderPresetModel {
2452                id: "gpt-4o",
2453                description: "Most capable",
2454                context_budget: 128000,
2455                thinking: false,
2456            },
2457            ProviderPresetModel {
2458                id: "gpt-4o-mini",
2459                description: "Fast & cheap",
2460                context_budget: 128000,
2461                thinking: false,
2462            },
2463        ],
2464        key_url: Some("https://platform.openai.com/api-keys"),
2465        needs_api_key: true,
2466    },
2467    ProviderPreset {
2468        name: "Anthropic",
2469        description: "Claude models",
2470        base_url: "https://api.anthropic.com",
2471        provider_type: "anthropic",
2472        models: &[ProviderPresetModel {
2473            id: "claude-sonnet-4-20250514",
2474            description: "Claude Sonnet 4",
2475            context_budget: 200000,
2476            thinking: true,
2477        }],
2478        key_url: Some("https://console.anthropic.com/settings/keys"),
2479        needs_api_key: true,
2480    },
2481    ProviderPreset {
2482        name: "ZhipuAI",
2483        description: "GLM models",
2484        base_url: "https://open.bigmodel.cn/api/paas/v4",
2485        provider_type: "openai-compat",
2486        models: &[ProviderPresetModel {
2487            id: "glm-5.2",
2488            description: "GLM 5.2",
2489            context_budget: 1000000,
2490            thinking: true,
2491        }],
2492        key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
2493        needs_api_key: true,
2494    },
2495    ProviderPreset {
2496        name: "Ollama",
2497        description: "Local models, no API key needed",
2498        base_url: "http://localhost:11434/v1",
2499        provider_type: "openai-compat",
2500        models: &[],
2501        key_url: None,
2502        needs_api_key: false,
2503    },
2504    ProviderPreset {
2505        name: "Codex",
2506        description: "ChatGPT Plus/Pro OAuth",
2507        base_url: "https://chatgpt.com/backend-api/codex",
2508        provider_type: "codex",
2509        models: &[],
2510        key_url: None,
2511        needs_api_key: false,
2512    },
2513];
2514
2515pub fn is_first_run() -> bool {
2516    let providers = all_provider_entries();
2517    let config_configured = providers.iter().any(|(_, e)| {
2518        e.api_key.as_deref().is_some_and(|k| !k.is_empty())
2519            || e.api_key_env
2520                .as_deref()
2521                .is_some_and(|env| std::env::var(env).is_ok_and(|v| !v.trim().is_empty()))
2522    });
2523    let env_configured =
2524        std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
2525    let auth_configured = AuthStore::load()
2526        .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
2527    let smart_resolves = {
2528        let resolved = resolve_alias("smart");
2529        resolved != "smart" && model_entry(&resolved).is_some()
2530    };
2531    !(config_configured || env_configured || auth_configured) || !smart_resolves
2532}
2533
2534#[cfg(test)]
2535mod tests {
2536    use super::*;
2537
2538    /// Tests that mutate the global registry must hold this lock to avoid races
2539    /// when cargo test runs them in parallel.
2540    static TEST_CFG_LOCK: &std::sync::Mutex<()> = &MODEL_CONFIG_LOCK;
2541
2542    struct IsolatedRegistry {
2543        _lock: std::sync::MutexGuard<'static, ()>,
2544    }
2545
2546    impl Drop for IsolatedRegistry {
2547        fn drop(&mut self) {
2548            *REGISTRY_STATE.write().unwrap() = RegistryState::default();
2549            CATALOG_REVISION.send_replace(0);
2550        }
2551    }
2552
2553    fn isolated_registry() -> IsolatedRegistry {
2554        let lock = TEST_CFG_LOCK
2555            .lock()
2556            .unwrap_or_else(std::sync::PoisonError::into_inner);
2557        *REGISTRY_STATE.write().unwrap() = RegistryState::default();
2558        CATALOG_REVISION.send_replace(0);
2559        IsolatedRegistry { _lock: lock }
2560    }
2561
2562    fn descriptor(
2563        provider_key: &str,
2564        namespace: &str,
2565        wire_profile: ReasoningWireProfile,
2566    ) -> ProviderDescriptor {
2567        ProviderDescriptor {
2568            provider_key: provider_key.to_string(),
2569            provider_name: "Test Provider".to_string(),
2570            namespace: namespace.to_string(),
2571            wire_profile,
2572        }
2573    }
2574
2575    fn advertised_model(
2576        slug: &str,
2577        context_budget: u64,
2578        reasoning_efforts: Vec<ReasoningEffort>,
2579    ) -> crate::provider::DiscoveredModelDetails {
2580        crate::provider::DiscoveredModelDetails {
2581            slug: slug.to_string(),
2582            context_budget: Some(context_budget),
2583            capability_knowledge: CapabilityKnowledge::Advertised(ModelCapabilities {
2584                reasoning_efforts,
2585                input_modalities: vec![InputModality::Text, InputModality::Image],
2586                ..Default::default()
2587            }),
2588        }
2589    }
2590
2591    #[test]
2592    fn enabled_auth_provider_names_match_discovered_model_groups() {
2593        let _registry = isolated_registry();
2594        let auth = AuthStore {
2595            providers: vec![
2596                crate::auth_store::StoredProvider {
2597                    id: "disabled-id".into(),
2598                    name: "Codex".into(),
2599                    kind: crate::auth_store::ProviderKind::Codex,
2600                    access_token: String::new(),
2601                    refresh_token: None,
2602                    expires_at: 0,
2603                    account: Some("old@example.com".into()),
2604                    enabled: false,
2605                    model_cache: None,
2606                },
2607                crate::auth_store::StoredProvider {
2608                    id: "enabled-id".into(),
2609                    name: "Codex".into(),
2610                    kind: crate::auth_store::ProviderKind::Codex,
2611                    access_token: String::new(),
2612                    refresh_token: None,
2613                    expires_at: 0,
2614                    account: Some("current@example.com".into()),
2615                    enabled: true,
2616                    model_cache: None,
2617                },
2618            ],
2619        };
2620
2621        let names = enabled_provider_names_from_auth(&auth);
2622        assert!(names.contains("enabled-id"));
2623        assert!(!names.contains("disabled-id"));
2624
2625        let models = vec![crate::provider::DiscoveredModelDetails {
2626            slug: "codex/gpt-test".into(),
2627            context_budget: Some(272_000),
2628            capability_knowledge: crate::provider::CapabilityKnowledge::Advertised(
2629                ModelCapabilities::default(),
2630            ),
2631        }];
2632        assert_eq!(
2633            shortest_unique_provider_id("1234567-account", &["1234567-account".into()]),
2634            "123456"
2635        );
2636        assert_eq!(
2637            shortest_unique_provider_id(
2638                "abcdef1-account",
2639                &["abcdef1-account".into(), "abcdef2-account".into()]
2640            ),
2641            "abcdef1"
2642        );
2643        register_discovered_details_for_provider("enabled-id", "Codex", &models).unwrap();
2644        let model_key = "enable@Codex:codex/gpt-test";
2645        let entry = model_entry(model_key).unwrap();
2646        assert_eq!(entry.provider.as_deref(), Some("enabled-id"));
2647        assert_eq!(entry.model, "codex/gpt-test");
2648    }
2649
2650    #[test]
2651    fn unregistered_model_returns_zero_budget() {
2652        let _registry = isolated_registry();
2653        *REGISTRY_STATE.write().unwrap() = RegistryState::default();
2654        assert_eq!(model_info("mystery-model").context_budget, 0);
2655        assert_eq!(model_info("").context_budget, 0);
2656    }
2657
2658    #[test]
2659    fn threshold_is_eighty_percent() {
2660        let _registry = isolated_registry();
2661        let mut cfg = ModelConfig::default();
2662        cfg.models.insert(
2663            "claude-opus-4.7".into(),
2664            ModelEntry {
2665                model: "claude-opus-4.7".into(),
2666                context_budget: Some(200_000),
2667                compact_threshold_ratio: Some(0.8),
2668                thinking: None,
2669                ..Default::default()
2670            },
2671        );
2672        set_model_config(cfg);
2673        let info = model_info("claude-opus-4.7");
2674        assert_eq!(info.compact_threshold_tokens(), 160_000);
2675    }
2676
2677    #[test]
2678    fn compaction_trigger_is_near_budget_top() {
2679        let _registry = isolated_registry();
2680        let mut cfg = ModelConfig::default();
2681        cfg.models.insert(
2682            "claude-opus-4.7".into(),
2683            ModelEntry {
2684                model: "claude-opus-4.7".into(),
2685                context_budget: Some(200_000),
2686                compact_threshold_ratio: Some(0.8),
2687                thinking: None,
2688                ..Default::default()
2689            },
2690        );
2691        set_model_config(cfg);
2692        let info = model_info("claude-opus-4.7");
2693        let trigger = info.compaction_trigger_threshold();
2694        assert!(
2695            trigger > 150_000 && trigger <= 190_000,
2696            "trigger should be near the top of the budget, got {trigger}"
2697        );
2698    }
2699
2700    #[test]
2701    fn compaction_target_is_lower_than_trigger() {
2702        let _registry = isolated_registry();
2703        let mut cfg = ModelConfig::default();
2704        cfg.models.insert(
2705            "claude-opus-4.7".into(),
2706            ModelEntry {
2707                model: "claude-opus-4.7".into(),
2708                context_budget: Some(200_000),
2709                compact_threshold_ratio: Some(0.8),
2710                thinking: None,
2711                ..Default::default()
2712            },
2713        );
2714        set_model_config(cfg);
2715        let info = model_info("claude-opus-4.7");
2716        let trigger = info.compaction_trigger_threshold();
2717        let target = info.compaction_target_after();
2718        assert!(
2719            target < trigger,
2720            "target {target} should be less than trigger {trigger}"
2721        );
2722        assert_eq!(target, 50_000, "compaction target should be 25% of context");
2723    }
2724
2725    #[test]
2726    fn alias_resolves_to_real_model() {
2727        let _registry = isolated_registry();
2728        let mut cfg = ModelConfig::default();
2729        cfg.models.insert(
2730            "claude-opus-4.7".into(),
2731            ModelEntry {
2732                model: "claude-opus-4.7".into(),
2733                context_budget: Some(200_000),
2734                ..Default::default()
2735            },
2736        );
2737        cfg.aliases.insert(
2738            "smart".into(),
2739            AliasEntry {
2740                model: "claude-opus-4.7".into(),
2741            },
2742        );
2743        set_model_config(cfg);
2744        let info = model_info("smart");
2745        assert_eq!(info.context_budget, 200_000);
2746        assert_eq!(info.name, "claude-opus-4.7");
2747    }
2748
2749    #[test]
2750    fn custom_model_overrides_budget() {
2751        let _registry = isolated_registry();
2752        let mut cfg = ModelConfig::default();
2753        cfg.models.insert(
2754            "my-local-model".into(),
2755            ModelEntry {
2756                model: "my-local-model".into(),
2757                context_budget: Some(8192),
2758                compact_threshold_ratio: Some(0.9),
2759                thinking: None,
2760                ..Default::default()
2761            },
2762        );
2763        set_model_config(cfg);
2764        let info = model_info("my-local-model");
2765        assert_eq!(info.context_budget, 8192);
2766        assert_eq!(info.compact_threshold_ratio, 0.9);
2767    }
2768
2769    #[test]
2770    fn compact_threshold_reserves_configured_output_tokens() {
2771        let _registry = isolated_registry();
2772        let mut cfg = ModelConfig::default();
2773        cfg.models.insert(
2774            "large-output".into(),
2775            ModelEntry {
2776                model: "large-output".into(),
2777                context_budget: Some(1_000_000),
2778                compact_threshold_ratio: Some(0.8),
2779                thinking: None,
2780                max_tokens: Some(400_000),
2781                ..Default::default()
2782            },
2783        );
2784        set_model_config(cfg);
2785        let info = model_info("large-output");
2786        assert_eq!(info.compact_threshold_tokens(), 480_000);
2787        let trigger = info.compaction_trigger_threshold();
2788        assert!(
2789            trigger > 700_000,
2790            "trigger with capped output reserve should be > 700K, got {trigger}"
2791        );
2792    }
2793
2794    #[test]
2795    fn alias_chains_through_custom_model() {
2796        let _registry = isolated_registry();
2797        let mut cfg = ModelConfig::default();
2798        cfg.aliases.insert(
2799            "default".into(),
2800            AliasEntry {
2801                model: "my-model".into(),
2802            },
2803        );
2804        cfg.models.insert(
2805            "my-model".into(),
2806            ModelEntry {
2807                model: "my-model".into(),
2808                context_budget: Some(65_536),
2809                compact_threshold_ratio: None,
2810                thinking: None,
2811                ..Default::default()
2812            },
2813        );
2814        set_model_config(cfg);
2815        let info = model_info("default");
2816        assert_eq!(info.name, "my-model");
2817        assert_eq!(info.context_budget, 65_536);
2818    }
2819
2820    #[test]
2821    fn discovered_models_survive_set_model_config() {
2822        let _registry = isolated_registry();
2823        set_discovered_models(vec!["z".into(), "a".into(), "z".into()]);
2824        assert_eq!(discovered_models(), ["z", "a", "z"]);
2825        register_discovered(
2826            "pid-abc",
2827            "Codex",
2828            &[crate::provider::DiscoveredModel {
2829                slug: "codex/gpt-5".to_string(),
2830                context_budget: Some(128_000),
2831                thinking: true,
2832            }],
2833        );
2834        assert!(model_entry("Codex:codex/gpt-5").is_some());
2835        assert_eq!(
2836            model_entry("Codex:codex/gpt-5").unwrap().model,
2837            "Codex:codex/gpt-5"
2838        );
2839        register_discovered("pid-abc", "Codex", &[]);
2840        assert!(model_entry("Codex:codex/gpt-5").is_some());
2841
2842        let mut cfg = ModelConfig::default();
2843        cfg.aliases.insert(
2844            "cheap".into(),
2845            AliasEntry {
2846                model: "claude-opus-4.7".into(),
2847            },
2848        );
2849        set_model_config(cfg);
2850
2851        assert!(
2852            model_entry("Codex:codex/gpt-5").is_some(),
2853            "discovered models should survive set_model_config"
2854        );
2855        assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
2856    }
2857
2858    #[test]
2859    fn reload_replaces_config_models_and_preserves_discovered_models() {
2860        let _registry = isolated_registry();
2861        let mut initial = ProviderConfig::default();
2862        initial.models.insert(
2863            "old-config".into(),
2864            ModelEntry {
2865                model: "provider/old".into(),
2866                discovered: false,
2867                ..Default::default()
2868            },
2869        );
2870        set_provider_config(initial);
2871        register_discovered_details(
2872            "dynamic-provider",
2873            "Dynamic",
2874            &[crate::provider::DiscoveredModelDetails {
2875                slug: "provider/dynamic".into(),
2876                context_budget: Some(64_000),
2877                capability_knowledge: CapabilityKnowledge::Advertised(ModelCapabilities::default()),
2878            }],
2879        )
2880        .unwrap();
2881
2882        reload_from_text(
2883            r#"
2884[models.new-config]
2885model = "provider/new"
2886"#,
2887        )
2888        .unwrap();
2889
2890        assert!(model_entry("old-config").is_none());
2891        assert!(model_entry("new-config").is_some());
2892        assert!(model_entry("dynami@Dynamic:provider/dynamic").is_some());
2893    }
2894
2895    #[test]
2896    fn discovered_models_survive_reload_from_text_alias_crud() {
2897        let _registry = isolated_registry();
2898        register_discovered(
2899            "pid-abc",
2900            "Codex",
2901            &[crate::provider::DiscoveredModel {
2902                slug: "codex/gpt-5".to_string(),
2903                context_budget: Some(128_000),
2904                thinking: true,
2905            }],
2906        );
2907
2908        let toml = r#"
2909[alias]
2910smart = { model = "Codex:codex/gpt-5" }
2911"#;
2912        reload_from_text(toml).unwrap();
2913
2914        assert!(
2915            model_entry("Codex:codex/gpt-5").is_some(),
2916            "discovered models should survive alias CRUD"
2917        );
2918        assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
2919    }
2920
2921    #[test]
2922    fn model_config_update_replaces_name_and_preserves_other_sections() {
2923        let mut doc = r#"
2924# keep this comment
2925[providers.openai]
2926kind = "openai"
2927
2928[alias]
2929smart = { model = "old-name" }
2930cheap = { model = "other-name" }
2931
2932[alias.deep]
2933model = "old-name"
2934
2935[models.old-name]
2936model = "old-id"
2937provider = "openai"
2938enabled = true
2939"#
2940        .parse::<toml_edit::DocumentMut>()
2941        .unwrap();
2942
2943        apply_model_config_update(
2944            &mut doc,
2945            ModelConfigUpdate {
2946                old_name: Some("old-name"),
2947                name: "new-name",
2948                model: "new-id",
2949                provider: Some("openai"),
2950                context_budget: 128_000,
2951                reasoning: ReasoningSelection::Effort {
2952                    effort: ReasoningEffort::High,
2953                    execution_mode: None,
2954                },
2955                capabilities: None,
2956                image_detail: None,
2957                max_tokens: Some(4096),
2958                enabled: false,
2959            },
2960        )
2961        .unwrap();
2962
2963        let out = doc.to_string();
2964        assert!(out.contains("# keep this comment"));
2965        assert!(out.contains("[providers.openai]"));
2966        assert!(out.contains("[alias]"));
2967        assert!(out.contains("smart = { model = \"new-name\" }"));
2968        assert!(out.contains("cheap = { model = \"other-name\" }"));
2969        assert!(out.contains("[alias.deep]"));
2970        assert!(out.contains("model = \"new-name\""));
2971        assert!(out.contains("[models.new-name]"));
2972        assert!(!out.contains("[models.old-name]"));
2973        assert!(out.contains("model = \"new-id\""));
2974        assert!(out.contains("context_budget = 128000"));
2975        assert!(out.contains("reasoning = \"high\""));
2976        assert!(out.contains("max_tokens = 4096"));
2977        assert!(out.contains("enabled = false"));
2978    }
2979
2980    #[test]
2981    fn model_config_update_preserves_explicit_empty_capability_knowledge() {
2982        let _registry = isolated_registry();
2983        let mut doc = r#"
2984[providers.official]
2985kind = "openai"
2986"#
2987        .parse::<toml_edit::DocumentMut>()
2988        .unwrap();
2989
2990        apply_model_config_update(
2991            &mut doc,
2992            ModelConfigUpdate {
2993                old_name: None,
2994                name: "known-empty",
2995                model: "api/model",
2996                provider: Some("official"),
2997                context_budget: 128_000,
2998                reasoning: ReasoningSelection::ProviderDefault,
2999                capabilities: Some(ModelCapabilities::default()),
3000                image_detail: None,
3001                max_tokens: None,
3002                enabled: true,
3003            },
3004        )
3005        .unwrap();
3006
3007        let out = doc.to_string();
3008        assert!(out.contains("reasoning_efforts = []"));
3009        assert!(out.contains("reasoning_modes = []"));
3010        assert!(out.contains("input_modalities = []"));
3011        reload_from_text(&out).unwrap();
3012        assert_eq!(
3013            reasoning_selections_for_model("known-empty")
3014                .into_iter()
3015                .map(|selection| selection.to_string())
3016                .collect::<Vec<_>>(),
3017            ["default", "off", "auto"]
3018        );
3019    }
3020
3021    #[test]
3022    fn model_config_reads_reasoning_capabilities_and_legacy_bool() {
3023        let _registry = isolated_registry();
3024        let cfg = parse_config(
3025            r#"
3026[models.modern]
3027model = "gpt-modern"
3028reasoning = "xhigh"
3029reasoning_mode = "pro"
3030reasoning_efforts = ["low", "high", "xhigh"]
3031reasoning_modes = ["standard", "pro"]
3032input_modalities = ["text", "image"]
3033image_detail = "high"
3034
3035[models.legacy]
3036model = "legacy"
3037thinking = true
3038"#,
3039        )
3040        .unwrap();
3041        set_model_config(cfg);
3042
3043        let modern = model_info("modern");
3044        assert_eq!(
3045            modern.reasoning,
3046            ReasoningSelection::Effort {
3047                effort: ReasoningEffort::XHigh,
3048                execution_mode: Some(ReasoningExecutionMode::Pro),
3049            }
3050        );
3051        assert_eq!(
3052            modern.capabilities.input_modalities,
3053            vec![InputModality::Text, InputModality::Image]
3054        );
3055        assert_eq!(modern.image_detail, ImageDetail::High);
3056        assert_eq!(
3057            model_info("legacy").reasoning,
3058            ReasoningSelection::Auto {
3059                execution_mode: None
3060            }
3061        );
3062    }
3063
3064    #[test]
3065    fn provider_config_reads_explicit_reasoning_wire_format() {
3066        let cfg = parse_config(
3067            r#"
3068[providers.openai-compatible]
3069kind = "openai-compat"
3070reasoning_format = "reasoning-effort"
3071"#,
3072        )
3073        .unwrap();
3074
3075        assert_eq!(
3076            cfg.providers["openai-compatible"].reasoning_format,
3077            Some(crate::providers::openai::OpenAiReasoningFormat::Official)
3078        );
3079    }
3080
3081    #[test]
3082    fn known_provider_profile_displays_default_reasoning_without_model_hints() {
3083        let _registry = isolated_registry();
3084        let cfg = parse_config(
3085            r#"
3086[providers.messages]
3087kind = "anthropic"
3088
3089[models.plain]
3090model = "claude-plain"
3091provider = "messages"
3092context_budget = 128000
3093
3094[models.disabled]
3095model = "claude-disabled"
3096provider = "messages"
3097context_budget = 128000
3098thinking = false
3099
3100[alias]
3101smart = { model = "plain" }
3102"#,
3103        )
3104        .unwrap();
3105        set_provider_config(cfg);
3106
3107        assert_eq!(
3108            effective_reasoning_for_model("smart", None).unwrap(),
3109            Some(ReasoningSelection::ProviderDefault)
3110        );
3111        assert_eq!(
3112            effective_reasoning_for_model("disabled", None).unwrap(),
3113            Some(ReasoningSelection::Disabled)
3114        );
3115    }
3116
3117    #[test]
3118    fn compatible_thinking_profile_limits_shared_model_choices() {
3119        let _registry = isolated_registry();
3120        let cfg = parse_config(
3121            r#"
3122[providers.openai-compatible]
3123kind = "openai-compat"
3124reasoning_format = "thinking-toggle"
3125
3126[models.custom-reasoning]
3127model = "vendor/reasoning-model"
3128provider = "openai-compatible"
3129context_budget = 128000
3130thinking = true
3131reasoning_efforts = ["high"]
3132default_reasoning_effort = "high"
3133"#,
3134        )
3135        .unwrap();
3136        set_provider_config(cfg);
3137
3138        assert_eq!(
3139            reasoning_wire_profile_for_model("custom-reasoning"),
3140            ReasoningWireProfile::CompatibleThinking
3141        );
3142        assert_eq!(
3143            reasoning_selections_for_model("custom-reasoning")
3144                .into_iter()
3145                .map(|selection| selection.to_string())
3146                .collect::<Vec<_>>(),
3147            ["default", "off", "auto"]
3148        );
3149        assert_eq!(
3150            resolve_reasoning_for_model(
3151                "custom-reasoning",
3152                &ReasoningSelection::Auto {
3153                    execution_mode: None,
3154                },
3155            )
3156            .unwrap(),
3157            ReasoningSelection::Auto {
3158                execution_mode: None,
3159            }
3160        );
3161        assert!(
3162            resolve_reasoning_for_model(
3163                "custom-reasoning",
3164                &ReasoningSelection::Effort {
3165                    effort: ReasoningEffort::High,
3166                    execution_mode: None,
3167                },
3168            )
3169            .unwrap_err()
3170            .contains("cannot represent effort `high`")
3171        );
3172    }
3173
3174    #[test]
3175    fn provider_catalog_replacement_is_atomic_and_tracks_api_identity() {
3176        let _registry = isolated_registry();
3177        let original = descriptor(
3178            "catalog-provider",
3179            "stable",
3180            ReasoningWireProfile::CodexResponses,
3181        );
3182        let first = vec![
3183            advertised_model("api/a", 64_000, vec![ReasoningEffort::Low]),
3184            advertised_model("api/b", 128_000, vec![ReasoningEffort::High]),
3185        ];
3186        assert_eq!(
3187            replace_provider_catalog(original.clone(), &first).unwrap(),
3188            CatalogDelta {
3189                added: 2,
3190                updated: 0,
3191                removed: 0,
3192                total: 2,
3193            }
3194        );
3195        let first_revision = model_catalog_revision();
3196        assert_eq!(model_entry("stable:api/a").unwrap().model, "api/a");
3197
3198        let colliding = descriptor(
3199            "other-provider",
3200            "stable",
3201            ReasoningWireProfile::OpenAiOfficial,
3202        );
3203        assert!(matches!(
3204            replace_provider_catalog(colliding, &[advertised_model("api/other", 32_000, vec![])]),
3205            Err(CatalogError::NamespaceInUse { .. })
3206        ));
3207        assert_eq!(model_catalog_revision(), first_revision);
3208        assert!(model_entry("stable:api/a").is_some());
3209
3210        replace_provider_catalog(
3211            descriptor("colon-left", "a", ReasoningWireProfile::OpenAiOfficial),
3212            &[advertised_model("b:c", 32_000, vec![])],
3213        )
3214        .unwrap();
3215        replace_provider_catalog(
3216            descriptor("colon-right", "a:b", ReasoningWireProfile::OpenAiOfficial),
3217            &[advertised_model("c", 32_000, vec![])],
3218        )
3219        .unwrap();
3220        assert_eq!(model_entry("a:b:c").unwrap().model, "b:c");
3221        assert_eq!(model_entry("a%3Ab:c").unwrap().model, "c");
3222
3223        let revision_after_colon_namespaces = model_catalog_revision();
3224        let duplicate = vec![first[0].clone(), first[0].clone()];
3225        assert!(matches!(
3226            replace_provider_catalog(original.clone(), &duplicate),
3227            Err(CatalogError::DuplicateModel { .. })
3228        ));
3229        assert_eq!(model_catalog_revision(), revision_after_colon_namespaces);
3230        assert!(model_entry("stable:api/b").is_some());
3231
3232        let second = vec![
3233            advertised_model("api/b", 256_000, vec![ReasoningEffort::XHigh]),
3234            advertised_model("api/c", 512_000, vec![ReasoningEffort::High]),
3235        ];
3236        assert_eq!(
3237            replace_provider_catalog(original.clone(), &second).unwrap(),
3238            CatalogDelta {
3239                added: 1,
3240                updated: 1,
3241                removed: 1,
3242                total: 2,
3243            }
3244        );
3245        assert!(model_entry("stable:api/a").is_none());
3246        assert_eq!(
3247            model_entry("stable:api/b").unwrap().context_budget,
3248            Some(256_000)
3249        );
3250        assert_eq!(model_entry("stable:api/c").unwrap().model, "api/c");
3251
3252        let renamed_namespace = descriptor(
3253            "catalog-provider",
3254            "changed",
3255            ReasoningWireProfile::CodexResponses,
3256        );
3257        assert!(matches!(
3258            replace_provider_catalog(renamed_namespace, &second),
3259            Err(CatalogError::NamespaceChanged { .. })
3260        ));
3261        assert!(model_entry("stable:api/b").is_some());
3262
3263        let empty = ProviderDescriptor {
3264            wire_profile: ReasoningWireProfile::OpenAiOfficial,
3265            ..original
3266        };
3267        assert_eq!(
3268            replace_provider_catalog(empty, &[]).unwrap(),
3269            CatalogDelta {
3270                added: 0,
3271                updated: 0,
3272                removed: 2,
3273                total: 0,
3274            }
3275        );
3276        assert!(model_entry("stable:api/b").is_none());
3277        assert_eq!(
3278            reasoning_wire_profile_for_provider("catalog-provider"),
3279            ReasoningWireProfile::OpenAiOfficial
3280        );
3281        assert!(
3282            all_provider_groups_with_empty()
3283                .iter()
3284                .any(|group| group.provider_name == "catalog-provider" && group.models.is_empty())
3285        );
3286    }
3287
3288    #[test]
3289    fn provider_namespace_prefixes_distinguish_peers_and_normalization_collisions() {
3290        assert_eq!(
3291            shortest_unique_provider_id(
3292                "abcdef-1111",
3293                &["abcdef-1111".into(), "abcdef-2222".into()],
3294            ),
3295            "abcdef1"
3296        );
3297        assert_eq!(
3298            shortest_unique_provider_id("abc-def", &["abc-def".into(), "abcdef".into()]),
3299            "abc-def"
3300        );
3301    }
3302
3303    #[test]
3304    fn config_overlay_uses_identity_and_explicit_empty_capabilities() {
3305        let _registry = isolated_registry();
3306        replace_provider_catalog(
3307            descriptor(
3308                "catalog-provider",
3309                "stable",
3310                ReasoningWireProfile::OpenAiOfficial,
3311            ),
3312            &[advertised_model(
3313                "api/reasoning",
3314                128_000,
3315                vec![ReasoningEffort::High],
3316            )],
3317        )
3318        .unwrap();
3319
3320        reload_from_text(
3321            r#"
3322[models.renamed]
3323model = "api/reasoning"
3324provider = "catalog-provider"
3325reasoning_efforts = []
3326"#,
3327        )
3328        .unwrap();
3329
3330        assert!(model_entry("stable:api/reasoning").is_none());
3331        let renamed = model_entry("renamed").unwrap();
3332        assert_eq!(renamed.model, "api/reasoning");
3333        assert!(renamed.reasoning_efforts.is_empty());
3334        assert_eq!(
3335            renamed.input_modalities,
3336            vec![InputModality::Text, InputModality::Image]
3337        );
3338        assert_eq!(
3339            reasoning_selections_for_model("renamed")
3340                .into_iter()
3341                .map(|selection| selection.to_string())
3342                .collect::<Vec<_>>(),
3343            ["default", "off", "auto"]
3344        );
3345        assert!(
3346            resolve_reasoning_for_model(
3347                "renamed",
3348                &ReasoningSelection::Effort {
3349                    effort: ReasoningEffort::High,
3350                    execution_mode: None,
3351                },
3352            )
3353            .unwrap_err()
3354            .contains("does not advertise exact reasoning efforts")
3355        );
3356
3357        assert!(reload_from_text("[models.broken]\ncontext_budget = \"large\"").is_err());
3358        assert!(model_entry("renamed").is_some());
3359        assert!(model_entry("broken").is_none());
3360
3361        assert!(remove_provider_catalog("catalog-provider"));
3362        let remaining = model_entry("renamed").unwrap();
3363        assert!(remaining.reasoning_efforts.is_empty());
3364        assert!(remaining.input_modalities.is_empty());
3365    }
3366
3367    #[test]
3368    fn same_key_config_requires_api_model_to_inherit_catalog_metadata() {
3369        let _registry = isolated_registry();
3370        replace_provider_catalog(
3371            descriptor(
3372                "catalog-provider",
3373                "stable",
3374                ReasoningWireProfile::OpenAiOfficial,
3375            ),
3376            &[advertised_model(
3377                "api/reasoning",
3378                128_000,
3379                vec![ReasoningEffort::High],
3380            )],
3381        )
3382        .unwrap();
3383
3384        reload_from_text(
3385            r#"
3386[models."stable:api/reasoning"]
3387model = "api/reasoning"
3388context_budget = 112000
3389"#,
3390        )
3391        .unwrap();
3392
3393        let bound = model_entry("stable:api/reasoning").unwrap();
3394        assert_eq!(bound.model, "api/reasoning");
3395        assert_eq!(bound.provider.as_deref(), Some("catalog-provider"));
3396        assert_eq!(bound.context_budget, Some(112_000));
3397        assert_eq!(bound.reasoning_efforts, vec![ReasoningEffort::High]);
3398        assert_eq!(
3399            bound.input_modalities,
3400            vec![InputModality::Text, InputModality::Image]
3401        );
3402        assert!(bound.discovered);
3403
3404        reload_from_text(
3405            r#"
3406[models."stable:api/reasoning"]
3407context_budget = 96000
3408"#,
3409        )
3410        .unwrap();
3411
3412        let model = model_entry("stable:api/reasoning").unwrap();
3413        assert!(model.model.is_empty());
3414        assert_eq!(model.provider, None);
3415        assert_eq!(model.context_budget, Some(96_000));
3416        assert!(model.reasoning_efforts.is_empty());
3417        assert!(model.input_modalities.is_empty());
3418        assert!(!model.discovered);
3419        assert_eq!(
3420            reasoning_selections_for_model("stable:api/reasoning")
3421                .into_iter()
3422                .map(|selection| selection.to_string())
3423                .collect::<Vec<_>>(),
3424            ["default", "off", "auto"]
3425        );
3426    }
3427
3428    #[test]
3429    fn input_capability_override_does_not_hide_reasoning_fallback() {
3430        let _registry = isolated_registry();
3431        reload_from_text(
3432            r#"
3433[providers.official]
3434kind = "openai"
3435
3436[models.vision-only]
3437model = "api/vision"
3438provider = "official"
3439input_modalities = ["text", "image"]
3440"#,
3441        )
3442        .unwrap();
3443
3444        let choices = reasoning_selections_for_model("vision-only")
3445            .into_iter()
3446            .map(|selection| selection.to_string())
3447            .collect::<Vec<_>>();
3448        assert!(choices.contains(&"high".to_string()));
3449        assert!(choices.contains(&"xhigh".to_string()));
3450        assert!(
3451            resolve_reasoning_for_model(
3452                "vision-only",
3453                &ReasoningSelection::Effort {
3454                    effort: ReasoningEffort::High,
3455                    execution_mode: None,
3456                },
3457            )
3458            .is_ok()
3459        );
3460    }
3461
3462    #[test]
3463    fn legacy_and_advertised_empty_catalogs_have_safe_reasoning_choices() {
3464        let _registry = isolated_registry();
3465        replace_provider_catalog(
3466            descriptor(
3467                "legacy-provider",
3468                "legacy",
3469                ReasoningWireProfile::CodexResponses,
3470            ),
3471            &[crate::provider::DiscoveredModelDetails {
3472                slug: "api/legacy".into(),
3473                context_budget: Some(64_000),
3474                capability_knowledge: CapabilityKnowledge::Legacy { thinking: false },
3475            }],
3476        )
3477        .unwrap();
3478        replace_provider_catalog(
3479            descriptor(
3480                "empty-provider",
3481                "empty",
3482                ReasoningWireProfile::CodexResponses,
3483            ),
3484            &[advertised_model("api/empty", 64_000, vec![])],
3485        )
3486        .unwrap();
3487
3488        for model in ["legacy:api/legacy", "empty:api/empty"] {
3489            assert_eq!(
3490                reasoning_selections_for_model(model)
3491                    .into_iter()
3492                    .map(|selection| selection.to_string())
3493                    .collect::<Vec<_>>(),
3494                ["default", "off", "auto"]
3495            );
3496            assert!(
3497                resolve_reasoning_for_model(
3498                    model,
3499                    &ReasoningSelection::Effort {
3500                        effort: ReasoningEffort::High,
3501                        execution_mode: None,
3502                    },
3503                )
3504                .is_err()
3505            );
3506        }
3507        assert_eq!(
3508            effective_reasoning_for_model("legacy:api/legacy", None).unwrap(),
3509            Some(ReasoningSelection::Disabled)
3510        );
3511        assert_eq!(
3512            effective_reasoning_for_model("empty:api/empty", None).unwrap(),
3513            Some(ReasoningSelection::ProviderDefault)
3514        );
3515    }
3516
3517    #[test]
3518    fn uuid_codex_catalog_uses_advertised_efforts_for_choices_and_validation() {
3519        let _registry = isolated_registry();
3520        replace_provider_catalog(
3521            descriptor(
3522                "018f0f2e-7b9a-7fd0-ae41-8c772ccae64b",
3523                "account",
3524                ReasoningWireProfile::CodexResponses,
3525            ),
3526            &[advertised_model(
3527                "gpt-5.6-sol",
3528                272_000,
3529                vec![ReasoningEffort::Low, ReasoningEffort::High],
3530            )],
3531        )
3532        .unwrap();
3533        let mut config = ProviderConfig::default();
3534        config.aliases.insert(
3535            "smart".into(),
3536            AliasEntry {
3537                model: "account:gpt-5.6-sol".into(),
3538            },
3539        );
3540        set_provider_config(config);
3541
3542        assert_eq!(
3543            reasoning_wire_profile_for_model("smart"),
3544            ReasoningWireProfile::CodexResponses
3545        );
3546        assert_eq!(
3547            reasoning_selections_for_model("smart")
3548                .into_iter()
3549                .map(|selection| selection.to_string())
3550                .collect::<Vec<_>>(),
3551            ["default", "off", "auto", "low", "high"]
3552        );
3553        assert!(
3554            resolve_reasoning_for_model(
3555                "smart",
3556                &ReasoningSelection::Effort {
3557                    effort: ReasoningEffort::High,
3558                    execution_mode: None,
3559                },
3560            )
3561            .is_ok()
3562        );
3563        let error = resolve_reasoning_for_model(
3564            "smart",
3565            &ReasoningSelection::Effort {
3566                effort: ReasoningEffort::XHigh,
3567                execution_mode: None,
3568            },
3569        )
3570        .unwrap_err();
3571        assert!(error.contains("available: low, high"));
3572    }
3573
3574    #[test]
3575    fn preset_and_catalog_overlays_do_not_duplicate_renamed_models() {
3576        let _registry = isolated_registry();
3577        let mut cfg = ProviderConfig::default();
3578        for provider in ["first", "second"] {
3579            cfg.providers.insert(
3580                provider.into(),
3581                ProviderEntry {
3582                    kind: "openai".into(),
3583                    base_url: Some("https://api.openai.com/v1".into()),
3584                    ..Default::default()
3585                },
3586            );
3587        }
3588        for name in ["renamed", "also-renamed"] {
3589            cfg.models.insert(
3590                name.into(),
3591                ModelEntry {
3592                    model: "gpt-4o".into(),
3593                    provider: Some("first".into()),
3594                    ..Default::default()
3595                },
3596            );
3597        }
3598        set_provider_config(cfg);
3599
3600        let names: BTreeSet<_> = all_model_entries()
3601            .into_iter()
3602            .map(|(name, _)| name)
3603            .collect();
3604        assert!(names.contains("renamed"));
3605        assert!(names.contains("also-renamed"));
3606        assert!(names.contains("second:gpt-4o"));
3607        assert!(!names.contains("first:gpt-4o"));
3608        assert!(!names.contains("gpt-4o"));
3609
3610        replace_provider_catalog(
3611            descriptor(
3612                "catalog-provider",
3613                "stable",
3614                ReasoningWireProfile::OpenAiOfficial,
3615            ),
3616            &[advertised_model(
3617                "api/reasoning",
3618                128_000,
3619                vec![ReasoningEffort::High],
3620            )],
3621        )
3622        .unwrap();
3623        let mut cfg = ProviderConfig::default();
3624        cfg.models.insert(
3625            "stable:api/reasoning".into(),
3626            ModelEntry {
3627                model: "api/different".into(),
3628                provider: Some("different-provider".into()),
3629                ..Default::default()
3630            },
3631        );
3632        set_provider_config(cfg);
3633        let shadow = model_entry("stable:api/reasoning").unwrap();
3634        assert_eq!(shadow.model, "api/different");
3635        assert!(shadow.reasoning_efforts.is_empty());
3636    }
3637
3638    #[test]
3639    fn catalog_rejects_a_registry_key_owned_by_a_different_preset_identity() {
3640        let _registry = isolated_registry();
3641        let mut cfg = ProviderConfig::default();
3642        cfg.providers.insert(
3643            "stable".into(),
3644            ProviderEntry {
3645                kind: "openai".into(),
3646                base_url: Some("https://api.openai.com/v1".into()),
3647                ..Default::default()
3648            },
3649        );
3650        set_provider_config(cfg);
3651
3652        assert!(matches!(
3653            replace_provider_catalog(
3654                descriptor(
3655                    "different-provider",
3656                    "stable",
3657                    ReasoningWireProfile::OpenAiOfficial,
3658                ),
3659                &[advertised_model("gpt-4o", 128_000, vec![])],
3660            ),
3661            Err(CatalogError::RegistryKeyInUse { .. })
3662        ));
3663        assert_eq!(model_entry("stable:gpt-4o").unwrap().model, "gpt-4o");
3664        assert_eq!(api_model_id("stable:gpt-4o"), "gpt-4o");
3665    }
3666
3667    #[test]
3668    fn config_reload_rejects_a_preset_key_owned_by_a_different_catalog_identity() {
3669        let _registry = isolated_registry();
3670        replace_provider_catalog(
3671            descriptor(
3672                "different-provider",
3673                "stable",
3674                ReasoningWireProfile::OpenAiOfficial,
3675            ),
3676            &[advertised_model("gpt-4o", 128_000, vec![])],
3677        )
3678        .unwrap();
3679
3680        let error = reload_from_text(
3681            r#"
3682[providers.stable]
3683kind = "openai"
3684base_url = "https://api.openai.com/v1"
3685"#,
3686        )
3687        .unwrap_err();
3688
3689        assert!(error.to_string().contains("already used"));
3690        assert!(all_provider_entries().is_empty());
3691        assert_eq!(
3692            model_entry("stable:gpt-4o").unwrap().provider.as_deref(),
3693            Some("different-provider")
3694        );
3695    }
3696
3697    #[test]
3698    fn preset_keys_and_canonical_aliases_do_not_drift_as_providers_change() {
3699        let _registry = isolated_registry();
3700        let provider = || ProviderEntry {
3701            kind: "openai".into(),
3702            base_url: Some("https://api.openai.com/v1".into()),
3703            ..Default::default()
3704        };
3705        let mut one = ProviderConfig::default();
3706        one.providers.insert("first".into(), provider());
3707        one.aliases.insert(
3708            "smart".into(),
3709            AliasEntry {
3710                model: "first:gpt-4o".into(),
3711            },
3712        );
3713        one.aliases.insert(
3714            "legacy".into(),
3715            AliasEntry {
3716                model: "gpt-4o".into(),
3717            },
3718        );
3719        set_provider_config(one.clone());
3720        assert!(model_entry("first:gpt-4o").is_some());
3721        assert_eq!(model_entry("smart").unwrap().model, "gpt-4o");
3722        assert_eq!(model_entry("legacy").unwrap().model, "gpt-4o");
3723
3724        replace_provider_catalog(
3725            descriptor(
3726                "dynamic-provider",
3727                "dynamic",
3728                ReasoningWireProfile::OpenAiOfficial,
3729            ),
3730            &[advertised_model("gpt-4o", 128_000, vec![])],
3731        )
3732        .unwrap();
3733        assert_eq!(
3734            model_entry("legacy").unwrap().provider.as_deref(),
3735            Some("first")
3736        );
3737
3738        let mut two = one.clone();
3739        two.providers.insert("second".into(), provider());
3740        set_provider_config(two);
3741        assert!(model_entry("first:gpt-4o").is_some());
3742        assert!(model_entry("second:gpt-4o").is_some());
3743        assert_eq!(model_entry("smart").unwrap().model, "gpt-4o");
3744        assert!(model_entry("legacy").is_none());
3745
3746        set_provider_config(one);
3747        assert!(model_entry("first:gpt-4o").is_some());
3748        assert!(model_entry("second:gpt-4o").is_none());
3749        assert_eq!(model_entry("smart").unwrap().model, "gpt-4o");
3750        assert_eq!(model_entry("legacy").unwrap().model, "gpt-4o");
3751    }
3752
3753    #[test]
3754    fn resolver_rejects_known_unsupported_effort() {
3755        let capabilities = ModelCapabilities {
3756            reasoning_efforts: vec![ReasoningEffort::Low, ReasoningEffort::High],
3757            ..Default::default()
3758        };
3759        let error = resolve_reasoning(
3760            &ReasoningSelection::Effort {
3761                effort: ReasoningEffort::XHigh,
3762                execution_mode: None,
3763            },
3764            &capabilities,
3765        )
3766        .unwrap_err();
3767        assert!(error.contains("available: low, high"));
3768    }
3769}