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
380#[derive(Debug, Clone, PartialEq, Eq)]
383pub enum ModelMigrationOutcome {
384 NotNeeded,
385 Migrated { backup: std::path::PathBuf },
386}
387
388pub fn migrate_config_if_needed(
389 text: &str,
390) -> Result<Option<String>, crate::config_hub::ConfigError> {
391 let mut doc = text.parse::<toml_edit::DocumentMut>()?;
392 let legacy = has_legacy_model_fields(&doc);
393 match doc.get("config_version") {
394 None if !legacy => return Ok(None),
395 None => {}
396 Some(version) => match version.as_integer() {
397 Some(1) if legacy => {}
398 Some(1) | Some(2) => return Ok(None),
399 Some(value) => {
400 return Err(crate::config_hub::ConfigError::Invalid(format!(
401 "unsupported config_version {value}"
402 )));
403 }
404 None => {
405 return Err(crate::config_hub::ConfigError::Invalid(
406 "config_version must be an integer".into(),
407 ));
408 }
409 },
410 }
411
412 let provider_names: std::collections::HashSet<String> = doc
413 .get("providers")
414 .and_then(|item| item.as_table())
415 .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
416 .unwrap_or_default();
417 let models = doc
418 .get("models")
419 .and_then(|item| item.as_table())
420 .ok_or_else(|| crate::config_hub::ConfigError::Invalid("models is not a table".into()))?;
421 let mut groups: std::collections::BTreeMap<(String, String, String), Vec<String>> =
422 std::collections::BTreeMap::new();
423
424 for (name, entry) in models.iter() {
425 let api_key = entry
426 .get("api_key")
427 .and_then(|v| v.as_str())
428 .unwrap_or("")
429 .to_string();
430 let base_url = entry
431 .get("base_url")
432 .and_then(|v| v.as_str())
433 .unwrap_or("")
434 .to_string();
435 let ptype = entry
436 .get("provider")
437 .and_then(|v| v.as_str())
438 .unwrap_or("openai-compat")
439 .to_string();
440
441 if !api_key.is_empty()
442 || !base_url.is_empty()
443 || (matches!(
444 ptype.as_str(),
445 "openai" | "openai-compat" | "anthropic" | "codex"
446 ) && !provider_names.contains(&ptype))
447 {
448 groups
449 .entry((ptype, api_key, base_url))
450 .or_default()
451 .push(name.to_string());
452 }
453 }
454
455 if groups.is_empty() {
456 return Ok(None);
457 }
458
459 let mut used_names: std::collections::HashSet<String> = doc
460 .get("providers")
461 .and_then(|item| item.as_table())
462 .map(|providers| providers.iter().map(|(name, _)| name.to_string()).collect())
463 .unwrap_or_default();
464
465 for ((ptype, api_key, base_url), model_names) in &groups {
466 let provider_name = pick_provider_name(ptype, base_url, &mut used_names);
467 used_names.insert(provider_name.clone());
468
469 let mut table = toml_edit::Table::new();
471 table.insert("kind", toml_edit::value(ptype.clone()));
472 if !api_key.is_empty() {
473 table.insert("api_key", toml_edit::value(api_key.clone()));
474 }
475 if !base_url.is_empty() {
476 table.insert("base_url", toml_edit::value(base_url.clone()));
477 }
478 table.insert("enabled", toml_edit::value(true));
479
480 if doc.get("providers").is_none() {
482 doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
483 }
484 if let Some(providers) = doc.get_mut("providers").and_then(|p| p.as_table_mut()) {
485 providers.insert(&provider_name, toml_edit::Item::Table(table));
486 }
487
488 for model_name in model_names {
490 if let Some(model) = doc
491 .get_mut("models")
492 .and_then(|m| m.as_table_mut())
493 .and_then(|t| t.get_mut(model_name.as_str()))
494 .and_then(|e| e.as_table_mut())
495 {
496 model.insert("provider", toml_edit::value(&provider_name));
497 model.remove("api_key");
498 model.remove("base_url");
499 }
500 }
501 }
502
503 doc.insert("config_version", toml_edit::value(2i64));
504 let migrated = doc.to_string();
505 parse_config(&migrated).ok_or_else(|| {
506 crate::config_hub::ConfigError::Invalid("validate migrated config.toml".into())
507 })?;
508 Ok(Some(migrated))
509}
510
511fn has_legacy_model_fields(doc: &toml_edit::DocumentMut) -> bool {
512 let provider_names: std::collections::HashSet<&str> = doc
513 .get("providers")
514 .and_then(|item| item.as_table())
515 .map(|providers| providers.iter().map(|(name, _)| name).collect())
516 .unwrap_or_default();
517 doc.get("models")
518 .and_then(|item| item.as_table())
519 .is_some_and(|models| {
520 models.iter().any(|(_, entry)| {
521 entry.get("api_key").is_some()
522 || entry.get("base_url").is_some()
523 || entry
524 .get("provider")
525 .and_then(|value| value.as_str())
526 .is_some_and(|provider| {
527 matches!(provider, "openai" | "openai-compat" | "anthropic" | "codex")
528 && !provider_names.contains(provider)
529 })
530 })
531 })
532}
533
534fn pick_provider_name(
535 ptype: &str,
536 base_url: &str,
537 used: &mut std::collections::HashSet<String>,
538) -> String {
539 for preset in PROVIDER_PRESETS {
541 if !base_url.is_empty() && preset.base_url == base_url {
542 let name = preset.name.to_lowercase();
543 if !used.contains(&name) {
544 return name;
545 }
546 }
547 }
548 let base = ptype.to_string();
550 if !used.contains(&base) {
551 return base;
552 }
553 for i in 2.. {
554 let candidate = format!("{base}-{i}");
555 if !used.contains(&candidate) {
556 return candidate;
557 }
558 }
559 unreachable!()
560}
561
562pub fn read_config_toml_pub() -> Option<String> {
565 crate::config_hub::ConfigHub::global()
566 .ok()?
567 .read_config_toml()
568 .ok()
569}
570
571pub(crate) fn reload_from_text(text: &str) -> anyhow::Result<()> {
572 if !text.trim().is_empty() {
573 toml::from_str::<toml::Value>(text)
574 .map_err(|error| anyhow::anyhow!("parse config.toml: {error}"))?;
575 }
576 set_provider_config(parse_config(text).unwrap_or_default());
577 Ok(())
578}
579
580pub fn parse_config(text: &str) -> Option<ProviderConfig> {
585 #[derive(serde::Deserialize, Default)]
586 struct RawProvider {
587 #[serde(default)]
588 name: Option<String>,
589 #[serde(default)]
590 kind: Option<String>,
591 #[serde(default)]
592 api_key: Option<String>,
593 #[serde(default)]
594 api_key_env: Option<String>,
595 #[serde(default)]
596 base_url: Option<String>,
597 #[serde(default)]
598 max_tokens: Option<u32>,
599 #[serde(default)]
600 enabled: Option<bool>,
601 }
602
603 #[derive(serde::Deserialize, Default)]
604 struct RawModel {
605 #[serde(default)]
606 model: Option<String>,
607 #[serde(default)]
608 provider: Option<String>,
609 #[serde(default)]
610 context_budget: Option<u64>,
611 #[serde(default)]
612 compact_threshold_ratio: Option<f64>,
613 #[serde(default)]
614 thinking: Option<bool>,
615 #[serde(default)]
616 max_tokens: Option<u32>,
617 #[serde(default)]
618 enabled: Option<bool>,
619 #[serde(default)]
620 discovered: bool,
621 }
622
623 #[derive(serde::Deserialize, Default)]
624 struct RawAlias {
625 model: String,
626 }
627
628 #[derive(serde::Deserialize, Default)]
629 struct RawFile {
630 #[serde(default)]
631 providers: std::collections::HashMap<String, RawProvider>,
632 #[serde(default)]
633 models: std::collections::HashMap<String, RawModel>,
634 #[serde(default)]
635 alias: std::collections::HashMap<String, RawAlias>,
636 }
637
638 let raw: RawFile = toml::from_str(text).ok()?;
639 let mut cfg = ProviderConfig::default();
640
641 for (key, p) in raw.providers {
642 cfg.providers.insert(
643 key.clone(),
644 ProviderEntry {
645 name: p.name.unwrap_or(key),
646 kind: p.kind.unwrap_or_default(),
647 api_key: p.api_key,
648 api_key_env: p.api_key_env,
649 base_url: p.base_url,
650 max_tokens: p.max_tokens,
651 enabled: p.enabled,
652 },
653 );
654 }
655
656 for (name, m) in raw.models {
657 cfg.models.insert(
658 name,
659 ModelEntry {
660 model: m.model.unwrap_or_default(),
661 provider: m.provider,
662 context_budget: m.context_budget,
663 compact_threshold_ratio: m.compact_threshold_ratio,
664 thinking: m.thinking,
665 max_tokens: m.max_tokens,
666 enabled: m.enabled,
667 discovered: m.discovered,
668 },
669 );
670 }
671
672 for (name, a) in raw.alias {
673 cfg.aliases.insert(name, AliasEntry { model: a.model });
674 }
675
676 if cfg.providers.is_empty() && cfg.models.is_empty() && cfg.aliases.is_empty() {
677 return None;
678 }
679 Some(cfg)
680}
681
682pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
683 crate::config_hub::ConfigHub::global()?
684 .add_alias(alias, model)
685 .map_err(Into::into)
686}
687
688pub fn upsert_provider_config(
689 name: &str,
690 kind: &str,
691 api_key: Option<&str>,
692 api_key_env: Option<&str>,
693 base_url: Option<&str>,
694 max_tokens: Option<u32>,
695 enabled: bool,
696) -> anyhow::Result<()> {
697 crate::config_hub::ConfigHub::global()?
698 .upsert_provider(crate::config_hub::ProviderConfigUpdate {
699 name,
700 kind,
701 api_key,
702 api_key_env,
703 base_url,
704 max_tokens,
705 enabled,
706 })
707 .map_err(Into::into)
708}
709
710#[derive(Debug, Clone)]
711pub struct ModelConfigUpdate<'a> {
712 pub old_name: Option<&'a str>,
713 pub name: &'a str,
714 pub model: &'a str,
715 pub provider: Option<&'a str>,
716 pub context_budget: u64,
717 pub thinking: bool,
718 pub max_tokens: Option<u32>,
719 pub enabled: bool,
720}
721
722pub(crate) fn apply_model_config_update(
723 doc: &mut toml_edit::DocumentMut,
724 update: ModelConfigUpdate<'_>,
725) -> anyhow::Result<()> {
726 if doc.get("models").is_none() {
727 doc.insert("models", toml_edit::Item::Table(toml_edit::Table::new()));
728 }
729 {
730 let models = doc
731 .get_mut("models")
732 .and_then(|item| item.as_table_mut())
733 .ok_or_else(|| anyhow::anyhow!("models is not a table"))?;
734 if let Some(old_name) = update.old_name.filter(|old| *old != update.name) {
735 models.remove(old_name);
736 }
737 let entry = models
738 .entry(update.name)
739 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
740 .as_table_mut()
741 .ok_or_else(|| anyhow::anyhow!("model entry is not a table"))?;
742 entry.insert("model", toml_edit::value(update.model));
743 if let Some(provider) = update.provider {
744 entry.insert("provider", toml_edit::value(provider));
745 } else {
746 entry.remove("provider");
747 }
748 entry.insert(
749 "context_budget",
750 toml_edit::value(update.context_budget as i64),
751 );
752 entry.insert("thinking", toml_edit::value(update.thinking));
753 if let Some(max_tokens) = update.max_tokens {
754 entry.insert("max_tokens", toml_edit::value(max_tokens as i64));
755 } else {
756 entry.remove("max_tokens");
757 }
758 entry.insert("enabled", toml_edit::value(update.enabled));
759 }
760
761 if let Some(old_name) = update.old_name.filter(|old| *old != update.name)
762 && let Some(aliases) = doc.get_mut("alias").and_then(|item| item.as_table_mut())
763 {
764 for (_, alias) in aliases.iter_mut() {
765 if let Some(table) = alias.as_table_mut() {
766 if table.get("model").and_then(|item| item.as_str()) == Some(old_name) {
767 table.insert("model", toml_edit::value(update.name));
768 }
769 } else if let Some(inline) = alias.as_inline_table_mut()
770 && inline.get("model").and_then(|value| value.as_str()) == Some(old_name)
771 {
772 inline.insert("model", toml_edit::Value::from(update.name));
773 }
774 }
775 }
776 Ok(())
777}
778
779pub fn upsert_model_config(update: ModelConfigUpdate<'_>) -> anyhow::Result<()> {
780 crate::config_hub::ConfigHub::global()?
781 .upsert_model(update)
782 .map_err(Into::into)
783}
784
785pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
786 crate::config_hub::ConfigHub::global()?
787 .remove_alias(alias)
788 .map_err(Into::into)
789}
790
791pub fn update_alias_in_config(
792 old_alias: &str,
793 new_alias: &str,
794 new_model: &str,
795) -> anyhow::Result<()> {
796 crate::config_hub::ConfigHub::global()?
797 .update_alias(Some(old_alias), new_alias, new_model)
798 .map_err(Into::into)
799}
800
801pub struct ProviderPreset {
804 pub name: &'static str,
805 pub description: &'static str,
806 pub base_url: &'static str,
807 pub provider_type: &'static str,
808 pub models: &'static [ProviderPresetModel],
809 pub key_url: Option<&'static str>,
810 pub needs_api_key: bool,
811}
812
813pub struct ProviderPresetModel {
814 pub id: &'static str,
815 pub description: &'static str,
816 pub context_budget: u64,
817 pub thinking: bool,
818}
819
820pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
821 ProviderPreset {
822 name: "DeepSeek",
823 description: "Recommended — cheap, smart, supports thinking",
824 base_url: "https://api.deepseek.com",
825 provider_type: "openai-compat",
826 models: &[
827 ProviderPresetModel {
828 id: "deepseek-v4-flash",
829 description: "Fast & capable",
830 context_budget: 1000000,
831 thinking: false,
832 },
833 ProviderPresetModel {
834 id: "deepseek-v4-pro",
835 description: "Thinking mode",
836 context_budget: 1000000,
837 thinking: true,
838 },
839 ],
840 key_url: Some("https://platform.deepseek.com"),
841 needs_api_key: true,
842 },
843 ProviderPreset {
844 name: "OpenAI",
845 description: "GPT-4o / GPT-4o-mini",
846 base_url: "https://api.openai.com/v1",
847 provider_type: "openai",
848 models: &[
849 ProviderPresetModel {
850 id: "gpt-4o",
851 description: "Most capable",
852 context_budget: 128000,
853 thinking: false,
854 },
855 ProviderPresetModel {
856 id: "gpt-4o-mini",
857 description: "Fast & cheap",
858 context_budget: 128000,
859 thinking: false,
860 },
861 ],
862 key_url: Some("https://platform.openai.com/api-keys"),
863 needs_api_key: true,
864 },
865 ProviderPreset {
866 name: "Anthropic",
867 description: "Claude models",
868 base_url: "https://api.anthropic.com",
869 provider_type: "anthropic",
870 models: &[ProviderPresetModel {
871 id: "claude-sonnet-4-20250514",
872 description: "Claude Sonnet 4",
873 context_budget: 200000,
874 thinking: true,
875 }],
876 key_url: Some("https://console.anthropic.com/settings/keys"),
877 needs_api_key: true,
878 },
879 ProviderPreset {
880 name: "ZhipuAI",
881 description: "GLM models",
882 base_url: "https://open.bigmodel.cn/api/paas/v4",
883 provider_type: "openai-compat",
884 models: &[ProviderPresetModel {
885 id: "glm-5.2",
886 description: "GLM 5.2",
887 context_budget: 1000000,
888 thinking: true,
889 }],
890 key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
891 needs_api_key: true,
892 },
893 ProviderPreset {
894 name: "Ollama",
895 description: "Local models, no API key needed",
896 base_url: "http://localhost:11434/v1",
897 provider_type: "openai-compat",
898 models: &[],
899 key_url: None,
900 needs_api_key: false,
901 },
902 ProviderPreset {
903 name: "Codex",
904 description: "ChatGPT Plus/Pro OAuth",
905 base_url: "https://chatgpt.com/backend-api/codex",
906 provider_type: "codex",
907 models: &[],
908 key_url: None,
909 needs_api_key: false,
910 },
911];
912
913pub fn is_first_run() -> bool {
914 let providers = all_provider_entries();
915 let models = all_model_entries();
916 let config_configured = providers.iter().any(|(_, e)| {
917 e.api_key.as_deref().is_some_and(|k| !k.is_empty())
918 || e.api_key_env
919 .as_deref()
920 .is_some_and(|env| std::env::var(env).is_ok_and(|v| !v.trim().is_empty()))
921 });
922 let env_configured =
923 std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
924 let auth_configured = AuthStore::load()
925 .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
926 let smart_resolves = {
927 let resolved = resolve_alias("smart");
928 resolved != "smart" && models.iter().any(|(n, _)| *n == resolved)
929 };
930 !(config_configured || env_configured || auth_configured) || !smart_resolves
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936 use std::sync::Mutex as StdMutex;
937
938 static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
941
942 #[test]
943 fn unregistered_model_returns_zero_budget() {
944 let _lock = TEST_CFG_LOCK.lock().unwrap();
945 *MODEL_CONFIG.write().unwrap() = None;
946 assert_eq!(model_info("mystery-model").context_budget, 0);
947 assert_eq!(model_info("").context_budget, 0);
948 }
949
950 #[test]
951 fn threshold_is_eighty_percent() {
952 let _lock = TEST_CFG_LOCK.lock().unwrap();
953 let mut cfg = ModelConfig::default();
954 cfg.models.insert(
955 "claude-opus-4.7".into(),
956 ModelEntry {
957 model: "claude-opus-4.7".into(),
958 context_budget: Some(200_000),
959 compact_threshold_ratio: Some(0.8),
960 thinking: None,
961 ..Default::default()
962 },
963 );
964 set_model_config(cfg);
965 let info = model_info("claude-opus-4.7");
966 assert_eq!(info.compact_threshold_tokens(), 160_000);
967 }
968
969 #[test]
970 fn compaction_trigger_is_near_budget_top() {
971 let _lock = TEST_CFG_LOCK.lock().unwrap();
972 let mut cfg = ModelConfig::default();
973 cfg.models.insert(
974 "claude-opus-4.7".into(),
975 ModelEntry {
976 model: "claude-opus-4.7".into(),
977 context_budget: Some(200_000),
978 compact_threshold_ratio: Some(0.8),
979 thinking: None,
980 ..Default::default()
981 },
982 );
983 set_model_config(cfg);
984 let info = model_info("claude-opus-4.7");
985 let trigger = info.compaction_trigger_threshold();
986 assert!(
987 trigger > 150_000 && trigger <= 190_000,
988 "trigger should be near the top of the budget, got {trigger}"
989 );
990 }
991
992 #[test]
993 fn compaction_target_is_lower_than_trigger() {
994 let _lock = TEST_CFG_LOCK.lock().unwrap();
995 let mut cfg = ModelConfig::default();
996 cfg.models.insert(
997 "claude-opus-4.7".into(),
998 ModelEntry {
999 model: "claude-opus-4.7".into(),
1000 context_budget: Some(200_000),
1001 compact_threshold_ratio: Some(0.8),
1002 thinking: None,
1003 ..Default::default()
1004 },
1005 );
1006 set_model_config(cfg);
1007 let info = model_info("claude-opus-4.7");
1008 let trigger = info.compaction_trigger_threshold();
1009 let target = info.compaction_target_after();
1010 assert!(
1011 target < trigger,
1012 "target {target} should be less than trigger {trigger}"
1013 );
1014 assert_eq!(target, 50_000, "compaction target should be 25% of context");
1015 }
1016
1017 #[test]
1018 fn alias_resolves_to_real_model() {
1019 let _lock = TEST_CFG_LOCK.lock().unwrap();
1020 let mut cfg = ModelConfig::default();
1021 cfg.models.insert(
1022 "claude-opus-4.7".into(),
1023 ModelEntry {
1024 model: "claude-opus-4.7".into(),
1025 context_budget: Some(200_000),
1026 ..Default::default()
1027 },
1028 );
1029 cfg.aliases.insert(
1030 "smart".into(),
1031 AliasEntry {
1032 model: "claude-opus-4.7".into(),
1033 },
1034 );
1035 set_model_config(cfg);
1036 let info = model_info("smart");
1037 assert_eq!(info.context_budget, 200_000);
1038 assert_eq!(info.name, "claude-opus-4.7");
1039 }
1040
1041 #[test]
1042 fn custom_model_overrides_budget() {
1043 let _lock = TEST_CFG_LOCK.lock().unwrap();
1044 let mut cfg = ModelConfig::default();
1045 cfg.models.insert(
1046 "my-local-model".into(),
1047 ModelEntry {
1048 model: "my-local-model".into(),
1049 context_budget: Some(8192),
1050 compact_threshold_ratio: Some(0.9),
1051 thinking: None,
1052 ..Default::default()
1053 },
1054 );
1055 set_model_config(cfg);
1056 let info = model_info("my-local-model");
1057 assert_eq!(info.context_budget, 8192);
1058 assert_eq!(info.compact_threshold_ratio, 0.9);
1059 }
1060
1061 #[test]
1062 fn compact_threshold_reserves_configured_output_tokens() {
1063 let _lock = TEST_CFG_LOCK.lock().unwrap();
1064 let mut cfg = ModelConfig::default();
1065 cfg.models.insert(
1066 "large-output".into(),
1067 ModelEntry {
1068 model: "large-output".into(),
1069 context_budget: Some(1_000_000),
1070 compact_threshold_ratio: Some(0.8),
1071 thinking: None,
1072 max_tokens: Some(400_000),
1073 ..Default::default()
1074 },
1075 );
1076 set_model_config(cfg);
1077 let info = model_info("large-output");
1078 assert_eq!(info.compact_threshold_tokens(), 480_000);
1079 let trigger = info.compaction_trigger_threshold();
1080 assert!(
1081 trigger > 700_000,
1082 "trigger with capped output reserve should be > 700K, got {trigger}"
1083 );
1084 }
1085
1086 #[test]
1087 fn alias_chains_through_custom_model() {
1088 let _lock = TEST_CFG_LOCK.lock().unwrap();
1089 let mut cfg = ModelConfig::default();
1090 cfg.aliases.insert(
1091 "default".into(),
1092 AliasEntry {
1093 model: "my-model".into(),
1094 },
1095 );
1096 cfg.models.insert(
1097 "my-model".into(),
1098 ModelEntry {
1099 model: "my-model".into(),
1100 context_budget: Some(65_536),
1101 compact_threshold_ratio: None,
1102 thinking: None,
1103 ..Default::default()
1104 },
1105 );
1106 set_model_config(cfg);
1107 let info = model_info("default");
1108 assert_eq!(info.name, "my-model");
1109 assert_eq!(info.context_budget, 65_536);
1110 }
1111
1112 #[test]
1113 fn discovered_models_survive_set_model_config() {
1114 let _lock = TEST_CFG_LOCK.lock().unwrap();
1115 register_discovered(
1117 "pid-abc",
1118 "Codex",
1119 &[crate::provider::DiscoveredModel {
1120 slug: "codex/gpt-5".to_string(),
1121 context_budget: Some(128_000),
1122 thinking: true,
1123 }],
1124 );
1125 assert!(model_entry("Codex:codex/gpt-5").is_some());
1126
1127 let mut cfg = ModelConfig::default();
1129 cfg.aliases.insert(
1130 "cheap".into(),
1131 AliasEntry {
1132 model: "claude-opus-4.7".into(),
1133 },
1134 );
1135 set_model_config(cfg);
1136
1137 assert!(
1139 model_entry("Codex:codex/gpt-5").is_some(),
1140 "discovered models should survive set_model_config"
1141 );
1142 assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
1144 }
1145
1146 #[test]
1147 fn reload_replaces_config_models_and_preserves_discovered_models() {
1148 let _lock = TEST_CFG_LOCK.lock().unwrap();
1149 let mut initial = ProviderConfig::default();
1150 initial.models.insert(
1151 "old-config".into(),
1152 ModelEntry {
1153 model: "provider/old".into(),
1154 discovered: false,
1155 ..Default::default()
1156 },
1157 );
1158 initial.models.insert(
1159 "dynamic".into(),
1160 ModelEntry {
1161 model: "provider/dynamic".into(),
1162 discovered: true,
1163 ..Default::default()
1164 },
1165 );
1166 set_provider_config(initial);
1167
1168 reload_from_text(
1169 r#"
1170[models.new-config]
1171model = "provider/new"
1172"#,
1173 )
1174 .unwrap();
1175
1176 assert!(model_entry("old-config").is_none());
1177 assert!(model_entry("new-config").is_some());
1178 assert!(model_entry("dynamic").is_some());
1179 }
1180
1181 #[test]
1182 fn discovered_models_survive_reload_from_text_alias_crud() {
1183 let _lock = TEST_CFG_LOCK.lock().unwrap();
1184 register_discovered(
1185 "pid-abc",
1186 "Codex",
1187 &[crate::provider::DiscoveredModel {
1188 slug: "codex/gpt-5".to_string(),
1189 context_budget: Some(128_000),
1190 thinking: true,
1191 }],
1192 );
1193
1194 let toml = r#"
1195[alias]
1196smart = { model = "Codex:codex/gpt-5" }
1197"#;
1198 reload_from_text(toml).unwrap();
1199
1200 assert!(
1201 model_entry("Codex:codex/gpt-5").is_some(),
1202 "discovered models should survive alias CRUD"
1203 );
1204 assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
1205 }
1206
1207 #[test]
1208 fn model_config_update_replaces_name_and_preserves_other_sections() {
1209 let mut doc = r#"
1210# keep this comment
1211[providers.openai]
1212kind = "openai"
1213
1214[alias]
1215smart = { model = "old-name" }
1216cheap = { model = "other-name" }
1217
1218[alias.deep]
1219model = "old-name"
1220
1221[models.old-name]
1222model = "old-id"
1223provider = "openai"
1224enabled = true
1225"#
1226 .parse::<toml_edit::DocumentMut>()
1227 .unwrap();
1228
1229 apply_model_config_update(
1230 &mut doc,
1231 ModelConfigUpdate {
1232 old_name: Some("old-name"),
1233 name: "new-name",
1234 model: "new-id",
1235 provider: Some("openai"),
1236 context_budget: 128_000,
1237 thinking: true,
1238 max_tokens: Some(4096),
1239 enabled: false,
1240 },
1241 )
1242 .unwrap();
1243
1244 let out = doc.to_string();
1245 assert!(out.contains("# keep this comment"));
1246 assert!(out.contains("[providers.openai]"));
1247 assert!(out.contains("[alias]"));
1248 assert!(out.contains("smart = { model = \"new-name\" }"));
1249 assert!(out.contains("cheap = { model = \"other-name\" }"));
1250 assert!(out.contains("[alias.deep]"));
1251 assert!(out.contains("model = \"new-name\""));
1252 assert!(out.contains("[models.new-name]"));
1253 assert!(!out.contains("[models.old-name]"));
1254 assert!(out.contains("model = \"new-id\""));
1255 assert!(out.contains("context_budget = 128000"));
1256 assert!(out.contains("thinking = true"));
1257 assert!(out.contains("max_tokens = 4096"));
1258 assert!(out.contains("enabled = false"));
1259 }
1260}