Skip to main content

gglib_core/request_pipeline/
model_context.rs

1//! The resolved per-model context every request pipeline is built from.
2
3use super::truncation::CHARS_PER_TOKEN_APPROX;
4use crate::domain::{
5    DefaultsOrigin, DialectSpec, InferenceConfig, ModelCapabilities, TemplateCaps,
6};
7use crate::normalize::registry::dialect_for_tags;
8use crate::ports::ModelSummary;
9
10/// Everything a request pipeline needs to know about the target model,
11/// gathered in a single catalog round-trip.
12///
13/// The fields feed the resolution and shaping stages, which is why they
14/// travel together rather than being looked up where each is needed:
15///
16/// * [`capabilities`](Self::capabilities) — request-side transforms
17///   (strict-turn coalescing and friends).
18/// * [`dialect`](Self::dialect) — response-stream parser selection and
19///   decode-time grammar origination.
20/// * [`tags`](Self::tags) — the sampling floor (via the `reasoning` tag) and
21///   launch narration.
22/// * [`inference_defaults`](Self::inference_defaults) — the per-model layer of
23///   the sampling hierarchy.
24/// * [`defaults_origin`](Self::defaults_origin) — which rung
25///   [`inference_defaults`](Self::inference_defaults) occupies in that
26///   hierarchy.
27/// * [`context_length`](Self::context_length) — the history-truncation budget.
28///
29/// Before this type was shared, the proxy resolved all of them while every
30/// other surface resolved the same row and kept only `tags`, so capability
31/// coalescing and per-model defaults were unreachable outside the proxy.
32#[derive(Debug, Clone, Default, PartialEq)]
33pub struct ModelContext {
34    /// Stored capability bitfield — drives request-side transforms.
35    pub capabilities: ModelCapabilities,
36    /// The model's tags — the sampling floor (`reasoning`) and narration.
37    pub tags: Vec<String>,
38    /// Resolved tool-call dialect — drives response-stream parser selection
39    /// and decode-time grammar origination.
40    ///
41    /// Populated from the model's persisted spec when one exists, else from
42    /// the `format:*` tag fallback (see [`From<&ModelSummary>`]); `None`
43    /// selects the identity passthrough parser.
44    pub dialect: Option<DialectSpec>,
45    /// Per-model inference defaults to merge into each request.
46    pub inference_defaults: Option<InferenceConfig>,
47    /// Whether [`inference_defaults`](Self::inference_defaults) was set by
48    /// the user or auto-detected. See [`DefaultsOrigin`].
49    pub defaults_origin: Option<DefaultsOrigin>,
50    /// Maximum context the model supports, in tokens — the history-truncation
51    /// budget for every surface that cannot measure a live serving context.
52    pub context_length: Option<u64>,
53    /// llama-server's template-capability self-report, when a launch has
54    /// recorded one (ADR 0007).
55    ///
56    /// `None` — on a passthrough context *or* a resolved row nobody has
57    /// launched yet — means "never observed", which per decision 3 licenses
58    /// nothing: unknown never gates. Nothing consumes this yet; the effort
59    /// gate arrives in a later PR of the arc.
60    pub template_caps: Option<TemplateCaps>,
61    /// Whether this context came from an actual catalog row.
62    ///
63    /// `false` for [`passthrough`](Self::passthrough) — the fallback for
64    /// unknown or unresolvable models. Transforms that act on the *absence*
65    /// of a capability (tool stripping) must check this: an empty bitfield on
66    /// a passthrough context means "nobody knows", not "the model can't".
67    pub catalog_resolved: bool,
68}
69
70impl ModelContext {
71    /// The zeroed context: empty capabilities so every transform is a no-op,
72    /// empty tags so the identity passthrough parser is selected, no per-model
73    /// defaults, and no truncation budget.
74    ///
75    /// This is the conservative fallback used whenever the model cannot be
76    /// resolved — an unresolvable model must never block a request, only lose
77    /// its model-specific handling.
78    #[must_use]
79    pub fn passthrough() -> Self {
80        Self::default()
81    }
82
83    /// The history-truncation budget in characters, from the model's own
84    /// capacity: [`context_length`](Self::context_length) tokens converted at
85    /// [`CHARS_PER_TOKEN_APPROX`].
86    ///
87    /// `None` when the context size is unknown, which
88    /// [`apply`](super::apply()) reads as *do not truncate*. Guessing a budget
89    /// for an unresolvable model would risk rejecting a request over a number
90    /// nobody actually knows; losing model-specific handling is the whole
91    /// fallback policy of this module.
92    ///
93    /// Callers that know the **live** serving context — the proxy, which also
94    /// learns a per-model chars-per-token ratio from observed usage frames —
95    /// compute a better number and pass that instead. This is the answer for
96    /// everyone else.
97    #[must_use]
98    pub fn context_budget_chars(&self) -> Option<usize> {
99        let tokens = usize::try_from(self.context_length?).ok()?;
100        Some(tokens.saturating_mul(CHARS_PER_TOKEN_APPROX))
101    }
102}
103
104impl From<&ModelSummary> for ModelContext {
105    fn from(summary: &ModelSummary) -> Self {
106        Self {
107            capabilities: summary.capabilities,
108            tags: summary.tags.clone(),
109            // The single back-compat point for dialect resolution: a
110            // persisted spec wins; rows that predate specs (or whose spec
111            // could not be derived) fall back to their `format:*` tag.
112            // Every surface builds its context here, so all of them
113            // inherit the fallback.
114            dialect: summary
115                .dialect
116                .clone()
117                .or_else(|| dialect_for_tags(&summary.tags)),
118            inference_defaults: summary.inference_defaults.clone(),
119            defaults_origin: summary.defaults_origin,
120            context_length: summary.context_length,
121            template_caps: summary.template_caps.clone(),
122            catalog_resolved: true,
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn passthrough_is_inert() {
133        let ctx = ModelContext::passthrough();
134        assert!(ctx.capabilities.is_empty());
135        assert!(ctx.tags.is_empty());
136        assert!(ctx.inference_defaults.is_none());
137        assert!(ctx.context_length.is_none());
138    }
139
140    #[test]
141    fn from_summary_carries_every_field() {
142        let mut summary = super::super::tests_support::summary();
143        summary.capabilities = ModelCapabilities::REQUIRES_STRICT_TURNS;
144        summary.tags = vec!["format:qwen".to_string()];
145        summary.inference_defaults = Some(InferenceConfig {
146            temperature: Some(0.5),
147            ..Default::default()
148        });
149        summary.defaults_origin = Some(DefaultsOrigin::AutoDetected);
150        summary.context_length = Some(32_768);
151        summary.template_caps = Some(TemplateCaps {
152            supports_reasoning_effort: Some(true),
153            ..TemplateCaps::default()
154        });
155
156        let ctx = ModelContext::from(&summary);
157        assert_eq!(
158            ctx.template_caps
159                .as_ref()
160                .and_then(|c| c.supports_reasoning_effort),
161            Some(true)
162        );
163        assert_eq!(ctx.capabilities, ModelCapabilities::REQUIRES_STRICT_TURNS);
164        assert_eq!(ctx.tags, vec!["format:qwen".to_string()]);
165        assert_eq!(
166            ctx.dialect, None,
167            "an unrecognized tag maps to no dialect, not a guessed one"
168        );
169        assert_eq!(
170            ctx.inference_defaults.and_then(|c| c.temperature),
171            Some(0.5)
172        );
173        assert_eq!(ctx.defaults_origin, Some(DefaultsOrigin::AutoDetected));
174        assert_eq!(ctx.context_length, Some(32_768));
175    }
176
177    /// Legacy catalog rows: a `format:qwen-xml` tag with no persisted spec
178    /// resolves to the builtin — the permanent back-compat path.
179    #[test]
180    fn a_format_tag_without_a_spec_falls_back_to_the_builtin() {
181        let mut summary = super::super::tests_support::summary();
182        summary.tags = vec![crate::normalize::tags::FORMAT_QWEN_XML.to_owned()];
183        summary.dialect = None;
184
185        let ctx = ModelContext::from(&summary);
186        assert_eq!(ctx.dialect, Some(DialectSpec::qwen_xml()));
187    }
188
189    /// A persisted spec always beats the tag fallback — the tag may be
190    /// stale, the spec is what detection actually derived.
191    #[test]
192    fn a_persisted_spec_wins_over_the_tag_fallback() {
193        let derived = DialectSpec {
194            tool_open: "«TC»".to_owned(),
195            tool_close: "«/TC»".to_owned(),
196            ..DialectSpec::qwen_xml()
197        };
198        let mut summary = super::super::tests_support::summary();
199        summary.tags = vec![crate::normalize::tags::FORMAT_QWEN_XML.to_owned()];
200        summary.dialect = Some(derived.clone());
201
202        let ctx = ModelContext::from(&summary);
203        assert_eq!(ctx.dialect, Some(derived));
204    }
205
206    /// The budget scales with the model rather than sitting on a shared floor:
207    /// a small-context model gets a small one, a large-context model a large.
208    #[test]
209    fn the_budget_scales_with_the_model() {
210        let small = ModelContext {
211            context_length: Some(4_096),
212            ..ModelContext::passthrough()
213        };
214        let large = ModelContext {
215            context_length: Some(262_144),
216            ..ModelContext::passthrough()
217        };
218
219        assert_eq!(small.context_budget_chars(), Some(16_384));
220        assert_eq!(large.context_budget_chars(), Some(1_048_576));
221    }
222
223    /// An unresolvable model must not be handed a guessed budget — `None` means
224    /// "do not truncate", not "truncate at zero".
225    #[test]
226    fn an_unknown_context_length_yields_no_budget() {
227        assert_eq!(ModelContext::passthrough().context_budget_chars(), None);
228    }
229}