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