Skip to main content

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}