gglib_core/request_pipeline/sampling.rs
1//! Stage 4–5: resolving what the model is asked to sample with.
2//!
3//! Unlike [`super::messages`], nothing here reads `messages` — these transforms
4//! only ever touch top-level keys.
5//!
6//! **Tier B — Policy** ([ADR 0001]). llama-server is one process serving one
7//! model with no catalog, no profiles and no view of the client, so it cannot
8//! arbitrate between a `:coding` profile and a per-model default. The ladder,
9//! the trust gate and the provenance are permanently gglib's, and nothing here
10//! gates on [`RuntimeCapabilities`].
11//!
12//! The **floor beneath** the ladder is a separate question with a different
13//! answer: six of its seven values were measured to restate llama.cpp's own
14//! defaults, which makes them compensation rather than policy. See
15//! [ADR 0003], which decides they are deferred.
16//!
17//! [ADR 0001]: https://github.com/mmogr/gglib/blob/main/docs/adr/0001-runtime-capability-tiers.md
18//! [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
19//! [`RuntimeCapabilities`]: crate::domain::RuntimeCapabilities
20
21use serde_json::Value;
22use tracing::debug;
23
24use super::ModelContext;
25use crate::domain::inference::{
26 REASONING_BUDGET_TOKENS_KEY, REASONING_EFFORT_KEY, THINKING_BUDGET_TOKENS_KEY,
27};
28use crate::domain::{DefaultsOrigin, FieldIssue, FieldSources, InferenceConfig, ParamSource};
29
30/// The sampling layers that sit *below* the client's own request parameters.
31///
32/// Grouped because they are only ever used together, at the single point where
33/// [`resolve_sampling`] folds them through [`InferenceConfig::resolve_layers_with_sources`].
34///
35/// The per-model layer is deliberately absent: it arrives with the rest of the
36/// per-model facts, as
37/// [`ModelContext::inference_defaults`](super::ModelContext::inference_defaults),
38/// so no caller has to look the model up twice. The client's own parameters are
39/// absent for a different reason — they are read back out of the request body
40/// itself, which is what lets one function serve a proxy forwarding an
41/// arbitrary client payload and an adapter that built the body from a typed
42/// config.
43#[derive(Debug, Clone, Default, PartialEq)]
44pub struct SamplingLayers {
45 /// Operator-supplied overrides from the process's own command line
46 /// (`gglib proxy --temperature …`, `gglib serve --temperature …`), applied
47 /// *above* the client's request parameters.
48 ///
49 /// Above the client deliberately: this is the person running the server
50 /// stating what the server does, which cannot be true if any client can
51 /// silently outrank it. These previously merged into [`Self::global`],
52 /// which sits below the per-model layer — so on any model with stored
53 /// `inference_defaults` the flags did nothing at all.
54 pub cli_override: Option<InferenceConfig>,
55 /// The profile the request selected via `{model}:{profile}`, if any.
56 /// Sparse — see [`crate::domain::inference_profile`].
57 pub profile: Option<InferenceConfig>,
58 /// Global defaults from settings.
59 pub global: Option<InferenceConfig>,
60 /// Whether the client's own sampling parameters are honoured at all.
61 /// From `Settings.trust_client_sampling`. `false` (the default) drops
62 /// everything the client sent except [`CLIENT_AUTHORITATIVE_KEYS`] — the
63 /// client's own *budgets*, currently `max_tokens` and
64 /// `reasoning_budget_tokens` — see the field doc on `Settings` for why,
65 /// and that constant for what makes a key a budget. This is read from the
66 /// same settings snapshot as [`Self::global`], which is why it lives here
67 /// rather than as a separate parameter threaded through every caller.
68 pub trust_client_sampling: bool,
69 /// Whether a request carrying tools gets the agentic-turn temperature
70 /// ceiling — see [`InferenceConfig::agentic_temperature_ceiling`].
71 ///
72 /// Set by the caller rather than defaulted on, because the two callers
73 /// decide it differently: the proxy reads `Settings.agentic_sampling`
74 /// (opt-out — absent means on), while the in-process agent path has no
75 /// settings snapshot and enables it unconditionally. `Default` leaves it
76 /// off so a bare `SamplingLayers::default()` applies no adjustment.
77 pub agentic_adjustments: bool,
78}
79
80/// Which class floor sat beneath the ladder.
81///
82/// `sources` records that a value came from "the floor" but not *which* one,
83/// and the explain surfaces cannot show it at all — they resolve stored
84/// configuration with no request in hand.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum FloorClass {
87 /// [`InferenceConfig::with_hardcoded_defaults`].
88 Default,
89 /// [`InferenceConfig::reasoning_floor`] — a `reasoning`-tagged model.
90 Reasoning,
91}
92
93impl FloorClass {
94 /// The label the debug line and the readback use.
95 #[must_use]
96 pub const fn label(self) -> &'static str {
97 match self {
98 Self::Default => "default",
99 Self::Reasoning => "reasoning",
100 }
101 }
102}
103
104/// Everything [`resolve_sampling`] decided, and why.
105///
106/// # Why this is returned rather than logged
107///
108/// It used to be neither: `resolve_sampling` computed `sources` and consumed
109/// them only inside a `debug!`. Three consequences, all of which cost real
110/// defects:
111///
112/// - **No test could assert on the pipeline's own provenance.** The tests
113/// that look like they do build a ladder by hand and call
114/// `resolve_layers_with_sources` directly, bypassing this function
115/// entirely — and did so five rungs wide against a six-rung ladder.
116/// - **The agentic ceiling's provenance interaction is invisible.** Six tests
117/// assert the resulting temperature; none could assert what provenance
118/// reports when the ceiling bites, because nothing was reachable.
119/// - **There is no intent side to compare a readback against.** Verifying
120/// that what gglib resolved is what llama-server applied needs both halves,
121/// and this is the half that did not exist outside a log line.
122#[derive(Debug, Clone, PartialEq)]
123pub struct SamplingDecision {
124 /// The values written into the body.
125 pub resolved: InferenceConfig,
126 /// Which rung supplied each one. Indices are into [`Self::layer_names`].
127 pub sources: FieldSources,
128 /// The ladder's rung names, highest priority first.
129 pub layer_names: [&'static str; LADDER_RUNGS],
130 /// Which class floor sat beneath it.
131 pub floor: FloorClass,
132 /// Whether the request was eligible for the agentic-turn ceiling.
133 pub agentic_turn: bool,
134 /// The ceiling value, if it actually capped the temperature.
135 ///
136 /// `Some` does **not** mean `sources.temperature` is wrong: the cap is
137 /// applied after the fold and deliberately leaves the rung that supplied
138 /// the value named, because that rung did supply it — the ceiling capped
139 /// what it supplied.
140 pub agentic_ceiling_applied: Option<f32>,
141 /// Client fields that could not be read as sent. See [`FieldIssue`].
142 pub client_fields_rejected: Vec<FieldIssue>,
143 /// Client fields dropped by the trust gate rather than by a parse
144 /// failure — empty whenever `trust_client_sampling` is on.
145 ///
146 /// Carries both kinds of drop: modelled fields the gate binned, and
147 /// [`UNMODELLED_SAMPLER_KEYS`] stripped from the body itself, which have
148 /// no layer to be binned from.
149 pub client_fields_discarded: Vec<String>,
150 /// Whether the resolved values actually reached `body`.
151 ///
152 /// `false` when the body was not a JSON object, in which case everything
153 /// above describes a resolution that was computed and then not applied.
154 /// Distinguishing the two matters to a readback: nothing was sent, so
155 /// nothing can diverge.
156 pub applied: bool,
157}
158
159/// Rungs in the pipeline's ladder: `cli`, `client`, `profile`, `model`,
160/// `global`, `model (auto-detected)`.
161///
162/// Named because three separate doc comments drifted to three different
163/// numbers while the ladder stayed six wide, and because the provenance test
164/// helper was built five wide against it and so never checked the mapping.
165pub const LADDER_RUNGS: usize = 6;
166
167/// Environment kill switch for the agentic-turn adjustments.
168///
169/// Truthy values (case-insensitive `1`, `true`, `yes`, `on`) disable it for
170/// every caller, whatever their settings say — the same contract as
171/// [`DISABLE_GRAMMAR_ENV`].
172///
173/// [`DISABLE_GRAMMAR_ENV`]: super::constrain::DISABLE_GRAMMAR_ENV
174pub const DISABLE_AGENTIC_SAMPLING_ENV: &str = "GGLIB_DISABLE_AGENTIC_SAMPLING";
175
176/// Whether [`DISABLE_AGENTIC_SAMPLING_ENV`] is set to a truthy value.
177fn agentic_sampling_disabled_via_env() -> bool {
178 crate::debug_switches::enabled(DISABLE_AGENTIC_SAMPLING_ENV)
179}
180
181/// Read the client's own sampling parameters and apply the trust gate.
182///
183/// Returns the layer to fold, what could not be read, and what the gate
184/// dropped. Both lists are reported rather than swallowed: between them they
185/// are every way a value the client actually sent can fail to reach
186/// llama-server from this stage, and until recently neither was visible.
187fn read_client_layer(
188 body: &Value,
189 trust_client_sampling: bool,
190) -> (InferenceConfig, Vec<FieldIssue>, Vec<String>) {
191 let (client_params, issues) = InferenceConfig::extract_client_sampling(body);
192 if !issues.is_empty() {
193 // Not `warn!`: a client sending a field gglib cannot read is a fact
194 // about that client, not a fault in this server, and on the busiest
195 // path in the system a warning per request would be noise. It is
196 // recorded rather than swallowed because until now it was neither —
197 // one unreadable field discarded the client's whole sampling layer
198 // with nothing said.
199 debug!(
200 issues = %issues.iter().map(ToString::to_string).collect::<Vec<_>>().join(", "),
201 "client sampling: some fields were not usable as sent"
202 );
203 }
204
205 if trust_client_sampling {
206 return (client_params, issues, Vec::new());
207 }
208
209 // What the gate is about to bin. This is the default posture and the
210 // highest-volume path in the system, so it is the largest silent discard
211 // gglib performs — a sustained non-empty list here says clients are
212 // trying to steer sampling and are being overruled, which an operator may
213 // well want to know.
214 let discarded: Vec<String> = client_params
215 .to_openai_json_patch()
216 .into_iter()
217 .map(|(k, _)| k)
218 .filter(|k| !CLIENT_AUTHORITATIVE_KEYS.contains(&k.as_str()))
219 .collect();
220 if !discarded.is_empty() {
221 debug!(
222 discarded = %discarded.join(", "),
223 kept = %CLIENT_AUTHORITATIVE_KEYS.join(", "),
224 "client sampling: untrusted, dropping all but the client's own budgets"
225 );
226 }
227
228 // The carve-out, and it is exactly `CLIENT_AUTHORITATIVE_KEYS` — kept in
229 // sync by `every_client_authoritative_key_survives_an_untrusted_request`,
230 // because the list above governs the *discard record* and this struct
231 // governs what is actually kept, and a field in one and not the other is
232 // either a silent drop or an unreported survival.
233 let gated = InferenceConfig {
234 max_tokens: client_params.max_tokens,
235 reasoning_budget_tokens: client_params.reasoning_budget_tokens,
236 ..InferenceConfig::default()
237 };
238 (gated, issues, discarded)
239}
240
241/// The client's own fields that survive an untrusted request.
242///
243/// # Budgets, not tastes — and the reasoning pair splits along that line
244///
245/// `max_tokens` has always been here: it is a budget on the client's own turn,
246/// and dropping it would silently truncate answers the client sized
247/// deliberately. `UNMODELLED_SAMPLER_KEYS`' own scope note draws the rule —
248/// "Budgets (`max_tokens`), stops, constraint machinery and observation ...
249/// stay client-authoritative — they say what the request *is*, not how it
250/// should sample."
251///
252/// `reasoning_budget_tokens` is that category by name, so it joins. It caps
253/// how many tokens this turn may spend thinking, it is enforced by llama.cpp's
254/// own sampler-side budget rather than by a template, and — the load-bearing
255/// half — **upstream governs it**: `-2` comes back as an HTTP 400 naming the
256/// range ([ADR 0007] finding 7c). A client sending it is asking for a shape of
257/// turn, within bounds a second system already enforces.
258///
259/// `reasoning_effort` does **not** join, and the asymmetry runs the opposite
260/// way to what its name suggests. It is taste: it steers what the model is
261/// shown, its level vocabulary is per-template folklore, and upstream
262/// validates it *not at all* — `"banana"` is accepted and rendered into the
263/// prompt verbatim. So it is precisely the field where an untrusted client's
264/// value would reach the model unexamined by anyone, which is what the trust
265/// gate exists to stop. When untrusted it is dropped from the client layer,
266/// removed from the body by the cleanup in [`resolve_sampling`], and named in
267/// `client_fields_discarded`.
268///
269/// The gate only reaches a level gglib could *read*, so it is half the story:
270/// an unreadable one (`"banana"`, `"none"`) never becomes a layer value to
271/// discard. That half is the same cleanup's `issues` arm, and it applies on
272/// both sides of the gate — trusting a client is not trusting a typo.
273///
274/// Neither control is observable afterwards (ADR 0007 finding 7a), so the
275/// discard record is the only place the decision is ever visible.
276///
277/// # Public because the operator-facing surfaces describe it
278///
279/// `gglib model explain` and the GUI's sampling inspector both print a caveat
280/// naming what survives an untrusted request, because the client rung is a
281/// real rung neither table can show. That sentence read "except `max_tokens`"
282/// for as long as this list was one key long, and nothing would have failed
283/// had it stayed that way after `reasoning_budget_tokens` joined — a
284/// user-facing description of the trust boundary, silently false. Exporting
285/// the list lets `caveats_name_every_client_authoritative_key` in
286/// `gglib-cli`'s `explain_display` assert the sentence against it, so the
287/// next key added here fails a test instead of shipping a wrong caveat. The
288/// TypeScript half cannot read a Rust constant; it carries its own copy,
289/// named and pinned, with a pointer back here.
290///
291/// [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
292pub const CLIENT_AUTHORITATIVE_KEYS: &[&str] = &["max_tokens", REASONING_BUDGET_TOKENS_KEY];
293
294/// Sampler-taste keys llama-server reads that [`InferenceConfig`] does not
295/// model, stripped from an untrusted body by
296/// [`strip_unmodelled_sampler_keys`].
297///
298/// The trust gate discards the client's sampling *layer*, but the resolved
299/// patch is only ever **inserted** into the body — nothing removed the keys
300/// the ladder has no field for. So every key here was a way for an untrusted
301/// client to steer sampling past the gate: gglib's own values arrived intact,
302/// the readback saw no divergence (`/slots.params` echoes what was parsed,
303/// not what the chain did — ADR 0003 finding 7), and the applied chain was
304/// something nobody configured. `mirostat` alone replaces the entire
305/// truncation stack.
306///
307/// Scope: **taste, not function**. Budgets (`max_tokens`), stops, constraint
308/// machinery (`grammar`, `json_schema`, `response_format`) and observation
309/// (`n_probs`, `logprobs`) stay client-authoritative — they say what the
310/// request *is*, not how it should sample. `logit_bias` stays too, a
311/// deliberate edge: it is per-token surgery with legitimate functional uses
312/// (banning a token), and a dedicated decision should move it, not a sweep.
313///
314/// A modelled key must never appear here — the gate already governs those,
315/// and stripping one would delete the client's value *before* the trusted
316/// path could read it. `no_modelled_key_is_listed_as_unmodelled` pins this,
317/// so modelling a new parameter (as `frequency_penalty` just was) forces its
318/// removal from this list.
319const UNMODELLED_SAMPLER_KEYS: &[&str] = &[
320 "typical_p",
321 "xtc_probability",
322 "xtc_threshold",
323 "mirostat",
324 "mirostat_tau",
325 "mirostat_eta",
326 "dry_sequence_breakers",
327 "repeat_last_n",
328 "samplers",
329 "min_keep",
330];
331
332/// Remove [`UNMODELLED_SAMPLER_KEYS`] from an untrusted body, returning what
333/// was removed so it joins the discard record.
334///
335/// A no-op when the client is trusted — trusted means trusted, unmodelled
336/// keys included — and on a body that is not a JSON object, which the rest of
337/// the pipeline also leaves alone.
338fn strip_unmodelled_sampler_keys(body: &mut Value, trust_client_sampling: bool) -> Vec<String> {
339 if trust_client_sampling {
340 return Vec::new();
341 }
342 let Some(obj) = body.as_object_mut() else {
343 return Vec::new();
344 };
345 let mut stripped = Vec::new();
346 for key in UNMODELLED_SAMPLER_KEYS {
347 if obj.remove(*key).is_some() {
348 stripped.push((*key).to_owned());
349 }
350 }
351 if !stripped.is_empty() {
352 debug!(
353 stripped = %stripped.join(", "),
354 "client sampling: untrusted, stripping unmodelled sampler keys"
355 );
356 }
357 stripped
358}
359
360/// Remove from the body the client keys gglib must not forward: what the trust
361/// gate binned, the budget alias gglib never emits, and one refused field.
362///
363/// The single place body keys leave this stage, so "what does gglib delete
364/// from a request" has one answer in one function.
365///
366/// # `discarded` — what the trust gate binned
367///
368/// The resolved patch is only ever *inserted*, and since ADR 0003 six modelled
369/// fields resolve to nothing by design, so a gated key the ladder then stays
370/// silent on rides the body to llama-server exactly like an unmodelled one.
371/// Found live, not by review: an untrusted client's `frequency_penalty: 0.9`
372/// reached `/slots` intact, because no layer names that field and nothing
373/// overwrote it. Before the deferral this could not happen — the floor emitted
374/// every modelled key — which is why the gate never needed this until then.
375///
376/// Empty when the client is trusted.
377///
378/// # The budget alias — always, whatever the trust setting
379///
380/// llama-server reads [`THINKING_BUDGET_TOKENS_KEY`] as a second spelling of
381/// `reasoning_budget_tokens` (ADR 0007 finding 7c). gglib reads it too, so its
382/// value is already *in* the resolved ladder — but gglib emits the canonical
383/// key only, and a surviving alias is a second answer to the same question
384/// sitting next to the force-inserted first. Which one wins would then be
385/// llama-server's parse order rather than gglib's ladder. Removing it is not a
386/// trust decision, it is a consequence of gglib having one canonical spelling.
387///
388/// # `issues` — one field, and the asymmetry is upstream's
389///
390/// A refused value never becomes `Some`, so it never enters the resolved patch
391/// and never entered `discarded` either: the rejection stops at the layer, and
392/// the client's own text rides on. For nearly every field that is exactly
393/// right — these readers reject what llama-server rejects, so the forwarded
394/// value earns a clean HTTP 400 from the system that owns the field, which
395/// tells the client more than a silent substitution would and keeps gglib no
396/// stricter than upstream (the doctrine on
397/// [`InferenceConfig::extract_client_sampling`]).
398///
399/// ADR 0007 finding 7c measured where that stops holding. Upstream **governs
400/// the budget** — `reasoning_budget_tokens: -2` comes back a 400 naming the
401/// range — and **does not govern effort at all**. So the two reasoning
402/// controls split:
403///
404/// - [`REASONING_EFFORT_KEY`], refused → **deleted**. There is no downstream
405/// 400 to inherit: `"banana"` is accepted upstream and rendered into the
406/// user's prompt verbatim. Left in the body, gglib's refusal would be a
407/// record in `client_fields_rejected` of a value the model then read. This
408/// is the one field where gglib's "no" has to be the only "no" there is.
409/// - [`REASONING_BUDGET_TOKENS_KEY`], refused → **left in place**, like every
410/// other field. The client gets upstream's honest 400. And if the ladder
411/// resolves a budget of its own, the force-insert overwrites the client's
412/// text before it is ever sent, so the refusal costs nothing.
413///
414/// A [`FieldIssue::Normalised`] deletes nothing under either rule: the
415/// substitute is either force-inserted over the client's spelling or is an
416/// absence that llama.cpp reads from the client's own sentinel anyway
417/// (`max_tokens: -1`).
418///
419/// A body that is not a JSON object is left alone, as everywhere else here.
420fn erase_unadopted_client_keys(body: &mut Value, discarded: &[String], issues: &[FieldIssue]) {
421 let Some(obj) = body.as_object_mut() else {
422 return;
423 };
424 for key in discarded.iter().map(String::as_str) {
425 obj.remove(key);
426 }
427 obj.remove(THINKING_BUDGET_TOKENS_KEY);
428 let effort_refused = issues.iter().any(|issue| {
429 matches!(issue, FieldIssue::Rejected { field, .. } if *field == REASONING_EFFORT_KEY)
430 });
431 if effort_refused {
432 obj.remove(REASONING_EFFORT_KEY);
433 }
434}
435
436/// Which rung the model's stored defaults occupy, and the name that rung
437/// carries — both decided by [`DefaultsOrigin`] in one place, so resolution
438/// and its labelling cannot disagree.
439///
440/// Exhaustive on purpose — see the twin in `resolve_with_profile_explained`.
441/// A catch-all here would rank any future unreviewed origin above global
442/// settings, which is precisely backwards.
443///
444/// The name was the static `"model (auto-detected)"`, which lied in the
445/// debug line and the audit's provenance strings whenever the occupant was a
446/// published recipe — and would have credited gglib's guess for a tune
447/// sweep's winner the same way.
448const fn model_rung(
449 ctx: &ModelContext,
450) -> (
451 Option<&InferenceConfig>,
452 Option<&InferenceConfig>,
453 &'static str,
454) {
455 match ctx.defaults_origin {
456 Some(DefaultsOrigin::AutoDetected) => (
457 None,
458 ctx.inference_defaults.as_ref(),
459 "model (auto-detected)",
460 ),
461 Some(DefaultsOrigin::Published) => {
462 (None, ctx.inference_defaults.as_ref(), "model (published)")
463 }
464 Some(DefaultsOrigin::Measured) => {
465 (None, ctx.inference_defaults.as_ref(), "model (measured)")
466 }
467 Some(DefaultsOrigin::User) | None => (
468 ctx.inference_defaults.as_ref(),
469 None,
470 "model (auto-detected)",
471 ),
472 }
473}
474
475/// Resolve the sampling hierarchy into `body`, then pin `cache_prompt`.
476///
477/// This doc block used to sit above `read_client_layer`, where a split left
478/// it fused to that function's own first line — so the entry point of the
479/// whole stage was undocumented while a private helper carried a description
480/// of something else. Restored here; `read_client_layer` keeps its own.
481///
482/// # Force-insert, not `or_insert`
483///
484/// The client's own parameters are extracted from `body` first, folded
485/// through [`InferenceConfig::resolve_layers_with_sources`] alongside cli / profile /
486/// model / global, and the fully-resolved result is then written back over
487/// the top. Client parameters still win — they win by being the
488/// highest-priority *layer* in the fold, not by surviving an `or_insert`.
489/// Rewriting this as `or_insert` looks equivalent and silently breaks the
490/// hierarchy: every layer below the client would stop applying to any key
491/// the client happened to send.
492///
493/// # Client trust
494///
495/// `layers.trust_client_sampling` gates which of the client's own fields
496/// enter that layer at all. When `false` (the default — see
497/// `Settings::trust_client_sampling`), only [`CLIENT_AUTHORITATIVE_KEYS`]
498/// survive; the rest of `body`'s sampling keys are read but discarded before
499/// the fold, so a client with a hardcoded `temperature` can no longer outrank
500/// this server's own configuration, and every field it left unset still
501/// gap-fills from below exactly as if it had never sent that key.
502///
503/// That carve-out is a *category*, not a list of exceptions, and the two
504/// reasoning controls land on opposite sides of it: the budget is a budget and
505/// survives, the effort level is taste and does not. The reasoning is on
506/// [`CLIENT_AUTHORITATIVE_KEYS`].
507///
508/// The gate covers modelled fields; sampler keys the ladder has no field
509/// for (`UNMODELLED_SAMPLER_KEYS`) are stripped from the untrusted body
510/// itself, because a key with no layer has nothing to be discarded from and
511/// would otherwise ride the body to llama-server ungoverned.
512///
513/// # What leaves the body
514///
515/// `erase_unadopted_client_keys` is the one place keys are deleted, and it
516/// deletes three things — only the first of which is about trust:
517///
518/// - **What the gate binned** — empty when the client is trusted.
519/// - **`thinking_budget_tokens`** — always. It is upstream's alias for the
520/// budget, gglib reads it and then emits the canonical key alone, and two
521/// spellings of one parameter in one body is a disagreement waiting to be
522/// resolved by somebody else's parse order.
523/// - **A refused `reasoning_effort`** — on *both* sides of the gate, and it
524/// is the only field an `issues` entry removes. Every other refused value
525/// is forwarded exactly as before this PR, because upstream 400s on it and
526/// that 400 is a better answer to the client than gglib quietly rewriting
527/// the request. `reasoning_effort` is the exception because upstream
528/// validates it not at all: a refused `"banana"` left in the body is not
529/// rejected downstream, it is rendered into the prompt.
530///
531/// So a client's `top_k: "5"` still reaches llama-server and still earns its
532/// HTTP 400, unchanged by the reasoning work. The helper carries the full
533/// argument and ADR 0007's finding behind it.
534///
535/// A body that is not a JSON object is left alone.
536pub fn resolve_sampling(
537 body: &mut Value,
538 ctx: &ModelContext,
539 layers: &SamplingLayers,
540) -> SamplingDecision {
541 let (client_layer, issues, mut discarded) =
542 read_client_layer(body, layers.trust_client_sampling);
543
544 // Keys the ladder cannot govern get no layer to lose in — they would ride
545 // the body straight to llama-server, past the gate that just ran. Strip
546 // them here, before the fold, so the discard record carries the whole of
547 // what an untrusted client asked for and did not get.
548 discarded.extend(strip_unmodelled_sampler_keys(
549 body,
550 layers.trust_client_sampling,
551 ));
552
553 // Runs before the fold, so any key the ladder does resolve is re-inserted
554 // below with gglib's own value. Narrow on purpose: the gate's drops, the
555 // budget alias gglib never emits, and a refused `reasoning_effort` — every
556 // other unreadable value is left for llama-server to answer, as it always
557 // was.
558 erase_unadopted_client_keys(body, &discarded, &issues);
559
560 // The `reasoning` tag selects the floor beneath every layer here — a
561 // model that degrades into repetitive loops under greedy decoding still
562 // gets a real anti-repetition guard when nothing above the floor sets
563 // one, rather than the universal neutral default. See
564 // `InferenceConfig::reasoning_floor`.
565 let model_is_reasoning = crate::domain::capability_tags::is_reasoning(&ctx.tags);
566 let floor = if model_is_reasoning {
567 InferenceConfig::reasoning_floor()
568 } else {
569 InferenceConfig::with_hardcoded_defaults()
570 };
571
572 // Whether an agentic-turn temperature ceiling is eligible to apply. Only
573 // eligibility — whether it actually bites depends on where the resolved
574 // temperature came from, which is not known until after the fold.
575 //
576 // Keyed on tools being present, not on `tool_choice: "required"`: agentic
577 // clients send `"auto"` almost universally, so a `required`-only trigger
578 // would describe nearly no real traffic. See `request_shape::carries_tools`.
579 //
580 // Stage 2b has already removed `tools` for a model that cannot call them,
581 // so this cannot fire on a model that would never emit a tool call —
582 // except on a passthrough context, where nothing is known about the model
583 // and nothing was stripped.
584 let agentic_turn = layers.agentic_adjustments
585 && !agentic_sampling_disabled_via_env()
586 && super::request_shape::carries_tools(body);
587
588 // `model` occupies one of two rungs depending on how it was set — never
589 // both — so an auto-detected guess can't silently outrank global
590 // settings the way a deliberate per-model choice should. See
591 // `DefaultsOrigin` and `InferenceConfig::resolve_with_profile`.
592 let (user_model, auto_model, below_global_rung_name) = model_rung(ctx);
593
594 // Highest priority first. The single ordering both resolution and
595 // provenance reporting read from, so they can never drift apart.
596 let ordered: [(&str, Option<&InferenceConfig>); 6] = [
597 ("cli", layers.cli_override.as_ref()),
598 ("client", Some(&client_layer)),
599 ("profile", layers.profile.as_ref()),
600 ("model", user_model),
601 ("global", layers.global.as_ref()),
602 (below_global_rung_name, auto_model),
603 ];
604 let layer_configs: Vec<Option<&InferenceConfig>> =
605 ordered.iter().map(|(_, config)| *config).collect();
606 // Values and provenance come from the same pass over the same ladder, so
607 // the log can never name a layer the resolution did not use.
608 let (mut resolved, sources) =
609 InferenceConfig::resolve_layers_with_sources(&layer_configs, &floor);
610
611 // Cap the temperature for an agentic turn — but only over a value nobody
612 // deliberately chose.
613 //
614 // The gate is provenance, not rank. A ladder rung would have been the
615 // obvious way to express "outranks the auto-detected recipe", and it is
616 // wrong: a rung that names a `temperature` *claims the coupled trio* under
617 // `resolve_layers_with_sources`, so `presence_penalty`, `repeat_penalty` and `min_p`
618 // would drop to the floor behind it. A `reasoning` model would silently
619 // lose the 1.5 presence penalty its own recipe pairs with its temperature
620 // on every agentic turn. Clamping after the fold leaves the trio
621 // untouched.
622 //
623 // Eligible sources are the auto-detected rung and the floor. An
624 // auto-detected recipe is an unreviewed guess written at import time, and
625 // already ranks below global settings for that reason; a task-aware
626 // ceiling outranking it is consistent with that. Anything a person
627 // actually set — cli, client, profile, per-model, global — stands.
628 //
629 // Reasoning models have no ceiling at all — that is a measured decision,
630 // not an omission; see `agentic_temperature_ceiling` for the experiment
631 // that removed it (tune runs #12–#32) and ADR 0004's postscript.
632 let auto_detected_rung = ordered.len() - 1;
633 // A measured recipe is the one below-global origin the ceiling defers
634 // to. The tune sweep resolved its candidates against this model's real
635 // context (#748) precisely so the winner transfers to production —
636 // capping the stored winner here would un-measure it on exactly the
637 // turns it was measured for. Only the *model rung* is exempted: a
638 // Measured model whose recipe names no temperature still resolves from
639 // the floor, and the floor stays cappable — nobody measured the floor.
640 let measured_model_rung = matches!(ctx.defaults_origin, Some(DefaultsOrigin::Measured))
641 && sources.temperature == ParamSource::Layer(auto_detected_rung);
642 let temperature_is_unchosen =
643 !sources.temperature.is_deliberate_choice(auto_detected_rung) && !measured_model_rung;
644 let applied_ceiling =
645 InferenceConfig::agentic_temperature_ceiling(model_is_reasoning).filter(|&ceiling| {
646 agentic_turn
647 && temperature_is_unchosen
648 && resolved.temperature.is_some_and(|t| t > ceiling)
649 });
650 if let Some(ceiling) = applied_ceiling {
651 resolved.temperature = Some(ceiling);
652 }
653
654 // Nothing is logged here. The whole decision is rendered once, by
655 // `super::sampling_log`, after stage 5b — which can still delete a resolved
656 // `reasoning_effort` and would leave this line stating that gglib sent one.
657 // See that module for why a second, correcting line was not good enough.
658 let layer_names: [&'static str; LADDER_RUNGS] = ordered.map(|(name, _)| name);
659 let decision = |applied| SamplingDecision {
660 resolved: resolved.clone(),
661 sources,
662 layer_names,
663 floor: if model_is_reasoning {
664 FloorClass::Reasoning
665 } else {
666 FloorClass::Default
667 },
668 agentic_turn,
669 agentic_ceiling_applied: applied_ceiling,
670 client_fields_rejected: issues.clone(),
671 client_fields_discarded: discarded.clone(),
672 applied,
673 };
674
675 let Some(obj) = body.as_object_mut() else {
676 return decision(false);
677 };
678
679 for (key, value) in resolved.to_openai_json_patch() {
680 obj.insert(key, value);
681 }
682
683 // Force-insert (not or_insert) llama-server's own `cache_prompt` flag.
684 // It defaults to true server-side, but nothing guarantees the calling
685 // client doesn't send `false` — and if it ever did, llama-server's
686 // n_past = get_common_prefix(...) reuse computation (server-context.cpp)
687 // is skipped entirely, silently discarding 100% of any restored/hot KV
688 // state and forcing a full re-prefill regardless of how well the prompt
689 // actually matches. The whole KV cache session persistence feature depends
690 // on this staying true, so pin it rather than trusting it implicitly.
691 obj.insert("cache_prompt".to_owned(), Value::Bool(true));
692
693 decision(true)
694}
695
696#[cfg(test)]
697#[path = "sampling_tests.rs"]
698mod sampling_tests;