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}