use crate::logits::{StopSequence, TokenId};
use onnx_genai_kv::{CachePriority, DEFAULT_CHUNK_SIZE, KvDType, LocalTieredConfig, SequenceId};
use onnx_genai_metadata::{
MtpHiddenLayout as MetadataMtpHiddenLayout, MtpKvMode as MetadataMtpKvMode, MtpProposerSpec,
};
use onnx_genai_ort::{Eagle3DraftKvMode, MtpDraftKvMode};
use onnx_genai_scheduler::{Priority, ResourceLimit, ResourceLimits, SchedulerConfig};
use serde::Deserialize;
use std::path::PathBuf;
#[cfg(feature = "native-backend")]
use crate::native_decode::NativeDecodeDevice;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"invalid resource limit {input:?}: {reason}; use a byte count (for example 8589934592), \
a binary/decimal byte string (for example 8GiB or 8GB), a fraction in [0, 1] \
(for example 0.9), or \"auto\""
)]
pub struct LimitParseError {
input: String,
reason: String,
}
pub fn parse_resource_limit(input: &str) -> Result<ResourceLimit, LimitParseError> {
let input = input.trim();
if input.eq_ignore_ascii_case("auto") {
return Ok(ResourceLimit::Auto);
}
if let Ok(bytes) = input.parse::<u64>() {
return Ok(ResourceLimit::Bytes(bytes));
}
let unit_start = input
.find(|character: char| character.is_ascii_alphabetic())
.unwrap_or(input.len());
let (number, unit) = input.split_at(unit_start);
if unit.is_empty() {
let fraction = number.parse::<f64>().map_err(|_| {
limit_error(
input,
"the value is neither an integer byte count nor a numeric fraction",
)
})?;
if !fraction.is_finite() || !(0.0..=1.0).contains(&fraction) {
return Err(limit_error(
input,
"a unitless decimal is a fraction, but this value is outside [0, 1]",
));
}
return Ok(ResourceLimit::Fraction(fraction as f32));
}
let multiplier = match unit.to_ascii_uppercase().as_str() {
"KIB" => 1_u64 << 10,
"MIB" => 1_u64 << 20,
"GIB" => 1_u64 << 30,
"KB" => 1_000,
"MB" => 1_000_000,
"GB" => 1_000_000_000,
_ => {
return Err(limit_error(
input,
format!("the unit {unit:?} is not supported"),
));
}
};
if number.chars().all(|character| character.is_ascii_digit()) {
let quantity = number.parse::<u64>().map_err(|_| {
limit_error(
input,
format!("the integral byte quantity {number:?} does not fit in u64"),
)
})?;
let bytes = quantity.checked_mul(multiplier).ok_or_else(|| {
limit_error(
input,
format!(
"multiplying {quantity} by the {unit} unit size ({multiplier} bytes) \
overflows u64; use a smaller byte quantity or a smaller unit"
),
)
})?;
return Ok(ResourceLimit::Bytes(bytes));
}
let quantity = number.parse::<f64>().map_err(|_| {
limit_error(
input,
format!("the numeric part {number:?} is not a valid non-negative number"),
)
})?;
let bytes = quantity * multiplier as f64;
if !quantity.is_finite() || quantity < 0.0 || !bytes.is_finite() || bytes >= u64::MAX as f64 {
return Err(limit_error(
input,
"the byte quantity is negative, non-finite, or exceeds u64",
));
}
Ok(ResourceLimit::Bytes(bytes.round() as u64))
}
fn limit_error(input: impl Into<String>, reason: impl Into<String>) -> LimitParseError {
LimitParseError {
input: input.into(),
reason: reason.into(),
}
}
#[derive(Debug, thiserror::Error)]
pub enum EngineConfigError {
#[error("failed to parse engine YAML resource limits: {0}; check serving.memory.limits syntax")]
Yaml(#[from] serde_yaml::Error),
#[error("failed to parse serving.memory.limits.{field}: {source}")]
Limit {
field: &'static str,
#[source]
source: LimitParseError,
},
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LimitValue {
String(String),
Integer(u64),
Float(f64),
}
impl LimitValue {
fn parse(self, field: &'static str) -> Result<ResourceLimit, EngineConfigError> {
let parsed = match self {
Self::String(value) => parse_resource_limit(&value),
Self::Integer(value) => Ok(ResourceLimit::Bytes(value)),
Self::Float(value) if value.is_finite() && (0.0..=1.0).contains(&value) => {
Ok(ResourceLimit::Fraction(value as f32))
}
Self::Float(value) => Err(limit_error(
value.to_string(),
"a YAML floating-point limit is a fraction, but this value is outside [0, 1]",
)),
};
parsed.map_err(|source| EngineConfigError::Limit { field, source })
}
}
#[derive(Debug, Default, Deserialize)]
struct LimitsYaml {
vram_limit: Option<LimitValue>,
host_ram_limit: Option<LimitValue>,
disk_spill_limit: Option<LimitValue>,
#[serde(default)]
allow_runtime_override: bool,
}
#[derive(Debug, Default, Deserialize)]
struct MemoryYaml {
#[serde(default)]
limits: LimitsYaml,
}
#[derive(Debug, Default, Deserialize)]
struct ServingYaml {
#[serde(default)]
memory: MemoryYaml,
}
#[derive(Debug, Default, Deserialize)]
struct EngineConfigYaml {
#[serde(default)]
serving: ServingYaml,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MtpWeightSource {
File(PathBuf),
TargetInitializer(String),
}
impl From<PathBuf> for MtpWeightSource {
fn from(path: PathBuf) -> Self {
Self::File(path)
}
}
impl From<&str> for MtpWeightSource {
fn from(path: &str) -> Self {
Self::File(path.into())
}
}
impl From<String> for MtpWeightSource {
fn from(path: String) -> Self {
Self::File(path.into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MtpHiddenLayout {
Bsh,
Bshc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MtpCacheScope {
ProposalLocal,
AcceptedPrefix,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MtpConfig {
pub head_model: PathBuf,
pub target_hidden_output: String,
pub embedding_weights: PathBuf,
pub lm_head_weights: PathBuf,
pub vocab_size: usize,
pub hidden_size: usize,
pub kv_mode: MtpDraftKvMode,
pub num_speculative_tokens: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedMtpConfig {
pub(crate) public_config: MtpConfig,
pub(crate) target_hidden_layout: MtpHiddenLayout,
pub(crate) embedding_weights: MtpWeightSource,
pub(crate) lm_head_weights: MtpWeightSource,
pub(crate) hc_mult: usize,
pub(crate) mtp_hidden_output: String,
pub(crate) mtp_state_output: Option<String>,
pub(crate) cache_scope: MtpCacheScope,
}
impl ResolvedMtpConfig {
pub(crate) fn from_manual(config: MtpConfig) -> Self {
Self {
embedding_weights: MtpWeightSource::File(config.embedding_weights.clone()),
lm_head_weights: MtpWeightSource::File(config.lm_head_weights.clone()),
public_config: config,
target_hidden_layout: MtpHiddenLayout::Bsh,
hc_mult: 1,
mtp_hidden_output: "mtp_hidden".into(),
mtp_state_output: None,
cache_scope: MtpCacheScope::ProposalLocal,
}
}
pub(crate) fn from_sidecar_descriptor(spec: &MtpProposerSpec, vocab_size: usize) -> Self {
let public_config = MtpConfig {
head_model: spec.model.clone(),
target_hidden_output: spec.target_hidden_output.clone(),
embedding_weights: PathBuf::from(&spec.embedding_initializer),
lm_head_weights: PathBuf::from(&spec.lm_head_initializer),
vocab_size,
hidden_size: spec.target_hidden_size,
kv_mode: MtpDraftKvMode::GrowCache,
num_speculative_tokens: spec.num_speculative_tokens,
};
Self {
public_config,
target_hidden_layout: match spec.target_hidden_layout {
MetadataMtpHiddenLayout::Bsh => MtpHiddenLayout::Bsh,
MetadataMtpHiddenLayout::Bshc => MtpHiddenLayout::Bshc,
},
embedding_weights: MtpWeightSource::TargetInitializer(
spec.embedding_initializer.clone(),
),
lm_head_weights: MtpWeightSource::TargetInitializer(spec.lm_head_initializer.clone()),
hc_mult: spec.hc_mult,
mtp_hidden_output: spec.mtp_hidden_output.clone(),
mtp_state_output: Some(spec.mtp_state_output.clone()),
cache_scope: match spec.kv_mode {
MetadataMtpKvMode::ProposalLocal => MtpCacheScope::ProposalLocal,
MetadataMtpKvMode::AcceptedPrefix => MtpCacheScope::AcceptedPrefix,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Eagle3Config {
pub head_model: PathBuf,
pub target_hidden_outputs: Vec<String>,
pub embedding_weights: PathBuf,
pub vocab_size: usize,
pub hidden_size: usize,
pub kv_mode: Eagle3DraftKvMode,
pub num_speculative_tokens: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedKvProposerConfig {
pub assistant_model: PathBuf,
pub target_hidden_output: String,
pub input_embedding_weights: PathBuf,
pub backbone_hidden_size: usize,
pub vocab_size: usize,
pub num_speculative_tokens: usize,
pub shared_kv: Vec<SharedKvBinding>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedKvBinding {
pub name: String,
pub target_layers: Vec<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SpeculativeMode {
#[default]
None,
DraftModel,
PromptLookup {
ngram: usize,
max_tokens: usize,
},
Mtp(MtpConfig),
Eagle3(Eagle3Config),
SharedKv(SharedKvProposerConfig),
}
pub type SessionId = SequenceId;
#[derive(Debug, Clone, Default)]
pub enum KvConnectorBackend {
#[default]
Null,
LocalTiered(LocalTieredConfig),
}
#[derive(Debug, Clone)]
pub struct KvConnectorConfig {
pub backend: KvConnectorBackend,
pub model_id: Option<String>,
pub chunk_size: usize,
pub store_priority: CachePriority,
pub recompute_ms_per_token: f64,
}
impl Default for KvConnectorConfig {
fn default() -> Self {
Self {
backend: KvConnectorBackend::Null,
model_id: None,
chunk_size: DEFAULT_CHUNK_SIZE,
store_priority: CachePriority::Session,
recompute_ms_per_token: 0.05,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum EngineDecodeBackend {
#[default]
Auto,
Ort,
Native,
}
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub decode_backend: EngineDecodeBackend,
#[cfg(feature = "native-backend")]
pub native_device: Option<NativeDecodeDevice>,
#[cfg(feature = "native-backend")]
pub decode_precision: onnx_runtime_session::DecodePrecision,
pub num_gpu_pages: usize,
pub page_size: usize,
pub scheduler: SchedulerConfig,
pub draft_model: Option<PathBuf>,
pub num_speculative_tokens: usize,
pub speculative_mode: SpeculativeMode,
pub kv_cache_dtype: KvDType,
pub kv_connector: KvConnectorConfig,
pub limits: ResourceLimits,
pub allow_runtime_override: bool,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
decode_backend: EngineDecodeBackend::Auto,
#[cfg(feature = "native-backend")]
native_device: None,
#[cfg(feature = "native-backend")]
decode_precision: onnx_runtime_session::DecodePrecision::Model,
num_gpu_pages: 1024,
page_size: 16,
scheduler: SchedulerConfig::default(),
draft_model: None,
num_speculative_tokens: 4,
speculative_mode: SpeculativeMode::None,
kv_cache_dtype: KvDType::F32,
kv_connector: KvConnectorConfig::default(),
limits: ResourceLimits::default(),
allow_runtime_override: false,
}
}
}
impl EngineConfig {
pub fn from_yaml(yaml: &str) -> Result<Self, EngineConfigError> {
let document: EngineConfigYaml = serde_yaml::from_str(yaml)?;
let yaml_limits = document.serving.memory.limits;
let mut config = Self::default();
if let Some(limit) = yaml_limits.vram_limit {
config.limits.vram_limit = limit.parse("vram_limit")?;
}
if let Some(limit) = yaml_limits.host_ram_limit {
config.limits.host_ram_limit = limit.parse("host_ram_limit")?;
}
if let Some(limit) = yaml_limits.disk_spill_limit {
config.limits.disk_spill_limit = Some(limit.parse("disk_spill_limit")?);
}
config.allow_runtime_override = yaml_limits.allow_runtime_override;
Ok(config)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeneratePrompt {
Text(String),
TokenIds(Vec<TokenId>),
}
impl From<String> for GeneratePrompt {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for GeneratePrompt {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
#[cfg(test)]
mod resource_limit_tests {
use super::*;
#[test]
fn parses_integer_bytes_and_all_supported_units() {
let cases = [
("42", 42),
("2KiB", 2 * 1024),
("2MiB", 2 * 1024 * 1024),
("2GiB", 2 * 1024 * 1024 * 1024),
("2KB", 2_000),
("2MB", 2_000_000),
("2GB", 2_000_000_000),
("1.5KiB", 1536),
];
for (input, expected) in cases {
assert_eq!(
parse_resource_limit(input).unwrap(),
ResourceLimit::Bytes(expected),
"{input}"
);
}
}
#[test]
fn parses_fraction_and_case_insensitive_auto() {
assert_eq!(
parse_resource_limit("0.5").unwrap(),
ResourceLimit::Fraction(0.5)
);
assert_eq!(
parse_resource_limit("1.0").unwrap(),
ResourceLimit::Fraction(1.0)
);
assert_eq!(parse_resource_limit("AuTo").unwrap(), ResourceLimit::Auto);
}
#[test]
fn rejects_out_of_range_fractions_unknown_units_and_invalid_numbers() {
for input in ["1.01", "-0.1", "NaN", "inf"] {
let error = parse_resource_limit(input).unwrap_err().to_string();
assert!(error.contains("invalid resource limit"), "{error}");
assert!(error.contains("use a byte count"), "{error}");
}
for input in ["8TiB", "8G", "8Gi", "8XB"] {
let error = parse_resource_limit(input).unwrap_err().to_string();
assert!(error.contains("not supported"), "{input}: {error}");
assert!(error.contains("8GiB"), "{input}: {error}");
}
for input in ["GiB", "oneGiB", "-1GiB", "1e100GiB"] {
let error = parse_resource_limit(input).unwrap_err().to_string();
assert!(error.contains("invalid resource limit"), "{input}: {error}");
}
}
#[test]
fn rejects_integral_unit_overflow_at_exact_boundary() {
let error = parse_resource_limit("17179869184GiB")
.unwrap_err()
.to_string();
assert!(error.contains("overflows u64"), "{error}");
assert!(error.contains("use a smaller byte quantity"), "{error}");
}
#[test]
fn engine_config_defaults_to_scheduler_resource_defaults() {
let config = EngineConfig::default();
assert_eq!(config.decode_backend, EngineDecodeBackend::Auto);
assert_eq!(config.limits, ResourceLimits::default());
assert!(!config.allow_runtime_override);
}
#[test]
fn yaml_limits_parse_fraction_bytes_auto_null_and_override() {
let config = EngineConfig::from_yaml(
r#"
serving:
memory:
limits:
vram_limit: "0.5"
host_ram_limit: "8GiB"
disk_spill_limit: "auto"
allow_runtime_override: true
"#,
)
.unwrap();
assert_eq!(config.limits.vram_limit, ResourceLimit::Fraction(0.5));
assert_eq!(
config.limits.host_ram_limit,
ResourceLimit::Bytes(8_u64 << 30)
);
assert_eq!(config.limits.disk_spill_limit, Some(ResourceLimit::Auto));
assert!(config.allow_runtime_override);
let disabled = EngineConfig::from_yaml(
"serving:\n memory:\n limits:\n disk_spill_limit: null\n",
)
.unwrap();
assert_eq!(disabled.limits.disk_spill_limit, None);
}
#[test]
fn yaml_accepts_numeric_fraction_and_reports_field_context() {
for (value, expected) in [("1.0", 1.0), ("0.5", 0.5)] {
let config = EngineConfig::from_yaml(&format!(
"serving:\n memory:\n limits:\n vram_limit: {value}\n"
))
.unwrap();
assert_eq!(config.limits.vram_limit, ResourceLimit::Fraction(expected));
}
let error =
EngineConfig::from_yaml("serving:\n memory:\n limits:\n vram_limit: 1.5\n")
.unwrap_err()
.to_string();
assert!(error.contains("vram_limit"), "{error}");
assert!(error.contains("outside [0, 1]"), "{error}");
}
}
impl From<Vec<TokenId>> for GeneratePrompt {
fn from(value: Vec<TokenId>) -> Self {
Self::TokenIds(value)
}
}
#[derive(Debug, Clone)]
pub struct GenerateOptions {
pub max_new_tokens: usize,
pub temperature: f32,
pub top_p: f32,
pub top_k: usize,
pub min_p: f32,
pub repetition_penalty: f32,
pub repetition_window: Option<usize>,
pub frequency_penalty: f32,
pub presence_penalty: f32,
pub greedy: bool,
pub seed: Option<u64>,
pub stop_sequences: Vec<StopSequence>,
pub eos_token_id: Option<TokenId>,
pub stop_on_eos: bool,
pub max_context: Option<usize>,
pub num_speculative_tokens: Option<usize>,
pub speculative_mode: Option<SpeculativeMode>,
pub constraint: Option<GenerateConstraint>,
pub top_logprobs: Option<usize>,
}
impl Default for GenerateOptions {
fn default() -> Self {
Self {
max_new_tokens: 128,
temperature: 1.0,
top_p: 1.0,
top_k: 0,
min_p: 0.0,
repetition_penalty: 1.0,
repetition_window: None,
frequency_penalty: 0.0,
presence_penalty: 0.0,
greedy: true,
seed: None,
stop_sequences: Vec::new(),
eos_token_id: None,
stop_on_eos: true,
max_context: None,
num_speculative_tokens: None,
speculative_mode: None,
constraint: None,
top_logprobs: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GenerateConstraint {
Json,
JsonSchema(String),
Regex(String),
Lark(String),
}
impl GenerateOptions {
pub(crate) fn validate(&self) -> anyhow::Result<()> {
if self.max_new_tokens == 0 {
anyhow::bail!("max_new_tokens must be greater than zero");
}
if !self.temperature.is_finite() || self.temperature < 0.0 {
anyhow::bail!("temperature must be finite and non-negative");
}
if !self.top_p.is_finite() || self.top_p < 0.0 {
anyhow::bail!("top_p must be finite and non-negative");
}
if !self.min_p.is_finite() || !(0.0..=1.0).contains(&self.min_p) {
anyhow::bail!("min_p must be finite and between 0 and 1");
}
if !self.repetition_penalty.is_finite() || self.repetition_penalty <= 0.0 {
anyhow::bail!("repetition_penalty must be finite and greater than zero");
}
if !self.frequency_penalty.is_finite() {
anyhow::bail!("frequency_penalty must be finite");
}
if !self.presence_penalty.is_finite() {
anyhow::bail!("presence_penalty must be finite");
}
if self.max_context == Some(0) {
anyhow::bail!("max_context must be greater than zero when provided");
}
if self.num_speculative_tokens == Some(0) {
anyhow::bail!("num_speculative_tokens must be greater than zero when provided");
}
if let Some(SpeculativeMode::PromptLookup { ngram, max_tokens }) = &self.speculative_mode {
if *ngram == 0 {
anyhow::bail!("prompt-lookup ngram must be greater than zero");
}
if *max_tokens == 0 {
anyhow::bail!("prompt-lookup max_tokens must be greater than zero");
}
}
if let Some(SpeculativeMode::Mtp(config)) = &self.speculative_mode {
validate_mtp_config(config)?;
}
if let Some(SpeculativeMode::Eagle3(config)) = &self.speculative_mode {
validate_eagle3_config(config)?;
}
if let Some(SpeculativeMode::SharedKv(config)) = &self.speculative_mode {
validate_shared_kv_proposer_config(config)?;
}
Ok(())
}
}
pub(crate) fn validate_mtp_config(config: &MtpConfig) -> anyhow::Result<()> {
if config.head_model.as_os_str().is_empty() {
anyhow::bail!("MTP head_model must not be empty");
}
if config.target_hidden_output.is_empty() {
anyhow::bail!("MTP target_hidden_output must not be empty");
}
if config.embedding_weights.as_os_str().is_empty()
|| config.lm_head_weights.as_os_str().is_empty()
{
anyhow::bail!("MTP embedding_weights and lm_head_weights must not be empty");
}
if config.vocab_size == 0 || config.hidden_size == 0 {
anyhow::bail!("MTP vocab_size and hidden_size must be greater than zero");
}
if config.num_speculative_tokens == 0 {
anyhow::bail!("MTP num_speculative_tokens must be greater than zero");
}
Ok(())
}
pub(crate) fn validate_resolved_mtp_config(config: &ResolvedMtpConfig) -> anyhow::Result<()> {
validate_mtp_config(&config.public_config)?;
if config.mtp_hidden_output.is_empty() {
anyhow::bail!("MTP mtp_hidden_output must not be empty");
}
if config
.mtp_state_output
.as_ref()
.is_some_and(|name| name.is_empty())
{
anyhow::bail!("MTP mtp_state_output must not be empty when provided");
}
if config.hc_mult == 0 {
anyhow::bail!("MTP hc_mult must be greater than zero");
}
if config.target_hidden_layout == MtpHiddenLayout::Bsh && config.hc_mult != 1 {
anyhow::bail!("MTP BSH target_hidden_layout requires hc_mult == 1");
}
if config.hc_mult > 1 && config.mtp_state_output.is_none() {
anyhow::bail!("MTP hc_mult > 1 requires mtp_state_output");
}
for (field, source) in [
("embedding_weights", &config.embedding_weights),
("lm_head_weights", &config.lm_head_weights),
] {
let empty = match source {
MtpWeightSource::File(path) => path.as_os_str().is_empty(),
MtpWeightSource::TargetInitializer(name) => name.is_empty(),
};
if empty {
anyhow::bail!("MTP {field} must not be empty");
}
}
Ok(())
}
pub(crate) fn validate_eagle3_config(config: &Eagle3Config) -> anyhow::Result<()> {
if config.target_hidden_outputs.len() != 3
|| config
.target_hidden_outputs
.iter()
.any(|name| name.is_empty())
{
anyhow::bail!(
"EAGLE-3 target_hidden_outputs must contain exactly three non-empty low/middle/high output names"
);
}
if config.vocab_size == 0 || config.hidden_size == 0 {
anyhow::bail!("EAGLE-3 vocab_size and hidden_size must be greater than zero");
}
if config.num_speculative_tokens == 0 {
anyhow::bail!("EAGLE-3 num_speculative_tokens must be greater than zero");
}
Ok(())
}
pub(crate) fn validate_shared_kv_proposer_config(
config: &SharedKvProposerConfig,
) -> anyhow::Result<()> {
if config.target_hidden_output.is_empty() {
anyhow::bail!("shared-KV proposer target_hidden_output must not be empty");
}
if config.backbone_hidden_size == 0 || config.vocab_size == 0 {
anyhow::bail!(
"shared-KV proposer backbone_hidden_size and vocab_size must be greater than zero"
);
}
if config.num_speculative_tokens == 0 {
anyhow::bail!("shared-KV proposer num_speculative_tokens must be greater than zero");
}
if config.shared_kv.is_empty() {
anyhow::bail!("shared-KV proposer requires at least one shared_kv binding group");
}
for group in &config.shared_kv {
if group.name.is_empty() {
anyhow::bail!("shared-KV proposer shared_kv group name must not be empty");
}
if group.target_layers.is_empty() {
anyhow::bail!(
"shared-KV proposer shared_kv group '{}' must list at least one target layer",
group.name
);
}
}
Ok(())
}
#[cfg(test)]
mod mtp_config_tests {
use super::*;
use onnx_genai_metadata::{
InferenceMetadata, SpeculatorProposerStatus, resolve_speculator_config,
};
use std::path::Path;
#[test]
fn mobius_sidecar_metadata_builds_complete_mtp_config() {
let metadata: InferenceMetadata = serde_yaml::from_str(
r#"
speculative:
proposal_type: mtp
model: mtp/model.onnx
num_speculative_tokens: 4
target_hidden_output: hidden_states
target_hidden_layout: BSHC
target_hidden_size: 4096
hc_mult: 4
mtp_hidden_output: mtp_hidden
mtp_state_output: mtp_state
kv_mode: proposal_local
embedding:
source: target_initializer
name: model.embed_tokens.weight
lm_head:
source: target_initializer
name: lm_head.weight
"#,
)
.expect("metadata parses");
let descriptor = resolve_speculator_config(
Path::new("/models/deepseek-v4"),
metadata.speculative.expect("speculative descriptor"),
);
let SpeculatorProposerStatus::Mtp(spec) = descriptor.proposer else {
panic!("MTP descriptor did not resolve");
};
let config = ResolvedMtpConfig::from_sidecar_descriptor(&spec, 129_280);
assert_eq!(
config.public_config.head_model,
Path::new("/models/deepseek-v4/mtp/model.onnx")
);
assert_eq!(config.public_config.target_hidden_output, "hidden_states");
assert_eq!(config.target_hidden_layout, MtpHiddenLayout::Bshc);
assert_eq!(
config.embedding_weights,
MtpWeightSource::TargetInitializer("model.embed_tokens.weight".into())
);
assert_eq!(
config.lm_head_weights,
MtpWeightSource::TargetInitializer("lm_head.weight".into())
);
assert_eq!(config.public_config.vocab_size, 129_280);
assert_eq!(config.public_config.hidden_size, 4096);
assert_eq!(config.hc_mult, 4);
assert_eq!(config.mtp_hidden_output, "mtp_hidden");
assert_eq!(config.mtp_state_output.as_deref(), Some("mtp_state"));
assert_eq!(config.public_config.kv_mode, MtpDraftKvMode::GrowCache);
assert_eq!(config.cache_scope, MtpCacheScope::ProposalLocal);
assert_eq!(config.public_config.num_speculative_tokens, 4);
validate_resolved_mtp_config(&config).expect("resolved config validates");
}
#[test]
fn malformed_mtp_metadata_is_rejected_before_config_construction() {
let metadata: InferenceMetadata = serde_yaml::from_str(
r#"
speculative:
proposal_type: mtp
model: mtp/model.onnx
target_hidden_size: 4096
hc_mult: 4
embedding:
source: target_initializer
name: model.embed_tokens.weight
"#,
)
.expect("metadata syntax parses");
let descriptor = resolve_speculator_config(
Path::new("/models/deepseek-v4"),
metadata.speculative.expect("speculative descriptor"),
);
assert_eq!(
descriptor.proposer,
SpeculatorProposerStatus::Unknown("mtp metadata is missing `lm_head`".into())
);
}
#[test]
fn mtp_validation_enforces_hc_layout_and_state_contract() {
let mut config = ResolvedMtpConfig {
public_config: MtpConfig {
head_model: "mtp/model.onnx".into(),
target_hidden_output: "hidden_states".into(),
embedding_weights: "embedding.f32".into(),
lm_head_weights: "lm_head.f32".into(),
vocab_size: 32,
hidden_size: 16,
kv_mode: MtpDraftKvMode::HiddenThreaded,
num_speculative_tokens: 4,
},
target_hidden_layout: MtpHiddenLayout::Bshc,
embedding_weights: MtpWeightSource::File("embedding.f32".into()),
lm_head_weights: MtpWeightSource::File("lm_head.f32".into()),
hc_mult: 0,
mtp_hidden_output: "mtp_hidden".into(),
mtp_state_output: Some("mtp_state".into()),
cache_scope: MtpCacheScope::ProposalLocal,
};
assert!(
validate_resolved_mtp_config(&config)
.unwrap_err()
.to_string()
.contains("hc_mult")
);
config.hc_mult = 2;
config.mtp_state_output = None;
assert!(
validate_resolved_mtp_config(&config)
.unwrap_err()
.to_string()
.contains("mtp_state_output")
);
config.target_hidden_layout = MtpHiddenLayout::Bsh;
config.mtp_state_output = Some("mtp_state".into());
assert!(
validate_resolved_mtp_config(&config)
.unwrap_err()
.to_string()
.contains("requires hc_mult == 1")
);
}
}
#[derive(Debug, Clone)]
pub struct GenerateRequest {
pub prompt: GeneratePrompt,
pub options: GenerateOptions,
}
impl GenerateRequest {
pub fn new(prompt: impl Into<GeneratePrompt>) -> Self {
Self {
prompt: prompt.into(),
options: GenerateOptions::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct PrioritizedGenerateRequest {
pub session_id: SessionId,
pub request: GenerateRequest,
pub priority: Priority,
}
#[derive(Debug, Clone)]
pub struct ScheduledGenerateArrival {
pub arrival_step: usize,
pub request: PrioritizedGenerateRequest,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PrioritizedGenerateResult {
pub session_id: SessionId,
pub result: GenerateResult,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FinishReason {
MaxTokens,
EosToken,
StopSequence { index: usize },
Length,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GenerateResult {
pub text: String,
pub token_ids: Vec<TokenId>,
pub finish_reason: FinishReason,
pub prefix_cache_hit_len: usize,
pub logprobs: Option<Vec<TokenLogprob>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TokenLogprob {
pub token_id: TokenId,
pub logprob: f32,
pub top: Vec<(TokenId, f32)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerateToken {
pub token_id: TokenId,
pub text: String,
pub finish_reason: Option<FinishReason>,
}
pub type GenerateTokenCallback<'a> = dyn FnMut(GenerateToken) -> anyhow::Result<()> + Send + 'a;