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}