polyc_llm/model_info.rs
1//! Bundled model catalog: per-model context-window facts and the longest-prefix
2//! lookup that drives compaction.
3//!
4//! The catalog is a compile-checked Rust table (no JSON file, no runtime
5//! parsing). It is the single home for "how big is this model's window". The
6//! control plane (compaction trigger, via `model_select`) is the consumer today;
7//! it lives in the shared `polyc-llm` crate so a future harness-side reader gets
8//! the same numbers rather than a divergent copy. An unknown slug never refuses —
9//! it falls back to a conservative window with a single `tracing::warn`, taking
10//! a "warn, don't fail" stance.
11
12/// Per-model context facts, looked up by slug.
13///
14/// `Eq` is intentionally NOT derived: `force_temperature` is an `f32`, for which
15/// total equality is undefined. `PartialEq` is enough for the table's tests.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct ModelInfo {
18 /// Maximum total tokens (prompt + generation) the model accepts.
19 pub context_window: u32,
20 /// Whole-percent fraction of `context_window` that is considered usable
21 /// (default 95). usable = `context_window` * pct / 100.
22 pub effective_context_window_percent: u16,
23 /// Optional forced sampling temperature for this model, overriding whatever
24 /// the caller requested. `Some` for models that require a fixed temperature
25 /// (e.g. GLM-4.6/4.7 want `1.0`); `None` leaves the caller's value alone.
26 ///
27 /// Lives here — keyed by slug, with the same longest-prefix discipline as
28 /// the context window — so the pin is resolved from the *per-request*
29 /// model, never baked from a provider's `default_model`.
30 pub force_temperature: Option<f32>,
31}
32
33impl ModelInfo {
34 /// Default usable fraction of a model's window — reserved headroom so the
35 /// prompt never runs flush against the hard limit.
36 pub const DEFAULT_EFFECTIVE_PERCENT: u16 = 95;
37
38 /// A catalog entry with the default effective percentage and no temperature
39 /// pin.
40 #[must_use]
41 pub const fn new(context_window: u32) -> Self {
42 Self {
43 context_window,
44 effective_context_window_percent: Self::DEFAULT_EFFECTIVE_PERCENT,
45 force_temperature: None,
46 }
47 }
48
49 /// Builder: set a forced sampling temperature (see
50 /// [`force_temperature`](Self::force_temperature)).
51 #[must_use]
52 pub const fn with_forced_temperature(mut self, temperature: f32) -> Self {
53 self.force_temperature = Some(temperature);
54 self
55 }
56
57 /// floor(`context_window` * `effective_context_window_percent` / 100): the
58 /// usable token budget after reserving headroom.
59 #[must_use]
60 pub const fn effective_window(self) -> u32 {
61 let usable = (self.context_window as u64 * self.effective_context_window_percent as u64)
62 / Self::PERCENT_DENOM;
63 // `usable <= context_window` (the percent is a fraction of 100), so it
64 // always fits a u32; clamp defensively rather than wrap on the cast.
65 // `u32::try_from` is not yet const-stable, so the checked cast is
66 // expressed manually.
67 if usable > u32::MAX as u64 {
68 u32::MAX
69 } else {
70 // SAFETY (value): guarded above to fit in u32, so this never truncates.
71 #[allow(clippy::cast_possible_truncation)]
72 {
73 usable as u32
74 }
75 }
76 }
77
78 /// Whole-percent denominator for [`effective_window`](Self::effective_window).
79 const PERCENT_DENOM: u64 = 100;
80}
81
82/// Conservative fallback window for an unknown slug. `128_000` chosen so an
83/// unknown model compacts early rather than overflowing a real (possibly
84/// smaller) window.
85pub const FALLBACK_CONTEXT_WINDOW: u32 = 128_000;
86
87/// The bundled catalog. Keys are matched by longest prefix (see
88/// [`lookup_model`]), so dated/preview suffixes resolve to their base entry and
89/// more-specific slugs (`gpt-4o`) beat shorter ones (`gpt-4`).
90static CATALOG: &[(&str, ModelInfo)] = &[
91 // Gemini 3+ only — pre-3 (2.5 and earlier) is deprecated and unsupported.
92 // The `gemini-3` catch-all covers 3 / 3.1 / flash / pro / flash-lite (all 1M).
93 ("gemini-3-flash", ModelInfo::new(1_048_576)),
94 ("gemini-3.1-pro", ModelInfo::new(1_048_576)),
95 ("gemini-3-pro", ModelInfo::new(1_048_576)),
96 ("gemini-3", ModelInfo::new(1_048_576)),
97 ("gpt-4o", ModelInfo::new(128_000)),
98 ("gpt-4-turbo", ModelInfo::new(128_000)),
99 ("gpt-4", ModelInfo::new(8_192)),
100 ("llama3.2", ModelInfo::new(131_072)),
101 ("llama3", ModelInfo::new(131_072)),
102 // Z.AI GLM family (served OpenAI-compatible). Only GLM 5.2 is 1M; the rest
103 // are 128k–204.8k. Longest-prefix keeps `glm-5.1`/`glm-5.2` ahead of `glm-5`.
104 ("glm-5.2", ModelInfo::new(1_048_576)),
105 ("glm-5.1", ModelInfo::new(200_000)),
106 ("glm-5", ModelInfo::new(204_800)),
107 // GLM-4.6/4.7 want a fixed temperature of 1.0 (matches opencode); the 5.x
108 // family and 4.5 must NOT be pinned.
109 (
110 "glm-4.7",
111 ModelInfo::new(204_800).with_forced_temperature(1.0),
112 ),
113 (
114 "glm-4.6",
115 ModelInfo::new(204_800).with_forced_temperature(1.0),
116 ),
117 ("glm-4.5", ModelInfo::new(131_072)),
118];
119
120/// Longest-prefix match.
121///
122/// Iterate the catalog and keep the entry whose key is a prefix of `slug`
123/// (`slug.starts_with(key)`) with the GREATEST `key.len()`. An exact match is
124/// naturally the longest possible prefix. Returns `None` if no key is a prefix.
125#[must_use]
126pub fn lookup_model(slug: &str) -> Option<ModelInfo> {
127 CATALOG
128 .iter()
129 .filter(|(k, _)| slug.starts_with(k))
130 .max_by_key(|(k, _)| k.len())
131 .map(|(_, info)| *info)
132}
133
134/// [`lookup_model`] with the fallback applied plus ONE `tracing::warn` naming
135/// the slug and the fallback window. NEVER refuses.
136#[must_use]
137pub fn lookup_model_or_fallback(slug: &str) -> ModelInfo {
138 lookup_model(slug).unwrap_or_else(|| {
139 tracing::warn!(
140 model = %slug,
141 fallback_context_window = FALLBACK_CONTEXT_WINDOW,
142 "unknown model slug; using conservative fallback context window"
143 );
144 ModelInfo::new(FALLBACK_CONTEXT_WINDOW)
145 })
146}
147
148#[cfg(test)]
149mod tests {
150 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
151
152 use super::*;
153
154 #[test]
155 fn exact_match_resolves() {
156 assert_eq!(lookup_model("gpt-4").unwrap().context_window, 8_192);
157 assert_eq!(lookup_model("gpt-4o").unwrap().context_window, 128_000);
158 }
159
160 #[test]
161 fn longest_prefix_beats_shorter_keys() {
162 // A dated gpt-4o slug must resolve to gpt-4o (128k), NOT gpt-4 (8k).
163 assert_eq!(
164 lookup_model("gpt-4o-2024-08-06").unwrap().context_window,
165 128_000
166 );
167 // gpt-4-turbo beats gpt-4.
168 assert_eq!(
169 lookup_model("gpt-4-turbo-2024").unwrap().context_window,
170 128_000
171 );
172 }
173
174 #[test]
175 fn preview_and_tag_suffixes_resolve_to_base() {
176 assert_eq!(
177 lookup_model("gemini-3.1-pro-preview")
178 .unwrap()
179 .context_window,
180 1_048_576
181 );
182 assert_eq!(
183 lookup_model("gemini-3-flash-preview-09-2025")
184 .unwrap()
185 .context_window,
186 1_048_576
187 );
188 assert_eq!(
189 lookup_model("llama3.2:latest").unwrap().context_window,
190 131_072
191 );
192 }
193
194 #[test]
195 fn glm_family_windows_resolve() {
196 // Only GLM 5.2 is 1M; siblings are smaller.
197 assert_eq!(lookup_model("glm-5.2").unwrap().context_window, 1_048_576);
198 assert_eq!(lookup_model("glm-5.1").unwrap().context_window, 200_000);
199 assert_eq!(lookup_model("glm-5").unwrap().context_window, 204_800);
200 assert_eq!(lookup_model("glm-4.6").unwrap().context_window, 204_800);
201 assert_eq!(lookup_model("glm-4.5").unwrap().context_window, 131_072);
202 }
203
204 #[test]
205 fn glm_force_temperature_only_for_4_6_and_4_7() {
206 assert_eq!(
207 lookup_model("glm-4.6").unwrap().force_temperature,
208 Some(1.0)
209 );
210 assert_eq!(
211 lookup_model("glm-4.7-flash").unwrap().force_temperature,
212 Some(1.0)
213 );
214 // Catalog default and the 5.x family carry no pin.
215 assert_eq!(lookup_model("glm-4.5").unwrap().force_temperature, None);
216 assert_eq!(lookup_model("glm-5.2").unwrap().force_temperature, None);
217 assert_eq!(lookup_model("gpt-4o").unwrap().force_temperature, None);
218 }
219
220 #[test]
221 fn glm_longest_prefix_and_suffixes() {
222 // A dated/suffixed glm-5.2 slug must resolve to glm-5.2 (1M), NOT glm-5.
223 assert_eq!(
224 lookup_model("glm-5.2-0712").unwrap().context_window,
225 1_048_576
226 );
227 // glm-5.1 must not be shadowed by glm-5.
228 assert_eq!(lookup_model("glm-5.1-air").unwrap().context_window, 200_000);
229 }
230
231 #[test]
232 fn unknown_slug_has_no_catalog_entry_but_falls_back() {
233 assert!(lookup_model("foo-model").is_none());
234 assert_eq!(
235 lookup_model_or_fallback("foo-model").context_window,
236 FALLBACK_CONTEXT_WINDOW
237 );
238 }
239
240 #[test]
241 fn effective_window_math() {
242 assert_eq!(ModelInfo::new(1_048_576).effective_window(), 996_147);
243 assert_eq!(ModelInfo::new(128_000).effective_window(), 121_600);
244 }
245}