Skip to main content

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;