gglib_core/server_config.rs
1//! Canonical context-size resolver (5-level fallback chain).
2//!
3//! Extracted to `gglib-core` so that crates which cannot depend on
4//! `gglib-runtime` (e.g. `gglib-proxy`) can still use the same resolution
5//! logic for idle-model advertisements in `/v1/models`.
6
7use anyhow::{Result, anyhow};
8use std::path::PathBuf;
9
10use crate::domain::InferenceConfig;
11use crate::settings::DEFAULT_CONTEXT_SIZE;
12
13// =============================================================================
14// CLI flag parsing (deferred resolution)
15// =============================================================================
16
17/// A parsed `--ctx-size` CLI flag, before it is resolved against model
18/// metadata.
19///
20/// CLI argument parsing happens before the model is fetched from the
21/// database, so the raw flag cannot be resolved to a concrete value at parse
22/// time. [`CtxSizeArg::parse`] only validates the *shape* of the flag
23/// (numeric or the literal `max`); callers must call [`CtxSizeArg::resolve`]
24/// once the model (and its GGUF `context_length`) is available.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CtxSizeArg {
27 /// User passed `max` — resolve against the model's GGUF context length.
28 Max,
29 /// User passed an explicit numeric value.
30 Value(u64),
31}
32
33impl CtxSizeArg {
34 /// Parse a raw `--ctx-size` flag value.
35 ///
36 /// Accepts a positive integer or the case-insensitive literal `max`.
37 /// Anything else is a hard error — invalid input must never be
38 /// silently ignored.
39 pub fn parse(raw: &str) -> Result<Self> {
40 let trimmed = raw.trim();
41 if trimmed.eq_ignore_ascii_case("max") {
42 return Ok(Self::Max);
43 }
44 trimmed.parse::<u64>().map(Self::Value).map_err(|_| {
45 anyhow!("Invalid context size '{trimmed}'. Use a positive number or 'max'")
46 })
47 }
48
49 /// Resolve this flag into a concrete context size, now that the model's
50 /// GGUF metadata is available.
51 ///
52 /// - `Max` resolves to `model_max_ctx` (`None` if the model has no
53 /// recorded context length — falls through to the next tier).
54 /// - `Value(n)` always resolves to `Some(n)`.
55 pub const fn resolve(self, model_max_ctx: Option<u64>) -> Option<u64> {
56 match self {
57 Self::Max => model_max_ctx,
58 Self::Value(v) => Some(v),
59 }
60 }
61}
62
63/// Parse an optional raw `--ctx-size` flag into a [`CtxSizeArg`].
64///
65/// Convenience wrapper for CLI call sites: `None` (flag omitted) stays
66/// `None`; `Some(raw)` is parsed and propagates a hard error on invalid
67/// input via `?`.
68pub fn parse_ctx_size_flag(raw: Option<&str>) -> Result<Option<CtxSizeArg>> {
69 raw.map(CtxSizeArg::parse).transpose()
70}
71
72// =============================================================================
73// Options
74// =============================================================================
75
76/// Caller-supplied overrides for [`resolve_context_size`].
77///
78/// All fields default to `None`, which means "fall through to next tier".
79///
80/// Serialized as part of the daemon's HTTP contract: a pinned proxy start
81/// (`POST /api/proxy/start`) carries the model's fully-cascaded options in
82/// the request body. `#[serde(default)]` keeps that contract stable when a
83/// field is added — an older client's body simply resolves the new field to
84/// `None`.
85#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
86#[serde(default)]
87#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
88pub struct ServerConfigOptions {
89 /// Override the context window size forwarded to llama-server.
90 /// `None` lets llama-server use its built-in default.
91 #[cfg_attr(feature = "ts-bindings", ts(type = "number | null"))]
92 pub context_size: Option<u64>,
93
94 /// Per-model server defaults context length (from `Model.server_defaults.context_length`).
95 /// Second tier in fallback chain.
96 pub model_server_ctx: Option<usize>,
97
98 /// Global app setting for default context size (from `Settings.default_context_size`).
99 /// Third tier in fallback chain.
100 #[cfg_attr(feature = "ts-bindings", ts(type = "number | null"))]
101 pub global_default_ctx: Option<u64>,
102
103 /// Context fitted to this model and this machine, from
104 /// [`crate::domain::fit_context`]. Fourth tier in the fallback chain.
105 ///
106 /// `None` when it could not be computed — unknown KV shape, no memory
107 /// reading — which is a refusal, not a zero: the chain falls through to the
108 /// built-in default rather than launching against a guess.
109 #[serde(default)]
110 #[cfg_attr(feature = "ts-bindings", ts(type = "number | null"))]
111 pub fitted_ctx: Option<u64>,
112
113 /// Bind llama-server to a specific port instead of letting the allocator
114 /// choose.
115 pub port: Option<u16>,
116
117 /// Override Jinja template support.
118 /// - `None` → auto-detect: `--jinja` when the model has the `"agent"` tag,
119 /// and otherwise **no flag at all**, which leaves llama-server's own
120 /// default (jinja on) in place rather than disabling it.
121 /// - `Some(true)` → `--jinja` regardless of tags.
122 /// - `Some(false)` → `--no-jinja` regardless of tags. The only route to
123 /// actually turning jinja off; see [`crate::ports::JinjaMode`].
124 pub jinja: Option<bool>,
125
126 /// Override the reasoning format passed to llama-server.
127 /// - `None` → auto-detect from model tags (e.g. `"reasoning"` tag).
128 /// - `Some("none")` → explicitly suppress reasoning extraction even if the
129 /// model has a reasoning tag.
130 /// - `Some("deepseek")` / `Some("deepseek-legacy")` → force a specific
131 /// format.
132 pub reasoning_format: Option<String>,
133
134 /// Override the MTP draft token count.
135 /// - `None` → auto-detect: enabled with default `n=2` when the model has
136 /// the `"mtp"` tag.
137 /// - `Some(0)` → explicitly disable MTP even if the model has the `"mtp"`
138 /// tag.
139 /// - `Some(n)` → enable MTP with `n` draft tokens.
140 pub mtp_draft_n_max: Option<u32>,
141
142 /// Override the MTP acceptance probability threshold.
143 /// Only meaningful when MTP is enabled. `None` uses the default (`0.75`).
144 pub mtp_draft_p_min: Option<f32>,
145
146 /// Directory for llama-server KV cache slot persistence (`--slot-save-path`).
147 /// - `None` — disk slot persistence disabled, no `--slot-save-path` flag.
148 /// - `Some(dir)` — enables slot save/restore.
149 /// Direct pass-through, no tag-based auto-detection (unlike jinja/MTP/reasoning).
150 /// Independent of `cache_ram_mb`/`cache_reuse` below.
151 pub slot_save_path: Option<PathBuf>,
152
153 /// RAM budget in MiB for llama-server's own host-RAM prompt cache
154 /// (`--cache-ram`). `None` leaves llama-server's built-in default. `Some(0)`
155 /// disables the cache. Direct pass-through, no tag-based auto-detection.
156 #[cfg_attr(feature = "ts-bindings", ts(type = "number | null"))]
157 pub cache_ram_mb: Option<u64>,
158
159 /// Minimum chunk size in tokens for KV-shift cache reuse past the first
160 /// prefix divergence point (`--cache-reuse`). `None` leaves the feature
161 /// off. Direct pass-through, no tag-based auto-detection.
162 pub cache_reuse: Option<u32>,
163
164 /// Explicit override for the K cache element type (`--cache-type-k`).
165 /// `None` resolves to the `q8_0` default (see
166 /// `gglib_runtime::llama::args::resolve_kv_cache_types`), unless
167 /// `GGLIB_DISABLE_KV_QUANT=1` is set.
168 pub cache_type_k: Option<crate::cache_config::KvCacheType>,
169
170 /// Explicit override for the V cache element type (`--cache-type-v`).
171 /// Same resolution as [`Self::cache_type_k`]. Quantizing V additionally
172 /// requires Flash Attention to be active — see
173 /// `gglib_runtime::llama::args::kv_cache_type` module docs.
174 pub cache_type_v: Option<crate::cache_config::KvCacheType>,
175
176 /// Inference parameter overrides (temperature, top-p, etc.) forwarded
177 /// directly to llama-server.
178 pub inference_params: Option<InferenceConfig>,
179
180 /// Whether to memory-lock the model into RAM (`--mlock`).
181 /// `None` defaults to `false` in `build_server_config()`.
182 pub mlock: Option<bool>,
183}
184
185impl ServerConfigOptions {
186 /// Field-wise merge: every `Some` in `over` wins, every `None` falls
187 /// through to `self`.
188 ///
189 /// This is the single layering primitive behind both places where two sets
190 /// of options meet:
191 ///
192 /// - the 3-tier cascade in `UnifiedServerConfig::resolved_options`, where
193 /// global defaults are the base and explicit CLI/GUI overrides are `over`;
194 /// - per-call launch overrides layered on top of a `ProcessManager`'s
195 /// standing template.
196 ///
197 /// Note that this merges *options*, not resolved values — the tier chain
198 /// baked into [`resolve_context_size`] (request → per-model → global →
199 /// fitted → hardcoded) still runs afterwards on the merged result, so
200 /// overlaying never collapses those tiers early.
201 ///
202 /// `over` is destructured exhaustively on purpose: adding a field to this
203 /// struct then fails to compile until it is given merge semantics here,
204 /// rather than being silently dropped.
205 #[must_use]
206 pub fn overlay(&self, over: &Self) -> Self {
207 let Self {
208 context_size,
209 model_server_ctx,
210 global_default_ctx,
211 fitted_ctx,
212 port,
213 jinja,
214 reasoning_format,
215 mtp_draft_n_max,
216 mtp_draft_p_min,
217 slot_save_path,
218 cache_ram_mb,
219 cache_reuse,
220 cache_type_k,
221 cache_type_v,
222 inference_params,
223 mlock,
224 } = over;
225
226 Self {
227 context_size: context_size.or(self.context_size),
228 model_server_ctx: model_server_ctx.or(self.model_server_ctx),
229 global_default_ctx: global_default_ctx.or(self.global_default_ctx),
230 fitted_ctx: fitted_ctx.or(self.fitted_ctx),
231 port: port.or(self.port),
232 jinja: jinja.or(self.jinja),
233 reasoning_format: reasoning_format
234 .clone()
235 .or_else(|| self.reasoning_format.clone()),
236 mtp_draft_n_max: mtp_draft_n_max.or(self.mtp_draft_n_max),
237 mtp_draft_p_min: mtp_draft_p_min.or(self.mtp_draft_p_min),
238 slot_save_path: slot_save_path
239 .clone()
240 .or_else(|| self.slot_save_path.clone()),
241 cache_ram_mb: cache_ram_mb.or(self.cache_ram_mb),
242 cache_reuse: cache_reuse.or(self.cache_reuse),
243 cache_type_k: cache_type_k.or(self.cache_type_k),
244 cache_type_v: cache_type_v.or(self.cache_type_v),
245 inference_params: inference_params
246 .clone()
247 .or_else(|| self.inference_params.clone()),
248 mlock: mlock.or(self.mlock),
249 }
250 }
251}
252
253// =============================================================================
254// Resolver
255// =============================================================================
256
257/// Which rung of the context fallback chain supplied the resolved value.
258///
259/// Exists so a launch can state *why* it runs at a given context rather than
260/// only what that context is — the number alone cannot distinguish a value
261/// the user asked for from one inherited from the model's stored defaults or
262/// from the 4096 floor. See [`crate::domain::LaunchNarration`].
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum ContextSizeSource {
265 /// Runtime request or CLI flag (`opts.context_size`).
266 Explicit,
267 /// Per-model server defaults from the database (`opts.model_server_ctx`).
268 ModelServerDefaults,
269 /// Global app setting (`opts.global_default_ctx`).
270 GlobalDefault,
271 /// Computed from the model's trained context and this machine's memory.
272 FittedToHardware,
273 /// The hardcoded [`DEFAULT_CONTEXT_SIZE`] floor — nothing else was set.
274 BuiltInDefault,
275}
276
277impl ContextSizeSource {
278 /// Short label for display, e.g. `model server_defaults`.
279 #[must_use]
280 pub const fn label(self) -> &'static str {
281 match self {
282 Self::Explicit => "explicit",
283 Self::ModelServerDefaults => "model server_defaults",
284 Self::GlobalDefault => "global default",
285 Self::FittedToHardware => "fitted to hardware",
286 Self::BuiltInDefault => "built-in default",
287 }
288 }
289}
290
291/// Resolve context size using the 5-level fallback chain, reporting which
292/// rung won.
293///
294/// 1. Runtime request / CLI flag (`opts.context_size`) — highest priority
295/// 2. Per-model server defaults (`opts.model_server_ctx`) — from DB
296/// 3. Global app setting (`opts.global_default_ctx`) — only when the user set one
297/// 4. Fitted to this machine (`opts.fitted_ctx`)
298/// 5. Hardcoded default (`DEFAULT_CONTEXT_SIZE` = 4096) — lowest priority
299///
300/// [`resolve_context_size`] delegates here and discards the source, so the
301/// chain exists in exactly one place: a second copy that drifted would make
302/// the banner explain a decision the launch did not actually take.
303pub const fn resolve_context_size_with_source(
304 opts: &ServerConfigOptions,
305) -> (u64, ContextSizeSource) {
306 if let Some(ctx) = opts.context_size {
307 return (ctx, ContextSizeSource::Explicit);
308 }
309 if let Some(ctx) = opts.model_server_ctx {
310 return (ctx as u64, ContextSizeSource::ModelServerDefaults);
311 }
312 if let Some(ctx) = opts.global_default_ctx {
313 return (ctx, ContextSizeSource::GlobalDefault);
314 }
315 if let Some(ctx) = opts.fitted_ctx {
316 return (ctx, ContextSizeSource::FittedToHardware);
317 }
318 (DEFAULT_CONTEXT_SIZE, ContextSizeSource::BuiltInDefault)
319}
320
321/// Resolve context size using the 5-level fallback chain.
322/// 1. Runtime request / CLI flag (`opts.context_size`) — highest priority
323/// 2. Per-model server defaults (`opts.model_server_ctx`) — from DB
324/// 3. Global app setting (`opts.global_default_ctx`) — only when the user set one
325/// 4. Fitted to this machine (`opts.fitted_ctx`)
326/// 5. Hardcoded default (`DEFAULT_CONTEXT_SIZE` = 4096) — lowest priority
327///
328/// The fitted value sits *below* the global default deliberately: a number the
329/// user typed outranks one gglib computed. It sits above the built-in floor for
330/// the same reason — 4096 is what you serve when you know nothing, and by this
331/// rung something is known.
332pub const fn resolve_context_size(opts: &ServerConfigOptions) -> u64 {
333 resolve_context_size_with_source(opts).0
334}
335
336// =============================================================================
337// Host-RAM prompt cache budget (`--cache-ram`)
338// =============================================================================
339
340// `CacheRamSetting` now lives in `crate::cache_config`, alongside
341// `KvCacheType` — cache-related config resolution has one home. Re-exported
342// here so existing `gglib_core::server_config::CacheRamSetting` call sites
343// keep working.
344pub use crate::cache_config::CacheRamSetting;
345
346// Cache-RAM budget constants and [`compute_auto_cache_ram_mb`] now live in
347// `crate::domain::cache_budget` (re-exported from `crate::domain`), alongside
348// the rest of the domain's pure calculations.
349pub use crate::domain::cache_budget::{
350 CACHE_RAM_FLOOR_BYTES, CACHE_RAM_HEADROOM_BYTES, CACHE_RAM_UNKNOWN_KV_ALLOWANCE_BYTES,
351 compute_auto_cache_ram_mb,
352};
353
354#[cfg(test)]
355mod tests {
356 use crate::server_config::{ServerConfigOptions, resolve_context_size};
357 use crate::settings::DEFAULT_CONTEXT_SIZE;
358
359 #[test]
360 fn test_resolve_context_size_default_when_all_none() {
361 let opts = ServerConfigOptions::default();
362 assert_eq!(resolve_context_size(&opts), DEFAULT_CONTEXT_SIZE);
363 }
364
365 use crate::server_config::{ContextSizeSource, resolve_context_size_with_source};
366
367 /// Each rung wins in turn as the one above it is removed — this is the
368 /// precedence the banner claims to be reporting.
369 #[test]
370 fn context_source_names_the_winning_rung_at_each_level() {
371 let full = ServerConfigOptions {
372 context_size: Some(32_768),
373 model_server_ctx: Some(16_384),
374 global_default_ctx: Some(8192),
375 ..Default::default()
376 };
377 assert_eq!(
378 resolve_context_size_with_source(&full),
379 (32_768, ContextSizeSource::Explicit)
380 );
381
382 let no_explicit = ServerConfigOptions {
383 context_size: None,
384 ..full.clone()
385 };
386 assert_eq!(
387 resolve_context_size_with_source(&no_explicit),
388 (16_384, ContextSizeSource::ModelServerDefaults)
389 );
390
391 let global_only = ServerConfigOptions {
392 context_size: None,
393 model_server_ctx: None,
394 ..full
395 };
396 assert_eq!(
397 resolve_context_size_with_source(&global_only),
398 (8192, ContextSizeSource::GlobalDefault)
399 );
400
401 assert_eq!(
402 resolve_context_size_with_source(&ServerConfigOptions::default()),
403 (DEFAULT_CONTEXT_SIZE, ContextSizeSource::BuiltInDefault)
404 );
405 }
406
407 /// The bare resolver must stay a projection of the sourced one, or the
408 /// banner would explain a decision the launch did not take.
409 #[test]
410 fn bare_resolver_agrees_with_the_sourced_one() {
411 let opts = ServerConfigOptions {
412 model_server_ctx: Some(16_384),
413 global_default_ctx: Some(8192),
414 ..Default::default()
415 };
416 assert_eq!(
417 resolve_context_size(&opts),
418 resolve_context_size_with_source(&opts).0
419 );
420 }
421
422 // Cache-RAM budget math tests now live in
423 // `crate::domain::cache_budget::tests`, alongside the function itself.
424 use crate::server_config::CacheRamSetting;
425
426 /// Every launch surface should auto-size unless it opts out, so `Auto`
427 /// has to be the `Default` variant.
428 #[test]
429 fn cache_ram_setting_defaults_to_auto() {
430 assert_eq!(CacheRamSetting::default(), CacheRamSetting::Auto);
431 }
432
433 #[test]
434 fn test_resolve_context_size_global_beats_default() {
435 let opts = ServerConfigOptions {
436 global_default_ctx: Some(8192),
437 ..Default::default()
438 };
439 assert_eq!(resolve_context_size(&opts), 8192);
440 }
441
442 #[test]
443 fn test_resolve_context_size_model_beats_global() {
444 let opts = ServerConfigOptions {
445 model_server_ctx: Some(16_384),
446 global_default_ctx: Some(8192),
447 ..Default::default()
448 };
449 assert_eq!(resolve_context_size(&opts), 16_384);
450 }
451
452 #[test]
453 fn fitted_beats_the_built_in_default() {
454 // The rung that makes the whole change worth anything: with nothing
455 // configured, a machine-derived context is served instead of 4096.
456 let opts = ServerConfigOptions {
457 fitted_ctx: Some(32_768),
458 ..Default::default()
459 };
460 let (ctx, source) = resolve_context_size_with_source(&opts);
461 assert_eq!(ctx, 32_768);
462 assert_eq!(source, ContextSizeSource::FittedToHardware);
463 }
464
465 #[test]
466 fn a_user_set_global_default_beats_the_fitted_value() {
467 // A number somebody typed outranks one gglib computed, even a worse
468 // one — that is what "setting" means.
469 let opts = ServerConfigOptions {
470 global_default_ctx: Some(8192),
471 fitted_ctx: Some(65_536),
472 ..Default::default()
473 };
474 let (ctx, source) = resolve_context_size_with_source(&opts);
475 assert_eq!(ctx, 8192);
476 assert_eq!(source, ContextSizeSource::GlobalDefault);
477 }
478
479 #[test]
480 fn per_model_server_defaults_beat_the_fitted_value() {
481 let opts = ServerConfigOptions {
482 model_server_ctx: Some(16_384),
483 fitted_ctx: Some(65_536),
484 ..Default::default()
485 };
486 assert_eq!(resolve_context_size(&opts), 16_384);
487 }
488
489 #[test]
490 fn an_explicit_request_beats_the_fitted_value() {
491 let opts = ServerConfigOptions {
492 context_size: Some(4096),
493 fitted_ctx: Some(65_536),
494 ..Default::default()
495 };
496 assert_eq!(resolve_context_size(&opts), 4096);
497 }
498
499 #[test]
500 fn the_built_in_default_survives_when_nothing_can_be_fitted() {
501 // `fit_context` refuses rather than guessing, and a refusal must land
502 // on the floor rather than on nothing.
503 let opts = ServerConfigOptions {
504 fitted_ctx: None,
505 ..Default::default()
506 };
507 let (ctx, source) = resolve_context_size_with_source(&opts);
508 assert_eq!(ctx, DEFAULT_CONTEXT_SIZE);
509 assert_eq!(source, ContextSizeSource::BuiltInDefault);
510 }
511
512 #[test]
513 fn overlay_carries_a_fitted_value_through() {
514 let base = ServerConfigOptions {
515 fitted_ctx: Some(32_768),
516 ..Default::default()
517 };
518 assert_eq!(
519 base.overlay(&ServerConfigOptions::default()).fitted_ctx,
520 Some(32_768),
521 "an empty per-call overlay must not erase the fitted value"
522 );
523 }
524
525 #[test]
526 fn test_resolve_context_size_runtime_beats_all() {
527 let opts = ServerConfigOptions {
528 context_size: Some(32_768),
529 model_server_ctx: Some(16_384),
530 global_default_ctx: Some(8192),
531 ..Default::default()
532 };
533 assert_eq!(resolve_context_size(&opts), 32_768);
534 }
535
536 #[test]
537 fn test_resolve_context_size_model_without_global() {
538 let opts = ServerConfigOptions {
539 model_server_ctx: Some(2048),
540 ..Default::default()
541 };
542 assert_eq!(resolve_context_size(&opts), 2048);
543 }
544
545 #[test]
546 fn test_resolve_context_size_zero_is_valid() {
547 let opts = ServerConfigOptions {
548 context_size: Some(0),
549 ..Default::default()
550 };
551 assert_eq!(resolve_context_size(&opts), 0);
552 }
553
554 // -------------------------------------------------------------------
555 // CtxSizeArg / parse_ctx_size_flag
556 // -------------------------------------------------------------------
557
558 use crate::server_config::{CtxSizeArg, parse_ctx_size_flag};
559
560 #[test]
561 fn ctx_size_arg_parses_explicit_numeric() {
562 assert_eq!(CtxSizeArg::parse("8192").unwrap(), CtxSizeArg::Value(8192));
563 }
564
565 #[test]
566 fn ctx_size_arg_parses_max_case_insensitive() {
567 assert_eq!(CtxSizeArg::parse("max").unwrap(), CtxSizeArg::Max);
568 assert_eq!(CtxSizeArg::parse("MAX").unwrap(), CtxSizeArg::Max);
569 assert_eq!(CtxSizeArg::parse(" Max ").unwrap(), CtxSizeArg::Max);
570 }
571
572 #[test]
573 fn ctx_size_arg_invalid_string_is_hard_error() {
574 assert!(CtxSizeArg::parse("banana").is_err());
575 }
576
577 #[test]
578 fn ctx_size_arg_max_resolves_to_model_metadata() {
579 assert_eq!(CtxSizeArg::Max.resolve(Some(131_072)), Some(131_072));
580 }
581
582 #[test]
583 fn ctx_size_arg_max_without_model_metadata_resolves_to_none() {
584 assert_eq!(CtxSizeArg::Max.resolve(None), None);
585 }
586
587 #[test]
588 fn ctx_size_arg_value_ignores_model_metadata() {
589 assert_eq!(CtxSizeArg::Value(4096).resolve(Some(131_072)), Some(4096));
590 }
591
592 #[test]
593 fn parse_ctx_size_flag_none_when_flag_omitted() {
594 assert_eq!(parse_ctx_size_flag(None).unwrap(), None);
595 }
596
597 #[test]
598 fn parse_ctx_size_flag_propagates_parse_error() {
599 assert!(parse_ctx_size_flag(Some("not-a-number")).is_err());
600 }
601
602 // -------------------------------------------------------------------
603 // overlay
604 // -------------------------------------------------------------------
605
606 use crate::cache_config::KvCacheType;
607 use crate::domain::InferenceConfig;
608 use std::path::PathBuf;
609
610 /// Every field set, so a merge that drops one is visible. `marker` is a
611 /// `u8` purely so each field can widen losslessly via `From`.
612 fn populated(marker: u8) -> ServerConfigOptions {
613 ServerConfigOptions {
614 context_size: Some(u64::from(marker)),
615 model_server_ctx: Some(usize::from(marker)),
616 global_default_ctx: Some(u64::from(marker)),
617 fitted_ctx: Some(u64::from(marker)),
618 port: Some(u16::from(marker)),
619 jinja: Some(true),
620 reasoning_format: Some(format!("fmt-{marker}")),
621 mtp_draft_n_max: Some(u32::from(marker)),
622 mtp_draft_p_min: Some(f32::from(marker)),
623 slot_save_path: Some(PathBuf::from(format!("/slots/{marker}"))),
624 cache_ram_mb: Some(u64::from(marker)),
625 cache_reuse: Some(u32::from(marker)),
626 cache_type_k: Some(KvCacheType::Q8_0),
627 cache_type_v: Some(KvCacheType::F16),
628 inference_params: Some(InferenceConfig {
629 temperature: Some(f32::from(marker)),
630 ..Default::default()
631 }),
632 mlock: Some(true),
633 }
634 }
635
636 /// A fully-populated `over` must win on every single field. Compared
637 /// field-by-field rather than wholesale so a failure names the culprit.
638 #[test]
639 fn overlay_over_wins_on_every_field() {
640 let merged = populated(1).overlay(&populated(2));
641
642 assert_eq!(merged.context_size, Some(2));
643 assert_eq!(merged.model_server_ctx, Some(2));
644 assert_eq!(merged.global_default_ctx, Some(2));
645 assert_eq!(merged.port, Some(2));
646 assert_eq!(merged.jinja, Some(true));
647 assert_eq!(merged.reasoning_format.as_deref(), Some("fmt-2"));
648 assert_eq!(merged.mtp_draft_n_max, Some(2));
649 assert_eq!(merged.mtp_draft_p_min, Some(2.0));
650 assert_eq!(merged.slot_save_path, Some(PathBuf::from("/slots/2")));
651 assert_eq!(merged.cache_ram_mb, Some(2));
652 assert_eq!(merged.cache_reuse, Some(2));
653 assert_eq!(merged.cache_type_k, Some(KvCacheType::Q8_0));
654 assert_eq!(merged.cache_type_v, Some(KvCacheType::F16));
655 assert_eq!(
656 merged.inference_params.and_then(|c| c.temperature),
657 Some(2.0)
658 );
659 assert_eq!(merged.mlock, Some(true));
660 }
661
662 /// The direction that actually does the work — and the identity property
663 /// the cascade leans on when a tier has no opinion: a base with values and
664 /// an `over` that is silent must keep every base value.
665 #[test]
666 fn overlay_falls_through_to_base_on_every_field() {
667 let merged = populated(1).overlay(&ServerConfigOptions::default());
668
669 assert_eq!(merged.context_size, Some(1));
670 assert_eq!(merged.model_server_ctx, Some(1));
671 assert_eq!(merged.global_default_ctx, Some(1));
672 assert_eq!(merged.port, Some(1));
673 assert_eq!(merged.jinja, Some(true));
674 assert_eq!(merged.reasoning_format.as_deref(), Some("fmt-1"));
675 assert_eq!(merged.mtp_draft_n_max, Some(1));
676 assert_eq!(merged.mtp_draft_p_min, Some(1.0));
677 assert_eq!(merged.slot_save_path, Some(PathBuf::from("/slots/1")));
678 assert_eq!(merged.cache_ram_mb, Some(1));
679 assert_eq!(merged.cache_reuse, Some(1));
680 assert_eq!(merged.cache_type_k, Some(KvCacheType::Q8_0));
681 assert_eq!(merged.cache_type_v, Some(KvCacheType::F16));
682 assert_eq!(
683 merged.inference_params.and_then(|c| c.temperature),
684 Some(1.0)
685 );
686 assert_eq!(merged.mlock, Some(true));
687 }
688
689 /// Per-field interleaving: neither side wholesale-replaces the other.
690 #[test]
691 fn overlay_merges_per_field_not_wholesale() {
692 let base = ServerConfigOptions {
693 context_size: Some(8192),
694 mlock: Some(true),
695 ..Default::default()
696 };
697 let over = ServerConfigOptions {
698 port: Some(5500),
699 mlock: Some(false),
700 ..Default::default()
701 };
702
703 let merged = base.overlay(&over);
704
705 assert_eq!(merged.context_size, Some(8192), "base-only field survives");
706 assert_eq!(merged.port, Some(5500), "over-only field lands");
707 assert_eq!(merged.mlock, Some(false), "contested field goes to over");
708 }
709
710 /// `Some(false)` is an explicit opinion, not an absence — it has to beat a
711 /// `Some(true)` underneath it. This is what lets `--mtp-draft-n-max 0` and
712 /// an explicit jinja-off override a tag-derived default.
713 #[test]
714 fn overlay_treats_some_false_as_an_override() {
715 let base = ServerConfigOptions {
716 jinja: Some(true),
717 ..Default::default()
718 };
719 let over = ServerConfigOptions {
720 jinja: Some(false),
721 ..Default::default()
722 };
723
724 assert_eq!(base.overlay(&over).jinja, Some(false));
725 }
726}