Skip to main content

gglib_core/request_pipeline/
apply.rs

1//! The ordered request-shaping pipeline, and the one statement of its order.
2//!
3//! # The stages
4//!
5//! | # | Stage | Lives in | Reads |
6//! |---|---|---|---|
7//! | 1 | Strip prior reasoning | [`super::messages`] | `messages` |
8//! | 2 | Coalesce for capabilities | [`super::messages`] | `messages` |
9//! | 3 | Truncate stale history | [`super::truncation`] | `messages`, payload size |
10//! | 4 | Resolve the sampling hierarchy | [`super::sampling`] | top-level keys |
11//! | 5 | Pin `cache_prompt` | [`super::sampling`] | top-level keys |
12//!
13//! # The order is load-bearing
14//!
15//! **1 before 2.** Coalescing merges message *content*. Stripping afterwards
16//! would have to find and excise `<think>` blocks inside text that has already
17//! been concatenated with `"\n\n"` separators from other turns.
18//!
19//! **2 before 3.** Both stages 1 and 2 only ever shrink the body, and stage 3
20//! measures it. Truncating first would size its budget against bytes that were
21//! about to be discarded anyway, and trim history that did not need trimming.
22//!
23//! **3 before 4.** Stage 3 measures the payload; stage 4 inserts up to seven
24//! sampling keys. Resolving sampling first would have truncation size its
25//! budget against keys the client never sent. The margin is small, but it is
26//! the difference between measuring the conversation and measuring our own
27//! additions to it.
28//!
29//! **4 before 5.** `cache_prompt` is not an [`InferenceConfig`] field, so
30//! pinning it last means the resolved sampling patch can never overwrite it.
31//!
32//! [`InferenceConfig`]: crate::domain::InferenceConfig
33//!
34//! # Why the seam is `&mut Value` and not a typed request struct
35//!
36//! The proxy forwards requests from arbitrary external clients — IDE
37//! extensions, gateways — which send `OpenAI` parameters this workspace has
38//! never heard of. Round-tripping through a typed `ChatRequest` would silently
39//! drop every field the struct does not model: a passthrough regression that
40//! is invisible in tests and painful in the field. Mutating a `Value` in place
41//! preserves them by construction. The adapter builds its body with `json!` and
42//! already holds a `Value`, so this is also the cheaper side for it.
43//!
44//! # One pipeline, two callers, no second route
45//!
46//! Every request path calls [`apply`]. The proxy used to run the stages by hand
47//! with its own truncation pass spliced between them, because truncation gated
48//! on the payload's size in **wire bytes** and could reject the request with an
49//! `axum` response — neither of which fits here. Measuring the serialized
50//! `Value` and returning a domain error removed both obstacles, so there is now
51//! exactly one implementation of the order above and nothing to keep in sync.
52
53use serde_json::Value;
54
55use super::truncation::{TruncationError, TruncationReport};
56use super::{ModelContext, SamplingLayers, messages, sampling, truncation};
57
58/// Apply every request-shaping transform, in order, in place.
59///
60/// This is the whole pipeline as one call. See the [module docs](self) for the
61/// stage order and why it is fixed.
62///
63/// `budget_chars` is the history-truncation budget in characters.
64/// [`ModelContext::context_budget_chars`] is the answer for callers with no
65/// live serving context to measure; the proxy passes its own, computed from the
66/// running server's context size and a learned chars-per-token ratio. `None`
67/// skips stage 3 entirely and reports zeroes — the request is shaped but never
68/// measured, which is what an unresolvable model gets.
69///
70/// Unknown fields, top-level and per-message alike, are preserved.
71///
72/// # Errors
73///
74/// [`TruncationError`] when the conversation cannot be made to fit
75/// `budget_chars`. `body` is left shaped and trimmed; callers reject the
76/// request rather than forward it.
77pub fn apply(
78    body: &mut Value,
79    ctx: &ModelContext,
80    layers: &SamplingLayers,
81    budget_chars: Option<usize>,
82) -> Result<TruncationReport, TruncationError> {
83    messages::shape_messages(body, ctx);
84
85    let report = match budget_chars {
86        Some(limit) => truncation::truncate_history(body, limit)?,
87        None => TruncationReport::default(),
88    };
89
90    sampling::resolve_sampling(body, ctx, layers);
91    Ok(report)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::domain::{InferenceConfig, ModelCapabilities};
98    use serde_json::json;
99
100    fn strict_turn_ctx() -> ModelContext {
101        ModelContext {
102            capabilities: ModelCapabilities::REQUIRES_STRICT_TURNS,
103            inference_defaults: Some(InferenceConfig {
104                temperature: Some(0.33),
105                ..Default::default()
106            }),
107            ..ModelContext::passthrough()
108        }
109    }
110
111    fn kitchen_sink() -> Value {
112        json!({
113            "model": "m",
114            "cache_prompt": false,
115            "messages": [
116                {"role": "assistant", "content": "<think>x</think>a", "reasoning_content": "r"},
117                {"role": "assistant", "content": "b"},
118                {"role": "tool", "tool_call_id": "call_1", "content": "result"},
119            ],
120            "totally_made_up_key": {"nested": [1, 2]},
121        })
122    }
123
124    /// Stage 1 must have already run when stage 2 merges: the merged text
125    /// contains no `<think>` remnant, which it would if the order were flipped.
126    #[test]
127    fn reasoning_is_stripped_before_messages_are_merged() {
128        let mut body = kitchen_sink();
129        apply(
130            &mut body,
131            &strict_turn_ctx(),
132            &SamplingLayers::default(),
133            None,
134        )
135        .unwrap();
136
137        let merged = body["messages"][0]["content"].as_str().unwrap();
138        assert_eq!(merged, "a\n\nb");
139        assert!(body["messages"][0].get("reasoning_content").is_none());
140    }
141
142    #[test]
143    fn every_stage_runs_in_one_call() {
144        let mut body = kitchen_sink();
145        let report = apply(
146            &mut body,
147            &strict_turn_ctx(),
148            &SamplingLayers::default(),
149            Some(100_000),
150        )
151        .unwrap();
152
153        // 1 + 2: reasoning gone, assistant turns merged, tool turn intact.
154        assert_eq!(body["messages"].as_array().unwrap().len(), 2);
155        assert_eq!(body["messages"][1]["tool_call_id"], "call_1");
156        // 3: measured, nothing to trim.
157        assert_eq!(report.messages_truncated, 0);
158        assert!(report.payload_chars_before > 0);
159        // 4: the model's stored default resolved in.
160        assert!((body["temperature"].as_f64().unwrap() - 0.33).abs() < 1e-6);
161        // 5: pinned over the client's explicit `false`.
162        assert_eq!(body["cache_prompt"], true);
163        // …and nothing else was disturbed.
164        assert_eq!(body["model"], "m");
165        assert_eq!(body["totally_made_up_key"], json!({"nested": [1, 2]}));
166    }
167
168    /// A passthrough context must cost the request nothing but its
169    /// model-specific handling — the sampling stages still run.
170    #[test]
171    fn a_passthrough_context_still_resolves_sampling() {
172        let mut body = json!({"messages": [
173            {"role": "user", "content": "one"},
174            {"role": "user", "content": "two"},
175        ]});
176        apply(
177            &mut body,
178            &ModelContext::passthrough(),
179            &SamplingLayers::default(),
180            None,
181        )
182        .unwrap();
183
184        assert_eq!(
185            body["messages"].as_array().unwrap().len(),
186            2,
187            "unknown capabilities must not merge anything"
188        );
189        assert_eq!(body["cache_prompt"], true);
190        assert!(body["temperature"].as_f64().is_some());
191    }
192
193    // ── Stage 3 ──────────────────────────────────────────────────────────────
194
195    fn oversized_body() -> Value {
196        let mut messages = vec![json!({"role": "tool", "content": "x".repeat(50_000)})];
197        for _ in 0..8 {
198            messages.push(json!({"role": "user", "content": "ok"}));
199        }
200        json!({"model": "m", "messages": messages})
201    }
202
203    #[test]
204    fn an_oversized_conversation_is_trimmed() {
205        let mut body = oversized_body();
206        let report = apply(
207            &mut body,
208            &ModelContext::passthrough(),
209            &SamplingLayers::default(),
210            Some(20_000),
211        )
212        .unwrap();
213
214        assert_eq!(report.messages_truncated, 1);
215        assert!(report.payload_chars_after <= 20_000);
216    }
217
218    /// No budget means no measurement — not a zero budget that rejects
219    /// everything.
220    #[test]
221    fn no_budget_means_no_truncation() {
222        let mut body = oversized_body();
223        let report = apply(
224            &mut body,
225            &ModelContext::passthrough(),
226            &SamplingLayers::default(),
227            None,
228        )
229        .unwrap();
230
231        assert_eq!(report, TruncationReport::default());
232        assert_eq!(
233            body["messages"][0]["content"].as_str().unwrap().len(),
234            50_000
235        );
236    }
237
238    /// Stage 3 runs before stage 4, so the budget is measured against the
239    /// client's conversation and not against sampling keys we added ourselves.
240    #[test]
241    fn sampling_keys_are_not_counted_against_the_budget() {
242        let mut body = oversized_body();
243        // Small enough that even the fully-trimmed conversation cannot fit, so
244        // the run stops at stage 3 with stage 4 still ahead of it.
245        let err = apply(
246            &mut body,
247            &ModelContext::passthrough(),
248            &SamplingLayers::default(),
249            Some(200),
250        )
251        .unwrap_err();
252
253        let TruncationError::ExceedsBudgetAfterTruncation { payload_chars, .. } = err;
254        assert!(
255            body.get("temperature").is_none(),
256            "stage 4 must not have run before the measurement that rejected this"
257        );
258        assert!(payload_chars > 200);
259    }
260}