Skip to main content

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}