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 ModelEntry {
35 pub model: String,
36 pub provider: Option<String>,
37 pub api_key: Option<String>,
38 pub base_url: Option<String>,
39 pub context_budget: Option<u64>,
40 pub compact_threshold_ratio: Option<f64>,
41 pub thinking: Option<bool>,
42 pub max_tokens: Option<u32>,
43 pub enabled: Option<bool>,
44 #[allow(dead_code)]
45 pub discovered: bool,
46}
47
48#[derive(Debug, Clone, Default)]
49pub struct AliasEntry {
50 pub model: String,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct ModelConfig {
55 pub models: HashMap<String, ModelEntry>,
56 pub aliases: HashMap<String, AliasEntry>,
57}
58
59static MODEL_CONFIG: RwLock<Option<ModelConfig>> = RwLock::new(None);
60
61pub static MODEL_CONFIG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
66
67static DISCOVERED_MODELS: RwLock<Vec<String>> = RwLock::new(Vec::new());
69
70pub fn set_discovered_models(models: Vec<String>) {
71 *DISCOVERED_MODELS.write().unwrap() = models;
72}
73
74pub fn discovered_models() -> Vec<String> {
75 DISCOVERED_MODELS.read().unwrap().clone()
76}
77
78pub fn set_model_config(mut cfg: ModelConfig) {
81 let mut guard = MODEL_CONFIG.write().unwrap();
82 if let Some(old) = guard.take() {
83 for (name, entry) in old.models {
85 if entry.discovered {
86 cfg.models.entry(name).or_insert(entry);
87 }
88 }
89 }
93 *guard = Some(cfg);
94}
95
96pub fn register_model_entries(entries: Vec<(String, ModelEntry)>) {
98 let mut guard = MODEL_CONFIG.write().unwrap();
99 let mut cfg = guard.take().unwrap_or_default();
100 for (name, entry) in entries {
101 cfg.models.entry(name).or_insert(entry);
102 }
103 *guard = Some(cfg);
104}
105
106pub fn register_discovered(
109 _provider_id: &str,
110 provider_name: &str,
111 models: &[crate::provider::DiscoveredModel],
112) {
113 let entries: Vec<(String, ModelEntry)> = models
114 .iter()
115 .map(|m| {
116 let name = format!("{provider_name}:{}", m.slug);
117 let entry = ModelEntry {
118 model: name.clone(),
119 provider: Some(provider_name.to_string()),
120 context_budget: m.context_budget,
121 thinking: Some(m.thinking),
122 enabled: None,
123 discovered: true,
124 ..Default::default()
125 };
126 (name, entry)
127 })
128 .collect();
129 let slugs: Vec<String> = entries.iter().map(|(name, _)| name.clone()).collect();
130 register_model_entries(entries);
131 set_discovered_models(slugs);
132}
133
134#[derive(Debug, Clone)]
135pub struct ModelRow {
136 pub slug: String,
137 pub provider_name: String,
138 pub context_budget: u64,
139 pub max_output_tokens: Option<u32>,
140 pub thinking: bool,
141}
142
143#[derive(Debug, Clone)]
144pub struct ProviderGroup {
145 pub provider_name: String,
146 pub models: Vec<ModelRow>,
147}
148
149pub fn all_provider_groups() -> Vec<ProviderGroup> {
153 let entries = all_model_entries();
154 let mut groups: std::collections::BTreeMap<String, Vec<ModelRow>> =
155 std::collections::BTreeMap::new();
156 for (name, entry) in entries {
157 let info = model_info(&name);
158 let provider = entry.provider.unwrap_or_else(|| "unknown".to_string());
159 let row = ModelRow {
160 slug: name,
161 provider_name: provider.clone(),
162 context_budget: info.context_budget,
163 max_output_tokens: info.max_output_tokens,
164 thinking: info.thinking_enabled(),
165 };
166 groups.entry(provider).or_default().push(row);
167 }
168 groups
169 .into_iter()
170 .map(|(provider_name, models)| ProviderGroup {
171 provider_name,
172 models,
173 })
174 .collect()
175}
176
177pub fn resolve_alias(name: &str) -> String {
178 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
179 let mut current = name.to_string();
180 let mut seen = std::collections::HashSet::new();
181 while let Some(entry) = cfg.aliases.get(¤t) {
182 if !seen.insert(current.clone()) {
183 break;
184 }
185 current = entry.model.clone();
186 }
187 return current;
188 }
189 name.to_string()
190}
191
192pub fn model_entry(name: &str) -> Option<ModelEntry> {
193 let resolved = resolve_alias(name);
194 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
195 return cfg.models.get(&resolved).cloned();
196 }
197 None
198}
199
200pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
201 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
202 return cfg
203 .models
204 .iter()
205 .map(|(k, v)| (k.clone(), v.clone()))
206 .collect();
207 }
208 Vec::new()
209}
210
211pub fn all_aliases() -> Vec<(String, String)> {
212 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
213 return cfg
214 .aliases
215 .iter()
216 .map(|(k, v)| (k.clone(), v.model.clone()))
217 .collect();
218 }
219 Vec::new()
220}
221
222pub fn model_info(name: &str) -> ModelInfo {
223 let resolved = resolve_alias(name);
224 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
225 if let Some(entry) = cfg.models.get(&resolved) {
226 return ModelInfo {
227 name: resolved.clone(),
228 context_budget: entry.context_budget.unwrap_or(0),
229 compact_threshold_ratio: entry.compact_threshold_ratio.unwrap_or(0.8),
230 thinking_enabled: entry.thinking.unwrap_or(false),
231 max_output_tokens: entry.max_tokens,
232 };
233 }
234 }
235 ModelInfo {
236 name: resolved,
237 context_budget: 0,
238 compact_threshold_ratio: 0.8,
239 thinking_enabled: false,
240 max_output_tokens: None,
241 }
242}
243
244impl ModelInfo {
245 pub fn compact_threshold_tokens(&self) -> u64 {
246 let reserved = self.max_output_tokens.unwrap_or(0) as u64;
247 let available = self.context_budget.saturating_sub(reserved);
248 (available as f64 * self.compact_threshold_ratio) as u64
249 }
250
251 pub fn compaction_trigger_threshold(&self) -> u64 {
252 let budget = self.context_budget;
253
254 let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
255 let output_cap = (budget as f64 * 0.20) as u64;
256 let output_reserve = configured_output.min(output_cap).max(8_000);
257
258 let safety = (budget as f64 * 0.05) as u64;
259 let safety_margin = safety.max(4_000);
260
261 let trigger = budget
262 .saturating_sub(output_reserve)
263 .saturating_sub(safety_margin);
264 let floor = (budget as f64 * 0.50) as u64;
265 let ceiling = (budget as f64 * 0.95) as u64;
266 trigger.clamp(floor, ceiling)
267 }
268
269 pub fn compaction_target_after(&self) -> u64 {
270 let trigger = self.compaction_trigger_threshold();
271 let budget_cap = (self.context_budget as f64 * 0.60) as u64;
272 let trigger_cap = (trigger as f64 * 0.75) as u64;
273 budget_cap.min(trigger_cap)
274 }
275
276 pub fn thinking_enabled(&self) -> bool {
277 self.thinking_enabled
278 }
279}
280
281fn read_config_toml() -> Option<String> {
284 let path = crate::storage::config_dir().ok()?.join("config.toml");
285 std::fs::read_to_string(&path).ok()
286}
287
288fn write_config_toml(text: &str) -> anyhow::Result<()> {
289 let dir = crate::storage::config_dir().map_err(|e| anyhow::anyhow!("config dir: {e}"))?;
290 std::fs::create_dir_all(&dir)?;
291 let path = dir.join("config.toml");
292 let tmp = dir.join(".config.toml.tmp");
293 std::fs::write(&tmp, text)?;
294 std::fs::rename(&tmp, &path)?;
295 Ok(())
296}
297
298fn reload_from_text(text: &str) {
299 let Ok(raw) = toml::from_str::<toml::Value>(text) else {
300 return;
301 };
302 let mut guard = MODEL_CONFIG.write().unwrap();
303 let mut cfg = guard.take().unwrap_or_default();
304
305 cfg.aliases.clear();
308 if let Some(aliases) = raw.get("alias").and_then(|a| a.as_table()) {
309 for (name, entry) in aliases {
310 if let Some(model) = entry.get("model").and_then(|m| m.as_str()) {
311 cfg.aliases.insert(
312 name.clone(),
313 AliasEntry {
314 model: model.to_string(),
315 },
316 );
317 }
318 }
319 }
320
321 if let Some(models) = raw.get("models").and_then(|m| m.as_table()) {
324 for (name, entry) in models {
325 let provider = entry
326 .get("provider")
327 .and_then(|v| v.as_str())
328 .map(String::from);
329 let api_key = entry
330 .get("api_key")
331 .and_then(|v| v.as_str())
332 .map(String::from);
333 let base_url = entry
334 .get("base_url")
335 .and_then(|v| v.as_str())
336 .map(String::from);
337 let context_budget = entry
338 .get("context_budget")
339 .and_then(|v| v.as_integer())
340 .map(|n| n as u64);
341 let thinking = entry.get("thinking").and_then(|v| v.as_bool());
342 let max_tokens = entry
343 .get("max_tokens")
344 .and_then(|v| v.as_integer())
345 .map(|n| n as u32);
346 let model = entry
347 .get("model")
348 .and_then(|v| v.as_str())
349 .map(String::from);
350 cfg.models.insert(
351 name.clone(),
352 ModelEntry {
353 model: model.unwrap_or_default(),
354 provider,
355 api_key,
356 base_url,
357 context_budget,
358 compact_threshold_ratio: None,
359 thinking,
360 max_tokens,
361 enabled: None,
362 discovered: false,
363 },
364 );
365 }
366 }
367
368 *guard = Some(cfg);
369}
370
371pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
372 let text = read_config_toml().unwrap_or_default();
373 let new_text = upsert_alias_comment_preserving(&text, alias, model);
374 write_config_toml(&new_text)?;
375 reload_from_text(&new_text);
376 Ok(())
377}
378
379fn upsert_alias_comment_preserving(text: &str, alias: &str, model: &str) -> String {
380 let section_start = format!("[alias.{alias}]");
381 let model_line = format!("model = {model:?}");
382 let mut lines: Vec<String> = text.lines().map(String::from).collect();
383
384 if let Some(section) = lines
385 .iter()
386 .position(|l| l.trim().starts_with(&format!("[alias.{alias}")) && l.trim().ends_with(']'))
387 {
388 let mut inserted = false;
389 for line in lines.iter_mut().skip(section + 1) {
390 let t = line.trim();
391 if (t.starts_with("[alias.") || t == "[alias]") && t.ends_with(']') {
392 break;
393 }
394 if t.starts_with("model") && t.contains('=') {
395 *line = model_line.clone();
396 inserted = true;
397 break;
398 }
399 }
400 if !inserted {
401 lines.insert(section + 1, model_line);
402 }
403 } else {
404 while lines.last().is_some_and(|l| l.trim().is_empty()) {
405 lines.pop();
406 }
407 if !lines.is_empty() {
408 lines.push(String::new());
409 }
410 lines.push(section_start);
411 lines.push(model_line);
412 }
413
414 lines.join("\n")
415}
416
417pub fn upsert_model_config(
418 name: &str,
419 provider: &str,
420 api_key: Option<&str>,
421 base_url: Option<&str>,
422 context_budget: u64,
423 thinking: bool,
424) -> anyhow::Result<()> {
425 let text = read_config_toml().unwrap_or_default();
426 let mut raw: toml::Value = if text.trim().is_empty() {
427 toml::Value::Table(toml::value::Table::new())
428 } else {
429 toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
430 };
431 let models = raw
432 .as_table_mut()
433 .ok_or_else(|| anyhow::anyhow!("config.toml is not a table"))?
434 .entry("models")
435 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
436 if let Some(table) = models.as_table_mut() {
437 let mut entry = toml::value::Table::new();
438 entry.insert(
439 "provider".to_string(),
440 toml::Value::String(provider.to_string()),
441 );
442 if let Some(key) = api_key {
443 entry.insert("api_key".to_string(), toml::Value::String(key.to_string()));
444 }
445 if let Some(url) = base_url {
446 entry.insert("base_url".to_string(), toml::Value::String(url.to_string()));
447 }
448 entry.insert(
449 "context_budget".to_string(),
450 toml::Value::Integer(context_budget as i64),
451 );
452 if thinking {
453 entry.insert("thinking".to_string(), toml::Value::Boolean(true));
454 }
455 entry.insert("enabled".to_string(), toml::Value::Boolean(true));
456 table.insert(name.to_string(), toml::Value::Table(entry));
457 }
458 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
459 write_config_toml(&new_text)?;
460 reload_from_text(&new_text);
461 Ok(())
462}
463
464pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
465 let text = read_config_toml().unwrap_or_default();
466 let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
467 if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
468 table.remove(alias);
469 }
470 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
471 write_config_toml(&new_text)?;
472 reload_from_text(&new_text);
473 Ok(())
474}
475
476pub fn update_alias_in_config(
477 old_alias: &str,
478 new_alias: &str,
479 new_model: &str,
480) -> anyhow::Result<()> {
481 let text = read_config_toml().unwrap_or_default();
482 let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
483 if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
484 table.remove(old_alias);
485 let mut entry = toml::value::Table::new();
486 entry.insert(
487 "model".to_string(),
488 toml::Value::String(new_model.to_string()),
489 );
490 table.insert(new_alias.to_string(), toml::Value::Table(entry));
491 }
492 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
493 write_config_toml(&new_text)?;
494 reload_from_text(&new_text);
495 Ok(())
496}
497
498pub struct ProviderPreset {
501 pub name: &'static str,
502 pub description: &'static str,
503 pub base_url: &'static str,
504 pub provider_type: &'static str,
505 pub models: &'static [ProviderPresetModel],
506 pub key_url: Option<&'static str>,
507 pub needs_api_key: bool,
508}
509
510pub struct ProviderPresetModel {
511 pub id: &'static str,
512 pub description: &'static str,
513 pub context_budget: u64,
514 pub thinking: bool,
515}
516
517pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
518 ProviderPreset {
519 name: "DeepSeek",
520 description: "Recommended — cheap, smart, supports thinking",
521 base_url: "https://api.deepseek.com",
522 provider_type: "openai-compat",
523 models: &[
524 ProviderPresetModel {
525 id: "deepseek-v4-flash",
526 description: "Fast & capable",
527 context_budget: 1000000,
528 thinking: false,
529 },
530 ProviderPresetModel {
531 id: "deepseek-v4-pro",
532 description: "Thinking mode",
533 context_budget: 1000000,
534 thinking: true,
535 },
536 ],
537 key_url: Some("https://platform.deepseek.com"),
538 needs_api_key: true,
539 },
540 ProviderPreset {
541 name: "OpenAI",
542 description: "GPT-4o / GPT-4o-mini",
543 base_url: "https://api.openai.com/v1",
544 provider_type: "openai",
545 models: &[
546 ProviderPresetModel {
547 id: "gpt-4o",
548 description: "Most capable",
549 context_budget: 128000,
550 thinking: false,
551 },
552 ProviderPresetModel {
553 id: "gpt-4o-mini",
554 description: "Fast & cheap",
555 context_budget: 128000,
556 thinking: false,
557 },
558 ],
559 key_url: Some("https://platform.openai.com/api-keys"),
560 needs_api_key: true,
561 },
562 ProviderPreset {
563 name: "Anthropic",
564 description: "Claude models",
565 base_url: "https://api.anthropic.com",
566 provider_type: "anthropic",
567 models: &[ProviderPresetModel {
568 id: "claude-sonnet-4-20250514",
569 description: "Claude Sonnet 4",
570 context_budget: 200000,
571 thinking: true,
572 }],
573 key_url: Some("https://console.anthropic.com/settings/keys"),
574 needs_api_key: true,
575 },
576 ProviderPreset {
577 name: "ZhipuAI",
578 description: "GLM models",
579 base_url: "https://open.bigmodel.cn/api/paas/v4",
580 provider_type: "openai-compat",
581 models: &[ProviderPresetModel {
582 id: "glm-5.2",
583 description: "GLM 5.2",
584 context_budget: 1000000,
585 thinking: true,
586 }],
587 key_url: Some("https://open.bigmodel.cn/usercenter/apikeys"),
588 needs_api_key: true,
589 },
590 ProviderPreset {
591 name: "Ollama",
592 description: "Local models, no API key needed",
593 base_url: "http://localhost:11434/v1",
594 provider_type: "openai-compat",
595 models: &[],
596 key_url: None,
597 needs_api_key: false,
598 },
599 ProviderPreset {
600 name: "Codex",
601 description: "ChatGPT Plus/Pro OAuth",
602 base_url: "https://chatgpt.com/backend-api/codex",
603 provider_type: "codex",
604 models: &[],
605 key_url: None,
606 needs_api_key: false,
607 },
608];
609
610pub fn is_first_run() -> bool {
611 let models = all_model_entries();
612 let config_configured = models.iter().any(|(_, e)| {
613 e.api_key.as_deref().is_some_and(|k| !k.is_empty())
614 && e.provider.is_some()
615 && e.context_budget.unwrap_or(0) > 0
616 });
617 let env_configured =
618 std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok();
619 let auth_configured = AuthStore::load()
620 .is_ok_and(|store| store.providers.iter().any(|provider| provider.enabled));
621 let smart_resolves = {
622 let resolved = resolve_alias("smart");
623 resolved != "smart" && models.iter().any(|(n, _)| *n == resolved)
624 };
625 !(config_configured || env_configured || auth_configured) || !smart_resolves
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use std::sync::Mutex as StdMutex;
632
633 static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
636
637 #[test]
638 fn unregistered_model_returns_zero_budget() {
639 let _lock = TEST_CFG_LOCK.lock().unwrap();
640 *MODEL_CONFIG.write().unwrap() = None;
641 assert_eq!(model_info("mystery-model").context_budget, 0);
642 assert_eq!(model_info("").context_budget, 0);
643 }
644
645 #[test]
646 fn threshold_is_eighty_percent() {
647 let _lock = TEST_CFG_LOCK.lock().unwrap();
648 let mut cfg = ModelConfig::default();
649 cfg.models.insert(
650 "claude-opus-4.7".into(),
651 ModelEntry {
652 model: "claude-opus-4.7".into(),
653 context_budget: Some(200_000),
654 compact_threshold_ratio: Some(0.8),
655 thinking: None,
656 ..Default::default()
657 },
658 );
659 set_model_config(cfg);
660 let info = model_info("claude-opus-4.7");
661 assert_eq!(info.compact_threshold_tokens(), 160_000);
662 }
663
664 #[test]
665 fn compaction_trigger_is_near_budget_top() {
666 let _lock = TEST_CFG_LOCK.lock().unwrap();
667 let mut cfg = ModelConfig::default();
668 cfg.models.insert(
669 "claude-opus-4.7".into(),
670 ModelEntry {
671 model: "claude-opus-4.7".into(),
672 context_budget: Some(200_000),
673 compact_threshold_ratio: Some(0.8),
674 thinking: None,
675 ..Default::default()
676 },
677 );
678 set_model_config(cfg);
679 let info = model_info("claude-opus-4.7");
680 let trigger = info.compaction_trigger_threshold();
681 assert!(
682 trigger > 150_000 && trigger <= 190_000,
683 "trigger should be near the top of the budget, got {trigger}"
684 );
685 }
686
687 #[test]
688 fn compaction_target_is_lower_than_trigger() {
689 let _lock = TEST_CFG_LOCK.lock().unwrap();
690 let mut cfg = ModelConfig::default();
691 cfg.models.insert(
692 "claude-opus-4.7".into(),
693 ModelEntry {
694 model: "claude-opus-4.7".into(),
695 context_budget: Some(200_000),
696 compact_threshold_ratio: Some(0.8),
697 thinking: None,
698 ..Default::default()
699 },
700 );
701 set_model_config(cfg);
702 let info = model_info("claude-opus-4.7");
703 let trigger = info.compaction_trigger_threshold();
704 let target = info.compaction_target_after();
705 assert!(
706 target < trigger,
707 "target {target} should be less than trigger {trigger}"
708 );
709 }
710
711 #[test]
712 fn alias_resolves_to_real_model() {
713 let _lock = TEST_CFG_LOCK.lock().unwrap();
714 let mut cfg = ModelConfig::default();
715 cfg.models.insert(
716 "claude-opus-4.7".into(),
717 ModelEntry {
718 model: "claude-opus-4.7".into(),
719 context_budget: Some(200_000),
720 ..Default::default()
721 },
722 );
723 cfg.aliases.insert(
724 "smart".into(),
725 AliasEntry {
726 model: "claude-opus-4.7".into(),
727 },
728 );
729 set_model_config(cfg);
730 let info = model_info("smart");
731 assert_eq!(info.context_budget, 200_000);
732 assert_eq!(info.name, "claude-opus-4.7");
733 }
734
735 #[test]
736 fn custom_model_overrides_budget() {
737 let _lock = TEST_CFG_LOCK.lock().unwrap();
738 let mut cfg = ModelConfig::default();
739 cfg.models.insert(
740 "my-local-model".into(),
741 ModelEntry {
742 model: "my-local-model".into(),
743 context_budget: Some(8192),
744 compact_threshold_ratio: Some(0.9),
745 thinking: None,
746 ..Default::default()
747 },
748 );
749 set_model_config(cfg);
750 let info = model_info("my-local-model");
751 assert_eq!(info.context_budget, 8192);
752 assert_eq!(info.compact_threshold_ratio, 0.9);
753 }
754
755 #[test]
756 fn compact_threshold_reserves_configured_output_tokens() {
757 let _lock = TEST_CFG_LOCK.lock().unwrap();
758 let mut cfg = ModelConfig::default();
759 cfg.models.insert(
760 "large-output".into(),
761 ModelEntry {
762 model: "large-output".into(),
763 context_budget: Some(1_000_000),
764 compact_threshold_ratio: Some(0.8),
765 thinking: None,
766 max_tokens: Some(400_000),
767 ..Default::default()
768 },
769 );
770 set_model_config(cfg);
771 let info = model_info("large-output");
772 assert_eq!(info.compact_threshold_tokens(), 480_000);
773 let trigger = info.compaction_trigger_threshold();
774 assert!(
775 trigger > 700_000,
776 "trigger with capped output reserve should be > 700K, got {trigger}"
777 );
778 }
779
780 #[test]
781 fn alias_chains_through_custom_model() {
782 let _lock = TEST_CFG_LOCK.lock().unwrap();
783 let mut cfg = ModelConfig::default();
784 cfg.aliases.insert(
785 "default".into(),
786 AliasEntry {
787 model: "my-model".into(),
788 },
789 );
790 cfg.models.insert(
791 "my-model".into(),
792 ModelEntry {
793 model: "my-model".into(),
794 context_budget: Some(65_536),
795 compact_threshold_ratio: None,
796 thinking: None,
797 ..Default::default()
798 },
799 );
800 set_model_config(cfg);
801 let info = model_info("default");
802 assert_eq!(info.name, "my-model");
803 assert_eq!(info.context_budget, 65_536);
804 }
805
806 #[test]
807 fn discovered_models_survive_set_model_config() {
808 let _lock = TEST_CFG_LOCK.lock().unwrap();
809 register_discovered(
811 "pid-abc",
812 "Codex",
813 &[crate::provider::DiscoveredModel {
814 slug: "codex/gpt-5".to_string(),
815 context_budget: Some(128_000),
816 thinking: true,
817 }],
818 );
819 assert!(model_entry("Codex:codex/gpt-5").is_some());
820
821 let mut cfg = ModelConfig::default();
823 cfg.aliases.insert(
824 "cheap".into(),
825 AliasEntry {
826 model: "claude-opus-4.7".into(),
827 },
828 );
829 set_model_config(cfg);
830
831 assert!(
833 model_entry("Codex:codex/gpt-5").is_some(),
834 "discovered models should survive set_model_config"
835 );
836 assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
838 }
839
840 #[test]
841 fn discovered_models_survive_reload_from_text_alias_crud() {
842 let _lock = TEST_CFG_LOCK.lock().unwrap();
843 register_discovered(
844 "pid-abc",
845 "Codex",
846 &[crate::provider::DiscoveredModel {
847 slug: "codex/gpt-5".to_string(),
848 context_budget: Some(128_000),
849 thinking: true,
850 }],
851 );
852
853 let toml = r#"
855[alias]
856smart = { model = "Codex:codex/gpt-5" }
857"#;
858 reload_from_text(toml);
859
860 assert!(
861 model_entry("Codex:codex/gpt-5").is_some(),
862 "discovered models should survive alias CRUD"
863 );
864 assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
865 }
866
867 #[test]
868 fn add_alias_preserves_comments() {
869 let toml = "# top comment\n[alias]\n# smart line comment\nsmart = { model = \"claude\" }\n";
870 let out = upsert_alias_comment_preserving(toml, "smart", "claude-opus-4.7");
871 assert!(out.contains("# top comment"));
872 assert!(out.contains("# smart line comment"));
873 assert!(out.contains("model = \"claude-opus-4.7\""));
874 }
875}