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}