Skip to main content

gglib_core/
server_config.rs

1//! Canonical context-size resolver (4-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#[derive(Debug, Clone, Default)]
80pub struct ServerConfigOptions {
81    /// Override the context window size forwarded to llama-server.
82    /// `None` lets llama-server use its built-in default.
83    pub context_size: Option<u64>,
84
85    /// Per-model server defaults context length (from `Model.server_defaults.context_length`).
86    /// Second tier in fallback chain.
87    pub model_server_ctx: Option<usize>,
88
89    /// Global app setting for default context size (from `Settings.default_context_size`).
90    /// Third tier in fallback chain.
91    pub global_default_ctx: Option<u64>,
92
93    /// Bind llama-server to a specific port instead of letting the allocator
94    /// choose.
95    pub port: Option<u16>,
96
97    /// Override Jinja template support.
98    /// - `None` → auto-detect: enabled when the model has the `"agent"` tag.
99    /// - `Some(true)` → force enable regardless of tags.
100    /// - `Some(false)` → force disable regardless of tags.
101    pub jinja: Option<bool>,
102
103    /// Override the reasoning format passed to llama-server.
104    /// - `None` → auto-detect from model tags (e.g. `"reasoning"` tag).
105    /// - `Some("none")` → explicitly suppress reasoning extraction even if the
106    ///   model has a reasoning tag.
107    /// - `Some("deepseek")` / `Some("deepseek-legacy")` → force a specific
108    ///   format.
109    pub reasoning_format: Option<String>,
110
111    /// Override the MTP draft token count.
112    /// - `None` → auto-detect: enabled with default `n=2` when the model has
113    ///   the `"mtp"` tag.
114    /// - `Some(0)` → explicitly disable MTP even if the model has the `"mtp"`
115    ///   tag.
116    /// - `Some(n)` → enable MTP with `n` draft tokens.
117    pub mtp_draft_n_max: Option<u32>,
118
119    /// Override the MTP acceptance probability threshold.
120    /// Only meaningful when MTP is enabled. `None` uses the default (`0.75`).
121    pub mtp_draft_p_min: Option<f32>,
122
123    /// Directory for llama-server KV cache slot persistence (`--slot-save-path`).
124    /// - `None` — disk slot persistence disabled, no `--slot-save-path` flag.
125    /// - `Some(dir)` — enables slot save/restore.
126    ///   Direct pass-through, no tag-based auto-detection (unlike jinja/MTP/reasoning).
127    ///   Independent of `cache_ram_mb`/`cache_reuse` below.
128    pub slot_save_path: Option<PathBuf>,
129
130    /// RAM budget in MiB for llama-server's own host-RAM prompt cache
131    /// (`--cache-ram`). `None` leaves llama-server's built-in default. `Some(0)`
132    /// disables the cache. Direct pass-through, no tag-based auto-detection.
133    pub cache_ram_mb: Option<u64>,
134
135    /// Minimum chunk size in tokens for KV-shift cache reuse past the first
136    /// prefix divergence point (`--cache-reuse`). `None` leaves the feature
137    /// off. Direct pass-through, no tag-based auto-detection.
138    pub cache_reuse: Option<u32>,
139
140    /// Explicit override for the K cache element type (`--cache-type-k`).
141    /// `None` resolves to the `q8_0` default (see
142    /// `gglib_runtime::llama::args::resolve_kv_cache_types`), unless
143    /// `GGLIB_DISABLE_KV_QUANT=1` is set.
144    pub cache_type_k: Option<crate::cache_config::KvCacheType>,
145
146    /// Explicit override for the V cache element type (`--cache-type-v`).
147    /// Same resolution as [`Self::cache_type_k`]. Quantizing V additionally
148    /// requires Flash Attention to be active — see
149    /// `gglib_runtime::llama::args::kv_cache_type` module docs.
150    pub cache_type_v: Option<crate::cache_config::KvCacheType>,
151
152    /// Inference parameter overrides (temperature, top-p, etc.) forwarded
153    /// directly to llama-server.
154    pub inference_params: Option<InferenceConfig>,
155
156    /// Whether to memory-lock the model into RAM (`--mlock`).
157    /// `None` defaults to `false` in `build_server_config()`.
158    pub mlock: Option<bool>,
159}
160
161impl ServerConfigOptions {
162    /// Field-wise merge: every `Some` in `over` wins, every `None` falls
163    /// through to `self`.
164    ///
165    /// This is the single layering primitive behind both places where two sets
166    /// of options meet:
167    ///
168    /// - the 3-tier cascade in `UnifiedServerConfig::resolved_options`, where
169    ///   global defaults are the base and explicit CLI/GUI overrides are `over`;
170    /// - per-call launch overrides layered on top of a `ProcessManager`'s
171    ///   standing template.
172    ///
173    /// Note that this merges *options*, not resolved values — the tier chain
174    /// baked into [`resolve_context_size`] (request → per-model → global →
175    /// hardcoded) still runs afterwards on the merged result, so overlaying
176    /// never collapses those tiers early.
177    ///
178    /// `over` is destructured exhaustively on purpose: adding a field to this
179    /// struct then fails to compile until it is given merge semantics here,
180    /// rather than being silently dropped.
181    #[must_use]
182    pub fn overlay(&self, over: &Self) -> Self {
183        let Self {
184            context_size,
185            model_server_ctx,
186            global_default_ctx,
187            port,
188            jinja,
189            reasoning_format,
190            mtp_draft_n_max,
191            mtp_draft_p_min,
192            slot_save_path,
193            cache_ram_mb,
194            cache_reuse,
195            cache_type_k,
196            cache_type_v,
197            inference_params,
198            mlock,
199        } = over;
200
201        Self {
202            context_size: context_size.or(self.context_size),
203            model_server_ctx: model_server_ctx.or(self.model_server_ctx),
204            global_default_ctx: global_default_ctx.or(self.global_default_ctx),
205            port: port.or(self.port),
206            jinja: jinja.or(self.jinja),
207            reasoning_format: reasoning_format
208                .clone()
209                .or_else(|| self.reasoning_format.clone()),
210            mtp_draft_n_max: mtp_draft_n_max.or(self.mtp_draft_n_max),
211            mtp_draft_p_min: mtp_draft_p_min.or(self.mtp_draft_p_min),
212            slot_save_path: slot_save_path
213                .clone()
214                .or_else(|| self.slot_save_path.clone()),
215            cache_ram_mb: cache_ram_mb.or(self.cache_ram_mb),
216            cache_reuse: cache_reuse.or(self.cache_reuse),
217            cache_type_k: cache_type_k.or(self.cache_type_k),
218            cache_type_v: cache_type_v.or(self.cache_type_v),
219            inference_params: inference_params
220                .clone()
221                .or_else(|| self.inference_params.clone()),
222            mlock: mlock.or(self.mlock),
223        }
224    }
225}
226
227// =============================================================================
228// Resolver
229// =============================================================================
230
231/// Resolve context size using the 4-level fallback chain.
232/// 1. Runtime request / CLI flag (`opts.context_size`) — highest priority
233/// 2. Per-model server defaults (`opts.model_server_ctx`) — from DB
234/// 3. Global app setting (`opts.global_default_ctx`)
235/// 4. Hardcoded default (`DEFAULT_CONTEXT_SIZE` = 4096) — lowest priority
236pub fn resolve_context_size(opts: &ServerConfigOptions) -> u64 {
237    opts.context_size
238        .or_else(|| opts.model_server_ctx.map(|v| v as u64))
239        .or(opts.global_default_ctx)
240        .unwrap_or(DEFAULT_CONTEXT_SIZE)
241}
242
243// =============================================================================
244// Host-RAM prompt cache budget (`--cache-ram`)
245// =============================================================================
246
247// `CacheRamSetting` now lives in `crate::cache_config`, alongside
248// `KvCacheType` — cache-related config resolution has one home. Re-exported
249// here so existing `gglib_core::server_config::CacheRamSetting` call sites
250// keep working.
251pub use crate::cache_config::CacheRamSetting;
252
253// Cache-RAM budget constants and [`compute_auto_cache_ram_mb`] now live in
254// `crate::domain::cache_budget` (re-exported from `crate::domain`), alongside
255// the rest of the domain's pure calculations.
256pub use crate::domain::cache_budget::{
257    CACHE_RAM_FLOOR_BYTES, CACHE_RAM_HEADROOM_BYTES, CACHE_RAM_UNKNOWN_KV_ALLOWANCE_BYTES,
258    compute_auto_cache_ram_mb,
259};
260
261#[cfg(test)]
262mod tests {
263    use crate::server_config::{ServerConfigOptions, resolve_context_size};
264    use crate::settings::DEFAULT_CONTEXT_SIZE;
265
266    #[test]
267    fn test_resolve_context_size_default_when_all_none() {
268        let opts = ServerConfigOptions::default();
269        assert_eq!(resolve_context_size(&opts), DEFAULT_CONTEXT_SIZE);
270    }
271
272    // Cache-RAM budget math tests now live in
273    // `crate::domain::cache_budget::tests`, alongside the function itself.
274    use crate::server_config::CacheRamSetting;
275
276    /// Every launch surface should auto-size unless it opts out, so `Auto`
277    /// has to be the `Default` variant.
278    #[test]
279    fn cache_ram_setting_defaults_to_auto() {
280        assert_eq!(CacheRamSetting::default(), CacheRamSetting::Auto);
281    }
282
283    #[test]
284    fn test_resolve_context_size_global_beats_default() {
285        let opts = ServerConfigOptions {
286            global_default_ctx: Some(8192),
287            ..Default::default()
288        };
289        assert_eq!(resolve_context_size(&opts), 8192);
290    }
291
292    #[test]
293    fn test_resolve_context_size_model_beats_global() {
294        let opts = ServerConfigOptions {
295            model_server_ctx: Some(16_384),
296            global_default_ctx: Some(8192),
297            ..Default::default()
298        };
299        assert_eq!(resolve_context_size(&opts), 16_384);
300    }
301
302    #[test]
303    fn test_resolve_context_size_runtime_beats_all() {
304        let opts = ServerConfigOptions {
305            context_size: Some(32_768),
306            model_server_ctx: Some(16_384),
307            global_default_ctx: Some(8192),
308            ..Default::default()
309        };
310        assert_eq!(resolve_context_size(&opts), 32_768);
311    }
312
313    #[test]
314    fn test_resolve_context_size_model_without_global() {
315        let opts = ServerConfigOptions {
316            model_server_ctx: Some(2048),
317            ..Default::default()
318        };
319        assert_eq!(resolve_context_size(&opts), 2048);
320    }
321
322    #[test]
323    fn test_resolve_context_size_zero_is_valid() {
324        let opts = ServerConfigOptions {
325            context_size: Some(0),
326            ..Default::default()
327        };
328        assert_eq!(resolve_context_size(&opts), 0);
329    }
330
331    // -------------------------------------------------------------------
332    // CtxSizeArg / parse_ctx_size_flag
333    // -------------------------------------------------------------------
334
335    use crate::server_config::{CtxSizeArg, parse_ctx_size_flag};
336
337    #[test]
338    fn ctx_size_arg_parses_explicit_numeric() {
339        assert_eq!(CtxSizeArg::parse("8192").unwrap(), CtxSizeArg::Value(8192));
340    }
341
342    #[test]
343    fn ctx_size_arg_parses_max_case_insensitive() {
344        assert_eq!(CtxSizeArg::parse("max").unwrap(), CtxSizeArg::Max);
345        assert_eq!(CtxSizeArg::parse("MAX").unwrap(), CtxSizeArg::Max);
346        assert_eq!(CtxSizeArg::parse("  Max  ").unwrap(), CtxSizeArg::Max);
347    }
348
349    #[test]
350    fn ctx_size_arg_invalid_string_is_hard_error() {
351        assert!(CtxSizeArg::parse("banana").is_err());
352    }
353
354    #[test]
355    fn ctx_size_arg_max_resolves_to_model_metadata() {
356        assert_eq!(CtxSizeArg::Max.resolve(Some(131_072)), Some(131_072));
357    }
358
359    #[test]
360    fn ctx_size_arg_max_without_model_metadata_resolves_to_none() {
361        assert_eq!(CtxSizeArg::Max.resolve(None), None);
362    }
363
364    #[test]
365    fn ctx_size_arg_value_ignores_model_metadata() {
366        assert_eq!(CtxSizeArg::Value(4096).resolve(Some(131_072)), Some(4096));
367    }
368
369    #[test]
370    fn parse_ctx_size_flag_none_when_flag_omitted() {
371        assert_eq!(parse_ctx_size_flag(None).unwrap(), None);
372    }
373
374    #[test]
375    fn parse_ctx_size_flag_propagates_parse_error() {
376        assert!(parse_ctx_size_flag(Some("not-a-number")).is_err());
377    }
378
379    // -------------------------------------------------------------------
380    // overlay
381    // -------------------------------------------------------------------
382
383    use crate::cache_config::KvCacheType;
384    use crate::domain::InferenceConfig;
385    use std::path::PathBuf;
386
387    /// Every field set, so a merge that drops one is visible. `marker` is a
388    /// `u8` purely so each field can widen losslessly via `From`.
389    fn populated(marker: u8) -> ServerConfigOptions {
390        ServerConfigOptions {
391            context_size: Some(u64::from(marker)),
392            model_server_ctx: Some(usize::from(marker)),
393            global_default_ctx: Some(u64::from(marker)),
394            port: Some(u16::from(marker)),
395            jinja: Some(true),
396            reasoning_format: Some(format!("fmt-{marker}")),
397            mtp_draft_n_max: Some(u32::from(marker)),
398            mtp_draft_p_min: Some(f32::from(marker)),
399            slot_save_path: Some(PathBuf::from(format!("/slots/{marker}"))),
400            cache_ram_mb: Some(u64::from(marker)),
401            cache_reuse: Some(u32::from(marker)),
402            cache_type_k: Some(KvCacheType::Q8_0),
403            cache_type_v: Some(KvCacheType::F16),
404            inference_params: Some(InferenceConfig {
405                temperature: Some(f32::from(marker)),
406                ..Default::default()
407            }),
408            mlock: Some(true),
409        }
410    }
411
412    /// A fully-populated `over` must win on every single field. Compared
413    /// field-by-field rather than wholesale so a failure names the culprit.
414    #[test]
415    fn overlay_over_wins_on_every_field() {
416        let merged = populated(1).overlay(&populated(2));
417
418        assert_eq!(merged.context_size, Some(2));
419        assert_eq!(merged.model_server_ctx, Some(2));
420        assert_eq!(merged.global_default_ctx, Some(2));
421        assert_eq!(merged.port, Some(2));
422        assert_eq!(merged.jinja, Some(true));
423        assert_eq!(merged.reasoning_format.as_deref(), Some("fmt-2"));
424        assert_eq!(merged.mtp_draft_n_max, Some(2));
425        assert_eq!(merged.mtp_draft_p_min, Some(2.0));
426        assert_eq!(merged.slot_save_path, Some(PathBuf::from("/slots/2")));
427        assert_eq!(merged.cache_ram_mb, Some(2));
428        assert_eq!(merged.cache_reuse, Some(2));
429        assert_eq!(merged.cache_type_k, Some(KvCacheType::Q8_0));
430        assert_eq!(merged.cache_type_v, Some(KvCacheType::F16));
431        assert_eq!(
432            merged.inference_params.and_then(|c| c.temperature),
433            Some(2.0)
434        );
435        assert_eq!(merged.mlock, Some(true));
436    }
437
438    /// The direction that actually does the work — and the identity property
439    /// the cascade leans on when a tier has no opinion: a base with values and
440    /// an `over` that is silent must keep every base value.
441    #[test]
442    fn overlay_falls_through_to_base_on_every_field() {
443        let merged = populated(1).overlay(&ServerConfigOptions::default());
444
445        assert_eq!(merged.context_size, Some(1));
446        assert_eq!(merged.model_server_ctx, Some(1));
447        assert_eq!(merged.global_default_ctx, Some(1));
448        assert_eq!(merged.port, Some(1));
449        assert_eq!(merged.jinja, Some(true));
450        assert_eq!(merged.reasoning_format.as_deref(), Some("fmt-1"));
451        assert_eq!(merged.mtp_draft_n_max, Some(1));
452        assert_eq!(merged.mtp_draft_p_min, Some(1.0));
453        assert_eq!(merged.slot_save_path, Some(PathBuf::from("/slots/1")));
454        assert_eq!(merged.cache_ram_mb, Some(1));
455        assert_eq!(merged.cache_reuse, Some(1));
456        assert_eq!(merged.cache_type_k, Some(KvCacheType::Q8_0));
457        assert_eq!(merged.cache_type_v, Some(KvCacheType::F16));
458        assert_eq!(
459            merged.inference_params.and_then(|c| c.temperature),
460            Some(1.0)
461        );
462        assert_eq!(merged.mlock, Some(true));
463    }
464
465    /// Per-field interleaving: neither side wholesale-replaces the other.
466    #[test]
467    fn overlay_merges_per_field_not_wholesale() {
468        let base = ServerConfigOptions {
469            context_size: Some(8192),
470            mlock: Some(true),
471            ..Default::default()
472        };
473        let over = ServerConfigOptions {
474            port: Some(5500),
475            mlock: Some(false),
476            ..Default::default()
477        };
478
479        let merged = base.overlay(&over);
480
481        assert_eq!(merged.context_size, Some(8192), "base-only field survives");
482        assert_eq!(merged.port, Some(5500), "over-only field lands");
483        assert_eq!(merged.mlock, Some(false), "contested field goes to over");
484    }
485
486    /// `Some(false)` is an explicit opinion, not an absence — it has to beat a
487    /// `Some(true)` underneath it. This is what lets `--mtp-draft-n-max 0` and
488    /// an explicit jinja-off override a tag-derived default.
489    #[test]
490    fn overlay_treats_some_false_as_an_override() {
491        let base = ServerConfigOptions {
492            jinja: Some(true),
493            ..Default::default()
494        };
495        let over = ServerConfigOptions {
496            jinja: Some(false),
497            ..Default::default()
498        };
499
500        assert_eq!(base.overlay(&over).jinja, Some(false));
501    }
502}