metrale_config/
dispatch.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: `parse_config`: route a `config.json` to its family parser by the top-level
4//! `model_type`, then fill the full stop-token set.
5//!
6//! Owner: config.
7//! Invariants:
8//! - On success, `eos_token_ids` is non-empty and its first element is `eos_token_id`.
9
10#![allow(unused_imports)]
11
12use anyhow::{Context, Result};
13
14use super::{
15    AttnPositionEncoding, LayerType, ModelConfig, default_conv_kernel, default_partial_rotary,
16    default_rms_eps, default_rope_theta, finalize_config, parse_deepseek_v4, parse_gemma4_params,
17    parse_glm5_next, parse_kimi_k3, parse_laguna, parse_longcat_ngram, parse_minimax_m2,
18    parse_mistral_params, parse_quantization_config, parse_qwen4_exp, parse_step3p7,
19    parse_vision_config, resolve_attn_position_encoding, sanitize_kimi_k3_eos, validate_config,
20};
21
22fn required_u64(raw: &serde_json::Value, key: &str, model_type: &str) -> Result<u64> {
23    let value = raw
24        .get(key)
25        .with_context(|| format!("{model_type} config missing required field `{key}`"))?;
26    value
27        .as_u64()
28        .with_context(|| format!("{model_type} config field `{key}` must be an unsigned integer"))
29}
30
31fn required_nonzero_usize(raw: &serde_json::Value, key: &str, model_type: &str) -> Result<usize> {
32    let value = required_u64(raw, key, model_type)? as usize;
33    if value == 0 {
34        anyhow::bail!("{model_type} config field `{key}` must be greater than zero");
35    }
36    Ok(value)
37}
38
39fn required_u32(raw: &serde_json::Value, key: &str, model_type: &str) -> Result<u32> {
40    let value = required_u64(raw, key, model_type)?;
41    u32::try_from(value)
42        .with_context(|| format!("{model_type} config field `{key}` does not fit in u32"))
43}
44
45/// 2026-09-26: Parse a checkpoint `config.json` into a [`ModelConfig`]: the family parse, then
46/// `eos_token_ids` from every declared stop id, then the Kimi-K3 stop-set clean-up.
47pub fn parse_config(json: &str) -> Result<ModelConfig> {
48    let mut config = parse_config_dispatch(json)?;
49    resolve_attn_position_encoding(&mut config)?;
50    populate_eos_token_ids(&mut config, json);
51    sanitize_kimi_k3_eos(&mut config);
52    Ok(config)
53}
54
55#[cfg(test)]
56pub(super) fn populate_eos_token_ids_for_test(config: &mut ModelConfig, json: &str) {
57    populate_eos_token_ids(config, json);
58}
59
60/// 2026-09-26: Collect every declared stop-token id into `eos_token_ids`, primary first.
61///
62/// `eos_token_id` may be a scalar or an array, at the top level or inside `text_config`; the
63/// family parsers keep one id in the `u32` `eos_token_id`, and this recovers the rest.
64/// `config.eos_token_id` is always element 0, whatever the family parser chose (laguna takes
65/// the first array entry, step3p7 the last). The other ids follow in config order,
66/// de-duplicated. Invalid JSON leaves the config unchanged.
67fn populate_eos_token_ids(config: &mut ModelConfig, json: &str) {
68    let Ok(raw) = serde_json::from_str::<serde_json::Value>(json) else {
69        return;
70    };
71    let mut ids: Vec<u32> = vec![config.eos_token_id];
72    let mut push = |v: &serde_json::Value| match v {
73        serde_json::Value::Number(n) => {
74            if let Some(x) = n.as_u64() {
75                ids.push(x as u32);
76            }
77        }
78        serde_json::Value::Array(a) => {
79            for e in a {
80                if let Some(x) = e.as_u64() {
81                    ids.push(x as u32);
82                }
83            }
84        }
85        _ => {}
86    };
87    if let Some(v) = raw.get("eos_token_id") {
88        push(v);
89    }
90    if let Some(v) = raw.get("text_config").and_then(|t| t.get("eos_token_id")) {
91        push(v);
92    }
93    let mut seen = std::collections::BTreeSet::new();
94    ids.retain(|id| seen.insert(*id));
95    config.eos_token_ids = ids;
96}
97
98fn parse_config_dispatch(json: &str) -> Result<ModelConfig> {
99    let raw: serde_json::Value =
100        serde_json::from_str(json).context("Invalid JSON in config.json")?;
101
102    // 2026-09-26: A remote-code checkpoint may declare only `architectures` and no `model_type`.
103    // The known architecture names map onto their `model_type`; the map is read only when
104    // `model_type` is absent or empty.
105    let top_model_type = raw
106        .get("model_type")
107        .and_then(serde_json::Value::as_str)
108        .filter(|s| !s.is_empty())
109        .or_else(|| {
110            raw.get("architectures")
111                .and_then(serde_json::Value::as_array)
112                .and_then(|a| a.first())
113                .and_then(serde_json::Value::as_str)
114                .and_then(|arch| match arch {
115                    "LongcatFlashNgramForCausalLM" => Some("longcat_flash_ngram"),
116                    "LongcatFlashForCausalLM" => Some("longcat_flash"),
117                    "Qwen4ExpForConditionalGeneration"
118                    | "Qwen3_8FlashNextForConditionalGeneration" => Some("qwen4_exp"),
119                    _ => None,
120                })
121        })
122        .unwrap_or("");
123
124    match top_model_type {
125        "qwen3_vl_moe" | "qwen3_5_moe" | "qwen3_5" => {
126            let text_config = raw
127                .get("text_config")
128                .context("qwen3_5_moe config missing text_config")?;
129            let mut config: ModelConfig = serde_json::from_value(text_config.clone())
130                .context("Failed to parse text_config")?;
131            config.model_type = top_model_type.to_string();
132            if config.eos_token_id == 0 {
133                config.eos_token_id = text_config
134                    .get("eos_token_id")
135                    .and_then(serde_json::Value::as_u64)
136                    .unwrap_or(0) as u32;
137            }
138            if config.vocab_size == 0 {
139                config.vocab_size = raw
140                    .get("vocab_size")
141                    .and_then(serde_json::Value::as_u64)
142                    .unwrap_or(0) as usize;
143            }
144            if let Some(rope_params) = text_config.get("rope_parameters") {
145                if config.rope_theta == default_rope_theta()
146                    && let Some(theta) = rope_params
147                        .get("rope_theta")
148                        .and_then(serde_json::Value::as_f64)
149                {
150                    config.rope_theta = theta;
151                }
152                if config.partial_rotary_factor == default_partial_rotary()
153                    && let Some(prf) = rope_params
154                        .get("partial_rotary_factor")
155                        .and_then(serde_json::Value::as_f64)
156                {
157                    config.partial_rotary_factor = prf;
158                }
159            }
160            config.norm_topk_prob = true;
161            config.nested_config = true;
162            config.attn_gated = top_model_type != "qwen3_vl_moe";
163            if top_model_type == "qwen3_vl_moe" || raw.get("vision_config").is_some() {
164                config.vision = parse_vision_config(&raw);
165            }
166            // 2026-09-26: A MoE checkpoint with interleaved MRoPE becomes `qwen3_6_moe`, the
167            // `model_type` the Qwen3.6 MoE kernel targets declare. Dense `qwen3_5` keeps its
168            // name and only records the MRoPE fields.
169            if let Some(rope_params) = text_config.get("rope_parameters") {
170                if let Some(ms) = rope_params.get("mrope_section").and_then(|v| v.as_array())
171                    && ms.len() == 3
172                {
173                    config.mrope_section = [
174                        ms[0].as_u64().unwrap_or(0) as usize,
175                        ms[1].as_u64().unwrap_or(0) as usize,
176                        ms[2].as_u64().unwrap_or(0) as usize,
177                    ];
178                }
179                config.mrope_interleaved = rope_params
180                    .get("mrope_interleaved")
181                    .and_then(|v| v.as_bool())
182                    .unwrap_or(false);
183                let is_moe = top_model_type == "qwen3_5_moe" || top_model_type == "qwen3_vl_moe";
184                if is_moe
185                    && config.mrope_interleaved
186                    && config.mrope_section.iter().sum::<usize>() > 0
187                {
188                    config.model_type = "qwen3_6_moe".to_string();
189                }
190            }
191            // 2026-09-26: Holo-3.1: a `qwen3_5_moe` config with a vision tower, no MTP
192            // layers and `image_token_id` 248056 becomes `holo3_1_moe`. The MTP check keeps
193            // Qwen3.6-35B-A3B, whose config declares `mtp_num_hidden_layers` 1 and the same
194            // image token, off this path.
195            if top_model_type == "qwen3_5_moe"
196                && config.vision.is_some()
197                && config.mtp_num_hidden_layers == 0
198                && raw
199                    .get("image_token_id")
200                    .and_then(serde_json::Value::as_u64)
201                    == Some(248_056)
202            {
203                config.model_type = "holo3_1_moe".to_string();
204            }
205            finalize_config(&mut config, &raw)?;
206            Ok(config)
207        }
208        "nemotron_h" | "nemotron_h_puzzle" => {
209            // 2026-09-26: Puzzle configs put the hybrid schedule in `layers_block_type` /
210            // `block_configs` and may leave `num_hidden_layers` null. Rewrite the JSON so serde
211            // can deserialize it, then derive the per-layer schedule below.
212            let mut raw_mut = raw.clone();
213            if top_model_type == "nemotron_h_puzzle" {
214                apply_nemotron_puzzle_json(&mut raw_mut)?;
215            }
216            let mut config: ModelConfig = serde_json::from_value(raw_mut.clone())
217                .context("Failed to parse nemotron_h config.json")?;
218            if config.num_experts == 0 && config.n_routed_experts > 0 {
219                config.num_experts = config.n_routed_experts;
220            }
221            if config.rms_norm_eps == default_rms_eps() && config.norm_eps > 0.0 {
222                config.rms_norm_eps = config.norm_eps;
223            }
224            if config.linear_conv_kernel_dim == default_conv_kernel() && config.conv_kernel > 0 {
225                config.linear_conv_kernel_dim = config.conv_kernel;
226            }
227            if config.shared_expert_intermediate_size == 0
228                && config.moe_shared_expert_intermediate_size > 0
229            {
230                config.shared_expert_intermediate_size = config.moe_shared_expert_intermediate_size;
231            }
232            config.attn_gated = false;
233            // 2026-09-29: Nemotron-H attention applies no rotary embedding (HF
234            // `NemotronHAttention.forward`, the checkpoint's own modelling code and vLLM's
235            // `nemotron_h.py` all feed the projected Q/K straight into attention). The
236            // config's `rope_theta` / `partial_rotary_factor` are inert HF defaults.
237            config.attn_position_encoding = Some(AttnPositionEncoding::None);
238            config.weight_prefix = "backbone".to_string();
239            if !config.hybrid_override_pattern.is_empty() && config.layer_types.is_empty() {
240                config.layer_types = config
241                    .hybrid_override_pattern
242                    .chars()
243                    .map(|c| match c {
244                        'M' => LayerType::LinearAttention,
245                        'E' => LayerType::Moe,
246                        '*' => LayerType::FullAttention,
247                        other => panic!("Unknown hybrid_override_pattern char: '{other}'"),
248                    })
249                    .collect();
250            }
251            if top_model_type == "nemotron_h_puzzle" {
252                apply_nemotron_puzzle_config(&mut config, &raw_mut)?;
253            }
254            finalize_config(&mut config, &raw_mut)?;
255            Ok(config)
256        }
257        "gemma4" => parse_gemma4_params(&raw),
258        "laguna" => parse_laguna(&raw),
259        "longcat_flash_ngram" | "longcat_flash" => parse_longcat_ngram(&raw),
260        // 2026-09-26: `qwen3_8_flash_next` is a second name for `qwen4_exp`; both use the same
261        // parser, which also reads the hyper-connection, indexer and n-gram PLE fields.
262        "qwen4_exp" | "qwen3_8_flash_next" => parse_qwen4_exp(&raw),
263        "m2m_100" | "nllb" => {
264            let mut config = ModelConfig::qwen3_next_80b_nvfp4();
265            config.model_type = "m2m_100".to_string();
266            config.hidden_size = required_nonzero_usize(&raw, "d_model", top_model_type)?;
267            config.num_hidden_layers =
268                required_nonzero_usize(&raw, "decoder_layers", top_model_type)?;
269            config.intermediate_size =
270                required_nonzero_usize(&raw, "decoder_ffn_dim", top_model_type)?;
271            config.vocab_size = required_nonzero_usize(&raw, "vocab_size", top_model_type)?;
272            config.num_attention_heads =
273                required_nonzero_usize(&raw, "decoder_attention_heads", top_model_type)?;
274            config.num_key_value_heads = config.num_attention_heads;
275            if !config
276                .hidden_size
277                .is_multiple_of(config.num_attention_heads)
278            {
279                anyhow::bail!(
280                    "{} config has d_model ({}) not divisible by decoder_attention_heads ({})",
281                    top_model_type,
282                    config.hidden_size,
283                    config.num_attention_heads,
284                );
285            }
286            config.head_dim = config.hidden_size / config.num_attention_heads;
287            config.max_position_embeddings =
288                required_nonzero_usize(&raw, "max_position_embeddings", top_model_type)?;
289            config.bos_token_id = required_u32(&raw, "bos_token_id", top_model_type)?;
290            config.eos_token_id = required_u32(&raw, "eos_token_id", top_model_type)?;
291            config.tie_word_embeddings = true;
292            config.attn_gated = false;
293            config.weight_prefix = "model.decoder".to_string();
294            config.num_experts = 0;
295            config.num_experts_per_tok = 1;
296            config.moe_intermediate_size = 0;
297            config.shared_expert_intermediate_size = 0;
298            config.layer_types.clear();
299            config.full_attention_interval = 1;
300            config.linear_num_key_heads = 0;
301            config.linear_key_head_dim = 0;
302            config.linear_num_value_heads = 0;
303            config.linear_value_head_dim = 0;
304            config.mtp_num_hidden_layers = 0;
305            config.vision = None;
306            config.quantization_config = parse_quantization_config(&raw)?;
307            validate_config(&config)?;
308            Ok(config)
309        }
310        "minimax_m2" => parse_minimax_m2(&raw),
311        "step3p7" => parse_step3p7(&raw),
312        "deepseek_v4" => parse_deepseek_v4(json),
313        // 2026-09-26: GLM-5.3-Flash; its fields sit under `text_config`.
314        "glm5_next" | "glm5_next_text" => parse_glm5_next(json),
315        // 2026-09-26: Kimi K3: the parser unwraps `text_config` when present (a flat
316        // `kimi_linear` config is read as is) and renames the family to `kimi_k3`.
317        "kimi_k3" | "kimi_linear" => parse_kimi_k3(json),
318        _ => {
319            let mut config: ModelConfig =
320                serde_json::from_str(json).context("Failed to parse config.json")?;
321            config.attn_gated = true;
322            finalize_config(&mut config, &raw)?;
323            Ok(config)
324        }
325    }
326}
327
328/// 2026-09-26: Rewrite Puzzle HF JSON so serde can load it as `ModelConfig`.
329///
330/// - `num_hidden_layers` null, 0 or absent: set to the length of `layers_block_type`, or
331///   of `block_configs` when that is missing.
332/// - `moe_intermediate_size` / `num_experts_per_tok` absent or 0: set to the maximum over
333///   `block_configs`, when some block declares one.
334fn apply_nemotron_puzzle_json(raw: &mut serde_json::Value) -> Result<()> {
335    let obj = raw
336        .as_object_mut()
337        .context("nemotron_h_puzzle config.json is not an object")?;
338    let n_layers = obj
339        .get("layers_block_type")
340        .and_then(|v| v.as_array())
341        .map(|a| a.len())
342        .or_else(|| {
343            obj.get("block_configs")
344                .and_then(|v| v.as_array())
345                .map(|a| a.len())
346        })
347        .context("nemotron_h_puzzle missing layers_block_type / block_configs")?;
348    if obj
349        .get("num_hidden_layers")
350        .map(|v| v.is_null() || v.as_u64() == Some(0))
351        .unwrap_or(true)
352    {
353        obj.insert("num_hidden_layers".into(), serde_json::json!(n_layers));
354    }
355    let mut max_inter = 0usize;
356    let mut max_topk = 0usize;
357    if let Some(blocks) = obj.get("block_configs").and_then(|v| v.as_array()) {
358        for b in blocks {
359            if let Some(mi) = b.get("moe_intermediate_size").and_then(|v| v.as_u64()) {
360                max_inter = max_inter.max(mi as usize);
361            }
362            if let Some(tk) = b.get("num_experts_per_tok").and_then(|v| v.as_u64()) {
363                max_topk = max_topk.max(tk as usize);
364            }
365        }
366    }
367    if obj
368        .get("moe_intermediate_size")
369        .and_then(|v| v.as_u64())
370        .unwrap_or(0)
371        == 0
372        && max_inter > 0
373    {
374        obj.insert("moe_intermediate_size".into(), serde_json::json!(max_inter));
375    }
376    if obj
377        .get("num_experts_per_tok")
378        .and_then(|v| v.as_u64())
379        .unwrap_or(0)
380        == 0
381        && max_topk > 0
382    {
383        obj.insert("num_experts_per_tok".into(), serde_json::json!(max_topk));
384    }
385    Ok(())
386}
387
388/// 2026-09-26: Map Puzzle `layers_block_type` onto `layer_types`, and the `moe` entries of
389/// `block_configs` onto the per-layer MoE sizes (0 for other layers).
390fn apply_nemotron_puzzle_config(config: &mut ModelConfig, raw: &serde_json::Value) -> Result<()> {
391    let block_types = raw
392        .get("layers_block_type")
393        .and_then(|v| v.as_array())
394        .context("nemotron_h_puzzle missing layers_block_type")?;
395    config.layer_types = block_types
396        .iter()
397        .map(|v| {
398            let s = v.as_str().unwrap_or("");
399            Ok(match s {
400                "mamba" => LayerType::LinearAttention,
401                "moe" => LayerType::Moe,
402                "attention" => LayerType::FullAttention,
403                other => anyhow::bail!("unknown layers_block_type entry: '{other}'"),
404            })
405        })
406        .collect::<Result<Vec<_>>>()?;
407    if config.num_hidden_layers == 0 {
408        config.num_hidden_layers = config.layer_types.len();
409    }
410    let n = config.num_hidden_layers;
411    let mut inters = vec![0usize; n];
412    let mut topks = vec![0usize; n];
413    if let Some(blocks) = raw.get("block_configs").and_then(|v| v.as_array()) {
414        for (i, b) in blocks.iter().enumerate().take(n) {
415            if b.get("block_type").and_then(|v| v.as_str()) != Some("moe") {
416                continue;
417            }
418            inters[i] = b
419                .get("moe_intermediate_size")
420                .and_then(|v| v.as_u64())
421                .unwrap_or(0) as usize;
422            topks[i] = b
423                .get("num_experts_per_tok")
424                .and_then(|v| v.as_u64())
425                .unwrap_or(0) as usize;
426        }
427    }
428    config.moe_intermediate_sizes = inters;
429    config.num_experts_per_toks = topks;
430    if let Some(m) = config
431        .moe_intermediate_sizes
432        .iter()
433        .copied()
434        .filter(|&s| s > 0)
435        .max()
436    {
437        config.moe_intermediate_size = m;
438    }
439    if let Some(m) = config
440        .num_experts_per_toks
441        .iter()
442        .copied()
443        .filter(|&k| k > 0)
444        .max()
445    {
446        config.num_experts_per_tok = m;
447    }
448    Ok(())
449}