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
6use serde_json::Value;
7use tracing::debug;
8
9use super::ModelContext;
10use crate::domain::{DefaultsOrigin, InferenceConfig};
11
12/// The sampling layers that sit *below* the client's own request parameters.
13///
14/// Grouped because they are only ever used together, at the single point where
15/// [`resolve_sampling`] folds them through [`InferenceConfig::resolve_layers`].
16///
17/// The per-model layer is deliberately absent: it arrives with the rest of the
18/// per-model facts, as
19/// [`ModelContext::inference_defaults`](super::ModelContext::inference_defaults),
20/// so no caller has to look the model up twice. The client's own parameters are
21/// absent for a different reason — they are read back out of the request body
22/// itself, which is what lets one function serve a proxy forwarding an
23/// arbitrary client payload and an adapter that built the body from a typed
24/// config.
25#[derive(Debug, Clone, Default, PartialEq)]
26pub struct SamplingLayers {
27    /// Operator-supplied overrides from the process's own command line
28    /// (`gglib proxy --temperature …`), applied *above* the client's request
29    /// parameters.
30    ///
31    /// Above the client deliberately: this is the person running the server
32    /// stating what the server does, which cannot be true if any client can
33    /// silently outrank it. These previously merged into [`Self::global`],
34    /// which sits below the per-model layer — so on any model with stored
35    /// `inference_defaults` the flags did nothing at all.
36    pub cli_override: Option<InferenceConfig>,
37    /// The profile the request selected via `{model}:{profile}`, if any.
38    /// Sparse — see [`crate::domain::inference_profile`].
39    pub profile: Option<InferenceConfig>,
40    /// Global defaults from settings.
41    pub global: Option<InferenceConfig>,
42    /// Whether the client's own sampling parameters are honoured at all.
43    /// From `Settings.trust_client_sampling`. `false` (the default) drops
44    /// everything the client sent except `max_tokens` — see the field doc on
45    /// `Settings` for why. This is read from the same settings snapshot as
46    /// [`Self::global`], which is why it lives here rather than as a
47    /// separate parameter threaded through every caller.
48    pub trust_client_sampling: bool,
49}
50
51/// Resolve the sampling hierarchy into `body`, then pin `cache_prompt`.
52///
53/// # Force-insert, not `or_insert`
54///
55/// The client's own parameters are extracted from `body` first, folded
56/// through [`InferenceConfig::resolve_layers`] alongside cli / profile /
57/// model / global, and the fully-resolved result is then written back over
58/// the top. Client parameters still win — they win by being the
59/// highest-priority *layer* in the fold, not by surviving an `or_insert`.
60/// Rewriting this as `or_insert` looks equivalent and silently breaks the
61/// hierarchy: every layer below the client would stop applying to any key
62/// the client happened to send.
63///
64/// # Client trust
65///
66/// `layers.trust_client_sampling` gates which of the client's own fields
67/// enter that layer at all. When `false` (the default — see
68/// `Settings::trust_client_sampling`), only `max_tokens` survives; the rest
69/// of `body`'s sampling keys are read but discarded before the fold, so a
70/// client with a hardcoded `temperature` can no longer outrank this
71/// server's own configuration, and every field it left unset still
72/// gap-fills from below exactly as if it had never sent that key.
73///
74/// A body that is not a JSON object is left alone.
75pub fn resolve_sampling(body: &mut Value, ctx: &ModelContext, layers: &SamplingLayers) {
76    let client_params = InferenceConfig::from_openai_json(body);
77    // `max_tokens` stays client-authoritative regardless of trust: it is a
78    // budget, not a taste, and dropping it would silently truncate the
79    // client's own turns. See `Settings::trust_client_sampling`.
80    let client_layer = if layers.trust_client_sampling {
81        client_params
82    } else {
83        InferenceConfig {
84            max_tokens: client_params.max_tokens,
85            ..InferenceConfig::default()
86        }
87    };
88
89    // The `reasoning` tag selects the floor beneath every layer here — a
90    // model that degrades into repetitive loops under greedy decoding still
91    // gets a real anti-repetition guard when nothing above the floor sets
92    // one, rather than the universal neutral default. See
93    // `InferenceConfig::reasoning_floor`.
94    let model_is_reasoning = ctx
95        .tags
96        .iter()
97        .any(|tag| tag.eq_ignore_ascii_case("reasoning"));
98    let floor = if model_is_reasoning {
99        InferenceConfig::reasoning_floor()
100    } else {
101        InferenceConfig::with_hardcoded_defaults()
102    };
103
104    // `model` occupies one of two rungs depending on how it was set — never
105    // both — so an auto-detected guess can't silently outrank global
106    // settings the way a deliberate per-model choice should. See
107    // `DefaultsOrigin` and `InferenceConfig::resolve_with_profile`.
108    let (user_model, auto_model) = match ctx.defaults_origin {
109        Some(DefaultsOrigin::AutoDetected) => (None, ctx.inference_defaults.as_ref()),
110        _ => (ctx.inference_defaults.as_ref(), None),
111    };
112
113    // Highest priority first. The single ordering both resolution and
114    // provenance reporting read from, so they can never drift apart.
115    let ordered: [(&str, Option<&InferenceConfig>); 6] = [
116        ("cli", layers.cli_override.as_ref()),
117        ("client", Some(&client_layer)),
118        ("profile", layers.profile.as_ref()),
119        ("model", user_model),
120        ("global", layers.global.as_ref()),
121        ("model (auto-detected)", auto_model),
122    ];
123    let layer_configs: Vec<Option<&InferenceConfig>> =
124        ordered.iter().map(|(_, config)| *config).collect();
125    // Values and provenance come from the same pass over the same ladder, so
126    // the log can never name a layer the resolution did not use.
127    let (resolved, sources) = InferenceConfig::resolve_layers_with_sources(&layer_configs, &floor);
128
129    if tracing::enabled!(tracing::Level::DEBUG) {
130        let names: Vec<&str> = ordered.iter().map(|(name, _)| *name).collect();
131        debug!(
132            temperature = ?resolved.temperature,
133            top_p = ?resolved.top_p,
134            top_k = ?resolved.top_k,
135            max_tokens = ?resolved.max_tokens,
136            presence_penalty = ?resolved.presence_penalty,
137            repeat_penalty = ?resolved.repeat_penalty,
138            min_p = ?resolved.min_p,
139            from = %sources.describe(&names),
140            "sampling resolved"
141        );
142    }
143
144    let Some(obj) = body.as_object_mut() else {
145        return;
146    };
147
148    for (key, value) in resolved.to_openai_json_patch() {
149        obj.insert(key, value);
150    }
151
152    // Force-insert (not or_insert) llama-server's own `cache_prompt` flag.
153    // It defaults to true server-side, but nothing guarantees the calling
154    // client doesn't send `false` — and if it ever did, llama-server's
155    // n_past = get_common_prefix(...) reuse computation (server-context.cpp)
156    // is skipped entirely, silently discarding 100% of any restored/hot KV
157    // state and forcing a full re-prefill regardless of how well the prompt
158    // actually matches. The whole KV cache session persistence feature depends
159    // on this staying true, so pin it rather than trusting it implicitly.
160    obj.insert("cache_prompt".to_owned(), Value::Bool(true));
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use serde_json::json;
167
168    fn temp(value: f32) -> InferenceConfig {
169        InferenceConfig {
170            temperature: Some(value),
171            ..Default::default()
172        }
173    }
174
175    fn model_ctx(defaults: Option<InferenceConfig>) -> ModelContext {
176        ModelContext {
177            inference_defaults: defaults,
178            ..ModelContext::passthrough()
179        }
180    }
181
182    /// `f32 → f64` widening makes exact literal comparison unreliable.
183    #[track_caller]
184    fn assert_param(body: &Value, key: &str, expected: f64) {
185        let actual = body
186            .get(key)
187            .and_then(Value::as_f64)
188            .unwrap_or_else(|| panic!("{key} missing from body: {body}"));
189        assert!(
190            (actual - expected).abs() < 1e-6,
191            "{key}: expected {expected}, got {actual}"
192        );
193    }
194
195    // ── The hierarchy ─────────────────────────────────────────────────────
196
197    /// One table, one row per layer: each wins only over the ones beneath it.
198    #[test]
199    fn each_layer_beats_the_ones_below_it() {
200        let cases = [
201            // (cli, client temperature, profile, model, global, expected, why)
202            (
203                Some(0.05),
204                Some(0.11),
205                Some(0.22),
206                Some(0.33),
207                Some(0.44),
208                0.05,
209                "cli override beats client",
210            ),
211            (
212                None,
213                Some(0.11),
214                Some(0.22),
215                Some(0.33),
216                Some(0.44),
217                0.11,
218                "client beats profile",
219            ),
220            (
221                None,
222                None,
223                Some(0.22),
224                Some(0.33),
225                Some(0.44),
226                0.22,
227                "profile beats model",
228            ),
229            (
230                None,
231                None,
232                None,
233                Some(0.33),
234                Some(0.44),
235                0.33,
236                "model beats global",
237            ),
238            (
239                None,
240                None,
241                None,
242                None,
243                Some(0.44),
244                0.44,
245                "global beats hardcoded",
246            ),
247            (None, None, None, None, None, 0.7, "hardcoded fallback"),
248        ];
249
250        for (cli, client, profile, model, global, expected, why) in cases {
251            let mut body = client.map_or_else(|| json!({}), |t| json!({"temperature": t}));
252            let layers = SamplingLayers {
253                cli_override: cli.map(temp),
254                profile: profile.map(temp),
255                global: global.map(temp),
256                // This table is specifically about layer precedence, not
257                // about the trust gate — trust it here so the "client beats
258                // profile" / "cli beats client" rows still exercise the
259                // client layer at all. See `client_sampling_is_ignored_by_default`.
260                trust_client_sampling: true,
261            };
262            resolve_sampling(&mut body, &model_ctx(model.map(temp)), &layers);
263            assert_param(&body, "temperature", expected);
264            assert!(
265                body["temperature"].as_f64().is_some(),
266                "{why}: temperature must be present"
267            );
268        }
269    }
270
271    /// Profiles are sparse: outranking the model layer must not blank out the
272    /// untuned parameters the profile says nothing about.
273    #[test]
274    fn a_sparse_profile_leaves_other_model_defaults_intact() {
275        let mut body = json!({});
276        let model = InferenceConfig {
277            temperature: Some(1.0),
278            top_p: Some(0.87),
279            top_k: Some(20),
280            ..Default::default()
281        };
282        resolve_sampling(
283            &mut body,
284            &model_ctx(Some(model)),
285            &SamplingLayers {
286                cli_override: None,
287                profile: Some(temp(0.2)),
288                global: None,
289                trust_client_sampling: false,
290            },
291        );
292
293        assert_param(&body, "temperature", 0.2);
294        assert_param(&body, "top_p", 0.87);
295        assert_param(&body, "top_k", 20.0);
296    }
297
298    // ── Provenance ────────────────────────────────────────────────────────
299
300    /// The five names the pipeline's own ladder uses, for the provenance
301    /// tests below.
302    const LAYER_NAMES: [&str; 5] = ["cli", "client", "profile", "model", "global"];
303
304    /// Resolve a ladder and render its provenance the way the debug line does.
305    fn provenance_of(layers: &[Option<&InferenceConfig>; 5]) -> String {
306        let floor = InferenceConfig::with_hardcoded_defaults();
307        InferenceConfig::resolve_layers_with_sources(layers, &floor)
308            .1
309            .describe(&LAYER_NAMES)
310    }
311
312    /// The `:coding` shape. The provenance must say the penalty came from the
313    /// floor, not from the model — otherwise the log would assert exactly the
314    /// leak the merge now prevents.
315    #[test]
316    fn provenance_reports_coupling_suppressed_layers_as_floor() {
317        let model = InferenceConfig {
318            temperature: Some(1.0),
319            presence_penalty: Some(1.5),
320            top_k: Some(20),
321            ..Default::default()
322        };
323        let profile = temp(0.2);
324        let got = provenance_of(&[None, None, Some(&profile), Some(&model), None]);
325
326        assert!(got.contains("temperature=profile"), "{got}");
327        assert!(got.contains("presence_penalty=floor"), "{got}");
328        // Untuned parameters are unaffected by the claim.
329        assert!(got.contains("top_k=model"), "{got}");
330    }
331
332    /// With nothing above it claiming a temperature, the model's own recipe is
333    /// reported intact.
334    #[test]
335    fn provenance_attributes_an_unclaimed_recipe_to_the_model() {
336        let model = InferenceConfig {
337            temperature: Some(1.0),
338            presence_penalty: Some(1.5),
339            ..Default::default()
340        };
341        let got = provenance_of(&[None, None, None, Some(&model), None]);
342
343        assert!(got.contains("temperature=model"), "{got}");
344        assert!(got.contains("presence_penalty=model"), "{got}");
345    }
346
347    /// Operator flags are reported as their own layer, above the client.
348    #[test]
349    fn provenance_names_the_cli_layer() {
350        let cli = temp(0.3);
351        let client = temp(0.9);
352        let got = provenance_of(&[Some(&cli), Some(&client), None, None, None]);
353
354        assert!(got.contains("temperature=cli"), "{got}");
355    }
356
357    /// The drift this unification removes. `cli` names a `presence_penalty`
358    /// but no `temperature`; `model` claims the temperature, so the coupling
359    /// rule resolves the penalty from `model` — and the provenance must say so.
360    ///
361    /// The previous `describe_provenance` scanned every layer down to the
362    /// claiming one and reported `cli`, naming a layer the resolution had
363    /// passed over.
364    #[test]
365    fn provenance_does_not_credit_a_layer_the_coupling_rule_passed_over() {
366        let cli = InferenceConfig {
367            presence_penalty: Some(1.2),
368            ..Default::default()
369        };
370        let model = InferenceConfig {
371            temperature: Some(1.0),
372            presence_penalty: Some(1.5),
373            ..Default::default()
374        };
375        let layers = [Some(&cli), None, None, Some(&model), None];
376
377        let got = provenance_of(&layers);
378        assert!(
379            got.contains("presence_penalty=model"),
380            "the claiming layer supplied it, got: {got}"
381        );
382
383        // And the value agrees with the name.
384        let floor = InferenceConfig::with_hardcoded_defaults();
385        let (resolved, _) = InferenceConfig::resolve_layers_with_sources(&layers, &floor);
386        assert_eq!(resolved.presence_penalty, Some(1.5));
387    }
388
389    /// Regression for #621: operator flags must beat the per-model layer.
390    ///
391    /// These previously merged into the *global* layer, which sits below the
392    /// model — so on any model with stored `inference_defaults`, every
393    /// `gglib proxy --temperature …` style flag silently did nothing.
394    #[test]
395    fn a_cli_override_beats_the_model_layer() {
396        let mut body = json!({});
397        resolve_sampling(
398            &mut body,
399            &model_ctx(Some(InferenceConfig {
400                temperature: Some(1.0),
401                top_k: Some(20),
402                ..Default::default()
403            })),
404            &SamplingLayers {
405                cli_override: Some(temp(0.3)),
406                ..Default::default()
407            },
408        );
409
410        assert_param(&body, "temperature", 0.3);
411        // Untuned parameters the operator said nothing about still resolve.
412        assert_param(&body, "top_k", 20.0);
413    }
414
415    /// The operator runs the server, so their flags also outrank the client's
416    /// own request parameters — otherwise any caller could quietly ignore them.
417    #[test]
418    fn a_cli_override_beats_client_request_params() {
419        let mut body = json!({"temperature": 0.9});
420        resolve_sampling(
421            &mut body,
422            &model_ctx(None),
423            &SamplingLayers {
424                cli_override: Some(temp(0.3)),
425                ..Default::default()
426            },
427        );
428
429        assert_param(&body, "temperature", 0.3);
430    }
431
432    /// Regression for #621, at the pipeline level: the `:coding` shape — a
433    /// profile that lowers the temperature — must not carry the model's
434    /// `presence_penalty`, which was tuned for the model's own temperature.
435    #[test]
436    fn a_profile_temperature_does_not_carry_model_penalties() {
437        let mut body = json!({});
438        let model = InferenceConfig {
439            temperature: Some(1.0),
440            presence_penalty: Some(1.5),
441            ..Default::default()
442        };
443        resolve_sampling(
444            &mut body,
445            &model_ctx(Some(model)),
446            &SamplingLayers {
447                cli_override: None,
448                profile: Some(temp(0.2)),
449                global: None,
450                trust_client_sampling: false,
451            },
452        );
453
454        assert_param(&body, "temperature", 0.2);
455        assert_param(&body, "presence_penalty", 0.0);
456    }
457
458    /// When the client IS trusted (`trust_client_sampling: true` — an
459    /// `OpenWebUI`-style client with real sampling controls exposed to its
460    /// user), a client that sends `temperature: 0` must still not silently
461    /// zero out a reasoning model's only anti-repetition guard. The client
462    /// still wins the temperature it asked for — it just doesn't also claim
463    /// penalties it never named an opinion on. See `resolve_layers`'s
464    /// coupling rule.
465    #[test]
466    fn trusted_client_temperature_zero_does_not_zero_a_reasoning_models_presence_penalty() {
467        let mut body = json!({"temperature": 0.0});
468        let ctx = ModelContext {
469            tags: vec!["reasoning".to_owned()],
470            inference_defaults: Some(InferenceConfig::reasoning_profile()),
471            ..ModelContext::passthrough()
472        };
473        resolve_sampling(
474            &mut body,
475            &ctx,
476            &SamplingLayers {
477                trust_client_sampling: true,
478                ..Default::default()
479            },
480        );
481
482        assert_param(&body, "temperature", 0.0);
483        assert_param(&body, "presence_penalty", 1.0);
484    }
485
486    /// Same as above, but a non-reasoning model gets the plain neutral floor,
487    /// not the reasoning one — the class floor is opt-in via the tag, not a
488    /// blanket change.
489    #[test]
490    fn trusted_client_temperature_zero_leaves_a_non_reasoning_model_at_the_neutral_floor() {
491        let mut body = json!({"temperature": 0.0});
492        let model = InferenceConfig {
493            temperature: Some(0.8),
494            presence_penalty: Some(0.6),
495            ..Default::default()
496        };
497        resolve_sampling(
498            &mut body,
499            &model_ctx(Some(model)),
500            &SamplingLayers {
501                trust_client_sampling: true,
502                ..Default::default()
503            },
504        );
505
506        assert_param(&body, "temperature", 0.0);
507        assert_param(&body, "presence_penalty", 0.0);
508    }
509
510    // ── Client sampling authority (Settings.trust_client_sampling) ─────────
511
512    /// The default. This is the actual fix for the incident that motivated
513    /// this whole refactor: without a client-trust escape hatch, a client
514    /// hardcoding `temperature: 0` with no way for its user to change it (VS
515    /// Code Copilot's LLM Gateway) claims the coupled trio on every request
516    /// and supplies none of it — so the model's own tuned recipe never has a
517    /// chance to apply, no matter what `resolve_layers`'s coupling rule does.
518    /// With the client out of the ladder entirely, the model's full recipe —
519    /// temperature *and* the penalties tuned for it — resolves untouched.
520    #[test]
521    fn client_sampling_is_ignored_by_default() {
522        let mut body = json!({"temperature": 0.0});
523        let ctx = ModelContext {
524            tags: vec!["reasoning".to_owned()],
525            inference_defaults: Some(InferenceConfig::reasoning_profile()),
526            ..ModelContext::passthrough()
527        };
528        resolve_sampling(&mut body, &ctx, &SamplingLayers::default());
529
530        assert_param(&body, "temperature", 1.0); // the model's own, not the client's 0.0
531        assert_param(&body, "presence_penalty", 1.5); // the model's tuned recipe, intact
532    }
533
534    /// `max_tokens` is a budget, not a taste — it stays client-authoritative
535    /// even when nothing else about the client's request is trusted, because
536    /// dropping it would silently truncate that client's own turns.
537    #[test]
538    fn max_tokens_is_still_honoured_when_client_sampling_is_untrusted() {
539        let mut body = json!({"temperature": 0.9, "max_tokens": 999});
540        resolve_sampling(
541            &mut body,
542            &model_ctx(Some(InferenceConfig {
543                temperature: Some(0.4),
544                ..Default::default()
545            })),
546            &SamplingLayers::default(),
547        );
548
549        assert_param(&body, "temperature", 0.4); // client's 0.9 dropped
550        assert_param(&body, "max_tokens", 999.0); // client's budget still honoured
551    }
552
553    // ── Model defaults provenance (Model.defaults_origin) ───────────────────
554
555    /// A user's own global settings must win over gglib's unreviewed guess —
556    /// this is the actual regression this feature exists for. Without it, a
557    /// `reasoning`-tagged model's auto-written recipe always wins over
558    /// anything configured globally, with no way to tell the two apart in
559    /// the resolved output.
560    #[test]
561    fn an_auto_detected_models_recipe_ranks_below_global_settings() {
562        let mut body = json!({});
563        let ctx = ModelContext {
564            inference_defaults: Some(InferenceConfig::reasoning_profile()), // temp 1.0, presence 1.5
565            defaults_origin: Some(DefaultsOrigin::AutoDetected),
566            ..ModelContext::passthrough()
567        };
568        let layers = SamplingLayers {
569            global: Some(InferenceConfig {
570                temperature: Some(0.2),
571                top_k: Some(20),
572                min_p: Some(0.05),
573                ..Default::default()
574            }),
575            ..Default::default()
576        };
577        resolve_sampling(&mut body, &ctx, &layers);
578
579        assert_param(&body, "temperature", 0.2); // global beats the auto-detected guess
580        assert_param(&body, "top_k", 20.0);
581        assert_param(&body, "min_p", 0.05);
582        // The claiming layer (global) left presence_penalty unset, so it
583        // falls to the floor — never to the auto-detected model's 1.5,
584        // which was tuned for a temperature global didn't choose.
585        assert_param(&body, "presence_penalty", 0.0);
586    }
587
588    /// The same model, but with a deliberate per-model choice instead of an
589    /// auto-detected one: it keeps outranking global settings exactly as
590    /// before this feature existed — that is what "per-model" is supposed
591    /// to mean.
592    #[test]
593    fn a_user_set_models_recipe_still_beats_global_settings() {
594        let mut body = json!({});
595        let ctx = ModelContext {
596            inference_defaults: Some(InferenceConfig::reasoning_profile()),
597            defaults_origin: Some(DefaultsOrigin::User),
598            ..ModelContext::passthrough()
599        };
600        let layers = SamplingLayers {
601            global: Some(InferenceConfig {
602                temperature: Some(0.2),
603                ..Default::default()
604            }),
605            ..Default::default()
606        };
607        resolve_sampling(&mut body, &ctx, &layers);
608
609        assert_param(&body, "temperature", 1.0); // the user's own choice wins
610        assert_param(&body, "presence_penalty", 1.5); // travels with it, intact
611    }
612
613    /// The force-insert. An `or_insert` implementation passes every test above
614    /// and fails this one. Trusted explicitly: this test is about force-insert
615    /// semantics, not about the trust gate.
616    #[test]
617    fn resolution_overwrites_a_partial_client_value_from_lower_layers() {
618        // The client named only `temperature`. Every other key must still be
619        // written from the layers beneath it rather than left absent.
620        let mut body = json!({"temperature": 0.11});
621        resolve_sampling(
622            &mut body,
623            &model_ctx(Some(InferenceConfig {
624                top_p: Some(0.42),
625                ..Default::default()
626            })),
627            &SamplingLayers {
628                trust_client_sampling: true,
629                ..Default::default()
630            },
631        );
632
633        assert_param(&body, "temperature", 0.11);
634        assert_param(&body, "top_p", 0.42);
635    }
636
637    // ── cache_prompt ──────────────────────────────────────────────────────
638
639    #[test]
640    fn cache_prompt_is_pinned_true_when_absent() {
641        let mut body = json!({});
642        resolve_sampling(
643            &mut body,
644            &ModelContext::passthrough(),
645            &SamplingLayers::default(),
646        );
647        assert_eq!(body["cache_prompt"], true);
648    }
649
650    /// The KV cache feature depends on this: a client that sends `false` must
651    /// not be able to discard the whole restored cache.
652    #[test]
653    fn cache_prompt_is_forced_true_over_an_explicit_false() {
654        let mut body = json!({"cache_prompt": false});
655        resolve_sampling(
656            &mut body,
657            &ModelContext::passthrough(),
658            &SamplingLayers::default(),
659        );
660        assert_eq!(body["cache_prompt"], true);
661    }
662
663    // ── Passthrough ───────────────────────────────────────────────────────
664
665    #[test]
666    fn unknown_fields_survive_untouched() {
667        let mut body = json!({
668            "model": "m",
669            "messages": [{"role": "user", "content": "hi"}],
670            "totally_made_up_key": {"nested": [1, 2, {"deep": true}]},
671        });
672        resolve_sampling(
673            &mut body,
674            &ModelContext::passthrough(),
675            &SamplingLayers::default(),
676        );
677
678        assert_eq!(body["model"], "m");
679        assert_eq!(body["messages"][0]["content"], "hi");
680        assert_eq!(
681            body["totally_made_up_key"],
682            json!({"nested": [1, 2, {"deep": true}]})
683        );
684    }
685
686    /// `max_tokens` has no hardcoded fallback on purpose — a value here would
687    /// cap every request that did not name its own.
688    #[test]
689    fn no_max_tokens_is_written_when_nothing_sets_one() {
690        let mut body = json!({});
691        resolve_sampling(
692            &mut body,
693            &ModelContext::passthrough(),
694            &SamplingLayers::default(),
695        );
696        assert!(body.as_object().unwrap().get("max_tokens").is_none());
697    }
698
699    #[test]
700    fn a_non_object_body_is_left_alone() {
701        let mut body = json!([1, 2, 3]);
702        resolve_sampling(
703            &mut body,
704            &ModelContext::passthrough(),
705            &SamplingLayers::default(),
706        );
707        assert_eq!(body, json!([1, 2, 3]));
708    }
709}