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::{DefaultsOrigin, InferenceConfig, ModelCapabilities};
5use crate::ports::ModelSummary;
6
7/// Everything a request pipeline needs to know about the target model,
8/// gathered in a single catalog round-trip.
9///
10/// The five fields feed the resolution and shaping stages, which is why they
11/// travel together rather than being looked up where each is needed:
12///
13/// * [`capabilities`](Self::capabilities) — request-side transforms
14/// (strict-turn coalescing and friends).
15/// * [`tags`](Self::tags) — response-stream parser selection, and (via the
16/// `reasoning` tag) the sampling floor.
17/// * [`inference_defaults`](Self::inference_defaults) — the per-model layer of
18/// the sampling hierarchy.
19/// * [`defaults_origin`](Self::defaults_origin) — which rung
20/// [`inference_defaults`](Self::inference_defaults) occupies in that
21/// hierarchy.
22/// * [`context_length`](Self::context_length) — the history-truncation budget.
23///
24/// Before this type was shared, the proxy resolved all of them while every
25/// other surface resolved the same row and kept only `tags`, so capability
26/// coalescing and per-model defaults were unreachable outside the proxy.
27#[derive(Debug, Clone, Default, PartialEq)]
28pub struct ModelContext {
29 /// Stored capability bitfield — drives request-side transforms.
30 pub capabilities: ModelCapabilities,
31 /// `format:*` tags — drives response-stream parser selection.
32 pub tags: Vec<String>,
33 /// Per-model inference defaults to merge into each request.
34 pub inference_defaults: Option<InferenceConfig>,
35 /// Whether [`inference_defaults`](Self::inference_defaults) was set by
36 /// the user or auto-detected. See [`DefaultsOrigin`].
37 pub defaults_origin: Option<DefaultsOrigin>,
38 /// Maximum context the model supports, in tokens — the history-truncation
39 /// budget for every surface that cannot measure a live serving context.
40 pub context_length: Option<u64>,
41}
42
43impl ModelContext {
44 /// The zeroed context: empty capabilities so every transform is a no-op,
45 /// empty tags so the identity passthrough parser is selected, no per-model
46 /// defaults, and no truncation budget.
47 ///
48 /// This is the conservative fallback used whenever the model cannot be
49 /// resolved — an unresolvable model must never block a request, only lose
50 /// its model-specific handling.
51 #[must_use]
52 pub fn passthrough() -> Self {
53 Self::default()
54 }
55
56 /// The history-truncation budget in characters, from the model's own
57 /// capacity: [`context_length`](Self::context_length) tokens converted at
58 /// [`CHARS_PER_TOKEN_APPROX`].
59 ///
60 /// `None` when the context size is unknown, which
61 /// [`apply`](super::apply) reads as *do not truncate*. Guessing a budget
62 /// for an unresolvable model would risk rejecting a request over a number
63 /// nobody actually knows; losing model-specific handling is the whole
64 /// fallback policy of this module.
65 ///
66 /// Callers that know the **live** serving context — the proxy, which also
67 /// learns a per-model chars-per-token ratio from observed usage frames —
68 /// compute a better number and pass that instead. This is the answer for
69 /// everyone else.
70 #[must_use]
71 pub fn context_budget_chars(&self) -> Option<usize> {
72 let tokens = usize::try_from(self.context_length?).ok()?;
73 Some(tokens.saturating_mul(CHARS_PER_TOKEN_APPROX))
74 }
75}
76
77impl From<&ModelSummary> for ModelContext {
78 fn from(summary: &ModelSummary) -> Self {
79 Self {
80 capabilities: summary.capabilities,
81 tags: summary.tags.clone(),
82 inference_defaults: summary.inference_defaults.clone(),
83 defaults_origin: summary.defaults_origin,
84 context_length: summary.context_length,
85 }
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn passthrough_is_inert() {
95 let ctx = ModelContext::passthrough();
96 assert!(ctx.capabilities.is_empty());
97 assert!(ctx.tags.is_empty());
98 assert!(ctx.inference_defaults.is_none());
99 assert!(ctx.context_length.is_none());
100 }
101
102 #[test]
103 fn from_summary_carries_every_field() {
104 let mut summary = super::super::tests_support::summary();
105 summary.capabilities = ModelCapabilities::REQUIRES_STRICT_TURNS;
106 summary.tags = vec!["format:qwen".to_string()];
107 summary.inference_defaults = Some(InferenceConfig {
108 temperature: Some(0.5),
109 ..Default::default()
110 });
111 summary.defaults_origin = Some(DefaultsOrigin::AutoDetected);
112 summary.context_length = Some(32_768);
113
114 let ctx = ModelContext::from(&summary);
115 assert_eq!(ctx.capabilities, ModelCapabilities::REQUIRES_STRICT_TURNS);
116 assert_eq!(ctx.tags, vec!["format:qwen".to_string()]);
117 assert_eq!(
118 ctx.inference_defaults.and_then(|c| c.temperature),
119 Some(0.5)
120 );
121 assert_eq!(ctx.defaults_origin, Some(DefaultsOrigin::AutoDetected));
122 assert_eq!(ctx.context_length, Some(32_768));
123 }
124
125 /// The budget scales with the model rather than sitting on a shared floor:
126 /// a small-context model gets a small one, a large-context model a large.
127 #[test]
128 fn the_budget_scales_with_the_model() {
129 let small = ModelContext {
130 context_length: Some(4_096),
131 ..ModelContext::passthrough()
132 };
133 let large = ModelContext {
134 context_length: Some(262_144),
135 ..ModelContext::passthrough()
136 };
137
138 assert_eq!(small.context_budget_chars(), Some(16_384));
139 assert_eq!(large.context_budget_chars(), Some(1_048_576));
140 }
141
142 /// An unresolvable model must not be handed a guessed budget — `None` means
143 /// "do not truncate", not "truncate at zero".
144 #[test]
145 fn an_unknown_context_length_yields_no_budget() {
146 assert_eq!(ModelContext::passthrough().context_budget_chars(), None);
147 }
148}