1use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::path::PathBuf;
17
18pub const DEFAULT_MODEL_PATTERN: &str = "default";
20
21pub const DEFAULT_MAX_CONTEXT_TOKENS: u32 = 1_000_000;
25
26pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 128_000;
28
29pub const DEFAULT_SAFETY_MARGIN: u32 = 1000;
32
33pub fn default_model_limit() -> ModelLimit {
35 builtin_limit(
36 DEFAULT_MODEL_PATTERN,
37 DEFAULT_MAX_CONTEXT_TOKENS,
38 DEFAULT_MAX_OUTPUT_TOKENS,
39 )
40}
41
42pub fn is_default_limit(limit: &ModelLimit) -> bool {
49 limit.max_context_tokens == DEFAULT_MAX_CONTEXT_TOKENS
50 && limit.max_output_tokens == Some(DEFAULT_MAX_OUTPUT_TOKENS)
51 && limit.safety_margin.is_none()
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ModelLimit {
57 pub model_pattern: String,
59 pub max_context_tokens: u32,
61 #[serde(default)]
64 pub max_output_tokens: Option<u32>,
65 #[serde(default)]
67 pub safety_margin: Option<u32>,
68}
69
70impl ModelLimit {
71 pub fn new(model_pattern: impl Into<String>, max_context_tokens: u32) -> Self {
73 Self {
74 model_pattern: model_pattern.into(),
75 max_context_tokens,
76 max_output_tokens: None,
77 safety_margin: None,
78 }
79 }
80
81 pub fn get_max_output_tokens(&self) -> u32 {
90 self.max_output_tokens
91 .unwrap_or_else(|| (self.max_context_tokens / 4).min(DEFAULT_MAX_OUTPUT_TOKENS))
92 }
93
94 pub fn get_safety_margin(&self) -> u32 {
96 self.safety_margin
97 .unwrap_or_else(|| (self.max_context_tokens / 100).max(DEFAULT_SAFETY_MARGIN))
98 }
99}
100
101fn builtin_limit(pattern: &str, max_context_tokens: u32, max_output_tokens: u32) -> ModelLimit {
102 let mut limit = ModelLimit::new(pattern.to_string(), max_context_tokens);
103 limit.max_output_tokens = Some(max_output_tokens);
104 limit
105}
106
107#[derive(Debug, Clone)]
109pub struct ModelLimitsRegistry {
110 user_limits: HashMap<String, ModelLimit>,
112 config_path: Option<PathBuf>,
114}
115
116impl ModelLimitsRegistry {
117 pub fn new() -> Self {
119 Self {
120 user_limits: HashMap::new(),
121 config_path: None,
122 }
123 }
124
125 pub fn with_config_path(path: impl Into<PathBuf>) -> Self {
127 Self {
128 user_limits: HashMap::new(),
129 config_path: Some(path.into()),
130 }
131 }
132
133 pub async fn load_user_config(&mut self) -> std::io::Result<()> {
142 let Some(path) = self.config_path.clone() else {
143 return Ok(());
144 };
145
146 if !path.exists() {
147 self.user_limits.clear();
151 return Ok(());
152 }
153
154 let content = tokio::fs::read_to_string(&path).await?;
155 let value: Value = serde_json::from_str(&content)
156 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
157 let data = value
158 .as_object()
159 .filter(|object| {
160 object.contains_key("schema_version")
161 && object.contains_key("revision")
162 && object.contains_key("data")
163 })
164 .and_then(|object| object.get("data"))
165 .cloned()
166 .unwrap_or(value);
167 let limits: Vec<ModelLimit> = serde_json::from_value(data)
168 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
169
170 self.user_limits = limits
171 .into_iter()
172 .map(|limit| (limit.model_pattern.clone(), limit))
173 .collect();
174
175 tracing::info!(
176 "Loaded {} user model limits from {:?}",
177 self.user_limits.len(),
178 path
179 );
180 Ok(())
181 }
182
183 pub fn add_limit(&mut self, limit: ModelLimit) {
185 self.user_limits.insert(limit.model_pattern.clone(), limit);
186 }
187
188 pub fn get(&self, model: &str) -> Option<ModelLimit> {
204 if let Some(limit) = self.user_limits.get(model) {
206 return Some(limit.clone());
207 }
208
209 self.user_limits
214 .iter()
215 .filter(|(pattern, _)| model.contains(pattern.as_str()))
216 .max_by_key(|(pattern, _)| pattern.len())
217 .map(|(_, limit)| limit.clone())
218 }
219
220 pub fn get_or_default(&self, model: &str) -> ModelLimit {
222 self.get(model).unwrap_or_else(default_model_limit)
223 }
224
225 pub async fn save_user_config(&self) -> std::io::Result<()> {
229 let Some(path) = self.config_path.clone() else {
230 return Ok(());
231 };
232
233 if let Some(parent) = path.parent() {
235 tokio::fs::create_dir_all(parent).await?;
236 }
237
238 let limits: Vec<&ModelLimit> = self.user_limits.values().collect();
239 let content = serde_json::to_string_pretty(&limits)
240 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
241 tokio::fs::write(&path, content).await?;
242
243 Ok(())
244 }
245
246 pub fn list_user_limits(&self) -> Vec<&ModelLimit> {
248 self.user_limits.values().collect()
249 }
250}
251
252impl Default for ModelLimitsRegistry {
253 fn default() -> Self {
254 Self::new()
255 }
256}
257
258pub fn get_default_config_path(bamboo_dir: &std::path::Path) -> PathBuf {
265 bamboo_dir.join("model_limits.json")
266}
267
268pub fn load_model_limits_from_unified_config(
279 raw_limits: Option<&Value>,
280) -> Result<Option<Vec<ModelLimit>>, String> {
281 let Some(raw_limits) = raw_limits else {
282 return Ok(None);
283 };
284
285 if raw_limits.is_null() {
286 return Ok(Some(Vec::new()));
287 }
288
289 match raw_limits {
290 Value::Array(_) => serde_json::from_value::<Vec<ModelLimit>>(raw_limits.clone())
291 .map(Some)
292 .map_err(|error| format!("invalid config.model_limits format: {error}")),
293 _ => Err("invalid config.model_limits format: expected array".to_string()),
294 }
295}
296
297pub fn create_budget_for_model(
308 model: &str,
309 strategy: crate::BudgetStrategy,
310 registry: &ModelLimitsRegistry,
311) -> crate::TokenBudget {
312 let limit = registry.get_or_default(model);
313
314 crate::TokenBudget {
315 max_context_tokens: limit.max_context_tokens,
316 max_output_tokens: limit.get_max_output_tokens(),
317 strategy,
318 safety_margin: limit.get_safety_margin(),
319 compression_trigger_percent: 85, compression_target_percent: 45,
321 working_reserve_tokens: 50_000,
322 fallback_trigger_percent: 75,
323 prompt_cache_min_tool_output_chars: 1_200,
324 prompt_cache_head_chars: 280,
325 prompt_cache_tail_chars: 180,
326 prompt_cache_recent_user_turns: 2,
327 prompt_cache_recent_tool_chains: 2,
328 max_tool_output_tokens: 0,
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn default_limit_is_1m_128k() {
338 let limit = default_model_limit();
339 assert_eq!(limit.model_pattern, DEFAULT_MODEL_PATTERN);
340 assert_eq!(limit.max_context_tokens, 1_000_000);
341 assert_eq!(limit.get_max_output_tokens(), 128_000);
342 }
343
344 #[test]
345 fn is_default_limit_detects_no_op_overrides() {
346 let mut noop = ModelLimit::new("gpt-4o", DEFAULT_MAX_CONTEXT_TOKENS);
348 noop.max_output_tokens = Some(DEFAULT_MAX_OUTPUT_TOKENS);
349 assert!(is_default_limit(&noop));
350
351 assert!(is_default_limit(&default_model_limit()));
353
354 let mut smaller = ModelLimit::new("gpt-4o", 128_000);
356 smaller.max_output_tokens = Some(DEFAULT_MAX_OUTPUT_TOKENS);
357 assert!(!is_default_limit(&smaller));
358
359 let mut custom_margin = ModelLimit::new("gpt-4o", DEFAULT_MAX_CONTEXT_TOKENS);
361 custom_margin.max_output_tokens = Some(DEFAULT_MAX_OUTPUT_TOKENS);
362 custom_margin.safety_margin = Some(500);
363 assert!(!is_default_limit(&custom_margin));
364 }
365
366 #[test]
367 fn registry_returns_none_for_unknown_without_overrides() {
368 let registry = ModelLimitsRegistry::new();
370 assert!(registry.get("gpt-5.2-codex").is_none());
371 assert!(registry.get("some-brand-new-model").is_none());
372 }
373
374 #[test]
375 fn registry_returns_default_for_unknown() {
376 let registry = ModelLimitsRegistry::new();
377 let limit = registry.get_or_default("unknown-model-xyz");
378 assert_eq!(limit.model_pattern, DEFAULT_MODEL_PATTERN);
379 assert_eq!(limit.max_context_tokens, 1_000_000);
380 assert_eq!(limit.get_max_output_tokens(), 128_000);
381 }
382
383 #[test]
384 fn user_override_exact_match_wins() {
385 let mut registry = ModelLimitsRegistry::new();
386 registry.add_limit(ModelLimit::new("gpt-5.2-codex", 64_000)); let limit = registry
389 .get("gpt-5.2-codex")
390 .expect("Should find overridden limit");
391 assert_eq!(limit.max_context_tokens, 64_000);
392 }
393
394 #[test]
395 fn user_override_partial_match_longest_wins() {
396 let mut registry = ModelLimitsRegistry::new();
397 registry.add_limit(ModelLimit::new("gpt-5", 111_000));
398 registry.add_limit(ModelLimit::new("gpt-5.2-codex", 222_000));
399
400 let limit = registry
402 .get("gpt-5.2-codex-preview")
403 .expect("Should partial-match a user override");
404 assert_eq!(limit.max_context_tokens, 222_000);
405 }
406
407 #[test]
408 fn model_limit_calculates_default_output_tokens() {
409 let limit = ModelLimit::new("test", 100_000);
410 assert_eq!(limit.get_max_output_tokens(), 25_000);
413 }
414
415 #[test]
416 fn user_override_without_explicit_output_is_not_capped_at_4096() {
417 let gpt4o = ModelLimit::new("gpt-4o", 128_000);
421 assert!(gpt4o.max_output_tokens.is_none());
422 assert_eq!(gpt4o.get_max_output_tokens(), 32_000);
423
424 let huge = ModelLimit::new("huge", 2_000_000);
427 assert_eq!(huge.get_max_output_tokens(), DEFAULT_MAX_OUTPUT_TOKENS);
428 }
429
430 #[test]
431 fn matching_is_directional_model_contains_pattern_only() {
432 let mut registry = ModelLimitsRegistry::new();
434 registry.add_limit(ModelLimit::new("gpt-4o-mini", 128_000));
435
436 assert!(registry.get("gpt-4o").is_none());
439
440 let mini = registry
442 .get("gpt-4o-mini-2024")
443 .expect("model id contains the pattern");
444 assert_eq!(mini.max_context_tokens, 128_000);
445 }
446
447 #[test]
448 fn model_limit_uses_custom_output_tokens() {
449 let mut limit = ModelLimit::new("test", 100_000);
450 limit.max_output_tokens = Some(8192);
451 assert_eq!(limit.get_max_output_tokens(), 8192);
452 }
453
454 #[test]
455 fn model_limit_calculates_small_context_output() {
456 let limit = ModelLimit::new("test", 8_192);
457 assert_eq!(limit.get_max_output_tokens(), 2048);
459 }
460
461 #[test]
462 fn unified_config_loader_returns_none_when_absent() {
463 let loaded = load_model_limits_from_unified_config(None).expect("should parse");
464 assert!(loaded.is_none());
465 }
466
467 #[test]
468 fn unified_config_loader_reads_valid_model_limits() {
469 let raw = serde_json::json!([
470 {
471 "model_pattern": "gpt-5.2-codex",
472 "max_context_tokens": 64000,
473 "max_output_tokens": 2048,
474 "safety_margin": 512
475 }
476 ]);
477
478 let loaded = load_model_limits_from_unified_config(Some(&raw))
479 .expect("should parse")
480 .expect("should exist");
481 assert_eq!(loaded.len(), 1);
482 assert_eq!(loaded[0].model_pattern, "gpt-5.2-codex");
483 assert_eq!(loaded[0].max_context_tokens, 64_000);
484 assert_eq!(loaded[0].max_output_tokens, Some(2048));
485 assert_eq!(loaded[0].safety_margin, Some(512));
486 }
487
488 #[test]
489 fn unified_config_loader_errors_on_invalid_shape() {
490 let raw = serde_json::json!({"unexpected": true});
491 let error = load_model_limits_from_unified_config(Some(&raw)).expect_err("should error");
492 assert!(error.contains("expected array"));
493 }
494
495 #[test]
496 fn safety_margin_scales_with_context_window() {
497 let small = ModelLimit::new("test", 8_192);
499 assert_eq!(small.get_safety_margin(), 1000);
500
501 let medium = ModelLimit::new("test", 200_000);
503 assert_eq!(medium.get_safety_margin(), 2000);
504
505 let large = ModelLimit::new("test", 1_050_000);
507 assert_eq!(large.get_safety_margin(), 10_500);
508
509 let mut custom = ModelLimit::new("test", 200_000);
511 custom.safety_margin = Some(500);
512 assert_eq!(custom.get_safety_margin(), 500);
513 }
514
515 #[tokio::test]
516 async fn persisted_overrides_drive_runtime_resolution() {
517 let dir = tempfile::tempdir().expect("tempdir");
519 let path = dir.path().join("model_limits.json");
520 tokio::fs::write(
521 &path,
522 r#"[{"model_pattern":"gpt-4o","max_context_tokens":128000,"max_output_tokens":16384}]"#,
523 )
524 .await
525 .expect("seed overrides");
526
527 let mut registry = ModelLimitsRegistry::with_config_path(path);
528 registry.load_user_config().await.expect("load user config");
529
530 let gpt4o = registry.get("gpt-4o").expect("override present");
532 assert_eq!(gpt4o.max_context_tokens, 128_000);
533 assert_eq!(gpt4o.get_max_output_tokens(), 16_384);
534
535 let unknown = registry.get_or_default("brand-new-frontier-model");
537 assert_eq!(unknown.model_pattern, DEFAULT_MODEL_PATTERN);
538 assert_eq!(unknown.max_context_tokens, 1_000_000);
539 assert_eq!(unknown.get_max_output_tokens(), 128_000);
540 }
541
542 #[tokio::test]
543 async fn revisioned_model_limits_sidecar_drives_runtime_resolution() {
544 let dir = tempfile::tempdir().expect("tempdir");
545 let path = dir.path().join("model_limits.json");
546 tokio::fs::write(
547 &path,
548 r#"{
549 "schema_version": 1,
550 "revision": 7,
551 "data": [{
552 "model_pattern": "dynamic-summary-model",
553 "max_context_tokens": 96000,
554 "max_output_tokens": 12000,
555 "safety_margin": 800
556 }]
557 }"#,
558 )
559 .await
560 .expect("seed revisioned overrides");
561
562 let mut registry = ModelLimitsRegistry::with_config_path(path);
563 registry
564 .load_user_config()
565 .await
566 .expect("load revisioned model-limit sidecar");
567
568 let limit = registry
569 .get("dynamic-summary-model")
570 .expect("revisioned override present");
571 assert_eq!(limit.max_context_tokens, 96_000);
572 assert_eq!(limit.get_max_output_tokens(), 12_000);
573 assert_eq!(limit.get_safety_margin(), 800);
574 }
575
576 #[tokio::test]
577 async fn reloading_user_config_replaces_removed_patterns() {
578 let dir = tempfile::tempdir().expect("tempdir");
579 let path = dir.path().join("model_limits.json");
580 tokio::fs::write(
581 &path,
582 r#"[{
583 "model_pattern": "removed-model",
584 "max_context_tokens": 64000,
585 "max_output_tokens": 8000
586 }]"#,
587 )
588 .await
589 .expect("seed legacy sidecar");
590
591 let mut registry = ModelLimitsRegistry::with_config_path(&path);
592 registry.load_user_config().await.expect("first load");
593 assert!(registry.get("removed-model").is_some());
594
595 tokio::fs::write(
596 &path,
597 r#"{
598 "schema_version": 1,
599 "revision": 2,
600 "data": [{
601 "model_pattern": "replacement-model",
602 "max_context_tokens": 96000,
603 "max_output_tokens": 12000
604 }]
605 }"#,
606 )
607 .await
608 .expect("replace sidecar");
609 registry.load_user_config().await.expect("reload");
610
611 assert!(registry.get("removed-model").is_none());
612 assert_eq!(
613 registry
614 .get("replacement-model")
615 .expect("replacement pattern")
616 .max_context_tokens,
617 96_000
618 );
619 }
620
621 #[tokio::test]
622 async fn reloading_after_sidecar_deletion_clears_stale_patterns() {
623 let dir = tempfile::tempdir().expect("tempdir");
624 let path = dir.path().join("model_limits.json");
625 tokio::fs::write(
626 &path,
627 r#"[{
628 "model_pattern": "deleted-model",
629 "max_context_tokens": 64000,
630 "max_output_tokens": 8000
631 }]"#,
632 )
633 .await
634 .expect("seed model-limit sidecar");
635
636 let mut registry = ModelLimitsRegistry::with_config_path(&path);
637 registry.load_user_config().await.expect("first load");
638 assert!(registry.get("deleted-model").is_some());
639
640 tokio::fs::remove_file(&path)
641 .await
642 .expect("remove model-limit sidecar");
643 registry.load_user_config().await.expect("reload deletion");
644
645 assert!(
646 registry.get("deleted-model").is_none(),
647 "deleting the sidecar must not leave its patterns active in memory"
648 );
649 }
650
651 #[tokio::test]
652 async fn persisted_override_drives_runtime_token_budget() {
653 let dir = tempfile::tempdir().expect("tempdir");
657 let path = dir.path().join("model_limits.json");
658 tokio::fs::write(
660 &path,
661 r#"[{"model_pattern":"gpt-4o","max_context_tokens":128000}]"#,
662 )
663 .await
664 .expect("seed overrides");
665
666 let mut registry = ModelLimitsRegistry::with_config_path(path);
667 registry.load_user_config().await.expect("load user config");
668
669 let budget = create_budget_for_model("gpt-4o", crate::BudgetStrategy::default(), ®istry);
671 assert_eq!(budget.max_context_tokens, 128_000);
672 assert_eq!(budget.max_output_tokens, 32_000);
674
675 let other =
678 create_budget_for_model("claude-sonnet", crate::BudgetStrategy::default(), ®istry);
679 assert_eq!(other.max_context_tokens, 1_000_000);
680 }
681
682 #[test]
683 fn create_budget_for_model_uses_global_default_for_unmatched_model() {
684 let registry = ModelLimitsRegistry::new();
686 let budget = create_budget_for_model(
687 "anything-at-all",
688 crate::BudgetStrategy::default(),
689 ®istry,
690 );
691 assert_eq!(budget.max_context_tokens, 1_000_000);
692 assert_eq!(budget.max_output_tokens, 128_000);
693 }
694
695 #[test]
696 fn create_budget_for_model_honors_registry_user_overrides() {
697 let mut registry = ModelLimitsRegistry::new();
700 registry.add_limit(ModelLimit::new("gpt-4o", 128_000));
701
702 let budget = create_budget_for_model("gpt-4o", crate::BudgetStrategy::default(), ®istry);
703 assert_eq!(budget.max_context_tokens, 128_000);
704 assert_eq!(budget.max_output_tokens, 32_000);
706 }
707}