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