1use std::collections::HashMap;
2use std::sync::RwLock;
3
4#[derive(Debug, Clone)]
5pub struct ModelInfo {
6 pub name: String,
7 pub context_budget: u64,
8 pub compact_threshold_ratio: f64,
9 pub thinking_enabled: bool,
10 pub max_output_tokens: Option<u32>,
11}
12
13#[derive(Debug, Clone, Default)]
14pub struct ModelEntry {
15 pub model: String,
16 pub provider: Option<String>,
17 pub api_key: Option<String>,
18 pub base_url: Option<String>,
19 pub context_budget: Option<u64>,
20 pub compact_threshold_ratio: Option<f64>,
21 pub thinking: Option<bool>,
22 pub max_tokens: Option<u32>,
23 #[allow(dead_code)]
26 pub discovered: bool,
27}
28
29#[derive(Debug, Clone, Default)]
30pub struct AliasEntry {
31 pub model: String,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct ModelConfig {
36 pub models: HashMap<String, ModelEntry>,
37 pub aliases: HashMap<String, AliasEntry>,
38}
39
40static MODEL_CONFIG: RwLock<Option<ModelConfig>> = RwLock::new(None);
41
42static DISCOVERED_MODELS: RwLock<Vec<String>> = RwLock::new(Vec::new());
44
45pub fn set_discovered_models(models: Vec<String>) {
46 *DISCOVERED_MODELS.write().unwrap() = models;
47}
48
49pub fn discovered_models() -> Vec<String> {
50 DISCOVERED_MODELS.read().unwrap().clone()
51}
52
53pub fn set_model_config(mut cfg: ModelConfig) {
56 let mut guard = MODEL_CONFIG.write().unwrap();
57 if let Some(old) = guard.take() {
58 for (name, entry) in old.models {
60 if entry.discovered {
61 cfg.models.entry(name).or_insert(entry);
62 }
63 }
64 }
68 *guard = Some(cfg);
69}
70
71pub fn register_model_entries(entries: Vec<(String, ModelEntry)>) {
73 let mut guard = MODEL_CONFIG.write().unwrap();
74 let mut cfg = guard.take().unwrap_or_default();
75 for (name, entry) in entries {
76 cfg.models.entry(name).or_insert(entry);
77 }
78 *guard = Some(cfg);
79}
80
81pub fn register_discovered(
84 _provider_id: &str,
85 provider_name: &str,
86 models: &[crate::provider::DiscoveredModel],
87) {
88 let entries: Vec<(String, ModelEntry)> = models
89 .iter()
90 .map(|m| {
91 let name = format!("{provider_name}:{}", m.slug);
92 let entry = ModelEntry {
93 model: name.clone(),
94 provider: Some(provider_name.to_string()),
95 context_budget: m.context_budget,
96 thinking: Some(m.thinking),
97 discovered: true,
98 ..Default::default()
99 };
100 (name, entry)
101 })
102 .collect();
103 let slugs: Vec<String> = entries.iter().map(|(name, _)| name.clone()).collect();
104 register_model_entries(entries);
105 set_discovered_models(slugs);
106}
107
108#[derive(Debug, Clone)]
109pub struct ModelRow {
110 pub slug: String,
111 pub provider_name: String,
112 pub context_budget: u64,
113 pub max_output_tokens: Option<u32>,
114 pub thinking: bool,
115}
116
117#[derive(Debug, Clone)]
118pub struct ProviderGroup {
119 pub provider_name: String,
120 pub models: Vec<ModelRow>,
121}
122
123pub fn all_provider_groups() -> Vec<ProviderGroup> {
127 let entries = all_model_entries();
128 let mut groups: std::collections::BTreeMap<String, Vec<ModelRow>> =
129 std::collections::BTreeMap::new();
130 for (name, entry) in entries {
131 let info = model_info(&name);
132 let provider = entry.provider.unwrap_or_else(|| "unknown".to_string());
133 let row = ModelRow {
134 slug: name,
135 provider_name: provider.clone(),
136 context_budget: info.context_budget,
137 max_output_tokens: info.max_output_tokens,
138 thinking: info.thinking_enabled(),
139 };
140 groups.entry(provider).or_default().push(row);
141 }
142 groups
143 .into_iter()
144 .map(|(provider_name, models)| ProviderGroup {
145 provider_name,
146 models,
147 })
148 .collect()
149}
150
151pub fn resolve_alias(name: &str) -> String {
152 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
153 if let Some(entry) = cfg.aliases.get(name) {
154 return entry.model.clone();
155 }
156 }
157 name.to_string()
158}
159
160pub fn model_entry(name: &str) -> Option<ModelEntry> {
161 let resolved = resolve_alias(name);
162 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
163 return cfg.models.get(&resolved).cloned();
164 }
165 None
166}
167
168pub fn all_model_entries() -> Vec<(String, ModelEntry)> {
169 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
170 return cfg
171 .models
172 .iter()
173 .map(|(k, v)| (k.clone(), v.clone()))
174 .collect();
175 }
176 Vec::new()
177}
178
179pub fn all_aliases() -> Vec<(String, String)> {
180 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
181 return cfg
182 .aliases
183 .iter()
184 .map(|(k, v)| (k.clone(), v.model.clone()))
185 .collect();
186 }
187 Vec::new()
188}
189
190pub fn model_info(name: &str) -> ModelInfo {
191 let resolved = resolve_alias(name);
192 if let Ok(Some(cfg)) = MODEL_CONFIG.read().as_deref() {
193 if let Some(entry) = cfg.models.get(&resolved) {
194 let (budget, ratio) = builtin_budget(&resolved);
195 return ModelInfo {
196 name: resolved.clone(),
197 context_budget: entry.context_budget.unwrap_or(budget),
198 compact_threshold_ratio: entry.compact_threshold_ratio.unwrap_or(ratio),
199 thinking_enabled: entry.thinking.unwrap_or(false),
200 max_output_tokens: entry.max_tokens,
201 };
202 }
203 }
204 let (budget, ratio) = builtin_budget(&resolved);
205 ModelInfo {
206 name: resolved,
207 context_budget: budget,
208 compact_threshold_ratio: ratio,
209 thinking_enabled: false,
210 max_output_tokens: None,
211 }
212}
213
214fn builtin_budget(name: &str) -> (u64, f64) {
215 let bare = match name.split_once('/') {
216 Some((_, rest)) => rest,
217 None => name.split_once(':').map(|(_, r)| r).unwrap_or(name),
218 };
219 match bare {
220 n if n.starts_with("codex-") => (272_000, 0.8),
221 n if n.starts_with("claude-opus") => (200_000, 0.8),
222 n if n.starts_with("claude-sonnet") => (200_000, 0.8),
223 n if n.starts_with("claude-haiku") => (200_000, 0.8),
224 n if n.starts_with("claude-") => (200_000, 0.8),
225 n if n.starts_with("gpt-5") => (128_000, 0.8),
226 n if n.starts_with("gpt-4o-mini") => (128_000, 0.8),
227 n if n.starts_with("gpt-4o") => (128_000, 0.8),
228 n if n.starts_with("gpt-4-turbo") => (128_000, 0.8),
229 n if n.starts_with("gpt-4") => (32_000, 0.8),
230 n if n.starts_with("gpt-3.5") => (16_000, 0.8),
231 n if n.starts_with("o1") => (128_000, 0.8),
232 n if n.starts_with("o3") => (128_000, 0.8),
233 n if n.starts_with("glm-5") => (128_000, 0.8),
234 n if n.starts_with("glm-4.5") => (128_000, 0.8),
235 n if n.starts_with("glm-4") => (128_000, 0.8),
236 n if n.starts_with("glm-") => (128_000, 0.8),
237 n if n.starts_with("deepseek-v4") => (1_000_000, 0.8),
238 n if n.starts_with("deepseek-v3") => (128_000, 0.8),
239 n if n.starts_with("deepseek-r1") => (128_000, 0.8),
240 n if n.starts_with("deepseek") => (64_000, 0.8),
241 n if n.starts_with("qwen3") => (128_000, 0.8),
242 n if n.starts_with("qwen-max") => (128_000, 0.8),
243 n if n.starts_with("qwen") => (32_000, 0.8),
244 n if n.starts_with("llama") => (8_000, 0.8),
245 _ => (32_000, 0.8),
246 }
247}
248
249impl ModelInfo {
250 pub fn compact_threshold_tokens(&self) -> u64 {
251 let reserved = self.max_output_tokens.unwrap_or(0) as u64;
252 let available = self.context_budget.saturating_sub(reserved);
253 (available as f64 * self.compact_threshold_ratio) as u64
254 }
255
256 pub fn compaction_trigger_threshold(&self) -> u64 {
257 let budget = self.context_budget;
258
259 let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
260 let output_cap = (budget as f64 * 0.20) as u64;
261 let output_reserve = configured_output.min(output_cap).max(8_000);
262
263 let safety = (budget as f64 * 0.05) as u64;
264 let safety_margin = safety.max(4_000);
265
266 let trigger = budget
267 .saturating_sub(output_reserve)
268 .saturating_sub(safety_margin);
269 let floor = (budget as f64 * 0.50) as u64;
270 let ceiling = (budget as f64 * 0.95) as u64;
271 trigger.clamp(floor, ceiling)
272 }
273
274 pub fn compaction_target_after(&self) -> u64 {
275 let trigger = self.compaction_trigger_threshold();
276 let budget_cap = (self.context_budget as f64 * 0.60) as u64;
277 let trigger_cap = (trigger as f64 * 0.75) as u64;
278 budget_cap.min(trigger_cap)
279 }
280
281 pub fn thinking_enabled(&self) -> bool {
282 self.thinking_enabled
283 }
284}
285
286fn read_config_toml() -> Option<String> {
289 let path = crate::storage::config_dir().ok()?.join("config.toml");
290 std::fs::read_to_string(&path).ok()
291}
292
293fn write_config_toml(text: &str) -> anyhow::Result<()> {
294 let dir = crate::storage::config_dir().map_err(|e| anyhow::anyhow!("config dir: {e}"))?;
295 std::fs::create_dir_all(&dir)?;
296 let path = dir.join("config.toml");
297 std::fs::write(&path, text)?;
298 Ok(())
299}
300
301fn reload_from_text(text: &str) {
302 let Ok(raw) = toml::from_str::<toml::Value>(text) else {
303 return;
304 };
305 let mut guard = MODEL_CONFIG.write().unwrap();
306 let mut cfg = guard.take().unwrap_or_default();
307
308 cfg.aliases.clear();
311 if let Some(aliases) = raw.get("alias").and_then(|a| a.as_table()) {
312 for (name, entry) in aliases {
313 if let Some(model) = entry.get("model").and_then(|m| m.as_str()) {
314 cfg.aliases.insert(
315 name.clone(),
316 AliasEntry {
317 model: model.to_string(),
318 },
319 );
320 }
321 }
322 }
323
324 if let Some(models) = raw.get("models").and_then(|m| m.as_table()) {
327 for (name, entry) in models {
328 let provider = entry
329 .get("provider")
330 .and_then(|v| v.as_str())
331 .map(String::from);
332 let api_key = entry
333 .get("api_key")
334 .and_then(|v| v.as_str())
335 .map(String::from);
336 let base_url = entry
337 .get("base_url")
338 .and_then(|v| v.as_str())
339 .map(String::from);
340 let context_budget = entry
341 .get("context_budget")
342 .and_then(|v| v.as_integer())
343 .map(|n| n as u64);
344 let thinking = entry.get("thinking").and_then(|v| v.as_bool());
345 let max_tokens = entry
346 .get("max_tokens")
347 .and_then(|v| v.as_integer())
348 .map(|n| n as u32);
349 let model = entry
350 .get("model")
351 .and_then(|v| v.as_str())
352 .map(String::from);
353 cfg.models.insert(
354 name.clone(),
355 ModelEntry {
356 model: model.unwrap_or_default(),
357 provider,
358 api_key,
359 base_url,
360 context_budget,
361 compact_threshold_ratio: None,
362 thinking,
363 max_tokens,
364 discovered: false,
365 },
366 );
367 }
368 }
369
370 *guard = Some(cfg);
371}
372
373pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
374 let text = read_config_toml().unwrap_or_default();
375 let mut raw: toml::Value = if text.trim().is_empty() {
376 toml::Value::Table(toml::value::Table::new())
377 } else {
378 toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
379 };
380 let aliases = raw
381 .as_table_mut()
382 .ok_or_else(|| anyhow::anyhow!("config.toml is not a table"))?
383 .entry("alias")
384 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
385 if let Some(table) = aliases.as_table_mut() {
386 let mut entry = toml::value::Table::new();
387 entry.insert("model".to_string(), toml::Value::String(model.to_string()));
388 table.insert(alias.to_string(), toml::Value::Table(entry));
389 }
390 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
391 write_config_toml(&new_text)?;
392 reload_from_text(&new_text);
393 Ok(())
394}
395
396pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
397 let text = read_config_toml().unwrap_or_default();
398 let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
399 if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
400 table.remove(alias);
401 }
402 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
403 write_config_toml(&new_text)?;
404 reload_from_text(&new_text);
405 Ok(())
406}
407
408pub fn update_alias_in_config(
409 old_alias: &str,
410 new_alias: &str,
411 new_model: &str,
412) -> anyhow::Result<()> {
413 let text = read_config_toml().unwrap_or_default();
414 let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
415 if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
416 table.remove(old_alias);
417 let mut entry = toml::value::Table::new();
418 entry.insert(
419 "model".to_string(),
420 toml::Value::String(new_model.to_string()),
421 );
422 table.insert(new_alias.to_string(), toml::Value::Table(entry));
423 }
424 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
425 write_config_toml(&new_text)?;
426 reload_from_text(&new_text);
427 Ok(())
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use std::sync::Mutex as StdMutex;
434
435 static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
438
439 #[test]
440 fn claude_opus_returns_200k() {
441 assert_eq!(model_info("claude-opus-4.7").context_budget, 200_000);
442 }
443
444 #[test]
445 fn gpt_4o_returns_128k() {
446 assert_eq!(model_info("gpt-4o-mini").context_budget, 128_000);
447 assert_eq!(model_info("gpt-4o-2024-08-06").context_budget, 128_000);
448 }
449
450 #[test]
451 fn unknown_model_falls_back_to_32k() {
452 assert_eq!(model_info("mystery-model").context_budget, 32_000);
453 assert_eq!(model_info("").context_budget, 32_000);
454 }
455
456 #[test]
457 fn threshold_is_eighty_percent() {
458 let info = model_info("claude-opus-4.7");
459 assert_eq!(info.compact_threshold_tokens(), 160_000);
460 }
461
462 #[test]
463 fn compaction_trigger_is_near_budget_top() {
464 let info = model_info("claude-opus-4.7");
465 let trigger = info.compaction_trigger_threshold();
466 assert!(
467 trigger > 150_000 && trigger <= 190_000,
468 "trigger should be near the top of the budget, got {trigger}"
469 );
470 }
471
472 #[test]
473 fn compaction_target_is_lower_than_trigger() {
474 let info = model_info("claude-opus-4.7");
475 let trigger = info.compaction_trigger_threshold();
476 let target = info.compaction_target_after();
477 assert!(
478 target < trigger,
479 "target {target} should be less than trigger {trigger}"
480 );
481 }
482
483 #[test]
484 fn alias_resolves_to_real_model() {
485 let _lock = TEST_CFG_LOCK.lock().unwrap();
486 let mut cfg = ModelConfig::default();
487 cfg.aliases.insert(
488 "smart".into(),
489 AliasEntry {
490 model: "claude-opus-4.7".into(),
491 },
492 );
493 set_model_config(cfg);
494 let info = model_info("smart");
495 assert_eq!(info.context_budget, 200_000);
496 assert_eq!(info.name, "claude-opus-4.7");
497 }
498
499 #[test]
500 fn custom_model_overrides_budget() {
501 let _lock = TEST_CFG_LOCK.lock().unwrap();
502 let mut cfg = ModelConfig::default();
503 cfg.models.insert(
504 "my-local-model".into(),
505 ModelEntry {
506 model: "my-local-model".into(),
507 context_budget: Some(8192),
508 compact_threshold_ratio: Some(0.9),
509 thinking: None,
510 ..Default::default()
511 },
512 );
513 set_model_config(cfg);
514 let info = model_info("my-local-model");
515 assert_eq!(info.context_budget, 8192);
516 assert_eq!(info.compact_threshold_ratio, 0.9);
517 }
518
519 #[test]
520 fn compact_threshold_reserves_configured_output_tokens() {
521 let _lock = TEST_CFG_LOCK.lock().unwrap();
522 let mut cfg = ModelConfig::default();
523 cfg.models.insert(
524 "large-output".into(),
525 ModelEntry {
526 model: "large-output".into(),
527 context_budget: Some(1_000_000),
528 compact_threshold_ratio: Some(0.8),
529 thinking: None,
530 max_tokens: Some(400_000),
531 ..Default::default()
532 },
533 );
534 set_model_config(cfg);
535 let info = model_info("large-output");
536 assert_eq!(info.compact_threshold_tokens(), 480_000);
537 let trigger = info.compaction_trigger_threshold();
538 assert!(
539 trigger > 700_000,
540 "trigger with capped output reserve should be > 700K, got {trigger}"
541 );
542 }
543
544 #[test]
545 fn alias_chains_through_custom_model() {
546 let _lock = TEST_CFG_LOCK.lock().unwrap();
547 let mut cfg = ModelConfig::default();
548 cfg.aliases.insert(
549 "default".into(),
550 AliasEntry {
551 model: "my-model".into(),
552 },
553 );
554 cfg.models.insert(
555 "my-model".into(),
556 ModelEntry {
557 model: "my-model".into(),
558 context_budget: Some(65_536),
559 compact_threshold_ratio: None,
560 thinking: None,
561 ..Default::default()
562 },
563 );
564 set_model_config(cfg);
565 let info = model_info("default");
566 assert_eq!(info.name, "my-model");
567 assert_eq!(info.context_budget, 65_536);
568 }
569
570 #[test]
571 fn discovered_models_survive_set_model_config() {
572 let _lock = TEST_CFG_LOCK.lock().unwrap();
573 register_discovered(
575 "pid-abc",
576 "Codex",
577 &[crate::provider::DiscoveredModel {
578 slug: "codex/gpt-5".to_string(),
579 context_budget: Some(128_000),
580 thinking: true,
581 }],
582 );
583 assert!(model_entry("Codex:codex/gpt-5").is_some());
584
585 let mut cfg = ModelConfig::default();
587 cfg.aliases.insert(
588 "cheap".into(),
589 AliasEntry {
590 model: "claude-opus-4.7".into(),
591 },
592 );
593 set_model_config(cfg);
594
595 assert!(
597 model_entry("Codex:codex/gpt-5").is_some(),
598 "discovered models should survive set_model_config"
599 );
600 assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
602 }
603
604 #[test]
605 fn discovered_models_survive_reload_from_text_alias_crud() {
606 let _lock = TEST_CFG_LOCK.lock().unwrap();
607 register_discovered(
608 "pid-abc",
609 "Codex",
610 &[crate::provider::DiscoveredModel {
611 slug: "codex/gpt-5".to_string(),
612 context_budget: Some(128_000),
613 thinking: true,
614 }],
615 );
616
617 let toml = r#"
619[alias]
620smart = { model = "Codex:codex/gpt-5" }
621"#;
622 reload_from_text(toml);
623
624 assert!(
625 model_entry("Codex:codex/gpt-5").is_some(),
626 "discovered models should survive alias CRUD"
627 );
628 assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
629 }
630}