use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
pub const DEFAULT_MODEL_PATTERN: &str = "default";
pub const DEFAULT_MAX_CONTEXT_TOKENS: u32 = 200_000;
pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 64_000;
pub const DEFAULT_SAFETY_MARGIN: u32 = 1000;
pub fn default_model_limit() -> ModelLimit {
builtin_limit(
DEFAULT_MODEL_PATTERN,
DEFAULT_MAX_CONTEXT_TOKENS,
DEFAULT_MAX_OUTPUT_TOKENS,
)
}
pub fn is_default_limit(limit: &ModelLimit) -> bool {
limit.max_context_tokens == DEFAULT_MAX_CONTEXT_TOKENS
&& limit.max_output_tokens == Some(DEFAULT_MAX_OUTPUT_TOKENS)
&& limit.safety_margin.is_none()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelLimit {
pub model_pattern: String,
pub max_context_tokens: u32,
#[serde(default)]
pub max_output_tokens: Option<u32>,
#[serde(default)]
pub safety_margin: Option<u32>,
}
impl ModelLimit {
pub fn new(model_pattern: impl Into<String>, max_context_tokens: u32) -> Self {
Self {
model_pattern: model_pattern.into(),
max_context_tokens,
max_output_tokens: None,
safety_margin: None,
}
}
pub fn get_max_output_tokens(&self) -> u32 {
self.max_output_tokens
.unwrap_or_else(|| (self.max_context_tokens / 4).min(4096))
}
pub fn get_safety_margin(&self) -> u32 {
self.safety_margin
.unwrap_or_else(|| (self.max_context_tokens / 100).max(DEFAULT_SAFETY_MARGIN))
}
}
fn builtin_limit(pattern: &str, max_context_tokens: u32, max_output_tokens: u32) -> ModelLimit {
let mut limit = ModelLimit::new(pattern.to_string(), max_context_tokens);
limit.max_output_tokens = Some(max_output_tokens);
limit
}
#[derive(Debug, Clone)]
pub struct ModelLimitsRegistry {
user_limits: HashMap<String, ModelLimit>,
config_path: Option<PathBuf>,
}
impl ModelLimitsRegistry {
pub fn new() -> Self {
Self {
user_limits: HashMap::new(),
config_path: None,
}
}
pub fn with_config_path(path: impl Into<PathBuf>) -> Self {
Self {
user_limits: HashMap::new(),
config_path: Some(path.into()),
}
}
pub async fn load_user_config(&mut self) -> std::io::Result<()> {
let path = self
.config_path
.clone()
.unwrap_or_else(get_default_config_path);
if !path.exists() {
return Ok(());
}
let content = tokio::fs::read_to_string(&path).await?;
let limits: Vec<ModelLimit> = serde_json::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
for limit in limits {
self.user_limits.insert(limit.model_pattern.clone(), limit);
}
tracing::info!(
"Loaded {} user model limits from {:?}",
self.user_limits.len(),
path
);
Ok(())
}
pub fn add_limit(&mut self, limit: ModelLimit) {
self.user_limits.insert(limit.model_pattern.clone(), limit);
}
pub fn get(&self, model: &str) -> Option<ModelLimit> {
if let Some(limit) = self.user_limits.get(model) {
return Some(limit.clone());
}
self.user_limits
.iter()
.filter(|(pattern, _)| model.contains(pattern.as_str()) || pattern.contains(model))
.max_by_key(|(pattern, _)| pattern.len())
.map(|(_, limit)| limit.clone())
}
pub fn get_or_default(&self, model: &str) -> ModelLimit {
self.get(model).unwrap_or_else(default_model_limit)
}
pub async fn save_user_config(&self) -> std::io::Result<()> {
let path = self
.config_path
.clone()
.unwrap_or_else(get_default_config_path);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let limits: Vec<&ModelLimit> = self.user_limits.values().collect();
let content = serde_json::to_string_pretty(&limits)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
tokio::fs::write(&path, content).await?;
Ok(())
}
pub fn list_user_limits(&self) -> Vec<&ModelLimit> {
self.user_limits.values().collect()
}
}
impl Default for ModelLimitsRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn get_default_config_path() -> PathBuf {
bamboo_infrastructure::paths::bamboo_dir().join("model_limits.json")
}
pub fn load_model_limits_from_unified_config(
config: &bamboo_infrastructure::Config,
) -> Result<Option<Vec<ModelLimit>>, String> {
let Some(raw_limits) = config.extra.get("model_limits") else {
return Ok(None);
};
if raw_limits.is_null() {
return Ok(Some(Vec::new()));
}
match raw_limits {
Value::Array(_) => serde_json::from_value::<Vec<ModelLimit>>(raw_limits.clone())
.map(Some)
.map_err(|error| format!("invalid config.model_limits format: {error}")),
_ => Err("invalid config.model_limits format: expected array".to_string()),
}
}
pub fn create_budget_for_model(model: &str, strategy: crate::BudgetStrategy) -> crate::TokenBudget {
let registry = ModelLimitsRegistry::default();
let limit = registry.get_or_default(model);
crate::TokenBudget {
max_context_tokens: limit.max_context_tokens,
max_output_tokens: limit.get_max_output_tokens(),
strategy,
safety_margin: limit.get_safety_margin(),
compression_trigger_percent: 85, compression_target_percent: 45,
working_reserve_tokens: 50_000,
fallback_trigger_percent: 75,
prompt_cache_min_tool_output_chars: 1_200,
prompt_cache_head_chars: 280,
prompt_cache_tail_chars: 180,
prompt_cache_recent_user_turns: 2,
prompt_cache_recent_tool_chains: 2,
max_tool_output_tokens: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_limit_is_200k_64k() {
let limit = default_model_limit();
assert_eq!(limit.model_pattern, DEFAULT_MODEL_PATTERN);
assert_eq!(limit.max_context_tokens, 200_000);
assert_eq!(limit.get_max_output_tokens(), 64_000);
}
#[test]
fn is_default_limit_detects_no_op_overrides() {
let mut noop = ModelLimit::new("gpt-4o", DEFAULT_MAX_CONTEXT_TOKENS);
noop.max_output_tokens = Some(DEFAULT_MAX_OUTPUT_TOKENS);
assert!(is_default_limit(&noop));
assert!(is_default_limit(&default_model_limit()));
let mut smaller = ModelLimit::new("gpt-4o", 128_000);
smaller.max_output_tokens = Some(DEFAULT_MAX_OUTPUT_TOKENS);
assert!(!is_default_limit(&smaller));
let mut custom_margin = ModelLimit::new("gpt-4o", DEFAULT_MAX_CONTEXT_TOKENS);
custom_margin.max_output_tokens = Some(DEFAULT_MAX_OUTPUT_TOKENS);
custom_margin.safety_margin = Some(500);
assert!(!is_default_limit(&custom_margin));
}
#[test]
fn registry_returns_none_for_unknown_without_overrides() {
let registry = ModelLimitsRegistry::new();
assert!(registry.get("gpt-5.2-codex").is_none());
assert!(registry.get("some-brand-new-model").is_none());
}
#[test]
fn registry_returns_default_for_unknown() {
let registry = ModelLimitsRegistry::new();
let limit = registry.get_or_default("unknown-model-xyz");
assert_eq!(limit.model_pattern, DEFAULT_MODEL_PATTERN);
assert_eq!(limit.max_context_tokens, 200_000);
assert_eq!(limit.get_max_output_tokens(), 64_000);
}
#[test]
fn user_override_exact_match_wins() {
let mut registry = ModelLimitsRegistry::new();
registry.add_limit(ModelLimit::new("gpt-5.2-codex", 64_000));
let limit = registry
.get("gpt-5.2-codex")
.expect("Should find overridden limit");
assert_eq!(limit.max_context_tokens, 64_000);
}
#[test]
fn user_override_partial_match_longest_wins() {
let mut registry = ModelLimitsRegistry::new();
registry.add_limit(ModelLimit::new("gpt-5", 111_000));
registry.add_limit(ModelLimit::new("gpt-5.2-codex", 222_000));
let limit = registry
.get("gpt-5.2-codex-preview")
.expect("Should partial-match a user override");
assert_eq!(limit.max_context_tokens, 222_000);
}
#[test]
fn model_limit_calculates_default_output_tokens() {
let limit = ModelLimit::new("test", 100_000);
assert_eq!(limit.get_max_output_tokens(), 4096);
}
#[test]
fn model_limit_uses_custom_output_tokens() {
let mut limit = ModelLimit::new("test", 100_000);
limit.max_output_tokens = Some(8192);
assert_eq!(limit.get_max_output_tokens(), 8192);
}
#[test]
fn model_limit_calculates_small_context_output() {
let limit = ModelLimit::new("test", 8_192);
assert_eq!(limit.get_max_output_tokens(), 2048);
}
#[test]
fn unified_config_loader_returns_none_when_absent() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config =
bamboo_infrastructure::Config::from_data_dir(Some(temp_dir.path().to_path_buf()));
let loaded = load_model_limits_from_unified_config(&config).expect("should parse");
assert!(loaded.is_none());
}
#[test]
fn unified_config_loader_reads_valid_model_limits() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let mut config =
bamboo_infrastructure::Config::from_data_dir(Some(temp_dir.path().to_path_buf()));
config.extra.insert(
"model_limits".to_string(),
serde_json::json!([
{
"model_pattern": "gpt-5.2-codex",
"max_context_tokens": 64000,
"max_output_tokens": 2048,
"safety_margin": 512
}
]),
);
let loaded = load_model_limits_from_unified_config(&config)
.expect("should parse")
.expect("should exist");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].model_pattern, "gpt-5.2-codex");
assert_eq!(loaded[0].max_context_tokens, 64_000);
assert_eq!(loaded[0].max_output_tokens, Some(2048));
assert_eq!(loaded[0].safety_margin, Some(512));
}
#[test]
fn unified_config_loader_errors_on_invalid_shape() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let mut config =
bamboo_infrastructure::Config::from_data_dir(Some(temp_dir.path().to_path_buf()));
config.extra.insert(
"model_limits".to_string(),
serde_json::json!({"unexpected": true}),
);
let error = load_model_limits_from_unified_config(&config).expect_err("should error");
assert!(error.contains("expected array"));
}
#[test]
fn safety_margin_scales_with_context_window() {
let small = ModelLimit::new("test", 8_192);
assert_eq!(small.get_safety_margin(), 1000);
let medium = ModelLimit::new("test", 200_000);
assert_eq!(medium.get_safety_margin(), 2000);
let large = ModelLimit::new("test", 1_050_000);
assert_eq!(large.get_safety_margin(), 10_500);
let mut custom = ModelLimit::new("test", 200_000);
custom.safety_margin = Some(500);
assert_eq!(custom.get_safety_margin(), 500);
}
#[tokio::test]
async fn persisted_overrides_drive_runtime_resolution() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("model_limits.json");
tokio::fs::write(
&path,
r#"[{"model_pattern":"gpt-4o","max_context_tokens":128000,"max_output_tokens":16384}]"#,
)
.await
.expect("seed overrides");
let mut registry = ModelLimitsRegistry::with_config_path(path);
registry.load_user_config().await.expect("load user config");
let gpt4o = registry.get("gpt-4o").expect("override present");
assert_eq!(gpt4o.max_context_tokens, 128_000);
assert_eq!(gpt4o.get_max_output_tokens(), 16_384);
let unknown = registry.get_or_default("brand-new-frontier-model");
assert_eq!(unknown.model_pattern, DEFAULT_MODEL_PATTERN);
assert_eq!(unknown.max_context_tokens, 200_000);
assert_eq!(unknown.get_max_output_tokens(), 64_000);
}
#[test]
fn create_budget_for_model_uses_global_default_for_any_model() {
let budget = create_budget_for_model("anything-at-all", crate::BudgetStrategy::default());
assert_eq!(budget.max_context_tokens, 200_000);
assert_eq!(budget.max_output_tokens, 64_000);
}
}