1use std::sync::Arc;
7
8use anyhow::{Result, ensure};
9use serde::{Deserialize, Deserializer, Serialize};
10
11use crate::engine::common::speculative::normalize_conditional_accept_rates;
12use crate::engine::handoff::TransferTimingMode;
13use crate::engine::timing::{TimingModel, TimingModelConfig, built_in_timing_model};
14
15const DEFAULT_MAX_PREFILL_TOKENS: usize = 16_384;
16const DEFAULT_CHUNKED_PREFILL_SIZE: usize = 8_192;
17const DEFAULT_CLIP_MAX_NEW_TOKENS: usize = 4_096;
18const DEFAULT_SCHEDULE_CONSERVATIVENESS: f64 = 1.0;
19const DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS: f64 = 32.0;
20
21fn default_num_gpu_blocks() -> usize {
22 16_384
23}
24
25fn default_block_size() -> usize {
26 64
27}
28
29fn default_max_num_seqs() -> usize {
30 256
31}
32
33fn default_max_num_batched_tokens() -> usize {
34 8_192
35}
36
37fn default_true() -> bool {
38 true
39}
40
41fn default_one() -> f64 {
42 1.0
43}
44
45fn default_aic_mtp_seed() -> u64 {
46 42
47}
48
49fn default_max_prefill_tokens() -> usize {
50 DEFAULT_MAX_PREFILL_TOKENS
51}
52
53fn default_chunked_prefill_size() -> usize {
54 DEFAULT_CHUNKED_PREFILL_SIZE
55}
56
57fn default_clip_max_new_tokens() -> usize {
58 DEFAULT_CLIP_MAX_NEW_TOKENS
59}
60
61fn default_schedule_conservativeness() -> f64 {
62 DEFAULT_SCHEDULE_CONSERVATIVENESS
63}
64
65fn default_host_offload_bandwidth_gbps() -> f64 {
66 DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS
67}
68
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum Backend {
73 #[default]
75 Vllm,
76 Sglang,
78 Trtllm,
80}
81
82impl Backend {
83 pub const fn default_block_size(self) -> usize {
85 match self {
86 Self::Vllm => 64,
87 Self::Sglang => 1,
88 Self::Trtllm => 32,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum WorkerType {
97 #[default]
99 Aggregated,
100 Prefill,
102 Decode,
104}
105
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum PreemptionMode {
110 #[default]
112 Lifo,
113 Fifo,
115}
116
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum SglangSchedulePolicy {
121 #[default]
123 Fifo,
124 Lpm,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
130#[serde(default, deny_unknown_fields)]
131pub struct SglangConfig {
132 pub schedule_policy: SglangSchedulePolicy,
134 #[serde(default = "default_max_prefill_tokens")]
136 pub max_prefill_tokens: usize,
137 #[serde(default = "default_chunked_prefill_size")]
139 pub chunked_prefill_size: usize,
140 #[serde(default = "default_clip_max_new_tokens")]
142 pub clip_max_new_tokens: usize,
143 #[serde(default = "default_schedule_conservativeness")]
145 pub schedule_conservativeness: f64,
146}
147
148impl Default for SglangConfig {
149 fn default() -> Self {
150 Self {
151 schedule_policy: SglangSchedulePolicy::Fifo,
152 max_prefill_tokens: default_max_prefill_tokens(),
153 chunked_prefill_size: default_chunked_prefill_size(),
154 clip_max_new_tokens: default_clip_max_new_tokens(),
155 schedule_conservativeness: default_schedule_conservativeness(),
156 }
157 }
158}
159
160impl SglangConfig {
161 pub(crate) fn validate(&self) -> Result<()> {
162 ensure!(
163 self.max_prefill_tokens > 0,
164 "sglang.max_prefill_tokens must be positive"
165 );
166 ensure!(
167 self.chunked_prefill_size > 0,
168 "sglang.chunked_prefill_size must be positive"
169 );
170 ensure!(
171 self.schedule_conservativeness.is_finite() && self.schedule_conservativeness >= 0.0,
172 "sglang.schedule_conservativeness must be finite and non-negative"
173 );
174 Ok(())
175 }
176}
177
178#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum TrtllmCapacityPolicy {
186 #[default]
188 GuaranteedNoEvict,
189}
190
191#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(default, deny_unknown_fields)]
194pub struct TrtllmConfig {
195 pub capacity_scheduler_policy: TrtllmCapacityPolicy,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208#[non_exhaustive]
209pub struct NativeHostOffloadConfig {
210 pub num_host_blocks: usize,
212 #[serde(default = "default_host_offload_bandwidth_gbps")]
214 pub d2h_bandwidth_gbps: f64,
215 #[serde(default = "default_host_offload_bandwidth_gbps")]
217 pub h2d_bandwidth_gbps: f64,
218}
219
220impl NativeHostOffloadConfig {
221 pub const fn new(num_host_blocks: usize) -> Self {
222 Self {
223 num_host_blocks,
224 d2h_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
225 h2d_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
226 }
227 }
228
229 pub const fn with_bandwidths(mut self, d2h_gbps: f64, h2d_gbps: f64) -> Self {
230 self.d2h_bandwidth_gbps = d2h_gbps;
231 self.h2d_bandwidth_gbps = h2d_gbps;
232 self
233 }
234
235 fn validate(&self) -> Result<()> {
236 ensure!(
237 self.num_host_blocks > 0,
238 "native_host_offload.num_host_blocks must be positive"
239 );
240 ensure!(
241 self.d2h_bandwidth_gbps.is_finite() && self.d2h_bandwidth_gbps >= 0.0,
242 "native_host_offload.d2h_bandwidth_gbps must be finite and non-negative"
243 );
244 ensure!(
245 self.h2d_bandwidth_gbps.is_finite() && self.h2d_bandwidth_gbps >= 0.0,
246 "native_host_offload.h2d_bandwidth_gbps must be finite and non-negative"
247 );
248 Ok(())
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Serialize)]
264pub struct EngineConfig {
265 pub backend: Backend,
270 #[serde(default = "default_num_gpu_blocks")]
272 pub num_gpu_blocks: usize,
273 #[serde(default = "default_block_size")]
275 pub block_size: usize,
276 pub max_model_len: Option<usize>,
278 #[serde(default = "default_max_num_seqs")]
280 pub max_num_seqs: usize,
281 #[serde(default = "default_max_num_batched_tokens")]
283 pub max_num_batched_tokens: usize,
284 #[serde(default = "default_true")]
286 pub enable_prefix_caching: bool,
287 #[serde(default = "default_true")]
289 pub enable_chunked_prefill: bool,
290 #[serde(default = "default_one")]
292 pub speedup_ratio: f64,
293 #[serde(default = "default_one")]
295 pub decode_speedup_ratio: f64,
296 pub aic_nextn: Option<usize>,
299 pub aic_nextn_accept_rates: Option<String>,
304 #[serde(default = "default_aic_mtp_seed")]
306 pub aic_mtp_seed: u64,
307 pub worker_type: WorkerType,
309 pub preemption_mode: PreemptionMode,
311 pub emit_kv_events: bool,
313 pub emit_kv_token_ids: bool,
315 pub kv_transfer_bytes_per_token: Option<usize>,
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub kv_cache_bytes_per_token: Option<usize>,
320 #[serde(skip_serializing_if = "Option::is_none")]
322 pub native_host_offload: Option<NativeHostOffloadConfig>,
323 pub kv_transfer_bandwidth: Option<f64>,
325 pub kv_transfer_timing_mode: TransferTimingMode,
327 pub timing_model: TimingModelConfig,
329 pub sglang: SglangConfig,
331 pub trtllm: TrtllmConfig,
333}
334
335#[derive(Deserialize)]
336#[serde(deny_unknown_fields)]
337struct EngineConfigWire {
338 #[serde(default)]
339 backend: Backend,
340 #[serde(default = "default_num_gpu_blocks")]
341 num_gpu_blocks: usize,
342 #[serde(default)]
343 block_size: Option<usize>,
344 #[serde(default)]
345 max_model_len: Option<usize>,
346 #[serde(default = "default_max_num_seqs")]
347 max_num_seqs: usize,
348 #[serde(default = "default_max_num_batched_tokens")]
349 max_num_batched_tokens: usize,
350 #[serde(default = "default_true")]
351 enable_prefix_caching: bool,
352 #[serde(default = "default_true")]
353 enable_chunked_prefill: bool,
354 #[serde(default = "default_one")]
355 speedup_ratio: f64,
356 #[serde(default = "default_one")]
357 decode_speedup_ratio: f64,
358 #[serde(default)]
359 aic_nextn: Option<usize>,
360 #[serde(default)]
361 aic_nextn_accept_rates: Option<String>,
362 #[serde(default = "default_aic_mtp_seed")]
363 aic_mtp_seed: u64,
364 #[serde(default)]
365 worker_type: WorkerType,
366 #[serde(default)]
367 preemption_mode: PreemptionMode,
368 #[serde(default)]
369 emit_kv_events: bool,
370 #[serde(default)]
371 emit_kv_token_ids: bool,
372 #[serde(default, alias = "kv_bytes_per_token")]
373 kv_transfer_bytes_per_token: Option<usize>,
374 #[serde(default)]
375 kv_cache_bytes_per_token: Option<usize>,
376 #[serde(default)]
377 native_host_offload: Option<NativeHostOffloadConfig>,
378 #[serde(default)]
379 kv_transfer_bandwidth: Option<f64>,
380 #[serde(default)]
381 kv_transfer_timing_mode: TransferTimingMode,
382 #[serde(default)]
383 timing_model: TimingModelConfig,
384 #[serde(default)]
385 sglang: SglangConfig,
386 #[serde(default)]
387 trtllm: TrtllmConfig,
388}
389
390impl<'de> Deserialize<'de> for EngineConfig {
391 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
392 where
393 D: Deserializer<'de>,
394 {
395 let wire = EngineConfigWire::deserialize(deserializer)?;
396 Ok(Self {
397 backend: wire.backend,
398 num_gpu_blocks: wire.num_gpu_blocks,
399 block_size: wire
400 .block_size
401 .unwrap_or_else(|| wire.backend.default_block_size()),
402 max_model_len: wire.max_model_len,
403 max_num_seqs: wire.max_num_seqs,
404 max_num_batched_tokens: wire.max_num_batched_tokens,
405 enable_prefix_caching: wire.enable_prefix_caching,
406 enable_chunked_prefill: wire.enable_chunked_prefill,
407 speedup_ratio: wire.speedup_ratio,
408 decode_speedup_ratio: wire.decode_speedup_ratio,
409 aic_nextn: wire.aic_nextn,
410 aic_nextn_accept_rates: wire.aic_nextn_accept_rates,
411 aic_mtp_seed: wire.aic_mtp_seed,
412 worker_type: wire.worker_type,
413 preemption_mode: wire.preemption_mode,
414 emit_kv_events: wire.emit_kv_events,
415 emit_kv_token_ids: wire.emit_kv_token_ids,
416 kv_transfer_bytes_per_token: wire.kv_transfer_bytes_per_token,
417 kv_cache_bytes_per_token: wire.kv_cache_bytes_per_token,
418 native_host_offload: wire.native_host_offload,
419 kv_transfer_bandwidth: wire.kv_transfer_bandwidth,
420 kv_transfer_timing_mode: wire.kv_transfer_timing_mode,
421 timing_model: wire.timing_model,
422 sglang: wire.sglang,
423 trtllm: wire.trtllm,
424 })
425 }
426}
427
428impl Default for EngineConfig {
429 fn default() -> Self {
430 Self {
431 backend: Backend::Vllm,
432 num_gpu_blocks: default_num_gpu_blocks(),
433 block_size: default_block_size(),
434 max_model_len: None,
435 max_num_seqs: default_max_num_seqs(),
436 max_num_batched_tokens: default_max_num_batched_tokens(),
437 enable_prefix_caching: true,
438 enable_chunked_prefill: true,
439 speedup_ratio: 1.0,
440 decode_speedup_ratio: 1.0,
441 aic_nextn: None,
442 aic_nextn_accept_rates: None,
443 aic_mtp_seed: default_aic_mtp_seed(),
444 worker_type: WorkerType::Aggregated,
445 preemption_mode: PreemptionMode::Lifo,
446 emit_kv_events: false,
447 emit_kv_token_ids: false,
448 kv_transfer_bytes_per_token: None,
449 kv_cache_bytes_per_token: None,
450 native_host_offload: None,
451 kv_transfer_bandwidth: None,
452 kv_transfer_timing_mode: TransferTimingMode::FullPrompt,
453 timing_model: TimingModelConfig::Polynomial,
454 sglang: SglangConfig::default(),
455 trtllm: TrtllmConfig::default(),
456 }
457 }
458}
459
460impl EngineConfig {
461 pub fn for_backend(backend: Backend) -> Self {
466 Self {
467 backend,
468 block_size: backend.default_block_size(),
469 ..Self::default()
470 }
471 }
472
473 pub(crate) fn validate(&self) -> Result<()> {
474 ensure!(self.num_gpu_blocks > 0, "num_gpu_blocks must be positive");
475 ensure!(self.block_size > 0, "block_size must be positive");
476 if matches!(self.backend, Backend::Vllm | Backend::Trtllm) {
477 ensure!(
478 self.block_size >= 2,
479 "vLLM/TRT-LLM block_size must be at least two"
480 );
481 }
482 ensure!(self.max_num_seqs > 0, "max_num_seqs must be positive");
483 ensure!(
484 self.max_num_batched_tokens > 0,
485 "max_num_batched_tokens must be positive"
486 );
487 ensure!(
488 self.max_model_len.is_none_or(|limit| limit > 0),
489 "max_model_len must be positive"
490 );
491 ensure!(
492 self.backend == Backend::Vllm || self.max_model_len.is_none(),
493 "max_model_len is supported only for backend=vllm"
494 );
495 ensure!(
496 self.speedup_ratio.is_finite() && self.speedup_ratio >= 0.0,
497 "speedup_ratio must be finite and non-negative"
498 );
499 ensure!(
500 self.decode_speedup_ratio.is_finite() && self.decode_speedup_ratio >= 0.0,
501 "decode_speedup_ratio must be finite and non-negative"
502 );
503 if let Some(nextn) = self.aic_nextn {
504 normalize_conditional_accept_rates(nextn, self.aic_nextn_accept_rates.as_deref())?;
505 ensure!(
506 self.decode_speedup_ratio == 1.0,
507 "aic_nextn requires decode_speedup_ratio=1.0 because MTP output acceleration is modeled by burst sampling"
508 );
509 } else {
510 ensure!(
511 self.aic_nextn_accept_rates.is_none(),
512 "aic_nextn_accept_rates requires aic_nextn"
513 );
514 }
515 if self.backend == Backend::Sglang {
516 ensure!(
517 !self.emit_kv_token_ids,
518 "emit_kv_token_ids=true is not supported for backend=sglang"
519 );
520 ensure!(
521 self.enable_chunked_prefill,
522 "enable_chunked_prefill=false is not supported for backend=sglang"
523 );
524 self.sglang.validate()?;
525 }
526 ensure!(
527 !self.emit_kv_token_ids || self.emit_kv_events,
528 "emit_kv_token_ids requires emit_kv_events"
529 );
530 ensure!(
531 self.kv_transfer_bytes_per_token
532 .is_none_or(|bytes| bytes > 0),
533 "kv_transfer_bytes_per_token must be positive"
534 );
535 ensure!(
536 self.kv_cache_bytes_per_token.is_none_or(|bytes| bytes > 0),
537 "kv_cache_bytes_per_token must be positive"
538 );
539 if let Some(host_offload) = &self.native_host_offload {
540 host_offload.validate()?;
541 ensure!(
542 self.backend == Backend::Vllm,
543 "native_host_offload is supported only for backend=vllm"
544 );
545 ensure!(
546 self.worker_type == WorkerType::Aggregated,
547 "native_host_offload is supported only for worker_type=aggregated"
548 );
549 ensure!(
550 self.enable_prefix_caching,
551 "native_host_offload requires enable_prefix_caching=true"
552 );
553 ensure!(
554 self.aic_nextn.is_none(),
555 "native_host_offload does not support aic_nextn in the initial implementation"
556 );
557 let kv_bytes_per_token = self.kv_cache_bytes_per_token.ok_or_else(|| {
558 anyhow::anyhow!(
559 "native_host_offload requires kv_cache_bytes_per_token to derive the physical host block size"
560 )
561 })?;
562 let block_bytes = self
563 .block_size
564 .checked_mul(kv_bytes_per_token)
565 .filter(|bytes| *bytes > 0)
566 .ok_or_else(|| {
567 anyhow::anyhow!(
568 "native_host_offload requires block_size * kv_cache_bytes_per_token to produce a positive, representable block size"
569 )
570 })?;
571 let capacity_bytes = host_offload
572 .num_host_blocks
573 .checked_mul(block_bytes)
574 .ok_or_else(|| {
575 anyhow::anyhow!("native_host_offload capacity in bytes overflowed")
576 })?;
577 for (name, bandwidth) in [
578 ("d2h_bandwidth_gbps", host_offload.d2h_bandwidth_gbps),
579 ("h2d_bandwidth_gbps", host_offload.h2d_bandwidth_gbps),
580 ] {
581 let bytes_per_ms = bandwidth * 1_000_000.0;
582 ensure!(
583 bytes_per_ms.is_finite()
584 && (bandwidth == 0.0 || (capacity_bytes as f64 / bytes_per_ms).is_finite()),
585 "native_host_offload.{name} produces an unrepresentable transfer duration"
586 );
587 }
588 }
589 ensure!(
590 self.kv_transfer_bandwidth
591 .is_none_or(|bandwidth| bandwidth.is_finite() && bandwidth >= 0.0),
592 "kv_transfer_bandwidth must be finite and non-negative"
593 );
594 match &self.timing_model {
595 TimingModelConfig::Polynomial => {}
596 TimingModelConfig::Fixed {
597 prefill_ms,
598 decode_ms,
599 } => {
600 ensure!(
601 prefill_ms.is_finite() && *prefill_ms >= 0.0,
602 "fixed prefill latency must be finite and non-negative"
603 );
604 ensure!(
605 decode_ms.is_finite() && *decode_ms >= 0.0,
606 "fixed decode latency must be finite and non-negative"
607 );
608 }
609 TimingModelConfig::External { provider, .. } => {
610 ensure!(
611 !provider.trim().is_empty(),
612 "timing provider cannot be empty"
613 );
614 }
615 }
616 Ok(())
617 }
618
619 pub(crate) fn built_in_timing_model(&self) -> Result<Arc<dyn TimingModel>> {
620 built_in_timing_model(&self.timing_model)
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 type InvalidHostConfigCase = (fn(&mut EngineConfig), &'static str);
629
630 fn native_host_offload_config() -> EngineConfig {
631 EngineConfig {
632 block_size: 16,
633 kv_cache_bytes_per_token: Some(128 * 1024),
634 native_host_offload: Some(NativeHostOffloadConfig {
635 num_host_blocks: 4_096,
636 d2h_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
637 h2d_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
638 }),
639 ..EngineConfig::default()
640 }
641 }
642
643 fn assert_invalid_host_config(mutate: impl FnOnce(&mut EngineConfig), expected_message: &str) {
644 let mut config = native_host_offload_config();
645 mutate(&mut config);
646 assert!(
647 config
648 .validate()
649 .unwrap_err()
650 .to_string()
651 .contains(expected_message),
652 "validation error did not contain {expected_message:?}"
653 );
654 }
655
656 #[test]
657 fn deserialization_uses_backend_native_block_size() {
658 for (backend, expected) in [("vllm", 64), ("sglang", 1), ("trtllm", 32)] {
659 let config: EngineConfig =
660 serde_json::from_value(serde_json::json!({ "backend": backend })).unwrap();
661 assert_eq!(config.block_size, expected, "backend={backend}");
662 }
663 }
664
665 #[test]
666 fn for_backend_uses_backend_native_block_size() {
667 for backend in [Backend::Vllm, Backend::Sglang, Backend::Trtllm] {
668 let config = EngineConfig::for_backend(backend);
669 assert_eq!(config.backend, backend);
670 assert_eq!(config.block_size, backend.default_block_size());
671 }
672 }
673
674 #[test]
675 fn deserialization_preserves_an_explicit_block_size() {
676 let config: EngineConfig = serde_json::from_value(serde_json::json!({
677 "backend": "sglang",
678 "block_size": 17
679 }))
680 .unwrap();
681 assert_eq!(config.block_size, 17);
682 }
683
684 #[test]
685 fn legacy_kv_bytes_per_token_deserializes_to_transfer_geometry() {
686 let config: EngineConfig = serde_json::from_value(serde_json::json!({
687 "kv_bytes_per_token": 131_072
688 }))
689 .unwrap();
690 assert_eq!(config.kv_transfer_bytes_per_token, Some(131_072));
691
692 let encoded = serde_json::to_value(config).unwrap();
693 assert_eq!(encoded["kv_transfer_bytes_per_token"], 131_072);
694 assert!(encoded.get("kv_bytes_per_token").is_none());
695 }
696
697 #[test]
698 fn transfer_geometry_rejects_duplicate_new_and_legacy_keys() {
699 let error = serde_json::from_value::<EngineConfig>(serde_json::json!({
700 "kv_transfer_bytes_per_token": 131_072,
701 "kv_bytes_per_token": 65_536
702 }))
703 .unwrap_err();
704 assert!(error.to_string().contains("duplicate field"));
705 }
706
707 #[test]
708 fn deserialization_still_rejects_unknown_fields() {
709 let error = serde_json::from_value::<EngineConfig>(serde_json::json!({
710 "backend": "vllm",
711 "unknown": true
712 }))
713 .unwrap_err();
714 assert!(error.to_string().contains("unknown field"));
715 }
716
717 #[test]
718 fn native_host_offload_deserializes_with_default_bandwidths() {
719 assert_eq!(DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS, 32.0);
720 assert_eq!(
721 NativeHostOffloadConfig::new(1),
722 NativeHostOffloadConfig {
723 num_host_blocks: 1,
724 d2h_bandwidth_gbps: 32.0,
725 h2d_bandwidth_gbps: 32.0,
726 }
727 );
728 let config: EngineConfig = serde_json::from_value(serde_json::json!({
729 "backend": "vllm",
730 "block_size": 16,
731 "kv_cache_bytes_per_token": 131_072,
732 "native_host_offload": {
733 "num_host_blocks": 4_096
734 }
735 }))
736 .unwrap();
737
738 assert_eq!(
739 config.native_host_offload,
740 Some(NativeHostOffloadConfig {
741 num_host_blocks: 4_096,
742 d2h_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
743 h2d_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
744 })
745 );
746 config.validate().unwrap();
747
748 let decoded: EngineConfig =
749 serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap();
750 assert_eq!(decoded, config);
751 }
752
753 #[test]
754 fn native_host_offload_rejects_missing_or_unknown_fields() {
755 let missing_capacity = serde_json::from_value::<EngineConfig>(serde_json::json!({
756 "native_host_offload": {}
757 }))
758 .unwrap_err();
759 assert!(missing_capacity.to_string().contains("num_host_blocks"));
760
761 let unknown = serde_json::from_value::<EngineConfig>(serde_json::json!({
762 "native_host_offload": {
763 "num_host_blocks": 4_096,
764 "policy": "custom"
765 }
766 }))
767 .unwrap_err();
768 assert!(unknown.to_string().contains("unknown field"));
769 }
770
771 #[test]
772 fn native_host_offload_validates_physical_controls() {
773 let cases: &[InvalidHostConfigCase] = &[
774 (
775 |config| {
776 config.native_host_offload.as_mut().unwrap().num_host_blocks = 0;
777 },
778 "num_host_blocks",
779 ),
780 (
781 |config| {
782 config
783 .native_host_offload
784 .as_mut()
785 .unwrap()
786 .d2h_bandwidth_gbps = f64::NAN;
787 },
788 "d2h_bandwidth_gbps",
789 ),
790 (
791 |config| {
792 config
793 .native_host_offload
794 .as_mut()
795 .unwrap()
796 .h2d_bandwidth_gbps = -1.0;
797 },
798 "h2d_bandwidth_gbps",
799 ),
800 (
801 |config| {
802 config.block_size = usize::MAX;
803 config.kv_cache_bytes_per_token = Some(2);
804 },
805 "positive, representable block size",
806 ),
807 (
808 |config| config.kv_cache_bytes_per_token = None,
809 "requires kv_cache_bytes_per_token",
810 ),
811 (
812 |config| {
813 config.native_host_offload.as_mut().unwrap().num_host_blocks = usize::MAX;
814 },
815 "capacity in bytes overflowed",
816 ),
817 (
818 |config| {
819 config
820 .native_host_offload
821 .as_mut()
822 .unwrap()
823 .d2h_bandwidth_gbps = f64::MIN_POSITIVE;
824 },
825 "unrepresentable transfer duration",
826 ),
827 ];
828 for &(mutate, expected) in cases {
829 assert_invalid_host_config(mutate, expected);
830 }
831 }
832
833 #[test]
834 fn native_host_offload_rejects_unsupported_scheduler_modes() {
835 let cases: &[InvalidHostConfigCase] = &[
836 (|config| config.backend = Backend::Sglang, "backend=vllm"),
837 (
838 |config| config.worker_type = WorkerType::Prefill,
839 "worker_type=aggregated",
840 ),
841 (
842 |config| config.enable_prefix_caching = false,
843 "enable_prefix_caching=true",
844 ),
845 (
846 |config| config.aic_nextn = Some(1),
847 "does not support aic_nextn",
848 ),
849 ];
850 for &(mutate, expected) in cases {
851 assert_invalid_host_config(mutate, expected);
852 }
853 }
854
855 #[test]
856 fn serialization_round_trip_preserves_runtime_neutral_controls() {
857 let config = EngineConfig {
858 backend: Backend::Sglang,
859 block_size: 8,
860 num_gpu_blocks: 123,
861 max_num_seqs: 7,
862 max_num_batched_tokens: 456,
863 worker_type: WorkerType::Decode,
864 preemption_mode: PreemptionMode::Fifo,
865 emit_kv_events: true,
866 emit_kv_token_ids: true,
867 timing_model: TimingModelConfig::Fixed {
868 prefill_ms: 2.5,
869 decode_ms: 0.75,
870 },
871 ..EngineConfig::for_backend(Backend::Sglang)
872 };
873 let encoded = serde_json::to_value(&config).unwrap();
874 let decoded: EngineConfig = serde_json::from_value(encoded).unwrap();
875 assert_eq!(decoded, config);
876 }
877
878 #[test]
879 fn validation_rejects_zero_or_backend_invalid_capacity_fields() {
880 let config = EngineConfig {
881 num_gpu_blocks: 0,
882 ..EngineConfig::default()
883 };
884 assert!(
885 config
886 .validate()
887 .unwrap_err()
888 .to_string()
889 .contains("num_gpu_blocks")
890 );
891
892 let config = EngineConfig {
893 block_size: 1,
894 ..EngineConfig::default()
895 };
896 assert!(
897 config
898 .validate()
899 .unwrap_err()
900 .to_string()
901 .contains("at least two")
902 );
903
904 let config = EngineConfig {
905 max_model_len: Some(0),
906 ..EngineConfig::default()
907 };
908 assert!(
909 config
910 .validate()
911 .unwrap_err()
912 .to_string()
913 .contains("max_model_len")
914 );
915 }
916
917 #[test]
918 fn validation_accepts_sglang_page_size_one_and_rejects_invalid_controls() {
919 let mut config = EngineConfig::for_backend(Backend::Sglang);
920 config.validate().unwrap();
921
922 config.sglang.chunked_prefill_size = 0;
923 assert!(
924 config
925 .validate()
926 .unwrap_err()
927 .to_string()
928 .contains("chunked_prefill_size")
929 );
930
931 let mut config = EngineConfig::for_backend(Backend::Sglang);
932 config.sglang.schedule_conservativeness = f64::NAN;
933 assert!(
934 config
935 .validate()
936 .unwrap_err()
937 .to_string()
938 .contains("schedule_conservativeness")
939 );
940 }
941
942 #[test]
943 fn sglang_supports_disabled_prefix_caching() {
944 let config = EngineConfig {
945 enable_prefix_caching: false,
946 ..EngineConfig::for_backend(Backend::Sglang)
947 };
948 config.validate().unwrap();
949 crate::engine::EngineFactory::new(config).unwrap();
950 }
951
952 #[test]
953 fn sglang_rejects_remaining_unsupported_controls_at_validation_and_factory_boundaries() {
954 let cases = [
955 ("emit_kv_token_ids", true, true, true),
956 ("enable_chunked_prefill", false, true, false),
957 ];
958
959 for (field, emit_kv_token_ids, enable_prefix_caching, enable_chunked_prefill) in cases {
960 let config = EngineConfig {
961 emit_kv_events: emit_kv_token_ids,
962 emit_kv_token_ids,
963 enable_prefix_caching,
964 enable_chunked_prefill,
965 ..EngineConfig::for_backend(Backend::Sglang)
966 };
967 assert!(config.validate().unwrap_err().to_string().contains(field));
968 let error = match crate::engine::EngineFactory::new(config) {
969 Ok(_) => panic!("expected EngineFactory to reject {field}"),
970 Err(error) => error,
971 };
972 assert!(error.to_string().contains(field));
973 }
974 }
975
976 #[test]
977 fn max_model_len_is_vllm_only() {
978 for backend in [Backend::Sglang, Backend::Trtllm] {
979 let mut config = EngineConfig::for_backend(backend);
980 config.max_model_len = Some(128);
981 assert!(
982 config
983 .validate()
984 .unwrap_err()
985 .to_string()
986 .contains("backend=vllm")
987 );
988 }
989 }
990
991 #[test]
992 fn mtp_configuration_validates_rates_and_decode_scaling() {
993 let mut config = EngineConfig {
994 aic_nextn: Some(2),
995 aic_nextn_accept_rates: Some("0.8,0.5".to_string()),
996 ..EngineConfig::default()
997 };
998 config.validate().unwrap();
999
1000 config.aic_nextn_accept_rates = Some("1.2".to_string());
1001 assert!(config.validate().is_err());
1002
1003 config.aic_nextn_accept_rates = Some("0.8,0.5".to_string());
1004 config.decode_speedup_ratio = 2.0;
1005 assert!(
1006 config
1007 .validate()
1008 .unwrap_err()
1009 .to_string()
1010 .contains("decode_speedup_ratio=1.0")
1011 );
1012 }
1013
1014 #[test]
1015 fn mtp_rates_require_mtp_to_be_enabled() {
1016 let config = EngineConfig {
1017 aic_nextn_accept_rates: Some("0.5".to_string()),
1018 ..EngineConfig::default()
1019 };
1020 assert!(
1021 config
1022 .validate()
1023 .unwrap_err()
1024 .to_string()
1025 .contains("requires aic_nextn")
1026 );
1027 }
1028
1029 #[test]
1030 fn kv_token_ids_require_kv_event_emission() {
1031 let config = EngineConfig {
1032 emit_kv_token_ids: true,
1033 emit_kv_events: false,
1034 ..EngineConfig::default()
1035 };
1036 assert!(
1037 config
1038 .validate()
1039 .unwrap_err()
1040 .to_string()
1041 .contains("emit_kv_token_ids")
1042 );
1043 }
1044
1045 #[test]
1046 fn timing_provider_descriptors_are_validated_without_loading_them() {
1047 let config = EngineConfig {
1048 timing_model: TimingModelConfig::External {
1049 provider: " ".to_string(),
1050 config: serde_json::Value::Null,
1051 },
1052 ..EngineConfig::default()
1053 };
1054 assert!(
1055 config
1056 .validate()
1057 .unwrap_err()
1058 .to_string()
1059 .contains("provider cannot be empty")
1060 );
1061
1062 let config = EngineConfig {
1063 timing_model: TimingModelConfig::Fixed {
1064 prefill_ms: f64::NAN,
1065 decode_ms: 1.0,
1066 },
1067 ..EngineConfig::default()
1068 };
1069 assert!(config.validate().is_err());
1070 }
1071}