Skip to main content

cherenkov/
config.rs

1//! Server configuration: built-ins, one TOML file, then explicit CLI overrides.
2
3use crate::options::{Options, PoolBudget, cut, positive};
4use crate::units::{BYTES_PER_GB, BYTES_PER_MIB};
5use anyhow::{Context, Result, ensure};
6use clap::Args;
7use serde::{Deserialize, Serialize};
8use std::io::Read;
9use std::path::{Path, PathBuf};
10
11pub fn default_socket() -> PathBuf {
12    std::env::temp_dir()
13        .join(format!("cherenkov-{}", unsafe { libc::geteuid() }))
14        .join("control.sock")
15}
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
18#[serde(default, deny_unknown_fields)]
19pub struct Config {
20    pub server: Server,
21    pub limits: Limits,
22    pub defaults: Defaults,
23    pub experts: Experts,
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct Server {
29    pub model: Option<String>,
30    pub model_dir: Option<PathBuf>,
31    /// Optional portable root for model data, scratch and configuration.
32    pub root: Option<PathBuf>,
33    pub port: u16,
34    pub socket: PathBuf,
35    pub drafts: u8,
36}
37
38impl Default for Server {
39    fn default() -> Self {
40        Self {
41            model: None,
42            model_dir: None,
43            root: None,
44            port: 8080,
45            socket: default_socket(),
46            drafts: 2,
47        }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(default, deny_unknown_fields)]
53pub struct Limits {
54    /// Metal allocations plus reserved prefix cache, not whole-process RSS.
55    pub memory_gb: f64,
56    pub context_tokens: usize,
57    pub prefix_cache_mib: usize,
58    pub cache_max_entries: usize,
59    /// Zero disables time-based expiry; the byte and entry limits still apply.
60    pub cache_idle_seconds: u64,
61    pub queued_requests: usize,
62    pub http_readers: usize,
63    pub request_bytes: usize,
64    pub max_output_tokens: usize,
65    pub active_requests: usize,
66    pub active_state_mib: usize,
67    pub prefill_quantum: usize,
68    /// Target chunk duration when requests compete; 0 disables pacing.
69    pub prefill_chunk_seconds: f64,
70    pub max_sessions: usize,
71    pub session_history_mib: usize,
72    pub session_idle_seconds: u64,
73    pub response_bytes: usize,
74}
75
76impl Default for Limits {
77    fn default() -> Self {
78        Self {
79            memory_gb: 25.0,
80            context_tokens: 2048,
81            prefix_cache_mib: 512,
82            cache_max_entries: 16,
83            cache_idle_seconds: 900,
84            queued_requests: 8,
85            http_readers: 32,
86            request_bytes: 4 * BYTES_PER_MIB,
87            max_output_tokens: 262144,
88            active_requests: 2,
89            active_state_mib: 1024,
90            prefill_quantum: 128,
91            prefill_chunk_seconds: 2.0,
92            max_sessions: 16,
93            session_history_mib: 16,
94            session_idle_seconds: 900,
95            response_bytes: 4 * BYTES_PER_MIB,
96        }
97    }
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(default, deny_unknown_fields)]
102pub struct Defaults {
103    pub max_tokens: usize,
104    pub no_eos: bool,
105    pub stream: bool,
106    pub include_usage: bool,
107    pub sampling: crate::sampling::Sampling,
108}
109
110impl Default for Defaults {
111    fn default() -> Self {
112        Self {
113            max_tokens: 64,
114            no_eos: false,
115            stream: false,
116            include_usage: false,
117            sampling: crate::sampling::Sampling::default(),
118        }
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(default, deny_unknown_fields)]
124pub struct Experts {
125    pub resident_bits: u32,
126    /// None follows resident_bits; mixed mode requires four-bit residents.
127    pub miss_bits: Option<u32>,
128    pub pool_gb: PoolBudget,
129    pub cut_weak: f32,
130    pub build_missing_store: bool,
131}
132
133impl Default for Experts {
134    fn default() -> Self {
135        Self {
136            resident_bits: 4,
137            miss_bits: None,
138            pool_gb: PoolBudget::Adaptive,
139            cut_weak: 0.0,
140            build_missing_store: true,
141        }
142    }
143}
144
145impl Config {
146    pub fn paths(&self) -> Result<crate::storage::Paths> {
147        crate::storage::Paths::new(self.server.root.as_deref())
148    }
149
150    pub fn model_dir(&self) -> Result<PathBuf> {
151        if let Some(reference) = &self.server.model {
152            return Ok(PathBuf::from(reference));
153        }
154
155        Ok(match &self.server.model_dir {
156            Some(dir) => dir.clone(),
157            None => PathBuf::from(crate::storage::default_model_reference()),
158        })
159    }
160
161    pub fn options(&self) -> Options {
162        Options {
163            experts: self.experts.resident_bits,
164            miss_experts: self.experts.miss_bits,
165            pool_gb: self.experts.pool_gb,
166            cut_weak: self.experts.cut_weak,
167            drafts: self.server.drafts,
168            max_ctx: self.limits.context_tokens,
169            max_tokens: self.defaults.max_tokens,
170            no_eos: self.defaults.no_eos,
171            sampling: self.defaults.sampling.clone(),
172            build_missing_store: self.experts.build_missing_store,
173            ..Options::default()
174        }
175    }
176
177    pub fn validate(&self) -> Result<()> {
178        ensure!(
179            self.server.model.is_none() || self.server.model_dir.is_none(),
180            "server.model and server.model_dir are mutually exclusive"
181        );
182
183        if let Some(reference) = &self.server.model {
184            crate::model::index::validate_selector(reference)?;
185        }
186
187        self.options().validate()?;
188        self.validate_sessions()?;
189
190        let l = &self.limits;
191
192        ensure!(
193            l.memory_gb.is_finite()
194                && l.memory_gb > 0.0
195                && l.memory_gb * (BYTES_PER_GB as f64) < usize::MAX as f64,
196            "limits.memory_gb must be positive and representable in bytes"
197        );
198        ensure!(
199            l.context_tokens <= 262144,
200            "context exceeds the model's 262144-token limit"
201        );
202        ensure!(
203            l.prefix_cache_mib <= 2048,
204            "prefix cache must be at most 2048 MiB"
205        );
206        ensure!(
207            (1..=1024).contains(&l.cache_max_entries),
208            "cache_max_entries must be 1..1024"
209        );
210        ensure!(
211            self.reserved_bytes() < self.memory_bytes(),
212            "cache and session reservations exceed memory budget"
213        );
214        ensure!(
215            (1..=1024).contains(&l.queued_requests),
216            "queued_requests must be 1..1024"
217        );
218        ensure!(
219            (1..=256).contains(&l.http_readers),
220            "http_readers must be 1..256"
221        );
222        ensure!(
223            (1..=16 * BYTES_PER_MIB).contains(&l.request_bytes),
224            "request_bytes must be 1..16777216"
225        );
226        ensure!(
227            (1..=262144).contains(&l.max_output_tokens),
228            "max_output_tokens must be 1..262144"
229        );
230        ensure!(
231            self.defaults.max_tokens <= l.max_output_tokens,
232            "default max_tokens exceeds output policy"
233        );
234        ensure!(
235            self.defaults
236                .max_tokens
237                .checked_add(self.server.drafts as usize)
238                .is_some_and(|n| n < l.context_tokens),
239            "default output and drafts leave no prompt capacity"
240        );
241        ensure!(
242            self.server.socket.is_absolute(),
243            "control socket path must be absolute"
244        );
245
246        Ok(())
247    }
248
249    pub fn cache_bytes(&self) -> usize {
250        self.limits.prefix_cache_mib * BYTES_PER_MIB
251    }
252
253    pub fn reserved_bytes(&self) -> usize {
254        (self.limits.prefix_cache_mib
255            + self.limits.active_state_mib
256            + self.limits.session_history_mib)
257            * BYTES_PER_MIB
258    }
259
260    fn validate_sessions(&self) -> Result<()> {
261        let l = &self.limits;
262
263        ensure!(
264            (1..=16).contains(&l.active_requests),
265            "active_requests must be 1..16"
266        );
267        ensure!(
268            (1..=8192).contains(&l.active_state_mib),
269            "active_state_mib must be 1..8192"
270        );
271        ensure!(
272            (1..=crate::qwen4_exp::gpu::prefill::MAX_PREFILL_ROWS).contains(&l.prefill_quantum),
273            "prefill_quantum must be 1..4096"
274        );
275        ensure!(
276            l.prefill_chunk_seconds.is_finite() && (0.0..=60.0).contains(&l.prefill_chunk_seconds),
277            "prefill_chunk_seconds must be 0..60"
278        );
279        ensure!(l.max_sessions <= 1024, "max_sessions must be 0..1024");
280        ensure!(
281            (1..=2048).contains(&l.session_history_mib),
282            "session_history_mib must be 1..2048"
283        );
284        ensure!(
285            (1..=16 * BYTES_PER_MIB).contains(&l.response_bytes),
286            "response_bytes must be 1..16777216"
287        );
288
289        Ok(())
290    }
291
292    pub fn memory_bytes(&self) -> usize {
293        (self.limits.memory_gb * BYTES_PER_GB as f64) as usize
294    }
295
296    pub fn restart_changes(&self, new: &Self) -> Vec<&'static str> {
297        let mut changes = Vec::new();
298
299        if self.server != new.server {
300            changes.push("server");
301        }
302
303        if self.limits != new.limits {
304            changes.push("limits");
305        }
306
307        if self.experts != new.experts {
308            changes.push("experts");
309        }
310
311        changes
312    }
313}
314
315/// Only explicit CLI values are retained and reapplied on every reload.
316/// Unlike resolved engine options, absent fields never supply a default.
317#[derive(Args, Debug, Clone, Default)]
318pub struct Overrides {
319    #[arg(skip)]
320    pub root: Option<PathBuf>,
321    /// Model alias, source URI or path; overrides TOML model selection
322    #[arg(long, conflicts_with = "model_dir")]
323    pub model: Option<String>,
324    pub model_dir: Option<PathBuf>,
325    /// HTTP port on localhost (default: 8080)
326    #[arg(long)]
327    pub port: Option<u16>,
328    /// Control socket (default: private per-user temporary directory)
329    #[arg(long)]
330    pub socket: Option<PathBuf>,
331    /// Prefix cache in MiB; 0 disables it
332    #[arg(long = "prefix-cache-mb")]
333    pub prefix_cache_mib: Option<usize>,
334    /// Expert precision: 4, 3 or 2 bits (default: 4)
335    #[arg(long, value_parser = clap::value_parser!(u32).range(2..=4))]
336    pub experts: Option<u32>,
337    /// Mid-step fetch precision; follows --experts (mixed mode needs resident 4-bit)
338    #[arg(long, value_parser = clap::value_parser!(u32).range(2..=4))]
339    pub miss_experts: Option<u32>,
340    /// Skip late weak experts; nonzero makes output non-reproducible
341    #[arg(long, value_parser = cut)]
342    pub cut_weak: Option<f32>,
343    /// Adaptive MTP drafts, 0..3 (default: 2); 0 unloads the draft head
344    #[arg(long, value_parser = clap::value_parser!(u8).range(0..=3))]
345    pub drafts: Option<u8>,
346    /// Wired expert pool in decimal GB, or max (default: adaptive)
347    #[arg(long, value_name = "N|max|adaptive")]
348    pub pool_gb: Option<PoolBudget>,
349    /// Rebuild the selected low-bit store at startup
350    #[arg(long)]
351    pub repack: bool,
352    /// Default output token limit (default: 64)
353    #[arg(long, value_parser = positive)]
354    pub max_tokens: Option<usize>,
355    /// Context capacity; ~22.5 KB/token comes out of the expert pool (default: 2048)
356    #[arg(long, value_parser = positive)]
357    pub max_ctx: Option<usize>,
358    /// Ignore EOS for benchmarking; =false overrides a configured true
359    #[arg(long, num_args = 0..=1, require_equals = true, default_missing_value = "true")]
360    pub no_eos: Option<bool>,
361}
362
363impl Overrides {
364    fn apply(&self, c: &mut Config) {
365        self.apply_server(&mut c.server);
366        self.apply_experts(&mut c.experts);
367
368        if let Some(n) = self.prefix_cache_mib {
369            c.limits.prefix_cache_mib = n;
370        }
371
372        if let Some(n) = self.max_ctx {
373            c.limits.context_tokens = n;
374        }
375
376        if let Some(n) = self.max_tokens {
377            c.defaults.max_tokens = n;
378        }
379
380        if let Some(no_eos) = self.no_eos {
381            c.defaults.no_eos = no_eos;
382        }
383    }
384
385    fn apply_server(&self, server: &mut Server) {
386        if let Some(root) = &self.root {
387            server.root = Some(root.clone());
388        }
389
390        if let Some(p) = &self.model_dir {
391            server.model_dir = Some(p.clone());
392            server.model = None;
393        }
394
395        if let Some(reference) = &self.model {
396            server.model = Some(reference.clone());
397            server.model_dir = None;
398        }
399
400        if let Some(p) = self.port {
401            server.port = p;
402        }
403
404        if let Some(p) = &self.socket {
405            server.socket = p.clone();
406        }
407
408        if let Some(n) = self.drafts {
409            server.drafts = n;
410        }
411    }
412
413    fn apply_experts(&self, experts: &mut Experts) {
414        if let Some(n) = self.experts {
415            experts.resident_bits = n;
416        }
417
418        if let Some(n) = self.miss_experts {
419            experts.miss_bits = Some(n);
420        }
421
422        if let Some(n) = self.cut_weak {
423            experts.cut_weak = n;
424        }
425
426        if let Some(pool) = self.pool_gb {
427            experts.pool_gb = pool;
428        }
429    }
430}
431
432#[derive(Debug, Clone)]
433pub struct Source {
434    pub path: Option<PathBuf>,
435    pub overrides: Overrides,
436}
437
438impl Source {
439    pub fn resolve(&self) -> Result<Config> {
440        let mut c = if let Some(path) = &self.path {
441            let file =
442                std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
443            let mut s = String::new();
444
445            file.take(65537).read_to_string(&mut s)?;
446            ensure!(s.len() <= 65536, "config exceeds 64 KiB");
447
448            let mut c: Config =
449                toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))?;
450            let base = path.parent().context("config path has no parent")?;
451
452            if let Some(model) = &mut c.server.model {
453                *model = crate::model::index::anchor_selector(Path::new(model), base)?
454                    .to_str()
455                    .context("model reference is not UTF-8")?
456                    .to_owned();
457            }
458
459            if let Some(p) = &mut c.server.model_dir
460                && p.is_relative()
461                && p.to_str()
462                    .is_none_or(|value| reqwest::Url::parse(value).is_err())
463            {
464                *p = base.join(&*p);
465            }
466
467            if let Some(root) = &mut c.server.root
468                && root.is_relative()
469            {
470                *root = base.join(&*root);
471            }
472
473            if c.server.socket.is_relative() {
474                c.server.socket = base.join(&c.server.socket);
475            }
476
477            c
478        } else {
479            Config::default()
480        };
481
482        self.overrides.apply(&mut c);
483        c.validate()?;
484
485        Ok(c)
486    }
487}
488
489pub fn absolute(path: &Path) -> Result<PathBuf> {
490    Ok(if path.is_absolute() {
491        path.to_owned()
492    } else {
493        std::env::current_dir()?.join(path)
494    })
495}
496
497#[cfg(test)]
498#[path = "../tests/unit/config.rs"]
499mod tests;