Skip to main content

lean_ctx/core/
model_registry.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4static BUNDLED_REGISTRY: &str = include_str!("../../data/model_registry.json");
5
6static PARSED_BUNDLED: OnceLock<Registry> = OnceLock::new();
7static PARSED_LOCAL: OnceLock<Option<Registry>> = OnceLock::new();
8
9#[derive(Debug, Clone)]
10struct ModelEntry {
11    context_window: usize,
12}
13
14#[derive(Debug, Clone, Default)]
15struct Registry {
16    models: HashMap<String, ModelEntry>,
17    families: HashMap<String, usize>,
18}
19
20fn parse_registry(json: &str) -> Option<Registry> {
21    let v: serde_json::Value = serde_json::from_str(json).ok()?;
22    let mut models = HashMap::new();
23    if let Some(obj) = v.get("models").and_then(|m| m.as_object()) {
24        for (key, entry) in obj {
25            if let Some(window) = entry
26                .get("context_window")
27                .and_then(serde_json::Value::as_u64)
28            {
29                models.insert(
30                    key.to_lowercase(),
31                    ModelEntry {
32                        context_window: window as usize,
33                    },
34                );
35            }
36        }
37    }
38    let mut families = HashMap::new();
39    if let Some(obj) = v.get("families").and_then(|f| f.as_object()) {
40        for (key, val) in obj {
41            if let Some(window) = val.as_u64() {
42                families.insert(key.to_lowercase(), window as usize);
43            }
44        }
45    }
46    Some(Registry { models, families })
47}
48
49fn bundled() -> &'static Registry {
50    PARSED_BUNDLED.get_or_init(|| parse_registry(BUNDLED_REGISTRY).unwrap_or_default())
51}
52
53fn local_registry() -> Option<&'static Registry> {
54    PARSED_LOCAL
55        .get_or_init(|| {
56            let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
57            let path = data_dir.join("model_registry.json");
58            let content = std::fs::read_to_string(path).ok()?;
59            parse_registry(&content)
60        })
61        .as_ref()
62}
63
64fn user_config_override(model: &str) -> Option<usize> {
65    let cfg = crate::core::config::Config::load();
66    cfg.model_context_windows
67        .get(model)
68        .or_else(|| cfg.model_context_windows.get(&model.to_lowercase()))
69        .copied()
70}
71
72/// Parse a trailing long-context marker like `[1m]` / `[1M]` / `[200k]` into
73/// its token window (GH #739). Clients append these suffixes for context-beta
74/// variants (e.g. `claude-opus-4-8[1m]`); the marker is an explicit statement
75/// of the window, so it wins over any registry entry for the base model.
76fn window_from_suffix(model: &str) -> Option<usize> {
77    let (_, marker) = split_window_suffix(model)?;
78    let digits: String = marker.chars().take_while(char::is_ascii_digit).collect();
79    let n: usize = digits.parse().ok()?;
80    let unit = &marker[digits.len()..];
81    match unit {
82        "m" => Some(n.checked_mul(1_000_000)?),
83        "k" => Some(n.checked_mul(1_000)?),
84        _ => None,
85    }
86}
87
88/// Split `name[marker]` into `(name, lowercased marker)` when the model ends
89/// in a bracketed suffix. Returns `None` for models without one.
90fn split_window_suffix(model: &str) -> Option<(&str, String)> {
91    let stripped = model.strip_suffix(']')?;
92    let open = stripped.rfind('[')?;
93    let marker = stripped[open + 1..].to_lowercase();
94    if marker.is_empty() {
95        return None;
96    }
97    Some((&model[..open], marker))
98}
99
100fn registry_lookup(model: &str, registry: &Registry) -> Option<usize> {
101    let m = model.to_lowercase();
102
103    // Exact match
104    if let Some(entry) = registry.models.get(&m) {
105        return Some(entry.context_window);
106    }
107
108    // Prefix match: "gpt-5.5-0513" should match "gpt-5.5"
109    let mut best_match: Option<(usize, usize)> = None; // (key_len, window)
110    for (key, entry) in &registry.models {
111        if m.starts_with(key.as_str()) && m[key.len()..].starts_with(['-', '_', '.']) || m == *key {
112            let key_len = key.len();
113            if best_match.is_none_or(|(bl, _)| key_len > bl) {
114                best_match = Some((key_len, entry.context_window));
115            }
116        }
117    }
118    if let Some((_, window)) = best_match {
119        return Some(window);
120    }
121
122    // Family match (substring)
123    let mut best_family: Option<(usize, usize)> = None;
124    for (family, window) in &registry.families {
125        if m.contains(family.as_str()) {
126            let flen = family.len();
127            if best_family.is_none_or(|(bl, _)| flen > bl) {
128                best_family = Some((flen, *window));
129            }
130        }
131    }
132    best_family.map(|(_, w)| w)
133}
134
135/// Look up context window for a model name.
136/// Layers: User Config → `[1m]`-style suffix → Local Registry → Bundled
137/// Registry → 200k default.
138pub fn context_window_for_model(model: &str) -> usize {
139    // Layer 1: User config override ([model_context_windows] in config.toml)
140    if let Some(w) = user_config_override(model) {
141        return w;
142    }
143
144    // Layer 2: explicit window marker in the model name itself (GH #739).
145    // `claude-opus-4-8[1m]` means the client runs the 1M-context variant —
146    // registries would only ever know the base model's window.
147    if let Some(w) = window_from_suffix(model) {
148        return w;
149    }
150
151    // Registry lookups see the base name so `foo[1m]` variants of unknown
152    // markers still match their base entry instead of falling through.
153    let base = split_window_suffix(model).map_or(model, |(base, _)| base);
154
155    // Layer 3: Local registry (auto-updated via lean-ctx update)
156    if let Some(local) = local_registry()
157        && let Some(w) = registry_lookup(base, local)
158    {
159        return w;
160    }
161
162    // Layer 4: Bundled registry (compiled into binary)
163    if let Some(w) = registry_lookup(base, bundled()) {
164        return w;
165    }
166
167    // Fallback
168    200_000
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn bundled_registry_parses() {
177        let reg = bundled();
178        assert!(!reg.models.is_empty());
179        assert!(!reg.families.is_empty());
180    }
181
182    #[test]
183    fn exact_match_gpt55() {
184        assert_eq!(context_window_for_model("gpt-5.5"), 1_048_576);
185    }
186
187    #[test]
188    fn prefix_match_gpt55_variant() {
189        assert_eq!(context_window_for_model("gpt-5.5-0513"), 1_048_576);
190    }
191
192    #[test]
193    fn exact_match_gpt41() {
194        assert_eq!(context_window_for_model("gpt-4.1"), 1_047_576);
195    }
196
197    #[test]
198    fn family_match_gpt5() {
199        assert_eq!(context_window_for_model("gpt-5.3-turbo"), 128_000);
200    }
201
202    #[test]
203    fn family_match_claude() {
204        assert_eq!(context_window_for_model("claude-unknown-version"), 200_000);
205    }
206
207    #[test]
208    fn family_match_gemini() {
209        assert_eq!(context_window_for_model("gemini-future-model"), 1_048_576);
210    }
211
212    #[test]
213    fn unknown_model_returns_default() {
214        assert_eq!(
215            context_window_for_model("totally-unknown-model-xyz"),
216            200_000
217        );
218    }
219
220    #[test]
221    fn long_context_suffix_wins_over_registry() {
222        // GH #739: the [1m] marker is the client's explicit window statement.
223        assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
224        assert_eq!(context_window_for_model("claude-opus-4-8[1M]"), 1_000_000);
225        assert_eq!(context_window_for_model("some-future-model[200k]"), 200_000);
226        assert_eq!(context_window_for_model("gpt-5.5[2m]"), 2_000_000);
227    }
228
229    #[test]
230    fn base_model_of_suffix_variant_resolves_normally() {
231        // Without the marker the registry (exact/prefix/family) decides.
232        assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
233    }
234
235    #[test]
236    fn unknown_marker_falls_back_to_base_lookup() {
237        // A non-window bracket suffix must not break base-model resolution.
238        assert_eq!(context_window_for_model("gpt-5.5[thinking]"), 1_048_576);
239    }
240
241    #[test]
242    fn suffix_parsing_is_strict() {
243        assert_eq!(window_from_suffix("model[1m]"), Some(1_000_000));
244        assert_eq!(window_from_suffix("model[128k]"), Some(128_000));
245        assert_eq!(window_from_suffix("model[]"), None);
246        assert_eq!(window_from_suffix("model[m]"), None);
247        assert_eq!(window_from_suffix("model[1x]"), None);
248        assert_eq!(window_from_suffix("model"), None);
249        assert_eq!(window_from_suffix("model[1m"), None);
250    }
251}