lean_ctx/core/
model_registry.rs1use 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
72fn 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
88fn 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 if let Some(entry) = registry.models.get(&m) {
105 return Some(entry.context_window);
106 }
107
108 let mut best_match: Option<(usize, usize)> = None; for (key, entry) in ®istry.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 let mut best_family: Option<(usize, usize)> = None;
124 for (family, window) in ®istry.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
135pub fn context_window_for_model(model: &str) -> usize {
139 if let Some(w) = user_config_override(model) {
141 return w;
142 }
143
144 if let Some(w) = window_from_suffix(model) {
148 return w;
149 }
150
151 let base = split_window_suffix(model).map_or(model, |(base, _)| base);
154
155 if let Some(local) = local_registry()
157 && let Some(w) = registry_lookup(base, local)
158 {
159 return w;
160 }
161
162 if let Some(w) = registry_lookup(base, bundled()) {
164 return w;
165 }
166
167 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 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 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 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}