Skip to main content

bamboo_compression/
limits.rs

1//! Model context window limits registry.
2//!
3//! There is intentionally **no** built-in per-model table. Real per-model
4//! values come from provider runtime metadata (e.g. Copilot reports real
5//! context/output) and user overrides persisted in `model_limits.json`.
6//! Runtime resolution gives explicit user configuration precedence over
7//! provider metadata.
8//!
9//! Anything without a match falls back to a single global default
10//! (`DEFAULT_MAX_CONTEXT_TOKENS` / `DEFAULT_MAX_OUTPUT_TOKENS`). This keeps the
11//! registry from going stale as models churn — see `token_budget.rs`.
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::path::PathBuf;
17
18/// Sentinel pattern used for the single global fallback limit.
19pub const DEFAULT_MODEL_PATTERN: &str = "default";
20
21/// Global default context window applied to any model without a provider
22/// metadata value or a user override. 1M reflects the current mainstream
23/// range across frontier models (Claude 3.5, GPT-4o, Gemini 1.5, etc.).
24pub const DEFAULT_MAX_CONTEXT_TOKENS: u32 = 1_000_000;
25
26/// Global default per-request output allowance.
27///
28/// Keep this below the largest theoretical model output so ordinary agent
29/// turns do not reserve half of a smaller context window before compression.
30pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 32_000;
31
32/// Default safety margin for token counting errors (floor; scales with context
33/// window via [`ModelLimit::get_safety_margin`]).
34pub const DEFAULT_SAFETY_MARGIN: u32 = 1000;
35
36/// Build the single global default limit (`1M` context / `32K` output).
37pub 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
45/// Whether a user override is a no-op — identical to the global default, so it
46/// carries no information and need not be persisted (diff-only storage).
47///
48/// The `model_pattern` is irrelevant: any model pinned to exactly the default
49/// context/output with no explicit safety margin resolves to the same budget
50/// as having no override at all.
51pub 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/// Model limit configuration (user-overridable).
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ModelLimit {
60    /// Model identifier (partial match supported, e.g., "gpt-4" matches "gpt-4o")
61    pub model_pattern: String,
62    /// Maximum total context window size (input + output) in tokens
63    pub max_context_tokens: u32,
64    /// Maximum output tokens (defaults to min(max_context / 4,
65    /// DEFAULT_MAX_OUTPUT_TOKENS) when unset — see [`Self::get_max_output_tokens`])
66    #[serde(default)]
67    pub max_output_tokens: Option<u32>,
68    /// Safety margin for token counting (defaults to 1000)
69    #[serde(default)]
70    pub safety_margin: Option<u32>,
71}
72
73impl ModelLimit {
74    /// Create a new model limit with defaults.
75    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    /// Get max output tokens with default calculation.
85    ///
86    /// When unset, derive from the context window (`max_context_tokens / 4`)
87    /// capped at the global [`DEFAULT_MAX_OUTPUT_TOKENS`]. The cap tracks the
88    /// global default rather than a hard-coded `4096`, so a user override like
89    /// `ModelLimit::new("gpt-4o", 128_000)` (no explicit `max_output_tokens`)
90    /// resolves to `min(32_000, 32_000) = 32_000` instead of collapsing to
91    /// `4096` — see issue #20, bug 4.
92    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    /// Get safety margin, scaling proportionally with context window.
98    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/// Registry for model limits with built-in defaults and user overrides.
111#[derive(Debug, Clone)]
112pub struct ModelLimitsRegistry {
113    /// User-provided overrides (higher priority than built-in)
114    user_limits: HashMap<String, ModelLimit>,
115    /// Default path for user configuration file
116    config_path: Option<PathBuf>,
117}
118
119impl ModelLimitsRegistry {
120    /// Create a new registry with built-in defaults only.
121    pub fn new() -> Self {
122        Self {
123            user_limits: HashMap::new(),
124            config_path: None,
125        }
126    }
127
128    /// Create a registry with a specific config file path.
129    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    /// Load user overrides from the registry's configured path.
137    ///
138    /// Accepts both the legacy raw array and the revisioned section envelope
139    /// written by Bamboo's modular configuration store.
140    ///
141    /// No-op when the registry was created without a `config_path` (use
142    /// [`Self::with_config_path`] — e.g. with [`get_default_config_path`] — to
143    /// point it at `{bamboo_data_dir}/model_limits.json`).
144    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            // A previously loaded file may have been deleted between reloads.
151            // Treat absence as an empty override set instead of retaining stale
152            // in-memory patterns.
153            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    /// Add a user limit override.
187    pub fn add_limit(&mut self, limit: ModelLimit) {
188        self.user_limits.insert(limit.model_pattern.clone(), limit);
189    }
190
191    /// Get limit for a model, with user overrides taking priority.
192    ///
193    /// Returns `None` if no matching limit is found.
194    ///
195    /// # Matching Strategy
196    /// 1. Exact match (highest priority)
197    /// 2. Model contains pattern (e.g., "gpt-4o-mini" contains "gpt-4o")
198    ///
199    /// For partial matches, the longest (most specific) pattern wins.
200    ///
201    /// Only the `model.contains(pattern)` direction is correct: the configured
202    /// pattern must be a substring of the runtime model id. The reverse
203    /// (`pattern.contains(model)`) was a bug (#20, bug 3) — it let a short model
204    /// id like `"gpt-4o"` match a longer, unrelated pattern like `"gpt-4o-mini"`
205    /// and inherit the wrong limit.
206    pub fn get(&self, model: &str) -> Option<ModelLimit> {
207        // Exact user override match (highest priority).
208        if let Some(limit) = self.user_limits.get(model) {
209            return Some(limit.clone());
210        }
211
212        // Best partial match among user overrides: the pattern must be a
213        // substring of the model id. Longer (more specific) patterns win for
214        // deterministic selection. There is no built-in table; a miss returns
215        // None and the caller falls back to the global default.
216        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    /// Get limit for a model with fallback to default.
224    pub fn get_or_default(&self, model: &str) -> ModelLimit {
225        self.get(model).unwrap_or_else(default_model_limit)
226    }
227
228    /// Save current user limits to the configured file.
229    ///
230    /// No-op when the registry has no `config_path`.
231    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        // Ensure parent directory exists
237        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    /// List all user-defined limits.
250    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
261/// Get the default configuration file path.
262///
263/// Returns `{bamboo_data_dir}/model_limits.json`, given the Bamboo data dir.
264///
265/// The caller supplies the base directory so this crate stays free of any
266/// infrastructure/filesystem-config dependency.
267pub fn get_default_config_path(bamboo_dir: &std::path::Path) -> PathBuf {
268    bamboo_dir.join("model_limits.json")
269}
270
271/// Load user model limits from the unified `config.json` `model_limits` value.
272///
273/// The caller extracts the raw `model_limits` JSON value (e.g. from
274/// `config.extra.get("model_limits")`) and passes it here, keeping this crate
275/// independent of the concrete `Config` type.
276///
277/// Returns:
278/// - `Ok(None)` when `model_limits` is absent.
279/// - `Ok(Some(vec))` when present and valid (including empty array).
280/// - `Err(...)` when present but not a valid `Vec<ModelLimit>`.
281pub 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
300/// Create a token budget for a specific model, resolving its limit from the
301/// supplied `registry` (with user overrides loaded) and falling back to the
302/// global default when there is no match.
303///
304/// The registry is a required parameter on purpose: a previous version built a
305/// fresh empty `ModelLimitsRegistry::default()` internally, which silently
306/// discarded every user override from `model_limits.json` and always returned
307/// the global default (#20, bug 2). Callers must pass a registry they have
308/// loaded user overrides into (or [`ModelLimitsRegistry::new`] when they
309/// genuinely want the global default).
310pub 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, // legacy — only used when working_reserve_tokens == 0
323        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        // A row pinned to exactly the default values (any pattern) is a no-op.
350        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        // The synthesized global default is itself a no-op override.
355        assert!(is_default_limit(&default_model_limit()));
356
357        // A different context window is a real override.
358        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        // An explicit safety margin is a real override even at default size.
363        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        // No built-in table: an unknown model with no user override has no match.
372        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)); // Override with smaller limit
390
391        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        // "gpt-5.2-codex-preview" contains both patterns; the longest wins.
404        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        // Default is min(max_context / 4, DEFAULT_MAX_OUTPUT_TOKENS)
414        //        = min(25_000, 32_000) = 25_000 (no longer capped at 4096, #20 bug 4)
415        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        // Issue #20 bug 4: a user override created with `ModelLimit::new` leaves
421        // `max_output_tokens = None`. The derived default must scale with the
422        // context window (context / 4) rather than collapsing to 4096.
423        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        // Very large context windows are still capped at the global default so a
428        // single override can't request an unbounded output budget.
429        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        // Issue #20 bug 3: a short model id must NOT match a longer pattern.
436        let mut registry = ModelLimitsRegistry::new();
437        registry.add_limit(ModelLimit::new("gpt-4o-mini", 128_000));
438
439        // "gpt-4o" does not contain "gpt-4o-mini", so it must NOT inherit the
440        // mini override (the old `pattern.contains(model)` direction did).
441        assert!(registry.get("gpt-4o").is_none());
442
443        // The reverse still works: a model id that contains the pattern matches.
444        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        // Default is min(8192 / 4, 4096) = 2048
461        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        // Small context → floor at DEFAULT_SAFETY_MARGIN (1000)
501        let small = ModelLimit::new("test", 8_192);
502        assert_eq!(small.get_safety_margin(), 1000);
503
504        // Medium context → proportional
505        let medium = ModelLimit::new("test", 200_000);
506        assert_eq!(medium.get_safety_margin(), 2000);
507
508        // Large context → proportional
509        let large = ModelLimit::new("test", 1_050_000);
510        assert_eq!(large.get_safety_margin(), 10_500);
511
512        // Explicit override takes precedence
513        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        // Integration: a `model_limits.json` on disk → registry load → resolve.
521        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        // Persisted override is applied at runtime.
534        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        // Unknown model with no override falls back to the single global default.
539        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        // Full chain (issue #20 acceptance): set a model limit on disk →
657        // load registry → build the runtime TokenBudget → the budget reflects
658        // the user-configured context window, not a stale/default value.
659        let dir = tempfile::tempdir().expect("tempdir");
660        let path = dir.path().join("model_limits.json");
661        // Note: NO explicit max_output_tokens, exercising the bug-4 default path.
662        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        // The runtime budget for the configured model matches the user limit...
673        let budget = create_budget_for_model("gpt-4o", crate::BudgetStrategy::default(), &registry);
674        assert_eq!(budget.max_context_tokens, 128_000);
675        // ...and the derived output budget is context/4 (32K), not the old 4096 cap.
676        assert_eq!(budget.max_output_tokens, 32_000);
677
678        // A model that does NOT contain the pattern is unaffected (bug-3 fix):
679        // it resolves to the global default, not the gpt-4o override.
680        let other =
681            create_budget_for_model("claude-sonnet", crate::BudgetStrategy::default(), &registry);
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        // An empty registry yields the global default for any model.
688        let registry = ModelLimitsRegistry::new();
689        let budget = create_budget_for_model(
690            "anything-at-all",
691            crate::BudgetStrategy::default(),
692            &registry,
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(), &registry);
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        // Issue #20 bug 2: the budget must reflect the user override carried by
714        // the registry, not silently fall back to the global default.
715        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(), &registry);
719        assert_eq!(budget.max_context_tokens, 128_000);
720        // And the derived output budget is the un-capped context/4 (bug 4), not 4096.
721        assert_eq!(budget.max_output_tokens, 32_000);
722    }
723}