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//! | 2b | Strip unsupported tools | [`super::tools`] | `tools`, capabilities |
10//! | 3 | Truncate stale history | [`super::truncation`] | `messages`, payload size |
11//! | 4 | Resolve the sampling hierarchy | [`super::sampling`] | top-level keys |
12//! | 5 | Pin `cache_prompt` | [`super::sampling`] | top-level keys |
13//! | 5b | Suppress an unreadable `reasoning_effort` | [`super::effort_gate`] | resolved effort, template caps |
14//! | — | Log the sampling decision | [`super::sampling_log`] | the decision |
15//! | 6 | Constrain dialect tool calls | [`super::constrain`] | `tools`, `tool_choice`, tags |
16//!
17//! # The order is load-bearing
18//!
19//! **1 before 2.** Coalescing merges message *content*. Stripping afterwards
20//! would have to find and excise `<think>` blocks inside text that has already
21//! been concatenated with `"\n\n"` separators from other turns.
22//!
23//! **2 before 3.** Both stages 1 and 2 only ever shrink the body, and stage 3
24//! measures it. Truncating first would size its budget against bytes that were
25//! about to be discarded anyway, and trim history that did not need trimming.
26//! The same goes for stage 2b: a stripped tools array must not count against
27//! the truncation budget.
28//!
29//! **3 before 4.** Stage 3 measures the payload; stage 4 inserts up to seven
30//! sampling keys. Resolving sampling first would have truncation size its
31//! budget against keys the client never sent. The margin is small, but it is
32//! the difference between measuring the conversation and measuring our own
33//! additions to it.
34//!
35//! **4 before 5.** `cache_prompt` is not an [`InferenceConfig`] field, so
36//! pinning it last means the resolved sampling patch can never overwrite it.
37//!
38//! **5b after 4.** Stage 5b deletes a `reasoning_effort` the model's observed
39//! template never reads, and the value it has to catch is usually not the
40//! client's. It arrives from the **ladder** — a `:high` profile, a per-model
41//! default, a global setting — which does not exist until stage 4 has folded
42//! it. Placed at 2b beside the tool strip, where the capability shape is
43//! otherwise identical, the gate would delete the client's key and stage 4
44//! would then force-insert gglib's own resolved level straight past it (the
45//! patch is *inserted*, not merged), so the case that matters most would
46//! sail through untouched while the tests still passed on a client-sent
47//! level. The stage runs after 4 for the same reason it takes
48//! `&mut SamplingDecision`: it can only suppress a value once something has
49//! resolved one, and it must correct that decision's own record when it does.
50//! `a_ladder_supplied_effort_is_suppressed_not_just_a_client_one` fails if
51//! this ever moves.
52//!
53//! **6 after 3.** The grammar stage 6 *adds* a top-level key, so it runs
54//! after the measurement for the same reason sampling does: the truncation
55//! budget measures the client's conversation, not our own additions to it.
56//!
57//! [`InferenceConfig`]: crate::domain::InferenceConfig
58//!
59//! # Why the seam is `&mut Value` and not a typed request struct
60//!
61//! The proxy forwards requests from arbitrary external clients — IDE
62//! extensions, gateways — which send `OpenAI` parameters this workspace has
63//! never heard of. Round-tripping through a typed `ChatRequest` would silently
64//! drop every field the struct does not model: a passthrough regression that
65//! is invisible in tests and painful in the field. Mutating a `Value` in place
66//! preserves them by construction. The adapter builds its body with `json!` and
67//! already holds a `Value`, so this is also the cheaper side for it.
68//!
69//! # One pipeline, two callers, no second route
70//!
71//! Every request path calls [`apply`]. The proxy used to run the stages by hand
72//! with its own truncation pass spliced between them, because truncation gated
73//! on the payload's size in **wire bytes** and could reject the request with an
74//! `axum` response — neither of which fits here. Measuring the serialized
75//! `Value` and returning a domain error removed both obstacles, so there is now
76//! exactly one implementation of the order above and nothing to keep in sync.
77
78use serde_json::Value;
79
80use super::effort_gate::SuppressedEffort;
81use super::sampling::SamplingDecision;
82use super::truncation::{TruncationError, TruncationReport};
83use super::{
84    ModelContext, SamplingLayers, constrain, effort_gate, messages, sampling, sampling_log, tools,
85    truncation,
86};
87
88/// What the pipeline did, for the caller that has to report or verify it.
89///
90/// Both halves were previously unavailable in different ways: truncation was
91/// returned bare, and sampling was not returned at all — it went into a
92/// `debug!` and nowhere else. Bundling them keeps one return value as stages
93/// gain things worth saying.
94#[derive(Debug, Clone, PartialEq)]
95pub struct PipelineReport {
96    /// Stage 3. Zeroed when `budget_chars` was `None` — the request was
97    /// shaped but never measured.
98    pub truncation: TruncationReport,
99    /// Stages 4–5. See [`SamplingDecision`].
100    pub sampling: SamplingDecision,
101    /// Stage 5b. `Some` when a resolved `reasoning_effort` was thrown away
102    /// because the model's observed template does not read it.
103    ///
104    /// It lives here rather than on [`SamplingDecision`] because that type is
105    /// *what `resolve_sampling` decided*, and this is what a later stage did
106    /// to it — the same relationship [`truncation`](Self::truncation) has to
107    /// stage 3. The decision is not left lying, though: stage 5b rewrites its
108    /// `resolved` and `sources` in place, so a consumer holding only the
109    /// `SamplingDecision` (the dashboard, the audit) still sees the value gone
110    /// and its provenance reading
111    /// [`SuppressedByTemplate`](crate::domain::ParamSource::SuppressedByTemplate).
112    /// What only this field adds is the level that was dropped and the rung
113    /// that asked for it.
114    pub effort_suppressed: Option<SuppressedEffort>,
115}
116
117/// Apply every request-shaping transform, in order, in place.
118///
119/// This is the whole pipeline as one call. See the [module docs](self) for the
120/// stage order and why it is fixed.
121///
122/// `budget_chars` is the history-truncation budget in characters.
123/// [`ModelContext::context_budget_chars`] is the answer for callers with no
124/// live serving context to measure; the proxy passes its own, computed from the
125/// running server's context size and a learned chars-per-token ratio. `None`
126/// skips stage 3 entirely and reports zeroes — the request is shaped but never
127/// measured, which is what an unresolvable model gets.
128///
129/// Unknown fields, top-level and per-message alike, are preserved.
130///
131/// # Errors
132///
133/// [`TruncationError`] when the conversation cannot be made to fit
134/// `budget_chars`. `body` is left shaped and trimmed; callers reject the
135/// request rather than forward it.
136pub fn apply(
137    body: &mut Value,
138    ctx: &ModelContext,
139    layers: &SamplingLayers,
140    budget_chars: Option<usize>,
141) -> Result<PipelineReport, TruncationError> {
142    messages::shape_messages(body, ctx);
143    tools::strip_unsupported_tools(body, ctx);
144
145    let truncation = match budget_chars {
146        Some(limit) => truncation::truncate_history(body, limit)?,
147        None => TruncationReport::default(),
148    };
149
150    let mut sampling = sampling::resolve_sampling(body, ctx, layers);
151
152    // Stage 5b. After the fold, never before it: the level worth catching is
153    // the one gglib itself resolved, and until stage 4 has run there is no
154    // such value to catch. See the ordering rationale in the module docs.
155    let effort_suppressed = effort_gate::suppress_unsupported_effort(body, ctx, &mut sampling);
156
157    // Rendered here, not inside stage 4, so the one line that describes a
158    // request's sampling describes what was *sent*. See `sampling_log`.
159    sampling_log::log_resolution(&sampling);
160
161    // Stage 6 runs unconditionally, because there is only one kind of trip
162    // through this pipeline.
163    //
164    // A `PipelinePass` parameter used to exist so this stage could stand down
165    // on a tool-call repair: `constrain` fires on `tool_choice: "required"`
166    // for dialect models, installs gglib's own grammar and rewrites
167    // `tool_choice` to `"none"` (llama-server rejects a custom grammar
168    // alongside `tools`), which on a repair would silently convert the
169    // re-issue into a request for no tool call at all.
170    //
171    // That guard was never reachable. The repair path does not call `apply`
172    // at all — it mutates the already-resolved body and sends it — so every
173    // caller here passed `Initial` and the alternative branch was dead. The
174    // reasoning still matters, but it belongs where the risk actually lives:
175    // see `gglib_proxy::repair::repair_body`, which must keep bypassing this
176    // pipeline for exactly the reason above.
177    constrain::constrain_tool_calls(body, ctx);
178    Ok(PipelineReport {
179        truncation,
180        sampling,
181        effort_suppressed,
182    })
183}
184
185#[cfg(test)]
186mod live_shape_probe {
187    use super::*;
188    use crate::domain::{DefaultsOrigin, InferenceConfig};
189
190    /// The exact body and context shape of the live hardware check that
191    /// found the gated-key passthrough, end to end through `apply` rather
192    /// than through `resolve_sampling` alone.
193    #[test]
194    fn the_live_bodys_frequency_penalty_is_stripped() {
195        let mut body = serde_json::json!({
196            "model": "Qwen3.5-4B",
197            "messages": [{"role": "user", "content": "Say hello briefly."}],
198            "max_tokens": 20,
199            "frequency_penalty": 0.9,
200            "top_p": 0.3
201        });
202        let ctx = ModelContext {
203            tags: vec!["agent".into(), "reasoning".into(), "mtp".into()],
204            inference_defaults: Some(InferenceConfig::reasoning_profile()),
205            defaults_origin: Some(DefaultsOrigin::AutoDetected),
206            ..ModelContext::passthrough()
207        };
208        let layers = SamplingLayers::default();
209        let report = apply(&mut body, &ctx, &layers, None).expect("pipeline applies");
210        assert!(report.sampling.applied);
211
212        let obj = body.as_object().unwrap();
213        assert!(
214            !obj.contains_key("frequency_penalty"),
215            "frequency_penalty survived: {body}"
216        );
217        let top_p = obj["top_p"].as_f64().expect("recipe top_p present");
218        assert!((top_p - 0.95).abs() < 1e-6, "client top_p survived: {body}");
219    }
220}
221
222#[cfg(test)]
223#[path = "apply_tests.rs"]
224mod apply_tests;