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