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 pub enabled: Option<bool>,
24 #[allow(dead_code)]
25 pub discovered: bool,
26}
27
28#[derive(Debug, Clone, Default)]
29pub struct AliasEntry {
30 pub model: String,
31}
32
33#[derive(Debug, Clone, Default)]
34pub struct ModelConfig {
35 pub models: HashMap<String, ModelEntry>,
36 pub aliases: HashMap<String, AliasEntry>,
37}
38
39static MODEL_CONFIG: RwLock<Option<ModelConfig>> = RwLock::new(None);
40
41static DISCOVERED_MODELS: RwLock<Vec<String>> = RwLock::new(Vec::new());
43
44pub fn set_discovered_models(models: Vec<String>) {
45 *DISCOVERED_MODELS.write().unwrap() = models;
46}
47
48pub fn discovered_models() -> Vec<String> {
49 DISCOVERED_MODELS.read().unwrap().clone()
50}
51
52pub fn set_model_config(mut cfg: ModelConfig) {
55 let mut guard = MODEL_CONFIG.write().unwrap();
56 if let Some(old) = guard.take() {
57 for (name, entry) in old.models {
59 if entry.discovered {
60 cfg.models.entry(name).or_insert(entry);
61 }
62 }
63 }
67 *guard = Some(cfg);
68}
69
70pub fn register_model_entries(entries: Vec<(String, ModelEntry)>) {
72 let mut guard = MODEL_CONFIG.write().unwrap();
73 let mut cfg = guard.take().unwrap_or_default();
74 for (name, entry) in entries {
75 cfg.models.entry(name).or_insert(entry);
76 }
77 *guard = Some(cfg);
78}
79
80pub fn register_discovered(
83 _provider_id: &str,
84 provider_name: &str,
85 models: &[crate::provider::DiscoveredModel],
86) {
87 let entries: Vec<(String, ModelEntry)> = models
88 .iter()
89 .map(|m| {
90 let name = format!("{provider_name}:{}", m.slug);
91 let entry = ModelEntry {
92 model: name.clone(),
93 provider: Some(provider_name.to_string()),
94 context_budget: m.context_budget,
95 thinking: Some(m.thinking),
96 enabled: None,
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 return ModelInfo {
195 name: resolved.clone(),
196 context_budget: entry.context_budget.unwrap_or(0),
197 compact_threshold_ratio: entry.compact_threshold_ratio.unwrap_or(0.8),
198 thinking_enabled: entry.thinking.unwrap_or(false),
199 max_output_tokens: entry.max_tokens,
200 };
201 }
202 }
203 ModelInfo {
204 name: resolved,
205 context_budget: 0,
206 compact_threshold_ratio: 0.8,
207 thinking_enabled: false,
208 max_output_tokens: None,
209 }
210}
211
212impl ModelInfo {
213 pub fn compact_threshold_tokens(&self) -> u64 {
214 let reserved = self.max_output_tokens.unwrap_or(0) as u64;
215 let available = self.context_budget.saturating_sub(reserved);
216 (available as f64 * self.compact_threshold_ratio) as u64
217 }
218
219 pub fn compaction_trigger_threshold(&self) -> u64 {
220 let budget = self.context_budget;
221
222 let configured_output = self.max_output_tokens.unwrap_or(32_000) as u64;
223 let output_cap = (budget as f64 * 0.20) as u64;
224 let output_reserve = configured_output.min(output_cap).max(8_000);
225
226 let safety = (budget as f64 * 0.05) as u64;
227 let safety_margin = safety.max(4_000);
228
229 let trigger = budget
230 .saturating_sub(output_reserve)
231 .saturating_sub(safety_margin);
232 let floor = (budget as f64 * 0.50) as u64;
233 let ceiling = (budget as f64 * 0.95) as u64;
234 trigger.clamp(floor, ceiling)
235 }
236
237 pub fn compaction_target_after(&self) -> u64 {
238 let trigger = self.compaction_trigger_threshold();
239 let budget_cap = (self.context_budget as f64 * 0.60) as u64;
240 let trigger_cap = (trigger as f64 * 0.75) as u64;
241 budget_cap.min(trigger_cap)
242 }
243
244 pub fn thinking_enabled(&self) -> bool {
245 self.thinking_enabled
246 }
247}
248
249fn read_config_toml() -> Option<String> {
252 let path = crate::storage::config_dir().ok()?.join("config.toml");
253 std::fs::read_to_string(&path).ok()
254}
255
256fn write_config_toml(text: &str) -> anyhow::Result<()> {
257 let dir = crate::storage::config_dir().map_err(|e| anyhow::anyhow!("config dir: {e}"))?;
258 std::fs::create_dir_all(&dir)?;
259 let path = dir.join("config.toml");
260 std::fs::write(&path, text)?;
261 Ok(())
262}
263
264fn reload_from_text(text: &str) {
265 let Ok(raw) = toml::from_str::<toml::Value>(text) else {
266 return;
267 };
268 let mut guard = MODEL_CONFIG.write().unwrap();
269 let mut cfg = guard.take().unwrap_or_default();
270
271 cfg.aliases.clear();
274 if let Some(aliases) = raw.get("alias").and_then(|a| a.as_table()) {
275 for (name, entry) in aliases {
276 if let Some(model) = entry.get("model").and_then(|m| m.as_str()) {
277 cfg.aliases.insert(
278 name.clone(),
279 AliasEntry {
280 model: model.to_string(),
281 },
282 );
283 }
284 }
285 }
286
287 if let Some(models) = raw.get("models").and_then(|m| m.as_table()) {
290 for (name, entry) in models {
291 let provider = entry
292 .get("provider")
293 .and_then(|v| v.as_str())
294 .map(String::from);
295 let api_key = entry
296 .get("api_key")
297 .and_then(|v| v.as_str())
298 .map(String::from);
299 let base_url = entry
300 .get("base_url")
301 .and_then(|v| v.as_str())
302 .map(String::from);
303 let context_budget = entry
304 .get("context_budget")
305 .and_then(|v| v.as_integer())
306 .map(|n| n as u64);
307 let thinking = entry.get("thinking").and_then(|v| v.as_bool());
308 let max_tokens = entry
309 .get("max_tokens")
310 .and_then(|v| v.as_integer())
311 .map(|n| n as u32);
312 let model = entry
313 .get("model")
314 .and_then(|v| v.as_str())
315 .map(String::from);
316 cfg.models.insert(
317 name.clone(),
318 ModelEntry {
319 model: model.unwrap_or_default(),
320 provider,
321 api_key,
322 base_url,
323 context_budget,
324 compact_threshold_ratio: None,
325 thinking,
326 max_tokens,
327 enabled: None,
328 discovered: false,
329 },
330 );
331 }
332 }
333
334 *guard = Some(cfg);
335}
336
337pub fn add_alias_to_config(alias: &str, model: &str) -> anyhow::Result<()> {
338 let text = read_config_toml().unwrap_or_default();
339 let mut raw: toml::Value = if text.trim().is_empty() {
340 toml::Value::Table(toml::value::Table::new())
341 } else {
342 toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse config.toml: {e}"))?
343 };
344 let aliases = raw
345 .as_table_mut()
346 .ok_or_else(|| anyhow::anyhow!("config.toml is not a table"))?
347 .entry("alias")
348 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
349 if let Some(table) = aliases.as_table_mut() {
350 let mut entry = toml::value::Table::new();
351 entry.insert("model".to_string(), toml::Value::String(model.to_string()));
352 table.insert(alias.to_string(), toml::Value::Table(entry));
353 }
354 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
355 write_config_toml(&new_text)?;
356 reload_from_text(&new_text);
357 Ok(())
358}
359
360pub fn remove_alias_from_config(alias: &str) -> anyhow::Result<()> {
361 let text = read_config_toml().unwrap_or_default();
362 let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
363 if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
364 table.remove(alias);
365 }
366 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
367 write_config_toml(&new_text)?;
368 reload_from_text(&new_text);
369 Ok(())
370}
371
372pub fn update_alias_in_config(
373 old_alias: &str,
374 new_alias: &str,
375 new_model: &str,
376) -> anyhow::Result<()> {
377 let text = read_config_toml().unwrap_or_default();
378 let mut raw: toml::Value = toml::from_str(&text).map_err(|e| anyhow::anyhow!("parse: {e}"))?;
379 if let Some(table) = raw.get_mut("alias").and_then(|a| a.as_table_mut()) {
380 table.remove(old_alias);
381 let mut entry = toml::value::Table::new();
382 entry.insert(
383 "model".to_string(),
384 toml::Value::String(new_model.to_string()),
385 );
386 table.insert(new_alias.to_string(), toml::Value::Table(entry));
387 }
388 let new_text = toml::to_string_pretty(&raw).map_err(|e| anyhow::anyhow!("serialize: {e}"))?;
389 write_config_toml(&new_text)?;
390 reload_from_text(&new_text);
391 Ok(())
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use std::sync::Mutex as StdMutex;
398
399 static TEST_CFG_LOCK: StdMutex<()> = StdMutex::new(());
402
403 #[test]
404 fn unregistered_model_returns_zero_budget() {
405 let _lock = TEST_CFG_LOCK.lock().unwrap();
406 *MODEL_CONFIG.write().unwrap() = None;
407 assert_eq!(model_info("mystery-model").context_budget, 0);
408 assert_eq!(model_info("").context_budget, 0);
409 }
410
411 #[test]
412 fn threshold_is_eighty_percent() {
413 let _lock = TEST_CFG_LOCK.lock().unwrap();
414 let mut cfg = ModelConfig::default();
415 cfg.models.insert(
416 "claude-opus-4.7".into(),
417 ModelEntry {
418 model: "claude-opus-4.7".into(),
419 context_budget: Some(200_000),
420 compact_threshold_ratio: Some(0.8),
421 thinking: None,
422 ..Default::default()
423 },
424 );
425 set_model_config(cfg);
426 let info = model_info("claude-opus-4.7");
427 assert_eq!(info.compact_threshold_tokens(), 160_000);
428 }
429
430 #[test]
431 fn compaction_trigger_is_near_budget_top() {
432 let _lock = TEST_CFG_LOCK.lock().unwrap();
433 let mut cfg = ModelConfig::default();
434 cfg.models.insert(
435 "claude-opus-4.7".into(),
436 ModelEntry {
437 model: "claude-opus-4.7".into(),
438 context_budget: Some(200_000),
439 compact_threshold_ratio: Some(0.8),
440 thinking: None,
441 ..Default::default()
442 },
443 );
444 set_model_config(cfg);
445 let info = model_info("claude-opus-4.7");
446 let trigger = info.compaction_trigger_threshold();
447 assert!(
448 trigger > 150_000 && trigger <= 190_000,
449 "trigger should be near the top of the budget, got {trigger}"
450 );
451 }
452
453 #[test]
454 fn compaction_target_is_lower_than_trigger() {
455 let _lock = TEST_CFG_LOCK.lock().unwrap();
456 let mut cfg = ModelConfig::default();
457 cfg.models.insert(
458 "claude-opus-4.7".into(),
459 ModelEntry {
460 model: "claude-opus-4.7".into(),
461 context_budget: Some(200_000),
462 compact_threshold_ratio: Some(0.8),
463 thinking: None,
464 ..Default::default()
465 },
466 );
467 set_model_config(cfg);
468 let info = model_info("claude-opus-4.7");
469 let trigger = info.compaction_trigger_threshold();
470 let target = info.compaction_target_after();
471 assert!(
472 target < trigger,
473 "target {target} should be less than trigger {trigger}"
474 );
475 }
476
477 #[test]
478 fn alias_resolves_to_real_model() {
479 let _lock = TEST_CFG_LOCK.lock().unwrap();
480 let mut cfg = ModelConfig::default();
481 cfg.models.insert(
482 "claude-opus-4.7".into(),
483 ModelEntry {
484 model: "claude-opus-4.7".into(),
485 context_budget: Some(200_000),
486 ..Default::default()
487 },
488 );
489 cfg.aliases.insert(
490 "smart".into(),
491 AliasEntry {
492 model: "claude-opus-4.7".into(),
493 },
494 );
495 set_model_config(cfg);
496 let info = model_info("smart");
497 assert_eq!(info.context_budget, 200_000);
498 assert_eq!(info.name, "claude-opus-4.7");
499 }
500
501 #[test]
502 fn custom_model_overrides_budget() {
503 let _lock = TEST_CFG_LOCK.lock().unwrap();
504 let mut cfg = ModelConfig::default();
505 cfg.models.insert(
506 "my-local-model".into(),
507 ModelEntry {
508 model: "my-local-model".into(),
509 context_budget: Some(8192),
510 compact_threshold_ratio: Some(0.9),
511 thinking: None,
512 ..Default::default()
513 },
514 );
515 set_model_config(cfg);
516 let info = model_info("my-local-model");
517 assert_eq!(info.context_budget, 8192);
518 assert_eq!(info.compact_threshold_ratio, 0.9);
519 }
520
521 #[test]
522 fn compact_threshold_reserves_configured_output_tokens() {
523 let _lock = TEST_CFG_LOCK.lock().unwrap();
524 let mut cfg = ModelConfig::default();
525 cfg.models.insert(
526 "large-output".into(),
527 ModelEntry {
528 model: "large-output".into(),
529 context_budget: Some(1_000_000),
530 compact_threshold_ratio: Some(0.8),
531 thinking: None,
532 max_tokens: Some(400_000),
533 ..Default::default()
534 },
535 );
536 set_model_config(cfg);
537 let info = model_info("large-output");
538 assert_eq!(info.compact_threshold_tokens(), 480_000);
539 let trigger = info.compaction_trigger_threshold();
540 assert!(
541 trigger > 700_000,
542 "trigger with capped output reserve should be > 700K, got {trigger}"
543 );
544 }
545
546 #[test]
547 fn alias_chains_through_custom_model() {
548 let _lock = TEST_CFG_LOCK.lock().unwrap();
549 let mut cfg = ModelConfig::default();
550 cfg.aliases.insert(
551 "default".into(),
552 AliasEntry {
553 model: "my-model".into(),
554 },
555 );
556 cfg.models.insert(
557 "my-model".into(),
558 ModelEntry {
559 model: "my-model".into(),
560 context_budget: Some(65_536),
561 compact_threshold_ratio: None,
562 thinking: None,
563 ..Default::default()
564 },
565 );
566 set_model_config(cfg);
567 let info = model_info("default");
568 assert_eq!(info.name, "my-model");
569 assert_eq!(info.context_budget, 65_536);
570 }
571
572 #[test]
573 fn discovered_models_survive_set_model_config() {
574 let _lock = TEST_CFG_LOCK.lock().unwrap();
575 register_discovered(
577 "pid-abc",
578 "Codex",
579 &[crate::provider::DiscoveredModel {
580 slug: "codex/gpt-5".to_string(),
581 context_budget: Some(128_000),
582 thinking: true,
583 }],
584 );
585 assert!(model_entry("Codex:codex/gpt-5").is_some());
586
587 let mut cfg = ModelConfig::default();
589 cfg.aliases.insert(
590 "cheap".into(),
591 AliasEntry {
592 model: "claude-opus-4.7".into(),
593 },
594 );
595 set_model_config(cfg);
596
597 assert!(
599 model_entry("Codex:codex/gpt-5").is_some(),
600 "discovered models should survive set_model_config"
601 );
602 assert_eq!(resolve_alias("cheap"), "claude-opus-4.7");
604 }
605
606 #[test]
607 fn discovered_models_survive_reload_from_text_alias_crud() {
608 let _lock = TEST_CFG_LOCK.lock().unwrap();
609 register_discovered(
610 "pid-abc",
611 "Codex",
612 &[crate::provider::DiscoveredModel {
613 slug: "codex/gpt-5".to_string(),
614 context_budget: Some(128_000),
615 thinking: true,
616 }],
617 );
618
619 let toml = r#"
621[alias]
622smart = { model = "Codex:codex/gpt-5" }
623"#;
624 reload_from_text(toml);
625
626 assert!(
627 model_entry("Codex:codex/gpt-5").is_some(),
628 "discovered models should survive alias CRUD"
629 );
630 assert_eq!(resolve_alias("smart"), "Codex:codex/gpt-5");
631 }
632}