1use 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 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 pub memory_gb: f64,
56 pub context_tokens: usize,
57 pub prefix_cache_mib: usize,
58 pub cache_max_entries: usize,
59 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 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 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#[derive(Args, Debug, Clone, Default)]
318pub struct Overrides {
319 #[arg(skip)]
320 pub root: Option<PathBuf>,
321 #[arg(long, conflicts_with = "model_dir")]
323 pub model: Option<String>,
324 pub model_dir: Option<PathBuf>,
325 #[arg(long)]
327 pub port: Option<u16>,
328 #[arg(long)]
330 pub socket: Option<PathBuf>,
331 #[arg(long = "prefix-cache-mb")]
333 pub prefix_cache_mib: Option<usize>,
334 #[arg(long, value_parser = clap::value_parser!(u32).range(2..=4))]
336 pub experts: Option<u32>,
337 #[arg(long, value_parser = clap::value_parser!(u32).range(2..=4))]
339 pub miss_experts: Option<u32>,
340 #[arg(long, value_parser = cut)]
342 pub cut_weak: Option<f32>,
343 #[arg(long, value_parser = clap::value_parser!(u8).range(0..=3))]
345 pub drafts: Option<u8>,
346 #[arg(long, value_name = "N|max|adaptive")]
348 pub pool_gb: Option<PoolBudget>,
349 #[arg(long)]
351 pub repack: bool,
352 #[arg(long, value_parser = positive)]
354 pub max_tokens: Option<usize>,
355 #[arg(long, value_parser = positive)]
357 pub max_ctx: Option<usize>,
358 #[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;