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 maximum output tokens.
27pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 128_000;
28
29/// Default safety margin for token counting errors (floor; scales with context
30/// window via [`ModelLimit::get_safety_margin`]).
31pub const DEFAULT_SAFETY_MARGIN: u32 = 1000;
32
33/// Build the single global default limit (`1M` context / `128K` output).
34pub 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
42/// Whether a user override is a no-op — identical to the global default, so it
43/// carries no information and need not be persisted (diff-only storage).
44///
45/// The `model_pattern` is irrelevant: any model pinned to exactly the default
46/// context/output with no explicit safety margin resolves to the same budget
47/// as having no override at all.
48pub 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/// Model limit configuration (user-overridable).
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ModelLimit {
57    /// Model identifier (partial match supported, e.g., "gpt-4" matches "gpt-4o")
58    pub model_pattern: String,
59    /// Maximum total context window size (input + output) in tokens
60    pub max_context_tokens: u32,
61    /// Maximum output tokens (defaults to min(max_context / 4,
62    /// DEFAULT_MAX_OUTPUT_TOKENS) when unset — see [`Self::get_max_output_tokens`])
63    #[serde(default)]
64    pub max_output_tokens: Option<u32>,
65    /// Safety margin for token counting (defaults to 1000)
66    #[serde(default)]
67    pub safety_margin: Option<u32>,
68}
69
70impl ModelLimit {
71    /// Create a new model limit with defaults.
72    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    /// Get max output tokens with default calculation.
82    ///
83    /// When unset, derive from the context window (`max_context_tokens / 4`)
84    /// capped at the global [`DEFAULT_MAX_OUTPUT_TOKENS`]. The cap tracks the
85    /// global default rather than a hard-coded `4096`, so a user override like
86    /// `ModelLimit::new("gpt-4o", 128_000)` (no explicit `max_output_tokens`)
87    /// resolves to `min(32_000, 128_000) = 32_000` instead of collapsing to
88    /// `4096` — see issue #20, bug 4.
89    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    /// Get safety margin, scaling proportionally with context window.
95    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/// Registry for model limits with built-in defaults and user overrides.
108#[derive(Debug, Clone)]
109pub struct ModelLimitsRegistry {
110    /// User-provided overrides (higher priority than built-in)
111    user_limits: HashMap<String, ModelLimit>,
112    /// Default path for user configuration file
113    config_path: Option<PathBuf>,
114}
115
116impl ModelLimitsRegistry {
117    /// Create a new registry with built-in defaults only.
118    pub fn new() -> Self {
119        Self {
120            user_limits: HashMap::new(),
121            config_path: None,
122        }
123    }
124
125    /// Create a registry with a specific config file path.
126    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    /// Load user overrides from the registry's configured path.
134    ///
135    /// Accepts both the legacy raw array and the revisioned section envelope
136    /// written by Bamboo's modular configuration store.
137    ///
138    /// No-op when the registry was created without a `config_path` (use
139    /// [`Self::with_config_path`] — e.g. with [`get_default_config_path`] — to
140    /// point it at `{bamboo_data_dir}/model_limits.json`).
141    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            // A previously loaded file may have been deleted between reloads.
148            // Treat absence as an empty override set instead of retaining stale
149            // in-memory patterns.
150            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    /// Add a user limit override.
184    pub fn add_limit(&mut self, limit: ModelLimit) {
185        self.user_limits.insert(limit.model_pattern.clone(), limit);
186    }
187
188    /// Get limit for a model, with user overrides taking priority.
189    ///
190    /// Returns `None` if no matching limit is found.
191    ///
192    /// # Matching Strategy
193    /// 1. Exact match (highest priority)
194    /// 2. Model contains pattern (e.g., "gpt-4o-mini" contains "gpt-4o")
195    ///
196    /// For partial matches, the longest (most specific) pattern wins.
197    ///
198    /// Only the `model.contains(pattern)` direction is correct: the configured
199    /// pattern must be a substring of the runtime model id. The reverse
200    /// (`pattern.contains(model)`) was a bug (#20, bug 3) — it let a short model
201    /// id like `"gpt-4o"` match a longer, unrelated pattern like `"gpt-4o-mini"`
202    /// and inherit the wrong limit.
203    pub fn get(&self, model: &str) -> Option<ModelLimit> {
204        // Exact user override match (highest priority).
205        if let Some(limit) = self.user_limits.get(model) {
206            return Some(limit.clone());
207        }
208
209        // Best partial match among user overrides: the pattern must be a
210        // substring of the model id. Longer (more specific) patterns win for
211        // deterministic selection. There is no built-in table; a miss returns
212        // None and the caller falls back to the global default.
213        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    /// Get limit for a model with fallback to default.
221    pub fn get_or_default(&self, model: &str) -> ModelLimit {
222        self.get(model).unwrap_or_else(default_model_limit)
223    }
224
225    /// Save current user limits to the configured file.
226    ///
227    /// No-op when the registry has no `config_path`.
228    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        // Ensure parent directory exists
234        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    /// List all user-defined limits.
247    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
258/// Get the default configuration file path.
259///
260/// Returns `{bamboo_data_dir}/model_limits.json`, given the Bamboo data dir.
261///
262/// The caller supplies the base directory so this crate stays free of any
263/// infrastructure/filesystem-config dependency.
264pub fn get_default_config_path(bamboo_dir: &std::path::Path) -> PathBuf {
265    bamboo_dir.join("model_limits.json")
266}
267
268/// Load user model limits from the unified `config.json` `model_limits` value.
269///
270/// The caller extracts the raw `model_limits` JSON value (e.g. from
271/// `config.extra.get("model_limits")`) and passes it here, keeping this crate
272/// independent of the concrete `Config` type.
273///
274/// Returns:
275/// - `Ok(None)` when `model_limits` is absent.
276/// - `Ok(Some(vec))` when present and valid (including empty array).
277/// - `Err(...)` when present but not a valid `Vec<ModelLimit>`.
278pub 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
297/// Create a token budget for a specific model, resolving its limit from the
298/// supplied `registry` (with user overrides loaded) and falling back to the
299/// global default when there is no match.
300///
301/// The registry is a required parameter on purpose: a previous version built a
302/// fresh empty `ModelLimitsRegistry::default()` internally, which silently
303/// discarded every user override from `model_limits.json` and always returned
304/// the global default (#20, bug 2). Callers must pass a registry they have
305/// loaded user overrides into (or [`ModelLimitsRegistry::new`] when they
306/// genuinely want the global default).
307pub 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, // legacy — only used when working_reserve_tokens == 0
320        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        // A row pinned to exactly the default values (any pattern) is a no-op.
347        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        // The synthesized global default is itself a no-op override.
352        assert!(is_default_limit(&default_model_limit()));
353
354        // A different context window is a real override.
355        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        // An explicit safety margin is a real override even at default size.
360        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        // No built-in table: an unknown model with no user override has no match.
369        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)); // Override with smaller limit
387
388        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        // "gpt-5.2-codex-preview" contains both patterns; the longest wins.
401        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        // Default is min(max_context / 4, DEFAULT_MAX_OUTPUT_TOKENS)
411        //        = min(25_000, 128_000) = 25_000 (no longer capped at 4096, #20 bug 4)
412        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        // Issue #20 bug 4: a user override created with `ModelLimit::new` leaves
418        // `max_output_tokens = None`. The derived default must scale with the
419        // context window (context / 4) rather than collapsing to 4096.
420        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        // Very large context windows are still capped at the global default so a
425        // single override can't request an unbounded output budget.
426        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        // Issue #20 bug 3: a short model id must NOT match a longer pattern.
433        let mut registry = ModelLimitsRegistry::new();
434        registry.add_limit(ModelLimit::new("gpt-4o-mini", 128_000));
435
436        // "gpt-4o" does not contain "gpt-4o-mini", so it must NOT inherit the
437        // mini override (the old `pattern.contains(model)` direction did).
438        assert!(registry.get("gpt-4o").is_none());
439
440        // The reverse still works: a model id that contains the pattern matches.
441        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        // Default is min(8192 / 4, 4096) = 2048
458        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        // Small context → floor at DEFAULT_SAFETY_MARGIN (1000)
498        let small = ModelLimit::new("test", 8_192);
499        assert_eq!(small.get_safety_margin(), 1000);
500
501        // Medium context → proportional
502        let medium = ModelLimit::new("test", 200_000);
503        assert_eq!(medium.get_safety_margin(), 2000);
504
505        // Large context → proportional
506        let large = ModelLimit::new("test", 1_050_000);
507        assert_eq!(large.get_safety_margin(), 10_500);
508
509        // Explicit override takes precedence
510        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        // Integration: a `model_limits.json` on disk → registry load → resolve.
518        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        // Persisted override is applied at runtime.
531        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        // Unknown model with no override falls back to the single global default.
536        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        // Full chain (issue #20 acceptance): set a model limit on disk →
654        // load registry → build the runtime TokenBudget → the budget reflects
655        // the user-configured context window, not a stale/default value.
656        let dir = tempfile::tempdir().expect("tempdir");
657        let path = dir.path().join("model_limits.json");
658        // Note: NO explicit max_output_tokens, exercising the bug-4 default path.
659        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        // The runtime budget for the configured model matches the user limit...
670        let budget = create_budget_for_model("gpt-4o", crate::BudgetStrategy::default(), &registry);
671        assert_eq!(budget.max_context_tokens, 128_000);
672        // ...and the derived output budget is context/4 (32K), not the old 4096 cap.
673        assert_eq!(budget.max_output_tokens, 32_000);
674
675        // A model that does NOT contain the pattern is unaffected (bug-3 fix):
676        // it resolves to the global default, not the gpt-4o override.
677        let other =
678            create_budget_for_model("claude-sonnet", crate::BudgetStrategy::default(), &registry);
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        // An empty registry yields the global default for any model.
685        let registry = ModelLimitsRegistry::new();
686        let budget = create_budget_for_model(
687            "anything-at-all",
688            crate::BudgetStrategy::default(),
689            &registry,
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        // Issue #20 bug 2: the budget must reflect the user override carried by
698        // the registry, not silently fall back to the global default.
699        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(), &registry);
703        assert_eq!(budget.max_context_tokens, 128_000);
704        // And the derived output budget is the un-capped context/4 (bug 4), not 4096.
705        assert_eq!(budget.max_output_tokens, 32_000);
706    }
707}