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 let entries = all_model_entries();
182 let mut groups: std::collections::BTreeMap<String, Vec<ModelRow>> =
183 std::collections::BTreeMap::new();
184 for (name, entry) in entries {
185 let info = model_info(&name);
186 let provider = entry.provider.unwrap_or_else(|| "unknown".to_string());
187 let row = ModelRow {
188 slug: name,
189 provider_name: provider.clone(),
190 context_budget: info.context_budget,
191 max_output_tokens: info.max_output_tokens,
192 thinking: info.thinking_enabled(),
193 };
194 groups.entry(provider).or_default().push(row);
195 }
196 groups
197 .into_iter()
198 .map(|(provider_name, models)| ProviderGroup {
199 provider_name,
200 models,
201 })
202 .collect()
203}
204
205pub fn resolve_alias(name: &str) -> String {
206 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
207 let mut current = name.to_string();
208 let mut seen = std::collections::HashSet::new();
209 while let Some(entry) = cfg.aliases.get(¤t) {
210 if !seen.insert(current.clone()) {
211 break;
212 }
213 current = entry.model.clone();
214 }
215 return current;
216 }
217 name.to_string()
218}
219
220pub fn model_entry(name: &str) -> Option<ModelEntry> {
221 let resolved = resolve_alias(name);
222 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
223 return cfg.models.get(&resolved).cloned();
224 }
225 None
226}
227
228pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
229 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
230 return cfg
231 .models
232 .iter()
233 .map(|(k, v)| (k.clone(), v.clone()))
234 .collect();
235 }
236 Vec::new()
237}
238
239pub fn is_provider_enabled(name: &str) -> bool {
240 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
241 if let Some(entry) = cfg.providers.get(name) {
242 return entry.enabled.unwrap_or(true);
243 }
244 }
245 true
246}
247
248pub fn all_provider_entries() -> Vec<(String, ProviderEntry)> {
249 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
250 return cfg
251 .providers
252 .iter()
253 .map(|(k, v)| (k.clone(), v.clone()))
254 .collect();
255 }
256 Vec::new()
257}
258
259pub fn all_aliases() -> Vec<(String, String)> {
260 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
261 return cfg
262 .aliases
263 .iter()
264 .map(|(k, v)| (k.clone(), v.model.clone()))
265 .collect();
266 }
267 Vec::new()
268}
269
270pub fn model_info(name: &str) -> ModelInfo {
271 let resolved = resolve_alias(name);
272 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
273 if let Some(entry) = cfg.models.get(&resolved) {
274 let enabled = entry.enabled.unwrap_or(true);
275 return ModelInfo {
276 name: resolved.clone(),
277 context_budget: if enabled {
278 entry.context_budget.unwrap_or(0)
279 } else {
280 0
281 },
282 compact_threshold_ratio: entry.compact_threshold_ratio.unwrap_or(0.8),
283 thinking_enabled: entry.thinking.unwrap_or(false),
284 max_output_tokens: entry.max_tokens,
285 };
286 }
287 }
288 ModelInfo {
289 name: resolved,
290 context_budget: 0,
291 compact_threshold_ratio: 0.8,
292 thinking_enabled: false,
293 max_output_tokens: None,
294 }
295}
296
297impl ModelInfo {
298 pub fn compact_threshold_tokens(&self) -> u64 {
299 let reserved = self.max_output_tokens.unwrap_or(0) as u64;
300 let available = self.context_budget.saturating_sub(reserved);
301 (available as f64 * self.compact_threshold_ratio) as u64
302 }
303
304 pub fn compaction_trigger_threshold(&self) -> u64 {
305 if self.context_budget == 0 {
306 return u64::MAX;
307 }
308 let budget = self.context_budget;
309
310 let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
311 let output_cap = (budget as f64 * 0.20) as u64;
312 let output_reserve = configured_output.min(output_cap).max(8_000);
313
314 let safety = (budget as f64 * 0.05) as u64;
315 let safety_margin = safety.max(4_000);
316
317 let trigger = budget
318 .saturating_sub(output_reserve)
319 .saturating_sub(safety_margin);
320 let floor = (budget as f64 * 0.50) as u64;
321 let ceiling = (budget as f64 * 0.95) as u64;
322 trigger.clamp(floor, ceiling)
323 }
324
325 pub fn compaction_target_after(&self) -> u64 {
326 let trigger = self.compaction_trigger_threshold();
327 let budget_cap = (self.context_budget as f64 * 0.25) as u64;
328 let trigger_cap = (trigger as f64 * 0.75) as u64;
329 budget_cap.min(trigger_cap)
330 }
331
332 pub fn thinking_enabled(&self) -> bool {
333 self.thinking_enabled
334 }
335}
336
337pub use crate::known_models::{KNOWN_MODELS, lookup_known_model};
340pub fn register_preset_models_for(provider_name: &str, base_url: &str) {
344 for preset in PROVIDER_PRESETS {
345 if preset.base_url == base_url && !preset.models.is_empty() {
346 let entries: Vec<(String, ModelEntry)> = preset
347 .models
348 .iter()
349 .map(|m| {
350 let name = m.id.to_string();
351 let entry = ModelEntry {
352 model: m.id.to_string(),
353 provider: Some(provider_name.to_string()),
354 context_budget: Some(m.context_budget),
355 thinking: Some(m.thinking),
356 enabled: None,
357 discovered: true,
358 ..Default::default()
359 };
360 (name, entry)
361 })
362 .collect();
363 register_model_entries(entries);
364 return;
365 }
366 }
367}
368
369pub fn register_all_preset_models() {
372 let providers = all_provider_entries();
373 for (name, entry) in &providers {
374 if let Some(base_url) = &entry.base_url {
375 register_preset_models_for(name, base_url);
376 }
377 }
378}
379
380pub fn needs_migration(text: &str) -> bool {
384 let Ok(doc) = text.parse::<toml_edit::DocumentMut>() else {
385 return false;
386 };
387 if doc.get("config_version").is_some() {
388 return false;
389 }
390 if let Some(models) = doc.get("models").and_then(|m| m.as_table()) {
391 for (_, entry) in models {
392 if entry.get("api_key").is_some() || entry.get("base_url").is_some() {
393 return true;
394 }
395 if let Some(p) = entry.get("provider").and_then(|v| v.as_str()) {
397 if matches!(p, "openai" | "openai-compat" | "anthropic" | "codex") {
398 return true;
399 }
400 }
401 }
402 }
403 false
404}
405
406pub fn migrate_config(text: &str) -> Option<String> {
413 let mut doc = text.parse::<toml_edit::DocumentMut>().ok()?;
414
415 let models = doc.get("models")?.as_table()?;
417 let mut groups: std::collections::BTreeMap<(String, String, String), Vec<String>> =
418 std::collections::BTreeMap::new();
419
420 for (name, entry) in models.iter() {
421 let api_key = entry
422 .get("api_key")
423 .and_then(|v| v.as_str())
424 .unwrap_or("")
425 .to_string();
426 let base_url = entry
427 .get("base_url")
428 .and_then(|v| v.as_str())
429 .unwrap_or("")
430 .to_string();
431 let ptype = entry
432 .get("provider")
433 .and_then(|v| v.as_str())
434 .unwrap_or("openai-compat")
435 .to_string();
436
437 if !api_key.is_empty()
438 || !base_url.is_empty()
439 || matches!(
440 ptype.as_str(),
441 "openai" | "openai-compat" | "anthropic" | "codex"
442 )
443 {
444 groups
445 .entry((ptype, api_key, base_url))
446 .or_default()
447 .push(name.to_string());
448 }
449 }
450
451 if groups.is_empty() {
452 return None;
453 }
454
455 let mut used_names: std::collections::HashSet<String> = std::collections::HashSet::new();
456
457 for ((ptype, api_key, base_url), model_names) in &groups {
458 let provider_name = pick_provider_name(ptype, base_url, &mut used_names);
459 used_names.insert(provider_name.clone());
460
461 let mut table = toml_edit::Table::new();
463 table.insert("kind", toml_edit::value(ptype.clone()));
464 if !api_key.is_empty() {
465 table.insert("api_key", toml_edit::value(api_key.clone()));
466 }
467 if !base_url.is_empty() {
468 table.insert("base_url", toml_edit::value(base_url.clone()));
469 }
470 table.insert("enabled", toml_edit::value(true));
471
472 if doc.get("providers").is_none() {
474 doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
475 }
476 if let Some(providers) = doc.get_mut("providers").and_then(|p| p.as_table_mut()) {
477 providers.insert(&provider_name, toml_edit::Item::Table(table));
478 }
479
480 for model_name in model_names {
482 if let Some(model) = doc
483 .get_mut("models")
484 .and_then(|m| m.as_table_mut())
485 .and_then(|t| t.get_mut(model_name.as_str()))
486 .and_then(|e| e.as_table_mut())
487 {
488 model.insert("provider", toml_edit::value(&provider_name));
489 model.remove("api_key");
490 model.remove("base_url");
491 }
492 }
493 }
494
495 doc.insert("config_version", toml_edit::value(2i64));
497
498 Some(doc.to_string())
499}
500
501fn pick_provider_name(
502 ptype: &str,
503 base_url: &str,
504 used: &mut std::collections::HashSet<String>,
505) -> String {
506 for preset in PROVIDER_PRESETS {
508 if !base_url.is_empty() && preset.base_url == base_url {
509 let name = preset.name.to_lowercase();
510 if !used.contains(&name) {
511 return name;
512 }
513 }
514 }
515 let base = ptype.to_string();
517 if !used.contains(&base) {
518 return base;
519 }
520 for i in 2.. {
521 let candidate = format!("{base}-{i}");
522 if !used.contains(&candidate) {
523 return candidate;
524 }
525 }
526 unreachable!()
527}
528
529pub fn run_migration_if_needed() -> bool {
531 let Ok(text) = read_config_toml().ok_or(()) else {
532 return false;
533 };
534 if !needs_migration(&text) {
535 return false;
536 }
537 let Some(migrated) = migrate_config(&text) else {
538 return false;
539 };
540 if let Ok(dir) = crate::storage::config_dir() {
542 let _ = std::fs::write(dir.join("config.toml.bak"), &text);
543 }
544 if write_config_toml(&migrated).is_err() {
545 return false;
546 }
547 crate::notify!(
548 info,
549 "config.toml migrated to v2 format (backup at config.toml.bak)"
550 );
551 true
552}
553
554pub fn read_config_toml_pub() -> Option<String> {
557 read_config_toml()
558}
559
560fn read_config_toml() -> Option<String> {
561 let path = crate::storage::config_dir().ok()?.join("config.toml");
562 std::fs::read_to_string(&path).ok()
563}
564
565fn write_config_toml(text: &str) -> anyhow::Result<()> {
566 let dir = crate::storage::config_dir().map_err(|e| anyhow::anyhow!("config dir: {e}"))?;
567 std::fs::create_dir_all(&dir)?;
568 let path = dir.join("config.toml");
569 let tmp = dir.join(".config.toml.tmp");
570 std::fs::write(&tmp, text)?;
571 std::fs::rename(&tmp, &path)?;
572 Ok(())
573}
574
575fn reload_from_text(text: &str) {
576 let Ok(raw) = toml::from_str::<toml::Value>(text) else {
577 return;
578 };
579 let mut guard = MODEL_CONFIG.write().unwrap();
580 let mut cfg = guard.take().unwrap_or_default();
581
582 cfg.aliases.clear();
585 if let Some(aliases) = raw.get("alias").and_then(|a| a.as_table()) {
586 for (name, entry) in aliases {
587 if let Some(model) = entry.get("model").and_then(|m| m.as_str()) {
588 cfg.aliases.insert(
589 name.clone(),
590 AliasEntry {
591 model: model.to_string(),
592 },
593 );
594 }
595 }
596 }
597
598 cfg.providers.clear();
600 if let Some(providers) = raw.get("providers").and_then(|p| p.as_table()) {
601 for (key, entry) in providers {
602 let name = entry
603 .get("name")
604 .and_then(|v| v.as_str())
605 .map(String::from)
606 .unwrap_or_else(|| key.clone());
607 let kind = entry
608 .get("kind")
609 .and_then(|v| v.as_str())
610 .map(String::from)
611 .unwrap_or_default();
612 let api_key = entry
613 .get("api_key")
614 .and_then(|v| v.as_str())
615 .map(String::from);
616 let api_key_env = entry
617 .get("api_key_env")
618 .and_then(|v| v.as_str())
619 .map(String::from);
620 let base_url = entry
621 .get("base_url")
622 .and_then(|v| v.as_str())
623 .map(String::from);
624 let max_tokens = entry
625 .get("max_tokens")
626 .and_then(|v| v.as_integer())
627 .map(|n| n as u32);
628 let enabled = entry.get("enabled").and_then(|v| v.as_bool());
629 cfg.providers.insert(
630 key.clone(),
631 ProviderEntry {
632 name,
633 kind,
634 api_key,
635 api_key_env,
636 base_url,
637 max_tokens,
638 enabled,
639 },
640 );
641 }
642 }
643
644 if let Some(models) = raw.get("models").and_then(|m| m.as_table()) {
647 for (name, entry) in models {
648 let provider = entry
649 .get("provider")
650 .and_then(|v| v.as_str())
651 .map(String::from);
652 let context_budget = entry
653 .get("context_budget")
654 .and_then(|v| v.as_integer())
655 .map(|n| n as u64);
656 let thinking = entry.get("thinking").and_then(|v| v.as_bool());
657 let max_tokens = entry
658 .get("max_tokens")
659 .and_then(|v| v.as_integer())
660 .map(|n| n as u32);
661 let model = entry
662 .get("model")
663 .and_then(|v| v.as_str())
664 .map(String::from);
665 cfg.models.insert(
666 name.clone(),
667 ModelEntry {
668 model: model.unwrap_or_default(),
669 provider,
670 context_budget,
671 compact_threshold_ratio: None,
672 thinking,
673 max_tokens,
674 enabled: None,
675 discovered: false,
676 },
677 );
678 }
679 }
680
681 *guard = Some(cfg);
682}
683
684pub fn parse_config(text: &str) -> Option<ProviderConfig> {
689 #[derive(serde::Deserialize, Default)]
690 struct RawProvider {
691 #[serde(default)]
692 name: Option<String>,
693 #[serde(default)]
694 kind: Option<String>,
695 #[serde(default)]
696 api_key: Option<String>,
697 #[serde(default)]
698 api_key_env: Option<String>,
699 #[serde(default)]
700 base_url: Option<String>,
701 #[serde(default)]
702 max_tokens: Option<u32>,
703 #[serde(default)]
704 enabled: Option<bool>,
705 }
706
707 #[derive(serde::Deserialize, Default)]
708 struct RawModel {
709 #[serde(default)]
710 model: Option<String>,
711 #[serde(default)]
712 provider: Option<String>,
713 #[serde(default)]
714 context_budget: Option<u64>,
715 #[serde(default)]
716 compact_threshold_ratio: Option<f64>,
717 #[serde(default)]
718 thinking: Option<bool>,
719 #[serde(default)]
720 max_tokens: Option<u32>,
721 #[serde(default)]
722 enabled: Option<bool>,
723 #[serde(default)]
724 discovered: bool,
725 }
726
727 #[derive(serde::Deserialize, Default)]
728 struct RawAlias {
729 model: String,
730 }
731
732 #[derive(serde::Deserialize, Default)]
733 struct RawFile {
734 #[serde(default)]
735 providers: std::collections::HashMap<String, RawProvider>,
736 #[serde(default)]
737 models: std::collections::HashMap<String, RawModel>,
738 #[serde(default)]
739 alias: std::collections::HashMap<String, RawAlias>,
740 }
741
742 let raw: RawFile = toml::from_str(text).ok()?;
743 let mut cfg = ProviderConfig::default();
744
745 for (key, p) in raw.providers {
746 cfg.providers.insert(
747 key.clone(),
748 ProviderEntry {
749 name: p.name.unwrap_or(key),
750 kind: p.kind.unwrap_or_default(),
751 api_key: p.api_key,
752 api_key_env: p.api_key_env,
753 base_url: p.base_url,
754 max_tokens: p.max_tokens,
755 enabled: p.enabled,
756 },
757 );
758 }
759
760 for (name, m) in raw.models {
761 cfg.models.insert(
762 name,
763 ModelEntry {
764 model: m.model.unwrap_or_default(),
765 provider: m.provider,
766 context_budget: m.context_budget,
767 compact_threshold_ratio: m.compact_threshold_ratio,
768 thinking: m.thinking,
769 max_tokens: m.max_tokens,
770 enabled: m.enabled,
771 discovered: m.discovered,
772 },
773 );
774 }
775
776 for (name, a) in raw.alias {
777 cfg.aliases.insert(name, AliasEntry { model: a.model });
778 }
779
780 if cfg.providers.is_empty() && cfg.models.is_empty() && cfg.aliases.is_empty() {
781 return None;
782 }
783 Some(cfg)
784}
785
786pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
787 let text = read_config_toml().unwrap_or_default();
788 let new_text = upsert_alias_comment_preserving(&text, alias, model);
789 write_config_toml(&new_text)?;
790 reload_from_text(&new_text);
791 Ok(())
792}
793
794fn upsert_alias_comment_preserving(text: &str, alias: &str, model: &str) -> String {
795 let section_start = format!("[alias.{alias}]");
796 let model_line = format!("model = {model:?}");
797 let mut lines: Vec<String> = text.lines().map(String::from).collect();
798
799 if let Some(section) = lines
800 .iter()
801 .position(|l| l.trim().starts_with(&format!("[alias.{alias}")) && l.trim().ends_with(']'))
802 {
803 let mut inserted = false;
804 for line in lines.iter_mut().skip(section + 1) {
805 let t = line.trim();
806 if (t.starts_with("[alias.") || t == "[alias]") && t.ends_with(']') {
807 break;
808 }
809 if t.starts_with("model") && t.contains('=') {
810 *line = model_line.clone();
811 inserted = true;
812 break;
813 }
814 }
815 if !inserted {
816 lines.insert(section + 1, model_line);
817 }
818 } else {
819 while lines.last().is_some_and(|l| l.trim().is_empty()) {
820 lines.pop();
821 }
822 if !lines.is_empty() {
823 lines.push(String::new());
824 }
825 lines.push(section_start);
826 lines.push(model_line);
827 }
828
829 lines.join("\n")
830}
831
832pub fn upsert_provider_config(
833 name: &str,
834 kind: &str,
835 api_key: Option<&str>,
836 api_key_env: Option<&str>,
837 base_url: Option<&str>,
838 max_tokens: Option<u32>,
839 enabled: bool,
840) -> anyhow::Result<()> {
841 let text = read_config_toml().unwrap_or_default();
842 let mut doc: toml_edit::DocumentMut = if text.trim().is_empty() {
843 toml_edit::DocumentMut::new()
844 } else {
845 text.parse()
846 .map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
847 };
848
849 if doc.get("providers").is_none() {
850 doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
851 }
852 let providers = doc
853 .get_mut("providers")
854 .and_then(|p| p.as_table_mut())
855 .ok_or_else(|| anyhow::anyhow!("providers is not a table"))?;
856
857 let mut entry = toml_edit::Table::new();
858 entry.insert("kind", toml_edit::value(kind));
859 if let Some(key) = api_key {
860 if !key.is_empty() {
861 entry.insert("api_key", toml_edit::value(key));
862 }
863 }
864 if let Some(env) = api_key_env {
865 if !env.is_empty() {
866 entry.insert("api_key_env", toml_edit::value(env));
867 }
868 }
869 if let Some(url) = base_url {
870 if !url.is_empty() {
871 entry.insert("base_url", toml_edit::value(url));
872 }
873 }
874 if let Some(mt) = max_tokens {
875 entry.insert("max_tokens", toml_edit::value(mt as i64));
876 }
877 entry.insert("enabled", toml_edit::value(enabled));
878 providers.insert(name, toml_edit::Item::Table(entry));
879
880 let new_text = doc.to_string();
881 write_config_toml(&new_text)?;
882 reload_from_text(&new_text);
883 Ok(())
884}
885
886#[derive(Debug, Clone)]
887pub struct ModelConfigUpdate<'a> {
888 pub old_name: Option<&'a str>,
889 pub name: &'a str,
890 pub model: &'a str,
891 pub provider: Option<&'a str>,
892 pub context_budget: u64,
893 pub thinking: bool,
894 pub max_tokens: Option<u32>,
895 pub enabled: bool,
896}
897
898fn apply_model_config_update(
899 doc: &mut toml_edit::DocumentMut,
900 update: ModelConfigUpdate<'_>,
901) -> anyhow::Result<()> {
902 if doc.get("models").is_none() {
903 doc.insert("models", toml_edit::Item::Table(toml_edit::Table::new()));
904 }
905 {
906 let models = doc
907 .get_mut("models")
908 .and_then(|item| item.as_table_mut())
909 .ok_or_else(|| anyhow::anyhow!("models is not a table"))?;
910 if let Some(old_name) = update.old_name.filter(|old| *old != update.name) {
911 models.remove(old_name);
912 }
913 let entry = models
914 .entry(update.name)
915 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
916 .as_table_mut()
917 .ok_or_else(|| anyhow::anyhow!("model entry is not a table"))?;
918 entry.insert("model", toml_edit::value(update.model));
919 if let Some(provider) = update.provider {
920 entry.insert("provider", toml_edit::value(provider));
921 } else {
922 entry.remove("provider");
923 }
924 entry.insert(
925 "context_budget",
926 toml_edit::value(update.context_budget as i64),
927 );
928 entry.insert("thinking", toml_edit::value(update.thinking));
929 if let Some(max_tokens) = update.max_tokens {
930 entry.insert("max_tokens", toml_edit::value(max_tokens as i64));
931 } else {
932 entry.remove("max_tokens");
933 }
934 entry.insert("enabled", toml_edit::value(update.enabled));
935 }
936
937 if let Some(old_name) = update.old_name.filter(|old| *old != update.name)
938 && let Some(aliases) = doc.get_mut("alias").and_then(|item| item.as_table_mut())
939 {
940 for (_, alias) in aliases.iter_mut() {
941 if let Some(table) = alias.as_table_mut() {
942 if table.get("model").and_then(|item| item.as_str()) == Some(old_name) {
943 table.insert("model", toml_edit::value(update.name));
944 }
945 } else if let Some(inline) = alias.as_inline_table_mut()
946 && inline.get("model").and_then(|value| value.as_str()) == Some(old_name)
947 {
948 inline.insert("model", toml_edit::Value::from(update.name));
949 }
950 }
951 }
952 Ok(())
953}
954
955pub fn upsert_model_config(update: ModelConfigUpdate<'_>) -> anyhow::Result<()> {
956 let text = read_config_toml().unwrap_or_default();
957 let mut doc: toml_edit::DocumentMut = if text.trim().is_empty() {
958 toml_edit::DocumentMut::new()
959 } else {
960 text.parse()
961 .map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
962 };
963 apply_model_config_update(&mut doc, update)?;
964
965 let new_text = doc.to_string();
966 write_config_toml(&new_text)?;
967 reload_from_text(&new_text);
968 Ok(())
969}
970
971pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
972 let text = read_config_toml().unwrap_or_default();
973 let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
974 if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
975 table.remove(alias);
976 }
977 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
978 write_config_toml(&new_text)?;
979 reload_from_text(&new_text);
980 Ok(())
981}
982
983pub fn update_alias_in_config(
984 old_alias: &str,
985 new_alias: &str,
986 new_model: &str,
987) -> anyhow::Result<()> {
988 let text = read_config_toml().unwrap_or_default();
989 let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
990 if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
991 table.remove(old_alias);
992 let mut entry = toml::value::Table::new();
993 entry.insert(
994 "model".to_string(),
995 toml::Value::String(new_model.to_string()),
996 );
997 table.insert(new_alias.to_string(), toml::Value::Table(entry));
998 }
999 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
1000 write_config_toml(&new_text)?;
1001 reload_from_text(&new_text);
1002 Ok(())
1003}
1004
1005pub struct ProviderPreset {
1008 pub name: &'static str,
1009 pub description: &'static str,
1010 pub base_url: &'static str,
1011 pub provider_type: &'static str,
1012 pub models: &'static [ProviderPresetModel],
1013 pub key_url: Option<&'static str>,
1014 pub needs_api_key: bool,
1015}
1016
1017pub struct ProviderPresetModel {
1018 pub id: &'static str,
1019 pub description: &'static str,
1020 pub context_budget: u64,
1021 pub thinking: bool,
1022}
1023
1024pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
1025 ProviderPreset {
1026 name: "DeepSeek",
1027 description: "Recommended — cheap, smart, supports thinking",
1028 base_url: "https://api.deepseek.com",
1029 provider_type: "openai-compat",
1030 models: &[
1031 ProviderPresetModel {
1032 id: "deepseek-v4-flash",
1033 description: "Fast & capable",
1034 context_budget: 1000000,
1035 thinking: false,
1036 },
1037 ProviderPresetModel {
1038 id: "deepseek-v4-pro",
1039 description: "Thinking mode",
1040 context_budget: 1000000,
1041 thinking: true,
1042 },
1043 ],
1044 key_url: Some("https://platform.deepseek.com"),
1045 needs_api_key: true,
1046 },
1047 ProviderPreset {
1048 name: "OpenAI",
1049 description: "GPT-4o / GPT-4o-mini",
1050 base_url: "https://api.openai.com/v1",
1051 provider_type: "openai",
1052 models: &[
1053 ProviderPresetModel {
1054 id: "gpt-4o",
1055 description: "Most capable",
1056 context_budget: 128000,
1057 thinking: false,
1058 },
1059 ProviderPresetModel {
1060 id: "gpt-4o-mini",
1061 description: "Fast & cheap",
1062 context_budget: 128000,
1063 thinking: false,
1064 },
1065 ],
1066 key_url: Some("https://platform.openai.com/api-keys"),
1067 needs_api_key: true,
1068 },
1069 ProviderPreset {
1070 name: "Anthropic",
1071 description: "Claude models",
1072 base_url: "https://api.anthropic.com",
1073 provider_type: "anthropic",
1074 models: &[ProviderPresetModel {
1075 id: "claude-sonnet-4-20250514",
1076 description: "Claude Sonnet 4",
1077 context_budget: 200000,
1078 thinking: true,
1079 }],
1080 key_url: Some("https://console.anthropic.com/settings/keys"),
1081 needs_api_key: true,
1082 },
1083 ProviderPreset {
1084 name: "ZhipuAI",
1085 description: "GLM models",
1086 base_url: "https://open.bigmodel.cn/api/paas/v4",
1087 provider_type: "openai-compat",
1088 models: &[ProviderPresetModel {
1089 id: "glm-5.2",
1090 description: "GLM 5.2",
1091 context_budget: 1000000,
1092 thinking: true,
1093 }],
1094 key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
1095 needs_api_key: true,
1096 },
1097 ProviderPreset {
1098 name: "Ollama",
1099 description: "Local models, no API key needed",
1100 base_url: "http://localhost:11434/v1",
1101 provider_type: "openai-compat",
1102 models: &[],
1103 key_url: None,
1104 needs_api_key: false,
1105 },
1106 ProviderPreset {
1107 name: "Codex",
1108 description: "ChatGPT Plus/Pro OAuth",
1109 base_url: "https://chatgpt.com/backend-api/codex",
1110 provider_type: "codex",
1111 models: &[],
1112 key_url: None,
1113 needs_api_key: false,
1114 },
1115];
1116
1117pub fn is_first_run() -> bool {
1118 let providers = all_provider_entries();
1119 let models = all_model_entries();
1120 let config_configured = providers.iter().any(|(_, e)| {
1121 e.api_key.as_deref().is_some_and(|k| !k.is_empty())
1122 || e.api_key_env
1123 .as_deref()
1124 .is_some_and(|env| std::env::var(env).is_ok_and(|v| !v.trim().is_empty()))
1125 });
1126 let env_configured =
1127 std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
1128 let auth_configured = AuthStore::load()
1129 .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
1130 let smart_resolves = {
1131 let resolved = resolve_alias("smart");
1132 resolved != "smart" && models.iter().any(|(n, _)| *n == resolved)
1133 };
1134 !(config_configured || env_configured || auth_configured) || !smart_resolves
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use std::sync::Mutex as StdMutex;
1141
1142 static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
1145
1146 #[test]
1147 fn unregistered_model_returns_zero_budget() {
1148 let _lock = TEST_CFG_LOCK.lock().unwrap();
1149 *MODEL_CONFIG.write().unwrap() = None;
1150 assert_eq!(model_info("mystery-model").context_budget, 0);
1151 assert_eq!(model_info("").context_budget, 0);
1152 }
1153
1154 #[test]
1155 fn threshold_is_eighty_percent() {
1156 let _lock = TEST_CFG_LOCK.lock().unwrap();
1157 let mut cfg = ModelConfig::default();
1158 cfg.models.insert(
1159 "claude-opus-4.7".into(),
1160 ModelEntry {
1161 model: "claude-opus-4.7".into(),
1162 context_budget: Some(200_000),
1163 compact_threshold_ratio: Some(0.8),
1164 thinking: None,
1165 ..Default::default()
1166 },
1167 );
1168 set_model_config(cfg);
1169 let info = model_info("claude-opus-4.7");
1170 assert_eq!(info.compact_threshold_tokens(), 160_000);
1171 }
1172
1173 #[test]
1174 fn compaction_trigger_is_near_budget_top() {
1175 let _lock = TEST_CFG_LOCK.lock().unwrap();
1176 let mut cfg = ModelConfig::default();
1177 cfg.models.insert(
1178 "claude-opus-4.7".into(),
1179 ModelEntry {
1180 model: "claude-opus-4.7".into(),
1181 context_budget: Some(200_000),
1182 compact_threshold_ratio: Some(0.8),
1183 thinking: None,
1184 ..Default::default()
1185 },
1186 );
1187 set_model_config(cfg);
1188 let info = model_info("claude-opus-4.7");
1189 let trigger = info.compaction_trigger_threshold();
1190 assert!(
1191 trigger > 150_000 && trigger <= 190_000,
1192 "trigger should be near the top of the budget, got {trigger}"
1193 );
1194 }
1195
1196 #[test]
1197 fn compaction_target_is_lower_than_trigger() {
1198 let _lock = TEST_CFG_LOCK.lock().unwrap();
1199 let mut cfg = ModelConfig::default();
1200 cfg.models.insert(
1201 "claude-opus-4.7".into(),
1202 ModelEntry {
1203 model: "claude-opus-4.7".into(),
1204 context_budget: Some(200_000),
1205 compact_threshold_ratio: Some(0.8),
1206 thinking: None,
1207 ..Default::default()
1208 },
1209 );
1210 set_model_config(cfg);
1211 let info = model_info("claude-opus-4.7");
1212 let trigger = info.compaction_trigger_threshold();
1213 let target = info.compaction_target_after();
1214 assert!(
1215 target < trigger,
1216 "target {target} should be less than trigger {trigger}"
1217 );
1218 assert_eq!(target, 50_000, "compaction target should be 25% of context");
1219 }
1220
1221 #[test]
1222 fn alias_resolves_to_real_model() {
1223 let _lock = TEST_CFG_LOCK.lock().unwrap();
1224 let mut cfg = ModelConfig::default();
1225 cfg.models.insert(
1226 "claude-opus-4.7".into(),
1227 ModelEntry {
1228 model: "claude-opus-4.7".into(),
1229 context_budget: Some(200_000),
1230 ..Default::default()
1231 },
1232 );
1233 cfg.aliases.insert(
1234 "smart".into(),
1235 AliasEntry {
1236 model: "claude-opus-4.7".into(),
1237 },
1238 );
1239 set_model_config(cfg);
1240 let info = model_info("smart");
1241 assert_eq!(info.context_budget, 200_000);
1242 assert_eq!(info.name, "claude-opus-4.7");
1243 }
1244
1245 #[test]
1246 fn custom_model_overrides_budget() {
1247 let _lock = TEST_CFG_LOCK.lock().unwrap();
1248 let mut cfg = ModelConfig::default();
1249 cfg.models.insert(
1250 "my-local-model".into(),
1251 ModelEntry {
1252 model: "my-local-model".into(),
1253 context_budget: Some(8192),
1254 compact_threshold_ratio: Some(0.9),
1255 thinking: None,
1256 ..Default::default()
1257 },
1258 );
1259 set_model_config(cfg);
1260 let info = model_info("my-local-model");
1261 assert_eq!(info.context_budget, 8192);
1262 assert_eq!(info.compact_threshold_ratio, 0.9);
1263 }
1264
1265 #[test]
1266 fn compact_threshold_reserves_configured_output_tokens() {
1267 let _lock = TEST_CFG_LOCK.lock().unwrap();
1268 let mut cfg = ModelConfig::default();
1269 cfg.models.insert(
1270 "large-output".into(),
1271 ModelEntry {
1272 model: "large-output".into(),
1273 context_budget: Some(1_000_000),
1274 compact_threshold_ratio: Some(0.8),
1275 thinking: None,
1276 max_tokens: Some(400_000),
1277 ..Default::default()
1278 },
1279 );
1280 set_model_config(cfg);
1281 let info = model_info("large-output");
1282 assert_eq!(info.compact_threshold_tokens(), 480_000);
1283 let trigger = info.compaction_trigger_threshold();
1284 assert!(
1285 trigger > 700_000,
1286 "trigger with capped output reserve should be > 700K, got {trigger}"
1287 );
1288 }
1289
1290 #[test]
1291 fn alias_chains_through_custom_model() {
1292 let _lock = TEST_CFG_LOCK.lock().unwrap();
1293 let mut cfg = ModelConfig::default();
1294 cfg.aliases.insert(
1295 "default".into(),
1296 AliasEntry {
1297 model: "my-model".into(),
1298 },
1299 );
1300 cfg.models.insert(
1301 "my-model".into(),
1302 ModelEntry {
1303 model: "my-model".into(),
1304 context_budget: Some(65_536),
1305 compact_threshold_ratio: None,
1306 thinking: None,
1307 ..Default::default()
1308 },
1309 );
1310 set_model_config(cfg);
1311 let info = model_info("default");
1312 assert_eq!(info.name, "my-model");
1313 assert_eq!(info.context_budget, 65_536);
1314 }
1315
1316 #[test]
1317 fn discovered_models_survive_set_model_config() {
1318 let _lock = TEST_CFG_LOCK.lock().unwrap();
1319 register_discovered(
1321 "pid-abc",
1322 "Codex",
1323 &[crate::provider::DiscoveredModel {
1324 slug: "codex/gpt-5".to_string(),
1325 context_budget: Some(128_000),
1326 thinking: true,
1327 }],
1328 );
1329 assert!(model_entry("Codex:codex/gpt-5").is_some());
1330
1331 let mut cfg = ModelConfig::default();
1333 cfg.aliases.insert(
1334 "cheap".into(),
1335 AliasEntry {
1336 model: "claude-opus-4.7".into(),
1337 },
1338 );
1339 set_model_config(cfg);
1340
1341 assert!(
1343 model_entry("Codex:codex/gpt-5").is_some(),
1344 "discovered models should survive set_model_config"
1345 );
1346 assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
1348 }
1349
1350 #[test]
1351 fn discovered_models_survive_reload_from_text_alias_crud() {
1352 let _lock = TEST_CFG_LOCK.lock().unwrap();
1353 register_discovered(
1354 "pid-abc",
1355 "Codex",
1356 &[crate::provider::DiscoveredModel {
1357 slug: "codex/gpt-5".to_string(),
1358 context_budget: Some(128_000),
1359 thinking: true,
1360 }],
1361 );
1362
1363 let toml = r#"
1365[alias]
1366smart = { model = "Codex:codex/gpt-5" }
1367"#;
1368 reload_from_text(toml);
1369
1370 assert!(
1371 model_entry("Codex:codex/gpt-5").is_some(),
1372 "discovered models should survive alias CRUD"
1373 );
1374 assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
1375 }
1376
1377 #[test]
1378 fn add_alias_preserves_comments() {
1379 let toml = "# top comment\n[alias]\n# smart line comment\nsmart = { model = \"claude\" }\n";
1380 let out = upsert_alias_comment_preserving(toml, "smart", "claude-opus-4.7");
1381 assert!(out.contains("# top comment"));
1382 assert!(out.contains("# smart line comment"));
1383 assert!(out.contains("model = \"claude-opus-4.7\""));
1384 }
1385
1386 #[test]
1387 fn model_config_update_replaces_name_and_preserves_other_sections() {
1388 let mut doc = r#"
1389# keep this comment
1390[providers.openai]
1391kind = "openai"
1392
1393[alias]
1394smart = { model = "old-name" }
1395cheap = { model = "other-name" }
1396
1397[alias.deep]
1398model = "old-name"
1399
1400[models.old-name]
1401model = "old-id"
1402provider = "openai"
1403enabled = true
1404"#
1405 .parse::<toml_edit::DocumentMut>()
1406 .unwrap();
1407
1408 apply_model_config_update(
1409 &mut doc,
1410 ModelConfigUpdate {
1411 old_name: Some("old-name"),
1412 name: "new-name",
1413 model: "new-id",
1414 provider: Some("openai"),
1415 context_budget: 128_000,
1416 thinking: true,
1417 max_tokens: Some(4096),
1418 enabled: false,
1419 },
1420 )
1421 .unwrap();
1422
1423 let out = doc.to_string();
1424 assert!(out.contains("# keep this comment"));
1425 assert!(out.contains("[providers.openai]"));
1426 assert!(out.contains("[alias]"));
1427 assert!(out.contains("smart = { model = \"new-name\" }"));
1428 assert!(out.contains("cheap = { model = \"other-name\" }"));
1429 assert!(out.contains("[alias.deep]"));
1430 assert!(out.contains("model = \"new-name\""));
1431 assert!(out.contains("[models.new-name]"));
1432 assert!(!out.contains("[models.old-name]"));
1433 assert!(out.contains("model = \"new-id\""));
1434 assert!(out.contains("context_budget = 128000"));
1435 assert!(out.contains("thinking = true"));
1436 assert!(out.contains("max_tokens = 4096"));
1437 assert!(out.contains("enabled = false"));
1438 }
1439}