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