1use std::collections::HashMap;
2use std::sync::RwLock;
3
4use crate::auth_store::AuthStore;
5
6#[derive(Debug, Clone)]
7pub struct ModelInfo {
8 pub name: String,
9 pub context_budget: u64,
10 pub compact_threshold_ratio: f64,
11 pub thinking_enabled: bool,
12 pub max_output_tokens: Option<u32>,
13}
14
15pub const DEFAULT_CONFIG_PROVIDER_TYPE: &str = "openai-compat";
16
17pub fn config_provider_types() -> Vec<&'static str> {
18 let mut types = Vec::new();
19 for preset in PROVIDER_PRESETS {
20 if preset.provider_type == "codex" {
21 continue;
22 }
23 if !types.contains(&preset.provider_type) {
24 types.push(preset.provider_type);
25 }
26 }
27 if types.is_empty() {
28 types.push(DEFAULT_CONFIG_PROVIDER_TYPE);
29 }
30 types
31}
32
33#[derive(Debug, Clone, Default)]
34pub struct ProviderEntry {
35 pub name: String,
36 pub kind: String,
37 pub api_key: Option<String>,
38 pub api_key_env: Option<String>,
39 pub base_url: Option<String>,
40 pub max_tokens: Option<u32>,
41 pub enabled: Option<bool>,
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct ModelEntry {
46 pub model: String,
47 pub provider: Option<String>,
48 pub context_budget: Option<u64>,
49 pub compact_threshold_ratio: Option<f64>,
50 pub thinking: Option<bool>,
51 pub max_tokens: Option<u32>,
52 pub enabled: Option<bool>,
53 #[allow(dead_code)]
54 pub discovered: bool,
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct AliasEntry {
59 pub model: String,
60}
61
62#[derive(Debug, Clone, Default)]
63pub struct ProviderConfig {
64 pub providers: HashMap<String, ProviderEntry>,
65 pub models: HashMap<String, ModelEntry>,
66 pub aliases: HashMap<String, AliasEntry>,
67}
68
69pub type ModelConfig = ProviderConfig;
71
72static MODEL_CONFIG: RwLock<Option<ProviderConfig>> = RwLock::new(None);
73
74pub static MODEL_CONFIG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
79
80static DISCOVERED_MODELS: RwLock<Vec<String>> = RwLock::new(Vec::new());
82
83pub fn set_discovered_models(models: Vec<String>) {
84 *DISCOVERED_MODELS.write().unwrap() = models;
85}
86
87pub fn discovered_models() -> Vec<String> {
88 DISCOVERED_MODELS.read().unwrap().clone()
89}
90
91pub fn set_provider_config(mut cfg: ProviderConfig) {
94 let mut guard = MODEL_CONFIG.write().unwrap();
95 if let Some(old) = guard.take() {
96 for (name, entry) in old.models {
98 if entry.discovered {
99 cfg.models.entry(name).or_insert(entry);
100 }
101 }
102 }
106 *guard = Some(cfg);
107}
108
109pub fn set_model_config(cfg: ModelConfig) {
111 set_provider_config(cfg);
112}
113
114pub fn register_model_entries(entries: Vec<(String, ModelEntry)>) {
116 let mut guard = MODEL_CONFIG.write().unwrap();
117 let mut cfg = guard.take().unwrap_or_default();
118 for (name, entry) in entries {
119 cfg.models.entry(name).or_insert(entry);
120 }
121 *guard = Some(cfg);
122}
123
124pub fn register_provider_entries(entries: Vec<(String, ProviderEntry)>) {
126 let mut guard = MODEL_CONFIG.write().unwrap();
127 let mut cfg = guard.take().unwrap_or_default();
128 for (name, entry) in entries {
129 cfg.providers.entry(name).or_insert(entry);
130 }
131 *guard = Some(cfg);
132}
133
134pub fn register_discovered(
137 _provider_id: &str,
138 provider_name: &str,
139 models: &[crate::provider::DiscoveredModel],
140) {
141 let entries: Vec<(String, ModelEntry)> = models
142 .iter()
143 .map(|m| {
144 let name = format!("{provider_name}:{}", m.slug);
145 let entry = ModelEntry {
146 model: name.clone(),
147 provider: Some(provider_name.to_string()),
148 context_budget: m.context_budget,
149 thinking: Some(m.thinking),
150 enabled: None,
151 discovered: true,
152 ..Default::default()
153 };
154 (name, entry)
155 })
156 .collect();
157 let slugs: Vec<String> = entries.iter().map(|(name, _)| name.clone()).collect();
158 register_model_entries(entries);
159 set_discovered_models(slugs);
160}
161
162#[derive(Debug, Clone)]
163pub struct ModelRow {
164 pub slug: String,
165 pub provider_name: String,
166 pub context_budget: u64,
167 pub max_output_tokens: Option<u32>,
168 pub thinking: bool,
169}
170
171#[derive(Debug, Clone)]
172pub struct ProviderGroup {
173 pub provider_name: String,
174 pub models: Vec<ModelRow>,
175}
176
177pub fn all_provider_groups() -> Vec<ProviderGroup> {
181 provider_groups(false)
182}
183
184pub fn all_provider_groups_with_empty() -> Vec<ProviderGroup> {
186 provider_groups(true)
187}
188
189fn provider_groups(include_empty: bool) -> Vec<ProviderGroup> {
190 let entries = all_model_entries();
191 let mut groups: std::collections::BTreeMap<String, Vec<ModelRow>> =
192 std::collections::BTreeMap::new();
193 for (name, entry) in entries {
194 let info = model_info(&name);
195 let provider = entry.provider.unwrap_or_else(|| "unknown".to_string());
196 let row = ModelRow {
197 slug: name,
198 provider_name: provider.clone(),
199 context_budget: info.context_budget,
200 max_output_tokens: info.max_output_tokens,
201 thinking: info.thinking_enabled(),
202 };
203 groups.entry(provider).or_default().push(row);
204 }
205 if include_empty {
206 for (provider, entry) in all_provider_entries() {
207 if entry.enabled.unwrap_or(true) {
208 groups.entry(provider).or_default();
209 }
210 }
211 }
212 groups
213 .into_iter()
214 .map(|(provider_name, models)| ProviderGroup {
215 provider_name,
216 models,
217 })
218 .collect()
219}
220
221pub fn resolve_alias(name: &str) -> String {
222 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
223 let mut current = name.to_string();
224 let mut seen = std::collections::HashSet::new();
225 while let Some(entry) = cfg.aliases.get(¤t) {
226 if !seen.insert(current.clone()) {
227 break;
228 }
229 current = entry.model.clone();
230 }
231 return current;
232 }
233 name.to_string()
234}
235
236pub fn model_entry(name: &str) -> Option<ModelEntry> {
237 let resolved = resolve_alias(name);
238 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
239 return cfg.models.get(&resolved).cloned();
240 }
241 None
242}
243
244pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
245 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
246 return cfg
247 .models
248 .iter()
249 .map(|(k, v)| (k.clone(), v.clone()))
250 .collect();
251 }
252 Vec::new()
253}
254
255pub fn is_provider_enabled(name: &str) -> bool {
256 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
257 if let Some(entry) = cfg.providers.get(name) {
258 return entry.enabled.unwrap_or(true);
259 }
260 }
261 true
262}
263
264pub fn all_provider_entries() -> Vec<(String, ProviderEntry)> {
265 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
266 return cfg
267 .providers
268 .iter()
269 .map(|(k, v)| (k.clone(), v.clone()))
270 .collect();
271 }
272 Vec::new()
273}
274
275pub fn all_aliases() -> Vec<(String, String)> {
276 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
277 return cfg
278 .aliases
279 .iter()
280 .map(|(k, v)| (k.clone(), v.model.clone()))
281 .collect();
282 }
283 Vec::new()
284}
285
286pub fn model_info(name: &str) -> ModelInfo {
287 let resolved = resolve_alias(name);
288 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
289 if let Some(entry) = cfg.models.get(&resolved) {
290 let enabled = entry.enabled.unwrap_or(true);
291 return ModelInfo {
292 name: resolved.clone(),
293 context_budget: if enabled {
294 entry.context_budget.unwrap_or(0)
295 } else {
296 0
297 },
298 compact_threshold_ratio: entry.compact_threshold_ratio.unwrap_or(0.8),
299 thinking_enabled: entry.thinking.unwrap_or(false),
300 max_output_tokens: entry.max_tokens,
301 };
302 }
303 }
304 ModelInfo {
305 name: resolved,
306 context_budget: 0,
307 compact_threshold_ratio: 0.8,
308 thinking_enabled: false,
309 max_output_tokens: None,
310 }
311}
312
313impl ModelInfo {
314 pub fn compact_threshold_tokens(&self) -> u64 {
315 let reserved = self.max_output_tokens.unwrap_or(0) as u64;
316 let available = self.context_budget.saturating_sub(reserved);
317 (available as f64 * self.compact_threshold_ratio) as u64
318 }
319
320 pub fn compaction_trigger_threshold(&self) -> u64 {
321 if self.context_budget == 0 {
322 return u64::MAX;
323 }
324 let budget = self.context_budget;
325
326 let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
327 let output_cap = (budget as f64 * 0.20) as u64;
328 let output_reserve = configured_output.min(output_cap).max(8_000);
329
330 let safety = (budget as f64 * 0.05) as u64;
331 let safety_margin = safety.max(4_000);
332
333 let trigger = budget
334 .saturating_sub(output_reserve)
335 .saturating_sub(safety_margin);
336 let floor = (budget as f64 * 0.50) as u64;
337 let ceiling = (budget as f64 * 0.95) as u64;
338 trigger.clamp(floor, ceiling)
339 }
340
341 pub fn compaction_target_after(&self) -> u64 {
342 let trigger = self.compaction_trigger_threshold();
343 let budget_cap = (self.context_budget as f64 * 0.25) as u64;
344 let trigger_cap = (trigger as f64 * 0.75) as u64;
345 budget_cap.min(trigger_cap)
346 }
347
348 pub fn thinking_enabled(&self) -> bool {
349 self.thinking_enabled
350 }
351}
352
353pub use crate::known_models::{KNOWN_MODELS, lookup_known_model};
356pub fn register_preset_models_for(provider_name: &str, base_url: &str) {
360 for preset in PROVIDER_PRESETS {
361 if preset.base_url == base_url && !preset.models.is_empty() {
362 let entries: Vec<(String, ModelEntry)> = preset
363 .models
364 .iter()
365 .map(|m| {
366 let name = m.id.to_string();
367 let entry = ModelEntry {
368 model: m.id.to_string(),
369 provider: Some(provider_name.to_string()),
370 context_budget: Some(m.context_budget),
371 thinking: Some(m.thinking),
372 enabled: None,
373 discovered: true,
374 ..Default::default()
375 };
376 (name, entry)
377 })
378 .collect();
379 register_model_entries(entries);
380 return;
381 }
382 }
383}
384
385pub fn register_all_preset_models() {
388 let providers = all_provider_entries();
389 for (name, entry) in &providers {
390 if let Some(base_url) = &entry.base_url {
391 register_preset_models_for(name, base_url);
392 }
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
399pub enum ModelMigrationOutcome {
400 NotNeeded,
401 Migrated { backup: std::path::PathBuf },
402}
403
404pub fn migrate_config_if_needed(
405 text: &str,
406) -> Result<Option<String>, crate::config_hub::ConfigError> {
407 let mut doc = text.parse::<toml_edit::DocumentMut>()?;
408 let legacy = has_legacy_model_fields(&doc);
409 match doc.get("config_version") {
410 None if !legacy => return Ok(None),
411 None => {}
412 Some(version) => match version.as_integer() {
413 Some(1) if legacy => {}
414 Some(1) | Some(2) => return Ok(None),
415 Some(value) => {
416 return Err(crate::config_hub::ConfigError::Invalid(format!(
417 "unsupported config_version {value}"
418 )));
419 }
420 None => {
421 return Err(crate::config_hub::ConfigError::Invalid(
422 "config_version must be an integer".into(),
423 ));
424 }
425 },
426 }
427
428 let provider_names: std::collections::HashSet<String> = doc
429 .get("providers")
430 .and_then(|item| item.as_table())
431 .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
432 .unwrap_or_default();
433 let models = doc
434 .get("models")
435 .and_then(|item| item.as_table())
436 .ok_or_else(|| crate::config_hub::ConfigError::Invalid("models is not a table".into()))?;
437 let mut groups: std::collections::BTreeMap<(String, String, String), Vec<String>> =
438 std::collections::BTreeMap::new();
439
440 for (name, entry) in models.iter() {
441 let api_key = entry
442 .get("api_key")
443 .and_then(|v| v.as_str())
444 .unwrap_or("")
445 .to_string();
446 let base_url = entry
447 .get("base_url")
448 .and_then(|v| v.as_str())
449 .unwrap_or("")
450 .to_string();
451 let ptype = entry
452 .get("provider")
453 .and_then(|v| v.as_str())
454 .unwrap_or("openai-compat")
455 .to_string();
456
457 if !api_key.is_empty()
458 || !base_url.is_empty()
459 || (matches!(
460 ptype.as_str(),
461 "openai" | "openai-compat" | "anthropic" | "codex"
462 ) && !provider_names.contains(&ptype))
463 {
464 groups
465 .entry((ptype, api_key, base_url))
466 .or_default()
467 .push(name.to_string());
468 }
469 }
470
471 if groups.is_empty() {
472 return Ok(None);
473 }
474
475 let mut used_names: std::collections::HashSet<String> = doc
476 .get("providers")
477 .and_then(|item| item.as_table())
478 .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
479 .unwrap_or_default();
480
481 for ((ptype, api_key, base_url), model_names) in &groups {
482 let provider_name = pick_provider_name(ptype, base_url, &mut used_names);
483 used_names.insert(provider_name.clone());
484
485 let mut table = toml_edit::Table::new();
487 table.insert("kind", toml_edit::value(ptype.clone()));
488 if !api_key.is_empty() {
489 table.insert("api_key", toml_edit::value(api_key.clone()));
490 }
491 if !base_url.is_empty() {
492 table.insert("base_url", toml_edit::value(base_url.clone()));
493 }
494 table.insert("enabled", toml_edit::value(true));
495
496 if doc.get("providers").is_none() {
498 doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
499 }
500 if let Some(providers) = doc.get_mut("providers").and_then(|p| p.as_table_mut()) {
501 providers.insert(&provider_name, toml_edit::Item::Table(table));
502 }
503
504 for model_name in model_names {
506 if let Some(model) = doc
507 .get_mut("models")
508 .and_then(|m| m.as_table_mut())
509 .and_then(|t| t.get_mut(model_name.as_str()))
510 .and_then(|e| e.as_table_mut())
511 {
512 model.insert("provider", toml_edit::value(&provider_name));
513 model.remove("api_key");
514 model.remove("base_url");
515 }
516 }
517 }
518
519 doc.insert("config_version", toml_edit::value(2i64));
520 let migrated = doc.to_string();
521 parse_config(&migrated).ok_or_else(|| {
522 crate::config_hub::ConfigError::Invalid("validate migrated config.toml".into())
523 })?;
524 Ok(Some(migrated))
525}
526
527fn has_legacy_model_fields(doc: &toml_edit::DocumentMut) -> bool {
528 let provider_names: std::collections::HashSet<&str> = doc
529 .get("providers")
530 .and_then(|item| item.as_table())
531 .map(|providers| providers.iter().map(|(name, _)| name).collect())
532 .unwrap_or_default();
533 doc.get("models")
534 .and_then(|item| item.as_table())
535 .is_some_and(|models| {
536 models.iter().any(|(_, entry)| {
537 entry.get("api_key").is_some()
538 || entry.get("base_url").is_some()
539 || entry
540 .get("provider")
541 .and_then(|value| value.as_str())
542 .is_some_and(|provider| {
543 matches!(provider, "openai" | "openai-compat" | "anthropic" | "codex")
544 && !provider_names.contains(provider)
545 })
546 })
547 })
548}
549
550fn pick_provider_name(
551 ptype: &str,
552 base_url: &str,
553 used: &mut std::collections::HashSet<String>,
554) -> String {
555 for preset in PROVIDER_PRESETS {
557 if !base_url.is_empty() && preset.base_url == base_url {
558 let name = preset.name.to_lowercase();
559 if !used.contains(&name) {
560 return name;
561 }
562 }
563 }
564 let base = ptype.to_string();
566 if !used.contains(&base) {
567 return base;
568 }
569 for i in 2.. {
570 let candidate = format!("{base}-{i}");
571 if !used.contains(&candidate) {
572 return candidate;
573 }
574 }
575 unreachable!()
576}
577
578pub fn read_config_toml_pub() -> Option<String> {
581 crate::config_hub::ConfigHub::global()
582 .ok()?
583 .read_config_toml()
584 .ok()
585}
586
587pub(crate) fn reload_from_text(text: &str) -> anyhow::Result<()> {
588 if !text.trim().is_empty() {
589 toml::from_str::<toml::Value>(text)
590 .map_err(|error| anyhow::anyhow!("parse config.toml: {error}"))?;
591 }
592 set_provider_config(parse_config(text).unwrap_or_default());
593 Ok(())
594}
595
596pub fn parse_config(text: &str) -> Option<ProviderConfig> {
601 #[derive(serde::Deserialize, Default)]
602 struct RawProvider {
603 #[serde(default)]
604 name: Option<String>,
605 #[serde(default)]
606 kind: Option<String>,
607 #[serde(default)]
608 api_key: Option<String>,
609 #[serde(default)]
610 api_key_env: Option<String>,
611 #[serde(default)]
612 base_url: Option<String>,
613 #[serde(default)]
614 max_tokens: Option<u32>,
615 #[serde(default)]
616 enabled: Option<bool>,
617 }
618
619 #[derive(serde::Deserialize, Default)]
620 struct RawModel {
621 #[serde(default)]
622 model: Option<String>,
623 #[serde(default)]
624 provider: Option<String>,
625 #[serde(default)]
626 context_budget: Option<u64>,
627 #[serde(default)]
628 compact_threshold_ratio: Option<f64>,
629 #[serde(default)]
630 thinking: Option<bool>,
631 #[serde(default)]
632 max_tokens: Option<u32>,
633 #[serde(default)]
634 enabled: Option<bool>,
635 #[serde(default)]
636 discovered: bool,
637 }
638
639 #[derive(serde::Deserialize, Default)]
640 struct RawAlias {
641 model: String,
642 }
643
644 #[derive(serde::Deserialize, Default)]
645 struct RawFile {
646 #[serde(default)]
647 providers: std::collections::HashMap<String, RawProvider>,
648 #[serde(default)]
649 models: std::collections::HashMap<String, RawModel>,
650 #[serde(default)]
651 alias: std::collections::HashMap<String, RawAlias>,
652 }
653
654 let raw: RawFile = toml::from_str(text).ok()?;
655 let mut cfg = ProviderConfig::default();
656
657 for (key, p) in raw.providers {
658 cfg.providers.insert(
659 key.clone(),
660 ProviderEntry {
661 name: p.name.unwrap_or(key),
662 kind: p.kind.unwrap_or_default(),
663 api_key: p.api_key,
664 api_key_env: p.api_key_env,
665 base_url: p.base_url,
666 max_tokens: p.max_tokens,
667 enabled: p.enabled,
668 },
669 );
670 }
671
672 for (name, m) in raw.models {
673 cfg.models.insert(
674 name,
675 ModelEntry {
676 model: m.model.unwrap_or_default(),
677 provider: m.provider,
678 context_budget: m.context_budget,
679 compact_threshold_ratio: m.compact_threshold_ratio,
680 thinking: m.thinking,
681 max_tokens: m.max_tokens,
682 enabled: m.enabled,
683 discovered: m.discovered,
684 },
685 );
686 }
687
688 for (name, a) in raw.alias {
689 cfg.aliases.insert(name, AliasEntry { model: a.model });
690 }
691
692 if cfg.providers.is_empty() && cfg.models.is_empty() && cfg.aliases.is_empty() {
693 return None;
694 }
695 Some(cfg)
696}
697
698pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
699 crate::config_hub::ConfigHub::global()?
700 .add_alias(alias, model)
701 .map_err(Into::into)
702}
703
704pub fn upsert_provider_config(
705 name: &str,
706 kind: &str,
707 api_key: Option<&str>,
708 api_key_env: Option<&str>,
709 base_url: Option<&str>,
710 max_tokens: Option<u32>,
711 enabled: bool,
712) -> anyhow::Result<()> {
713 crate::config_hub::ConfigHub::global()?
714 .upsert_provider(crate::config_hub::ProviderConfigUpdate {
715 name,
716 kind,
717 api_key,
718 api_key_env,
719 base_url,
720 max_tokens,
721 enabled,
722 })
723 .map_err(Into::into)
724}
725
726#[derive(Debug, Clone)]
727pub struct ModelConfigUpdate<'a> {
728 pub old_name: Option<&'a str>,
729 pub name: &'a str,
730 pub model: &'a str,
731 pub provider: Option<&'a str>,
732 pub context_budget: u64,
733 pub thinking: bool,
734 pub max_tokens: Option<u32>,
735 pub enabled: bool,
736}
737
738pub(crate) fn apply_model_config_update(
739 doc: &mut toml_edit::DocumentMut,
740 update: ModelConfigUpdate<'_>,
741) -> anyhow::Result<()> {
742 if doc.get("models").is_none() {
743 doc.insert("models", toml_edit::Item::Table(toml_edit::Table::new()));
744 }
745 {
746 let models = doc
747 .get_mut("models")
748 .and_then(|item| item.as_table_mut())
749 .ok_or_else(|| anyhow::anyhow!("models is not a table"))?;
750 if let Some(old_name) = update.old_name.filter(|old| *old != update.name) {
751 models.remove(old_name);
752 }
753 let entry = models
754 .entry(update.name)
755 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
756 .as_table_mut()
757 .ok_or_else(|| anyhow::anyhow!("model entry is not a table"))?;
758 entry.insert("model", toml_edit::value(update.model));
759 if let Some(provider) = update.provider {
760 entry.insert("provider", toml_edit::value(provider));
761 } else {
762 entry.remove("provider");
763 }
764 entry.insert(
765 "context_budget",
766 toml_edit::value(update.context_budget as i64),
767 );
768 entry.insert("thinking", toml_edit::value(update.thinking));
769 if let Some(max_tokens) = update.max_tokens {
770 entry.insert("max_tokens", toml_edit::value(max_tokens as i64));
771 } else {
772 entry.remove("max_tokens");
773 }
774 entry.insert("enabled", toml_edit::value(update.enabled));
775 }
776
777 if let Some(old_name) = update.old_name.filter(|old| *old != update.name)
778 && let Some(aliases) = doc.get_mut("alias").and_then(|item| item.as_table_mut())
779 {
780 for (_, alias) in aliases.iter_mut() {
781 if let Some(table) = alias.as_table_mut() {
782 if table.get("model").and_then(|item| item.as_str()) == Some(old_name) {
783 table.insert("model", toml_edit::value(update.name));
784 }
785 } else if let Some(inline) = alias.as_inline_table_mut()
786 && inline.get("model").and_then(|value| value.as_str()) == Some(old_name)
787 {
788 inline.insert("model", toml_edit::Value::from(update.name));
789 }
790 }
791 }
792 Ok(())
793}
794
795pub fn upsert_model_config(update: ModelConfigUpdate<'_>) -> anyhow::Result<()> {
796 crate::config_hub::ConfigHub::global()?
797 .upsert_model(update)
798 .map_err(Into::into)
799}
800
801pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
802 crate::config_hub::ConfigHub::global()?
803 .remove_alias(alias)
804 .map_err(Into::into)
805}
806
807pub fn update_alias_in_config(
808 old_alias: &str,
809 new_alias: &str,
810 new_model: &str,
811) -> anyhow::Result<()> {
812 crate::config_hub::ConfigHub::global()?
813 .update_alias(Some(old_alias), new_alias, new_model)
814 .map_err(Into::into)
815}
816
817pub struct ProviderPreset {
820 pub name: &'static str,
821 pub description: &'static str,
822 pub base_url: &'static str,
823 pub provider_type: &'static str,
824 pub models: &'static [ProviderPresetModel],
825 pub key_url: Option<&'static str>,
826 pub needs_api_key: bool,
827}
828
829pub struct ProviderPresetModel {
830 pub id: &'static str,
831 pub description: &'static str,
832 pub context_budget: u64,
833 pub thinking: bool,
834}
835
836pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
837 ProviderPreset {
838 name: "DeepSeek",
839 description: "Recommended — cheap, smart, supports thinking",
840 base_url: "https://api.deepseek.com",
841 provider_type: "openai-compat",
842 models: &[
843 ProviderPresetModel {
844 id: "deepseek-v4-flash",
845 description: "Fast & capable",
846 context_budget: 1000000,
847 thinking: false,
848 },
849 ProviderPresetModel {
850 id: "deepseek-v4-pro",
851 description: "Thinking mode",
852 context_budget: 1000000,
853 thinking: true,
854 },
855 ],
856 key_url: Some("https://platform.deepseek.com"),
857 needs_api_key: true,
858 },
859 ProviderPreset {
860 name: "OpenAI",
861 description: "GPT-4o / GPT-4o-mini",
862 base_url: "https://api.openai.com/v1",
863 provider_type: "openai",
864 models: &[
865 ProviderPresetModel {
866 id: "gpt-4o",
867 description: "Most capable",
868 context_budget: 128000,
869 thinking: false,
870 },
871 ProviderPresetModel {
872 id: "gpt-4o-mini",
873 description: "Fast & cheap",
874 context_budget: 128000,
875 thinking: false,
876 },
877 ],
878 key_url: Some("https://platform.openai.com/api-keys"),
879 needs_api_key: true,
880 },
881 ProviderPreset {
882 name: "Anthropic",
883 description: "Claude models",
884 base_url: "https://api.anthropic.com",
885 provider_type: "anthropic",
886 models: &[ProviderPresetModel {
887 id: "claude-sonnet-4-20250514",
888 description: "Claude Sonnet 4",
889 context_budget: 200000,
890 thinking: true,
891 }],
892 key_url: Some("https://console.anthropic.com/settings/keys"),
893 needs_api_key: true,
894 },
895 ProviderPreset {
896 name: "ZhipuAI",
897 description: "GLM models",
898 base_url: "https://open.bigmodel.cn/api/paas/v4",
899 provider_type: "openai-compat",
900 models: &[ProviderPresetModel {
901 id: "glm-5.2",
902 description: "GLM 5.2",
903 context_budget: 1000000,
904 thinking: true,
905 }],
906 key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
907 needs_api_key: true,
908 },
909 ProviderPreset {
910 name: "Ollama",
911 description: "Local models, no API key needed",
912 base_url: "http://localhost:11434/v1",
913 provider_type: "openai-compat",
914 models: &[],
915 key_url: None,
916 needs_api_key: false,
917 },
918 ProviderPreset {
919 name: "Codex",
920 description: "ChatGPT Plus/Pro OAuth",
921 base_url: "https://chatgpt.com/backend-api/codex",
922 provider_type: "codex",
923 models: &[],
924 key_url: None,
925 needs_api_key: false,
926 },
927];
928
929pub fn is_first_run() -> bool {
930 let providers = all_provider_entries();
931 let models = all_model_entries();
932 let config_configured = providers.iter().any(|(_, e)| {
933 e.api_key.as_deref().is_some_and(|k| !k.is_empty())
934 || e.api_key_env
935 .as_deref()
936 .is_some_and(|env| std::env::var(env).is_ok_and(|v| !v.trim().is_empty()))
937 });
938 let env_configured =
939 std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
940 let auth_configured = AuthStore::load()
941 .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
942 let smart_resolves = {
943 let resolved = resolve_alias("smart");
944 resolved != "smart" && models.iter().any(|(n, _)| *n == resolved)
945 };
946 !(config_configured || env_configured || auth_configured) || !smart_resolves
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952 use std::sync::Mutex as StdMutex;
953
954 static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
957
958 #[test]
959 fn unregistered_model_returns_zero_budget() {
960 let _lock = TEST_CFG_LOCK.lock().unwrap();
961 *MODEL_CONFIG.write().unwrap() = None;
962 assert_eq!(model_info("mystery-model").context_budget, 0);
963 assert_eq!(model_info("").context_budget, 0);
964 }
965
966 #[test]
967 fn threshold_is_eighty_percent() {
968 let _lock = TEST_CFG_LOCK.lock().unwrap();
969 let mut cfg = ModelConfig::default();
970 cfg.models.insert(
971 "claude-opus-4.7".into(),
972 ModelEntry {
973 model: "claude-opus-4.7".into(),
974 context_budget: Some(200_000),
975 compact_threshold_ratio: Some(0.8),
976 thinking: None,
977 ..Default::default()
978 },
979 );
980 set_model_config(cfg);
981 let info = model_info("claude-opus-4.7");
982 assert_eq!(info.compact_threshold_tokens(), 160_000);
983 }
984
985 #[test]
986 fn compaction_trigger_is_near_budget_top() {
987 let _lock = TEST_CFG_LOCK.lock().unwrap();
988 let mut cfg = ModelConfig::default();
989 cfg.models.insert(
990 "claude-opus-4.7".into(),
991 ModelEntry {
992 model: "claude-opus-4.7".into(),
993 context_budget: Some(200_000),
994 compact_threshold_ratio: Some(0.8),
995 thinking: None,
996 ..Default::default()
997 },
998 );
999 set_model_config(cfg);
1000 let info = model_info("claude-opus-4.7");
1001 let trigger = info.compaction_trigger_threshold();
1002 assert!(
1003 trigger > 150_000 && trigger <= 190_000,
1004 "trigger should be near the top of the budget, got {trigger}"
1005 );
1006 }
1007
1008 #[test]
1009 fn compaction_target_is_lower_than_trigger() {
1010 let _lock = TEST_CFG_LOCK.lock().unwrap();
1011 let mut cfg = ModelConfig::default();
1012 cfg.models.insert(
1013 "claude-opus-4.7".into(),
1014 ModelEntry {
1015 model: "claude-opus-4.7".into(),
1016 context_budget: Some(200_000),
1017 compact_threshold_ratio: Some(0.8),
1018 thinking: None,
1019 ..Default::default()
1020 },
1021 );
1022 set_model_config(cfg);
1023 let info = model_info("claude-opus-4.7");
1024 let trigger = info.compaction_trigger_threshold();
1025 let target = info.compaction_target_after();
1026 assert!(
1027 target < trigger,
1028 "target {target} should be less than trigger {trigger}"
1029 );
1030 assert_eq!(target, 50_000, "compaction target should be 25% of context");
1031 }
1032
1033 #[test]
1034 fn alias_resolves_to_real_model() {
1035 let _lock = TEST_CFG_LOCK.lock().unwrap();
1036 let mut cfg = ModelConfig::default();
1037 cfg.models.insert(
1038 "claude-opus-4.7".into(),
1039 ModelEntry {
1040 model: "claude-opus-4.7".into(),
1041 context_budget: Some(200_000),
1042 ..Default::default()
1043 },
1044 );
1045 cfg.aliases.insert(
1046 "smart".into(),
1047 AliasEntry {
1048 model: "claude-opus-4.7".into(),
1049 },
1050 );
1051 set_model_config(cfg);
1052 let info = model_info("smart");
1053 assert_eq!(info.context_budget, 200_000);
1054 assert_eq!(info.name, "claude-opus-4.7");
1055 }
1056
1057 #[test]
1058 fn custom_model_overrides_budget() {
1059 let _lock = TEST_CFG_LOCK.lock().unwrap();
1060 let mut cfg = ModelConfig::default();
1061 cfg.models.insert(
1062 "my-local-model".into(),
1063 ModelEntry {
1064 model: "my-local-model".into(),
1065 context_budget: Some(8192),
1066 compact_threshold_ratio: Some(0.9),
1067 thinking: None,
1068 ..Default::default()
1069 },
1070 );
1071 set_model_config(cfg);
1072 let info = model_info("my-local-model");
1073 assert_eq!(info.context_budget, 8192);
1074 assert_eq!(info.compact_threshold_ratio, 0.9);
1075 }
1076
1077 #[test]
1078 fn compact_threshold_reserves_configured_output_tokens() {
1079 let _lock = TEST_CFG_LOCK.lock().unwrap();
1080 let mut cfg = ModelConfig::default();
1081 cfg.models.insert(
1082 "large-output".into(),
1083 ModelEntry {
1084 model: "large-output".into(),
1085 context_budget: Some(1_000_000),
1086 compact_threshold_ratio: Some(0.8),
1087 thinking: None,
1088 max_tokens: Some(400_000),
1089 ..Default::default()
1090 },
1091 );
1092 set_model_config(cfg);
1093 let info = model_info("large-output");
1094 assert_eq!(info.compact_threshold_tokens(), 480_000);
1095 let trigger = info.compaction_trigger_threshold();
1096 assert!(
1097 trigger > 700_000,
1098 "trigger with capped output reserve should be > 700K, got {trigger}"
1099 );
1100 }
1101
1102 #[test]
1103 fn alias_chains_through_custom_model() {
1104 let _lock = TEST_CFG_LOCK.lock().unwrap();
1105 let mut cfg = ModelConfig::default();
1106 cfg.aliases.insert(
1107 "default".into(),
1108 AliasEntry {
1109 model: "my-model".into(),
1110 },
1111 );
1112 cfg.models.insert(
1113 "my-model".into(),
1114 ModelEntry {
1115 model: "my-model".into(),
1116 context_budget: Some(65_536),
1117 compact_threshold_ratio: None,
1118 thinking: None,
1119 ..Default::default()
1120 },
1121 );
1122 set_model_config(cfg);
1123 let info = model_info("default");
1124 assert_eq!(info.name, "my-model");
1125 assert_eq!(info.context_budget, 65_536);
1126 }
1127
1128 #[test]
1129 fn discovered_models_survive_set_model_config() {
1130 let _lock = TEST_CFG_LOCK.lock().unwrap();
1131 register_discovered(
1133 "pid-abc",
1134 "Codex",
1135 &[crate::provider::DiscoveredModel {
1136 slug: "codex/gpt-5".to_string(),
1137 context_budget: Some(128_000),
1138 thinking: true,
1139 }],
1140 );
1141 assert!(model_entry("Codex:codex/gpt-5").is_some());
1142
1143 let mut cfg = ModelConfig::default();
1145 cfg.aliases.insert(
1146 "cheap".into(),
1147 AliasEntry {
1148 model: "claude-opus-4.7".into(),
1149 },
1150 );
1151 set_model_config(cfg);
1152
1153 assert!(
1155 model_entry("Codex:codex/gpt-5").is_some(),
1156 "discovered models should survive set_model_config"
1157 );
1158 assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
1160 }
1161
1162 #[test]
1163 fn reload_replaces_config_models_and_preserves_discovered_models() {
1164 let _lock = TEST_CFG_LOCK.lock().unwrap();
1165 let mut initial = ProviderConfig::default();
1166 initial.models.insert(
1167 "old-config".into(),
1168 ModelEntry {
1169 model: "provider/old".into(),
1170 discovered: false,
1171 ..Default::default()
1172 },
1173 );
1174 initial.models.insert(
1175 "dynamic".into(),
1176 ModelEntry {
1177 model: "provider/dynamic".into(),
1178 discovered: true,
1179 ..Default::default()
1180 },
1181 );
1182 set_provider_config(initial);
1183
1184 reload_from_text(
1185 r#"
1186[models.new-config]
1187model = "provider/new"
1188"#,
1189 )
1190 .unwrap();
1191
1192 assert!(model_entry("old-config").is_none());
1193 assert!(model_entry("new-config").is_some());
1194 assert!(model_entry("dynamic").is_some());
1195 }
1196
1197 #[test]
1198 fn discovered_models_survive_reload_from_text_alias_crud() {
1199 let _lock = TEST_CFG_LOCK.lock().unwrap();
1200 register_discovered(
1201 "pid-abc",
1202 "Codex",
1203 &[crate::provider::DiscoveredModel {
1204 slug: "codex/gpt-5".to_string(),
1205 context_budget: Some(128_000),
1206 thinking: true,
1207 }],
1208 );
1209
1210 let toml = r#"
1211[alias]
1212smart = { model = "Codex:codex/gpt-5" }
1213"#;
1214 reload_from_text(toml).unwrap();
1215
1216 assert!(
1217 model_entry("Codex:codex/gpt-5").is_some(),
1218 "discovered models should survive alias CRUD"
1219 );
1220 assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
1221 }
1222
1223 #[test]
1224 fn model_config_update_replaces_name_and_preserves_other_sections() {
1225 let mut doc = r#"
1226# keep this comment
1227[providers.openai]
1228kind = "openai"
1229
1230[alias]
1231smart = { model = "old-name" }
1232cheap = { model = "other-name" }
1233
1234[alias.deep]
1235model = "old-name"
1236
1237[models.old-name]
1238model = "old-id"
1239provider = "openai"
1240enabled = true
1241"#
1242 .parse::<toml_edit::DocumentMut>()
1243 .unwrap();
1244
1245 apply_model_config_update(
1246 &mut doc,
1247 ModelConfigUpdate {
1248 old_name: Some("old-name"),
1249 name: "new-name",
1250 model: "new-id",
1251 provider: Some("openai"),
1252 context_budget: 128_000,
1253 thinking: true,
1254 max_tokens: Some(4096),
1255 enabled: false,
1256 },
1257 )
1258 .unwrap();
1259
1260 let out = doc.to_string();
1261 assert!(out.contains("# keep this comment"));
1262 assert!(out.contains("[providers.openai]"));
1263 assert!(out.contains("[alias]"));
1264 assert!(out.contains("smart = { model = \"new-name\" }"));
1265 assert!(out.contains("cheap = { model = \"other-name\" }"));
1266 assert!(out.contains("[alias.deep]"));
1267 assert!(out.contains("model = \"new-name\""));
1268 assert!(out.contains("[models.new-name]"));
1269 assert!(!out.contains("[models.old-name]"));
1270 assert!(out.contains("model = \"new-id\""));
1271 assert!(out.contains("context_budget = 128000"));
1272 assert!(out.contains("thinking = true"));
1273 assert!(out.contains("max_tokens = 4096"));
1274 assert!(out.contains("enabled = false"));
1275 }
1276}