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::{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}