Skip to main content

skippy_model/
gguf_template.rs

1//! Family-specific canonical GGUF metadata templates.
2
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result, ensure};
7use serde_json::Value;
8
9use crate::gguf_writer::GgufKv;
10use crate::inkling_metadata;
11use crate::tokenizer_metadata::push_tokenizer_metadata;
12
13#[derive(Debug, Clone, Copy)]
14pub struct MetadataOptions {
15    pub include_mtp: bool,
16}
17
18impl Default for MetadataOptions {
19    fn default() -> Self {
20        Self { include_mtp: true }
21    }
22}
23
24pub fn metadata_from_hf_config(source: &Path, tensor_count: usize) -> Result<Vec<GgufKv>> {
25    metadata_from_hf_config_with_options(source, tensor_count, MetadataOptions::default())
26}
27
28pub fn mtp_layer_start_from_hf_config(source: &Path) -> Result<Option<u32>> {
29    let config = read_hf_config(source)?;
30    if inkling_metadata::is_inkling_config(&config) {
31        return inkling_metadata::mtp_layer_start(&config);
32    }
33    let Some(nextn_layers) = optional_u32(&config, "num_nextn_predict_layers")
34        .or_else(|| optional_u32(&config, "mtp_num_hidden_layers"))
35    else {
36        return Ok(None);
37    };
38    if nextn_layers == 0 {
39        return Ok(None);
40    }
41    required_u32(&config, "num_hidden_layers").map(Some)
42}
43
44pub fn metadata_from_hf_config_with_options(
45    source: &Path,
46    tensor_count: usize,
47    options: MetadataOptions,
48) -> Result<Vec<GgufKv>> {
49    let config = read_hf_config(source)?;
50    if inkling_metadata::is_inkling_config(&config) {
51        return inkling_metadata::metadata(source, tensor_count, &config, options.include_mtp);
52    }
53    let arch = architecture_name(&config)?;
54    let mut metadata = vec![
55        GgufKv::string("general.architecture", arch),
56        GgufKv::string("general.name", model_name(source)),
57        GgufKv::bool("skippy.convert.raw_safetensors", false),
58        GgufKv::u64("skippy.convert.tensor_count", tensor_count as u64),
59    ];
60    push_common_llm_metadata(&mut metadata, arch, &config, options)?;
61    push_attention_metadata(&mut metadata, arch, &config)?;
62    push_glm_dsa_indexer_metadata(&mut metadata, arch, &config)?;
63    push_moe_metadata(&mut metadata, arch, &config)?;
64    push_tokenizer_metadata(&mut metadata, source, &config)?;
65    if options.include_mtp {
66        push_if_u32(
67            &mut metadata,
68            arch,
69            "nextn_predict_layers",
70            &config,
71            "num_nextn_predict_layers",
72        );
73    }
74    Ok(metadata)
75}
76
77fn read_hf_config(source: &Path) -> Result<Value> {
78    let config_path = source.join("config.json");
79    serde_json::from_slice(
80        &fs::read(&config_path).with_context(|| format!("read {}", config_path.display()))?,
81    )
82    .with_context(|| format!("parse {}", config_path.display()))
83}
84
85fn architecture_name(config: &Value) -> Result<&'static str> {
86    let model_type = config
87        .get("model_type")
88        .and_then(Value::as_str)
89        .unwrap_or_default();
90    if matches!(model_type, "glm4_moe_lite" | "deepseek_v2") {
91        return Ok("deepseek2");
92    }
93    if matches!(model_type, "glm4_moe" | "glm4v_moe") {
94        return Ok("glm4moe");
95    }
96    if matches!(model_type, "glm_moe_dsa" | "glm-dsa") {
97        return Ok("glm-dsa");
98    }
99    if model_type == "granitemoehybrid" {
100        return Ok("granitehybrid");
101    }
102    if is_unsupported_qwen3_variant(model_type) {
103        anyhow::bail!(
104            "native GGUF metadata for model_type={model_type:?} requires \
105             Qwen3.5/Qwen3Next/Qwen3VL-specific metadata and tensor support; \
106             use the external convert_hf_to_gguf.py backend"
107        );
108    }
109    if model_type.starts_with("qwen3_moe") {
110        return Ok("qwen3moe");
111    }
112    if model_type.starts_with("qwen2_moe") {
113        return Ok("qwen2moe");
114    }
115    if model_type.starts_with("qwen3") {
116        return Ok("qwen3");
117    }
118    if model_type.starts_with("qwen2") {
119        return Ok("qwen2");
120    }
121    if matches!(model_type, "llama" | "mistral") {
122        return Ok("llama");
123    }
124    anyhow::bail!("unsupported native GGUF metadata template for model_type={model_type:?}")
125}
126
127fn is_unsupported_qwen3_variant(model_type: &str) -> bool {
128    let normalized = model_type
129        .chars()
130        .filter(|ch| ch.is_ascii_alphanumeric())
131        .flat_map(char::to_lowercase)
132        .collect::<String>();
133    normalized.starts_with("qwen3next")
134        || normalized.starts_with("qwen3vl")
135        || normalized.starts_with("qwen35")
136}
137
138fn push_common_llm_metadata(
139    metadata: &mut Vec<GgufKv>,
140    arch: &str,
141    config: &Value,
142    options: MetadataOptions,
143) -> Result<()> {
144    push_required_u32(metadata, arch, "vocab_size", config, "vocab_size")?;
145    if arch == "granitehybrid" {
146        metadata.push(GgufKv::u32(&format!("{arch}.context_length"), 1 << 20));
147    } else {
148        push_required_u32(
149            metadata,
150            arch,
151            "context_length",
152            config,
153            "max_position_embeddings",
154        )?;
155    }
156    push_required_u32(metadata, arch, "embedding_length", config, "hidden_size")?;
157    let block_count = required_u32(config, "num_hidden_layers")?
158        + if options.include_mtp {
159            optional_u32(config, "num_nextn_predict_layers").unwrap_or(0)
160        } else {
161            0
162        };
163    metadata.push(GgufKv::u32(&format!("{arch}.block_count"), block_count));
164    push_if_u32(
165        metadata,
166        arch,
167        "feed_forward_length",
168        config,
169        "intermediate_size",
170    );
171    if arch == "glm-dsa" {
172        push_required_f32(
173            metadata,
174            arch,
175            "attention.layer_norm_rms_epsilon",
176            config,
177            "rms_norm_eps",
178        )?;
179    } else if let Some(eps) = optional_f32(config, "rms_norm_eps") {
180        metadata.push(GgufKv::f32(
181            &format!("{arch}.attention.layer_norm_rms_epsilon"),
182            eps,
183        ));
184    }
185    Ok(())
186}
187
188fn push_attention_metadata(metadata: &mut Vec<GgufKv>, arch: &str, config: &Value) -> Result<()> {
189    let head_count = required_u32(config, "num_attention_heads")?;
190    metadata.push(GgufKv::u32(
191        &format!("{arch}.attention.head_count"),
192        head_count,
193    ));
194    let head_count_kv = optional_u32(config, "num_key_value_heads").unwrap_or(head_count);
195    if arch == "granitehybrid" {
196        let layer_types = config
197            .get("layer_types")
198            .and_then(Value::as_array)
199            .context("Granite Hybrid config missing layer_types")?;
200        let block_count = required_u32(config, "num_hidden_layers")? as usize;
201        ensure!(
202            layer_types.len() == block_count,
203            "Granite Hybrid layer_types length {} must match num_hidden_layers {block_count}",
204            layer_types.len()
205        );
206        let mut per_layer = Vec::with_capacity(block_count);
207        for layer_type in layer_types {
208            let layer_type = layer_type
209                .as_str()
210                .context("Granite Hybrid layer_types must contain strings")?;
211            ensure!(
212                matches!(layer_type, "attention" | "mamba"),
213                "unsupported Granite Hybrid layer type {layer_type:?}"
214            );
215            per_layer.push(if layer_type == "attention" {
216                i32::try_from(head_count_kv).context("head count exceeds i32")?
217            } else {
218                0
219            });
220        }
221        metadata.push(GgufKv::array_i32(
222            &format!("{arch}.attention.head_count_kv"),
223            per_layer,
224        ));
225    } else {
226        metadata.push(GgufKv::u32(
227            &format!("{arch}.attention.head_count_kv"),
228            head_count_kv,
229        ));
230    }
231    let mut key_len_mla = None;
232    let (key_len, rope_dim) = if let Some((nope, rope)) =
233        optional_u32(config, "qk_nope_head_dim").zip(optional_u32(config, "qk_rope_head_dim"))
234    {
235        let dense_key_len = nope + rope;
236        if arch == "glm-dsa" {
237            ensure!(
238                rope > 0,
239                "GLM-DSA qk_rope_head_dim must be greater than zero"
240            );
241            ensure!(
242                rope % 2 == 0,
243                "GLM-DSA qk_rope_head_dim must be even for native llama.cpp RoPE"
244            );
245            ensure!(
246                nope > 0,
247                "GLM-DSA qk_nope_head_dim must be greater than zero for native MLA split"
248            );
249            let kv_lora_rank = required_u32(config, "kv_lora_rank")?;
250            ensure!(
251                kv_lora_rank > 0,
252                "GLM-DSA kv_lora_rank must be greater than zero"
253            );
254            key_len_mla = Some(dense_key_len);
255            (kv_lora_rank + rope, rope)
256        } else {
257            (dense_key_len, rope)
258        }
259    } else {
260        let head_dim = optional_u32(config, "head_dim").unwrap_or(
261            required_u32(config, "hidden_size")? / required_u32(config, "num_attention_heads")?,
262        );
263        (head_dim, head_dim)
264    };
265    metadata.push(GgufKv::u32(
266        &format!("{arch}.attention.key_length"),
267        key_len,
268    ));
269    metadata.push(GgufKv::u32(
270        &format!("{arch}.rope.dimension_count"),
271        rope_dim,
272    ));
273    let value_len = optional_u32(config, "v_head_dim")
274        .or_else(|| optional_u32(config, "head_dim"))
275        .unwrap_or(
276            required_u32(config, "hidden_size")? / required_u32(config, "num_attention_heads")?,
277        );
278    if arch == "glm-dsa" {
279        ensure!(
280            value_len > 0,
281            "GLM-DSA v_head_dim must be greater than zero"
282        );
283    }
284    metadata.push(GgufKv::u32(
285        &format!("{arch}.attention.value_length"),
286        value_len,
287    ));
288    if arch == "glm-dsa" {
289        let key_len_mla = key_len_mla.context(
290            "GLM-DSA metadata requires qk_nope_head_dim and qk_rope_head_dim for MLA key length",
291        )?;
292        metadata.push(GgufKv::u32(
293            &format!("{arch}.attention.key_length_mla"),
294            key_len_mla,
295        ));
296        metadata.push(GgufKv::u32(
297            &format!("{arch}.attention.value_length_mla"),
298            value_len,
299        ));
300    }
301    if let Some(theta) = optional_f32(config, "rope_theta") {
302        metadata.push(GgufKv::f32(&format!("{arch}.rope.freq_base"), theta));
303    }
304    if arch == "glm-dsa" {
305        push_required_u32(
306            metadata,
307            arch,
308            "attention.q_lora_rank",
309            config,
310            "q_lora_rank",
311        )?;
312        push_required_u32(
313            metadata,
314            arch,
315            "attention.kv_lora_rank",
316            config,
317            "kv_lora_rank",
318        )?;
319    } else {
320        push_if_u32(
321            metadata,
322            arch,
323            "attention.q_lora_rank",
324            config,
325            "q_lora_rank",
326        );
327        push_if_u32(
328            metadata,
329            arch,
330            "attention.kv_lora_rank",
331            config,
332            "kv_lora_rank",
333        );
334    }
335    Ok(())
336}
337
338fn push_glm_dsa_indexer_metadata(
339    metadata: &mut Vec<GgufKv>,
340    arch: &str,
341    config: &Value,
342) -> Result<()> {
343    if arch != "glm-dsa" {
344        return Ok(());
345    }
346    push_required_first_u32(
347        metadata,
348        arch,
349        "attention.indexer.head_count",
350        config,
351        &["index_n_heads", "indexer_n_head"],
352    )?;
353    push_required_first_u32(
354        metadata,
355        arch,
356        "attention.indexer.key_length",
357        config,
358        &["index_head_dim", "indexer_head_size"],
359    )?;
360    push_required_first_u32(
361        metadata,
362        arch,
363        "attention.indexer.top_k",
364        config,
365        &["index_topk", "indexer_top_k"],
366    )?;
367    push_first_u32(
368        metadata,
369        arch,
370        "attention.indexer.top_k_frequency",
371        config,
372        &["index_topk_freq", "indexer_top_k_freq"],
373    );
374    push_first_u32(
375        metadata,
376        arch,
377        "attention.indexer.skip_top_k_offset",
378        config,
379        &["index_skip_topk_offset", "indexer_skip_top_k_offset"],
380    );
381    push_glm_dsa_indexer_types(metadata, arch, config)?;
382    validate_glm_dsa_indexer_contract(config)?;
383    Ok(())
384}
385
386fn validate_glm_dsa_indexer_contract(config: &Value) -> Result<()> {
387    let index_head_dim = optional_u32(config, "index_head_dim")
388        .or_else(|| optional_u32(config, "indexer_head_size"))
389        .context("GLM-DSA config missing index_head_dim/indexer_head_size")?;
390    let rope_dim = required_u32(config, "qk_rope_head_dim")?;
391    ensure!(
392        index_head_dim > rope_dim,
393        "GLM-DSA index_head_dim must be greater than qk_rope_head_dim"
394    );
395
396    let freq = optional_u32(config, "index_topk_freq")
397        .or_else(|| optional_u32(config, "indexer_top_k_freq"));
398    let offset = optional_u32(config, "index_skip_topk_offset")
399        .or_else(|| optional_u32(config, "indexer_skip_top_k_offset"));
400    if let Some(freq) = freq {
401        ensure!(
402            freq > 0,
403            "GLM-DSA index_topk_freq must be greater than zero when present"
404        );
405        let offset = offset.context(
406            "GLM-DSA index_skip_topk_offset/indexer_skip_top_k_offset is required when index_topk_freq is present",
407        )?;
408        validate_glm_dsa_indexer_types_match_frequency(config, freq, offset)?;
409    }
410    Ok(())
411}
412
413fn validate_glm_dsa_indexer_types_match_frequency(
414    config: &Value,
415    freq: u32,
416    offset: u32,
417) -> Result<()> {
418    let Some(types) = config.get("indexer_types") else {
419        return Ok(());
420    };
421    let types = types
422        .as_array()
423        .context("config value \"indexer_types\" must be an array")?;
424    for (layer, value) in types.iter().enumerate() {
425        let role = value
426            .as_str()
427            .context("config value \"indexer_types\" must contain strings")?
428            .to_ascii_lowercase();
429        let role_full = role == "full";
430        let freq_full = glm_dsa_frequency_layer_is_full(layer as u32, offset, freq);
431        ensure!(
432            role_full == freq_full,
433            "GLM-DSA indexer_types conflicts with index_topk_freq at layer {layer}"
434        );
435    }
436    Ok(())
437}
438
439fn glm_dsa_frequency_layer_is_full(layer: u32, offset: u32, freq: u32) -> bool {
440    layer < offset || (layer - offset + 1).is_multiple_of(freq)
441}
442
443fn push_glm_dsa_indexer_types(
444    metadata: &mut Vec<GgufKv>,
445    arch: &str,
446    config: &Value,
447) -> Result<()> {
448    let Some(types) = config.get("indexer_types") else {
449        return Ok(());
450    };
451
452    let types = types
453        .as_array()
454        .context("config value \"indexer_types\" must be an array")?;
455    let n_layers = required_u32(config, "num_hidden_layers")? as usize;
456    ensure!(
457        types.len() == n_layers,
458        "config value \"indexer_types\" length {} must match num_hidden_layers {n_layers}",
459        types.len()
460    );
461
462    let mut normalized = Vec::with_capacity(types.len());
463    for value in types {
464        let role = value
465            .as_str()
466            .context("config value \"indexer_types\" must contain strings")?
467            .to_ascii_lowercase();
468        ensure!(
469            role == "full" || role == "shared",
470            "config value \"indexer_types\" must contain only \"full\" or \"shared\""
471        );
472        normalized.push(role);
473    }
474
475    metadata.push(GgufKv::array_string(
476        &format!("{arch}.attention.indexer.types"),
477        normalized,
478    ));
479    Ok(())
480}
481
482fn push_moe_metadata(metadata: &mut Vec<GgufKv>, arch: &str, config: &Value) -> Result<()> {
483    if arch == "granitehybrid" {
484        push_first_u32(
485            metadata,
486            arch,
487            "expert_count",
488            config,
489            &["num_local_experts"],
490        );
491        push_first_u32(
492            metadata,
493            arch,
494            "expert_used_count",
495            config,
496            &["num_experts_per_tok"],
497        );
498        push_first_u32(
499            metadata,
500            arch,
501            "expert_shared_feed_forward_length",
502            config,
503            &["shared_intermediate_size"],
504        );
505        push_granite_hybrid_metadata(metadata, arch, config)?;
506        return Ok(());
507    }
508    if arch == "glm-dsa" {
509        validate_glm_dsa_moe_contract(config)?;
510        push_required_first_u32(
511            metadata,
512            arch,
513            "expert_count",
514            config,
515            &["n_routed_experts", "num_experts"],
516        )?;
517        push_required_first_u32(
518            metadata,
519            arch,
520            "expert_used_count",
521            config,
522            &["num_experts_per_tok"],
523        )?;
524        push_required_first_u32(
525            metadata,
526            arch,
527            "expert_shared_count",
528            config,
529            &["n_shared_experts"],
530        )?;
531        push_required_first_u32(
532            metadata,
533            arch,
534            "expert_feed_forward_length",
535            config,
536            &["moe_intermediate_size"],
537        )?;
538        push_required_first_u32(
539            metadata,
540            arch,
541            "leading_dense_block_count",
542            config,
543            &["first_k_dense_replace"],
544        )?;
545        push_required_f32(
546            metadata,
547            arch,
548            "expert_weights_scale",
549            config,
550            "routed_scaling_factor",
551        )?;
552        push_required_bool(
553            metadata,
554            arch,
555            "expert_weights_norm",
556            config,
557            "norm_topk_prob",
558        )?;
559        push_first_u32(
560            metadata,
561            arch,
562            "expert_shared_feed_forward_length",
563            config,
564            &["shared_expert_intermediate_size"],
565        );
566        return Ok(());
567    }
568
569    push_first_u32(
570        metadata,
571        arch,
572        "expert_count",
573        config,
574        &["n_routed_experts", "num_experts"],
575    );
576    push_first_u32(
577        metadata,
578        arch,
579        "expert_used_count",
580        config,
581        &["num_experts_per_tok"],
582    );
583    push_first_u32(
584        metadata,
585        arch,
586        "expert_shared_count",
587        config,
588        &["n_shared_experts"],
589    );
590    push_first_u32(
591        metadata,
592        arch,
593        "expert_feed_forward_length",
594        config,
595        &["moe_intermediate_size"],
596    );
597    push_first_u32(
598        metadata,
599        arch,
600        "expert_shared_feed_forward_length",
601        config,
602        &["shared_expert_intermediate_size"],
603    );
604    push_first_u32(
605        metadata,
606        arch,
607        "leading_dense_block_count",
608        config,
609        &["first_k_dense_replace"],
610    );
611    if let Some(scale) = optional_f32(config, "routed_scaling_factor") {
612        metadata.push(GgufKv::f32(&format!("{arch}.expert_weights_scale"), scale));
613    }
614    if let Some(norm) = optional_bool(config, "norm_topk_prob") {
615        metadata.push(GgufKv::bool(&format!("{arch}.expert_weights_norm"), norm));
616    }
617    Ok(())
618}
619
620fn push_granite_hybrid_metadata(
621    metadata: &mut Vec<GgufKv>,
622    arch: &str,
623    config: &Value,
624) -> Result<()> {
625    for (gguf_suffix, config_key) in [
626        ("attention.scale", "attention_multiplier"),
627        ("embedding_scale", "embedding_multiplier"),
628        ("residual_scale", "residual_multiplier"),
629        ("logit_scale", "logits_scaling"),
630    ] {
631        if let Some(value) = optional_f32(config, config_key) {
632            metadata.push(GgufKv::f32(&format!("{arch}.{gguf_suffix}"), value));
633        }
634    }
635    push_required_u32(metadata, arch, "ssm.conv_kernel", config, "mamba_d_conv")?;
636    push_required_u32(metadata, arch, "ssm.state_size", config, "mamba_d_state")?;
637    push_required_u32(metadata, arch, "ssm.group_count", config, "mamba_n_groups")?;
638    let inner_size = required_u32(config, "mamba_expand")?
639        .checked_mul(required_u32(config, "hidden_size")?)
640        .context("Granite Hybrid SSM inner size overflow")?;
641    metadata.push(GgufKv::u32(&format!("{arch}.ssm.inner_size"), inner_size));
642    push_required_u32(
643        metadata,
644        arch,
645        "ssm.time_step_rank",
646        config,
647        "mamba_n_heads",
648    )?;
649    metadata.push(GgufKv::bool(
650        &format!("{arch}.rope.scaling.finetuned"),
651        false,
652    ));
653    Ok(())
654}
655
656fn validate_glm_dsa_moe_contract(config: &Value) -> Result<()> {
657    let target_layers = required_u32(config, "num_hidden_layers")?;
658    ensure!(
659        target_layers > 0,
660        "GLM-DSA num_hidden_layers must be greater than zero"
661    );
662    let expert_count = optional_u32(config, "n_routed_experts")
663        .or_else(|| optional_u32(config, "num_experts"))
664        .context("GLM-DSA config missing n_routed_experts/num_experts")?;
665    let expert_used = required_u32(config, "num_experts_per_tok")?;
666    ensure!(
667        expert_used <= expert_count,
668        "GLM-DSA num_experts_per_tok must be less than or equal to expert count"
669    );
670    let dense_lead = optional_u32(config, "first_k_dense_replace").unwrap_or(0);
671    ensure!(
672        dense_lead < target_layers,
673        "GLM-DSA first_k_dense_replace must be less than num_hidden_layers"
674    );
675    Ok(())
676}
677
678fn push_required_first_u32(
679    metadata: &mut Vec<GgufKv>,
680    arch: &str,
681    gguf_suffix: &str,
682    config: &Value,
683    config_keys: &[&str],
684) -> Result<()> {
685    for config_key in config_keys {
686        if let Some(value) = optional_u32(config, config_key) {
687            ensure!(
688                value > 0,
689                "config value {config_key:?} must be greater than zero"
690            );
691            metadata.push(GgufKv::u32(&format!("{arch}.{gguf_suffix}"), value));
692            return Ok(());
693        }
694    }
695    anyhow::bail!("config missing one of {config_keys:?}")
696}
697
698fn push_first_u32(
699    metadata: &mut Vec<GgufKv>,
700    arch: &str,
701    gguf_suffix: &str,
702    config: &Value,
703    config_keys: &[&str],
704) {
705    for config_key in config_keys {
706        if let Some(value) = optional_u32(config, config_key) {
707            metadata.push(GgufKv::u32(&format!("{arch}.{gguf_suffix}"), value));
708            return;
709        }
710    }
711}
712
713fn push_required_u32(
714    metadata: &mut Vec<GgufKv>,
715    arch: &str,
716    gguf_suffix: &str,
717    config: &Value,
718    config_key: &str,
719) -> Result<()> {
720    metadata.push(GgufKv::u32(
721        &format!("{arch}.{gguf_suffix}"),
722        required_u32(config, config_key)?,
723    ));
724    Ok(())
725}
726
727fn push_required_f32(
728    metadata: &mut Vec<GgufKv>,
729    arch: &str,
730    gguf_suffix: &str,
731    config: &Value,
732    config_key: &str,
733) -> Result<()> {
734    let value = optional_f32(config, config_key)
735        .with_context(|| format!("config missing {config_key:?}"))?;
736    metadata.push(GgufKv::f32(&format!("{arch}.{gguf_suffix}"), value));
737    Ok(())
738}
739
740fn push_required_bool(
741    metadata: &mut Vec<GgufKv>,
742    arch: &str,
743    gguf_suffix: &str,
744    config: &Value,
745    config_key: &str,
746) -> Result<()> {
747    let value = optional_bool(config, config_key)
748        .with_context(|| format!("config missing {config_key:?}"))?;
749    metadata.push(GgufKv::bool(&format!("{arch}.{gguf_suffix}"), value));
750    Ok(())
751}
752
753fn push_if_u32(
754    metadata: &mut Vec<GgufKv>,
755    arch: &str,
756    gguf_suffix: &str,
757    config: &Value,
758    config_key: &str,
759) {
760    if let Some(value) = optional_u32(config, config_key) {
761        metadata.push(GgufKv::u32(&format!("{arch}.{gguf_suffix}"), value));
762    }
763}
764
765fn required_u32(config: &Value, key: &str) -> Result<u32> {
766    let value = optional_u32(config, key).with_context(|| format!("config missing {key:?}"))?;
767    ensure!(value > 0, "config value {key:?} must be greater than zero");
768    Ok(value)
769}
770
771fn optional_u32(config: &Value, key: &str) -> Option<u32> {
772    config
773        .get(key)
774        .and_then(Value::as_u64)
775        .and_then(|value| u32::try_from(value).ok())
776}
777
778fn optional_f32(config: &Value, key: &str) -> Option<f32> {
779    config
780        .get(key)
781        .and_then(Value::as_f64)
782        .map(|value| value as f32)
783}
784
785fn optional_bool(config: &Value, key: &str) -> Option<bool> {
786    config.get(key).and_then(Value::as_bool)
787}
788
789fn model_name(source: &Path) -> &str {
790    source
791        .file_name()
792        .and_then(|value| value.to_str())
793        .unwrap_or("checkpoint")
794}
795
796#[cfg(test)]
797mod tests {
798    use std::path::PathBuf;
799
800    use super::*;
801
802    #[test]
803    fn builds_glm_moe_lite_metadata_from_config() {
804        let root = unique_temp_dir();
805        fs::create_dir_all(&root).unwrap();
806        fs::write(
807            root.join("config.json"),
808            r#"{
809              "model_type": "glm4_moe_lite",
810              "vocab_size": 154880,
811              "max_position_embeddings": 202752,
812              "hidden_size": 2048,
813              "intermediate_size": 10240,
814              "num_hidden_layers": 47,
815              "num_nextn_predict_layers": 1,
816              "num_attention_heads": 20,
817              "num_key_value_heads": 20,
818              "qk_nope_head_dim": 192,
819              "qk_rope_head_dim": 64,
820              "v_head_dim": 256,
821              "rope_theta": 1000000,
822              "q_lora_rank": 768,
823              "kv_lora_rank": 512,
824              "n_routed_experts": 64,
825              "num_experts_per_tok": 4,
826              "n_shared_experts": 1,
827              "moe_intermediate_size": 1536,
828              "first_k_dense_replace": 1,
829              "routed_scaling_factor": 1.8,
830              "norm_topk_prob": true,
831              "rms_norm_eps": 1e-5
832            }"#,
833        )
834        .unwrap();
835
836        let metadata = metadata_from_hf_config(&root, 3).unwrap();
837        let text = format!("{metadata:?}");
838
839        assert!(text.contains("general.architecture"));
840        assert!(text.contains("deepseek2"));
841        assert!(text.contains("deepseek2.block_count"));
842        assert!(text.contains("48"));
843        assert!(text.contains("deepseek2.attention.key_length"));
844        assert!(text.contains("256"));
845        assert!(text.contains("deepseek2.nextn_predict_layers"));
846        fs::remove_dir_all(root).unwrap();
847    }
848
849    #[test]
850    fn omits_mtp_metadata_when_requested() {
851        let root = unique_temp_dir();
852        fs::create_dir_all(&root).unwrap();
853        fs::write(
854            root.join("config.json"),
855            r#"{
856              "model_type": "glm4_moe_lite",
857              "vocab_size": 154880,
858              "max_position_embeddings": 202752,
859              "hidden_size": 2048,
860              "intermediate_size": 10240,
861              "num_hidden_layers": 47,
862              "num_nextn_predict_layers": 1,
863              "num_attention_heads": 20,
864              "num_key_value_heads": 20,
865              "qk_nope_head_dim": 192,
866              "qk_rope_head_dim": 64,
867              "v_head_dim": 256
868            }"#,
869        )
870        .unwrap();
871
872        let metadata =
873            metadata_from_hf_config_with_options(&root, 3, MetadataOptions { include_mtp: false })
874                .unwrap();
875
876        assert!(metadata.iter().any(|kv| {
877            matches!(
878                kv,
879                GgufKv::U32 { key, value }
880                    if key == "deepseek2.block_count" && *value == 47
881            )
882        }));
883        assert!(!metadata.iter().any(|kv| {
884            matches!(
885                kv,
886                GgufKv::U32 { key, .. } if key == "deepseek2.nextn_predict_layers"
887            )
888        }));
889        fs::remove_dir_all(root).unwrap();
890    }
891
892    #[test]
893    fn builds_glm_dsa_indexer_metadata_from_config() {
894        let root = unique_temp_dir();
895        fs::create_dir_all(&root).unwrap();
896        fs::write(
897            root.join("config.json"),
898            r#"{
899              "model_type": "glm_moe_dsa",
900              "vocab_size": 154880,
901              "max_position_embeddings": 1048576,
902              "hidden_size": 6144,
903              "intermediate_size": 12288,
904              "num_hidden_layers": 78,
905              "num_nextn_predict_layers": 1,
906              "num_attention_heads": 64,
907              "num_key_value_heads": 64,
908              "qk_nope_head_dim": 192,
909              "qk_rope_head_dim": 64,
910              "v_head_dim": 256,
911              "q_lora_rank": 2048,
912              "kv_lora_rank": 512,
913              "index_n_heads": 32,
914              "index_head_dim": 128,
915              "index_topk": 2048,
916              "index_topk_freq": 4,
917              "index_skip_topk_offset": 3,
918              "indexer_types": [
919                "full", "full", "full", "shared", "shared", "shared", "full",
920                "shared", "shared", "shared", "full", "shared", "shared",
921                "shared", "full", "shared", "shared", "shared", "full",
922                "shared", "shared", "shared", "full", "shared", "shared",
923                "shared", "full", "shared", "shared", "shared", "full",
924                "shared", "shared", "shared", "full", "shared", "shared",
925                "shared", "full", "shared", "shared", "shared", "full",
926                "shared", "shared", "shared", "full", "shared", "shared",
927                "shared", "full", "shared", "shared", "shared", "full",
928                "shared", "shared", "shared", "full", "shared", "shared",
929                "shared", "full", "shared", "shared", "shared", "full",
930                "shared", "shared", "shared", "full", "shared", "shared",
931                "shared", "full", "shared", "shared", "shared"
932              ],
933              "n_routed_experts": 256,
934              "num_experts_per_tok": 8,
935              "n_shared_experts": 1,
936              "moe_intermediate_size": 2048,
937              "first_k_dense_replace": 3,
938              "routed_scaling_factor": 2.5,
939              "norm_topk_prob": true,
940              "rms_norm_eps": 1e-5
941            }"#,
942        )
943        .unwrap();
944
945        let metadata = metadata_from_hf_config(&root, 3).unwrap();
946
947        assert!(metadata.iter().any(|kv| {
948            matches!(
949                kv,
950                GgufKv::U32 { key, value }
951                    if key == "glm-dsa.attention.key_length" && *value == 576
952            )
953        }));
954        assert!(metadata.iter().any(|kv| {
955            matches!(
956                kv,
957                GgufKv::U32 { key, value }
958                    if key == "glm-dsa.attention.key_length_mla" && *value == 256
959            )
960        }));
961        assert!(metadata.iter().any(|kv| {
962            matches!(
963                kv,
964                GgufKv::U32 { key, value }
965                    if key == "glm-dsa.attention.value_length_mla" && *value == 256
966            )
967        }));
968        assert!(metadata.iter().any(|kv| {
969            matches!(
970                kv,
971                GgufKv::U32 { key, value }
972                    if key == "glm-dsa.attention.indexer.head_count" && *value == 32
973            )
974        }));
975        assert!(metadata.iter().any(|kv| {
976            matches!(
977                kv,
978                GgufKv::U32 { key, value }
979                    if key == "glm-dsa.attention.indexer.key_length" && *value == 128
980            )
981        }));
982        assert!(metadata.iter().any(|kv| {
983            matches!(
984                kv,
985                GgufKv::U32 { key, value }
986                    if key == "glm-dsa.attention.indexer.top_k" && *value == 2048
987            )
988        }));
989        assert!(metadata.iter().any(|kv| {
990            matches!(
991                kv,
992                GgufKv::U32 { key, value }
993                    if key == "glm-dsa.attention.indexer.top_k_frequency" && *value == 4
994            )
995        }));
996        assert!(metadata.iter().any(|kv| {
997            matches!(
998                kv,
999                GgufKv::U32 { key, value }
1000                    if key == "glm-dsa.attention.indexer.skip_top_k_offset" && *value == 3
1001            )
1002        }));
1003        assert!(metadata.iter().any(|kv| {
1004            matches!(
1005                kv,
1006                GgufKv::ArrayString { key, value }
1007                    if key == "glm-dsa.attention.indexer.types"
1008                        && value.len() == 78
1009                        && value[0] == "full"
1010                        && value[3] == "shared"
1011                        && value[6] == "full"
1012            )
1013        }));
1014        assert!(metadata.iter().any(|kv| {
1015            matches!(
1016                kv,
1017                GgufKv::U32 { key, value }
1018                    if key == "glm-dsa.expert_count" && *value == 256
1019            )
1020        }));
1021        assert!(metadata.iter().any(|kv| {
1022            matches!(
1023                kv,
1024                GgufKv::U32 { key, value }
1025                    if key == "glm-dsa.expert_used_count" && *value == 8
1026            )
1027        }));
1028        assert!(metadata.iter().any(|kv| {
1029            matches!(
1030                kv,
1031                GgufKv::U32 { key, value }
1032                    if key == "glm-dsa.expert_shared_count" && *value == 1
1033            )
1034        }));
1035        assert!(metadata.iter().any(|kv| {
1036            matches!(
1037                kv,
1038                GgufKv::U32 { key, value }
1039                    if key == "glm-dsa.expert_feed_forward_length" && *value == 2048
1040            )
1041        }));
1042        assert!(metadata.iter().any(|kv| {
1043            matches!(
1044                kv,
1045                GgufKv::U32 { key, value }
1046                    if key == "glm-dsa.leading_dense_block_count" && *value == 3
1047            )
1048        }));
1049        assert!(metadata.iter().any(|kv| {
1050            matches!(
1051                kv,
1052                GgufKv::F32 { key, value }
1053                    if key == "glm-dsa.expert_weights_scale" && (*value - 2.5).abs() < f32::EPSILON
1054            )
1055        }));
1056        assert!(metadata.iter().any(|kv| {
1057            matches!(
1058                kv,
1059                GgufKv::Bool { key, value }
1060                    if key == "glm-dsa.expert_weights_norm" && *value
1061            )
1062        }));
1063        fs::remove_dir_all(root).unwrap();
1064    }
1065
1066    #[test]
1067    fn rejects_glm_dsa_config_missing_runtime_contract_metadata() {
1068        let root = unique_temp_dir();
1069        fs::create_dir_all(&root).unwrap();
1070        fs::write(
1071            root.join("config.json"),
1072            r#"{
1073              "model_type": "glm_moe_dsa",
1074              "vocab_size": 154880,
1075              "max_position_embeddings": 1048576,
1076              "hidden_size": 6144,
1077              "intermediate_size": 12288,
1078              "num_hidden_layers": 78,
1079              "num_attention_heads": 64,
1080              "num_key_value_heads": 64,
1081              "qk_nope_head_dim": 192,
1082              "qk_rope_head_dim": 64,
1083              "v_head_dim": 256,
1084              "q_lora_rank": 2048,
1085              "kv_lora_rank": 512,
1086              "index_n_heads": 32,
1087              "index_head_dim": 128,
1088              "index_topk": 2048,
1089              "n_routed_experts": 256,
1090              "n_shared_experts": 1,
1091              "moe_intermediate_size": 2048,
1092              "first_k_dense_replace": 3,
1093              "routed_scaling_factor": 2.5,
1094              "norm_topk_prob": true,
1095              "rms_norm_eps": 1e-5
1096            }"#,
1097        )
1098        .unwrap();
1099
1100        let err = metadata_from_hf_config(&root, 3).unwrap_err();
1101
1102        assert!(
1103            err.to_string().contains("num_experts_per_tok"),
1104            "unexpected error: {err:#}"
1105        );
1106        fs::remove_dir_all(root).unwrap();
1107    }
1108
1109    #[test]
1110    fn builds_llama_metadata_from_config() {
1111        let root = unique_temp_dir();
1112        fs::create_dir_all(&root).unwrap();
1113        fs::write(
1114            root.join("config.json"),
1115            r#"{
1116              "model_type": "llama",
1117              "vocab_size": 128256,
1118              "max_position_embeddings": 131072,
1119              "hidden_size": 4096,
1120              "intermediate_size": 14336,
1121              "num_hidden_layers": 32,
1122              "num_attention_heads": 32,
1123              "num_key_value_heads": 8,
1124              "head_dim": 128,
1125              "rope_theta": 500000,
1126              "rms_norm_eps": 1e-5
1127            }"#,
1128        )
1129        .unwrap();
1130
1131        let metadata = metadata_from_hf_config(&root, 3).unwrap();
1132        let text = format!("{metadata:?}");
1133
1134        assert!(text.contains("general.architecture"));
1135        assert!(text.contains("llama"));
1136        assert!(text.contains("llama.block_count"));
1137        assert!(text.contains("32"));
1138        assert!(text.contains("llama.attention.head_count_kv"));
1139        assert!(text.contains("8"));
1140        fs::remove_dir_all(root).unwrap();
1141    }
1142
1143    #[test]
1144    fn builds_granite_hybrid_metadata_from_fixed_model_config() {
1145        let root = unique_temp_dir();
1146        fs::create_dir_all(&root).unwrap();
1147        let mut layer_types = vec!["mamba"; 40];
1148        for layer in [5_usize, 15, 25, 35] {
1149            layer_types[layer] = "attention";
1150        }
1151        let config = serde_json::json!({
1152            "architectures": ["GraniteMoeHybridForCausalLM"],
1153            "model_type": "granitemoehybrid",
1154            "vocab_size": 100352,
1155            "hidden_size": 1536,
1156            "intermediate_size": 4096,
1157            "shared_intermediate_size": 4096,
1158            "num_hidden_layers": 40,
1159            "num_attention_heads": 12,
1160            "num_key_value_heads": 4,
1161            "head_dim": 128,
1162            "layer_types": layer_types,
1163            "num_local_experts": 0,
1164            "num_experts_per_tok": 0,
1165            "mamba_d_conv": 4,
1166            "mamba_d_state": 128,
1167            "mamba_n_groups": 1,
1168            "mamba_expand": 2,
1169            "mamba_n_heads": 48,
1170            "attention_multiplier": 0.0078125,
1171            "embedding_multiplier": 12.0,
1172            "residual_multiplier": 0.22,
1173            "logits_scaling": 6.0,
1174            "rope_theta": 10000.0,
1175            "rms_norm_eps": 1e-5
1176        });
1177        fs::write(
1178            root.join("config.json"),
1179            serde_json::to_vec(&config).unwrap(),
1180        )
1181        .unwrap();
1182
1183        let metadata = metadata_from_hf_config(&root, 506).unwrap();
1184
1185        assert!(metadata.iter().any(|kv| matches!(
1186            kv,
1187            GgufKv::String { key, value }
1188                if key == "general.architecture" && value == "granitehybrid"
1189        )));
1190        assert!(metadata.iter().any(|kv| matches!(
1191            kv,
1192            GgufKv::U32 { key, value }
1193                if key == "granitehybrid.context_length" && *value == 1 << 20
1194        )));
1195        assert!(metadata.iter().any(|kv| matches!(
1196            kv,
1197            GgufKv::ArrayI32 { key, value }
1198                if key == "granitehybrid.attention.head_count_kv"
1199                    && value.len() == 40
1200                    && value[5] == 4
1201                    && value[15] == 4
1202                    && value.iter().filter(|head_count| **head_count == 4).count() == 4
1203                    && value.iter().filter(|head_count| **head_count == 0).count() == 36
1204        )));
1205        for (key, expected) in [
1206            ("granitehybrid.ssm.conv_kernel", 4),
1207            ("granitehybrid.ssm.state_size", 128),
1208            ("granitehybrid.ssm.group_count", 1),
1209            ("granitehybrid.ssm.inner_size", 3072),
1210            ("granitehybrid.ssm.time_step_rank", 48),
1211            ("granitehybrid.expert_count", 0),
1212            ("granitehybrid.expert_used_count", 0),
1213            ("granitehybrid.expert_shared_feed_forward_length", 4096),
1214        ] {
1215            assert!(
1216                metadata.iter().any(|kv| matches!(
1217                    kv,
1218                    GgufKv::U32 { key: actual, value }
1219                        if actual == key && *value == expected
1220                )),
1221                "missing {key}={expected}"
1222            );
1223        }
1224        assert!(metadata.iter().any(|kv| matches!(
1225            kv,
1226            GgufKv::Bool { key, value }
1227                if key == "granitehybrid.rope.scaling.finetuned" && !*value
1228        )));
1229        fs::remove_dir_all(root).unwrap();
1230    }
1231
1232    #[test]
1233    fn defaults_kv_heads_to_attention_heads() {
1234        let root = unique_temp_dir();
1235        fs::create_dir_all(&root).unwrap();
1236        fs::write(
1237            root.join("config.json"),
1238            r#"{
1239              "model_type": "llama",
1240              "vocab_size": 32000,
1241              "max_position_embeddings": 4096,
1242              "hidden_size": 4096,
1243              "intermediate_size": 11008,
1244              "num_hidden_layers": 32,
1245              "num_attention_heads": 32
1246            }"#,
1247        )
1248        .unwrap();
1249
1250        let metadata = metadata_from_hf_config(&root, 3).unwrap();
1251        let text = format!("{metadata:?}");
1252
1253        assert!(text.contains("llama.attention.head_count_kv"));
1254        assert!(text.contains("32"));
1255        fs::remove_dir_all(root).unwrap();
1256    }
1257
1258    #[test]
1259    fn builds_qwen2_moe_metadata_from_qwen_config_keys() {
1260        let root = unique_temp_dir();
1261        fs::create_dir_all(&root).unwrap();
1262        fs::write(
1263            root.join("config.json"),
1264            r#"{
1265              "model_type": "qwen2_moe",
1266              "vocab_size": 151936,
1267              "max_position_embeddings": 32768,
1268              "hidden_size": 2048,
1269              "intermediate_size": 5632,
1270              "num_hidden_layers": 24,
1271              "num_attention_heads": 16,
1272              "num_key_value_heads": 16,
1273              "head_dim": 128,
1274              "num_experts": 60,
1275              "num_experts_per_tok": 4,
1276              "moe_intermediate_size": 1408,
1277              "shared_expert_intermediate_size": 5632,
1278              "rope_theta": 1000000,
1279              "rms_norm_eps": 1e-6
1280            }"#,
1281        )
1282        .unwrap();
1283
1284        let metadata = metadata_from_hf_config(&root, 3).unwrap();
1285        let text = format!("{metadata:?}");
1286
1287        assert!(text.contains("qwen2moe"));
1288        assert!(text.contains("qwen2moe.expert_count"));
1289        assert!(text.contains("60"));
1290        assert!(text.contains("qwen2moe.expert_feed_forward_length"));
1291        assert!(text.contains("1408"));
1292        assert!(text.contains("qwen2moe.expert_shared_feed_forward_length"));
1293        assert!(text.contains("5632"));
1294        fs::remove_dir_all(root).unwrap();
1295    }
1296
1297    #[test]
1298    fn rejects_newer_qwen_variants_that_need_specific_native_templates() {
1299        for model_type in [
1300            "qwen3_next",
1301            "qwen3next",
1302            "qwen3_vl",
1303            "qwen3vl_moe",
1304            "qwen3.5",
1305            "qwen3_5_moe",
1306            "qwen35",
1307            "qwen35moe",
1308        ] {
1309            let root = unique_temp_dir();
1310            fs::create_dir_all(&root).unwrap();
1311            fs::write(
1312                root.join("config.json"),
1313                format!(
1314                    r#"{{
1315                      "model_type": "{model_type}",
1316                      "vocab_size": 151936,
1317                      "max_position_embeddings": 32768,
1318                      "hidden_size": 2048,
1319                      "intermediate_size": 5632,
1320                      "num_hidden_layers": 24,
1321                      "num_attention_heads": 16
1322                    }}"#,
1323                ),
1324            )
1325            .unwrap();
1326
1327            let err = metadata_from_hf_config(&root, 3).unwrap_err();
1328            let message = err.to_string();
1329            assert!(
1330                message.contains("Qwen3.5/Qwen3Next/Qwen3VL-specific"),
1331                "{model_type}: {message}"
1332            );
1333            assert!(
1334                message.contains("external convert_hf_to_gguf.py backend"),
1335                "{model_type}: {message}"
1336            );
1337            fs::remove_dir_all(root).unwrap();
1338        }
1339    }
1340
1341    #[test]
1342    fn builds_inkling_multi_depth_mtp_metadata() {
1343        let root = unique_temp_dir();
1344        fs::create_dir_all(&root).unwrap();
1345        fs::write(
1346            root.join("config.json"),
1347            r#"{
1348              "model_type": "inkling_mm_model",
1349              "eos_token_id": 200006,
1350              "text_config": {
1351                "model_max_length": 1048576,
1352                "hidden_size": 6144,
1353                "num_hidden_layers": 66,
1354                "vocab_size": 201024,
1355                "num_attention_heads": 64,
1356                "num_key_value_heads": 8,
1357                "head_dim": 128,
1358                "d_rel": 16,
1359                "rel_extent": 1024,
1360                "log_scaling_n_floor": 128000,
1361                "log_scaling_alpha": 0.1,
1362                "rms_norm_eps": 1e-6,
1363                "local_layer_ids": [0, 1, 2, 3, 4],
1364                "dense_mlp_idx": 2,
1365                "sconv_kernel_size": 4,
1366                "unpadded_vocab_size": 200058,
1367                "logits_mup_width_multiplier": 24.0,
1368                "swa_num_key_value_heads": 16,
1369                "sliding_window_size": 512,
1370                "n_routed_experts": 256,
1371                "num_experts_per_tok": 6,
1372                "n_shared_experts": 2,
1373                "dense_intermediate_size": 24576,
1374                "intermediate_size": 3072,
1375                "route_scale": 8.0,
1376                "norm_after_topk": true,
1377                "shared_expert_sink": true,
1378                "use_sconv": true,
1379                "use_embed_norm": true,
1380                "use_gate_bias": true,
1381                "use_global_scale": true,
1382                "gate_activation": "sigmoid"
1383              },
1384              "mtp_config": {
1385                "num_nextn_predict_layers": 8,
1386                "chain_hidden_post_norm": false,
1387                "local_layer_ids": [0, 2, 4, 5, 6, 7]
1388              }
1389            }"#,
1390        )
1391        .unwrap();
1392
1393        assert_eq!(mtp_layer_start_from_hf_config(&root).unwrap(), Some(66));
1394        let metadata = metadata_from_hf_config(&root, 25).unwrap();
1395        assert!(metadata.iter().any(|kv| {
1396            matches!(kv, GgufKv::U32 { key, value } if key == "inkling.block_count" && *value == 74)
1397        }));
1398        assert!(metadata.iter().any(|kv| {
1399            matches!(kv, GgufKv::U32 { key, value } if key == "inkling.nextn_predict_layers" && *value == 8)
1400        }));
1401        assert!(metadata.iter().any(|kv| {
1402            matches!(kv, GgufKv::ArrayU32 { key, value } if key == "inkling.attention.head_count_kv" && value.len() == 74 && value[66] == 16 && value[67] == 8)
1403        }));
1404        assert!(metadata.iter().any(|kv| {
1405            matches!(kv, GgufKv::ArrayBool { key, value } if key == "inkling.attention.sliding_window_pattern" && value.len() == 74 && value[66] && !value[67])
1406        }));
1407        fs::remove_dir_all(root).unwrap();
1408    }
1409
1410    fn unique_temp_dir() -> PathBuf {
1411        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1412        let nanos = std::time::SystemTime::now()
1413            .duration_since(std::time::UNIX_EPOCH)
1414            .unwrap()
1415            .as_nanos();
1416        let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1417        std::env::temp_dir().join(format!("skippy-gguf-template-{nanos}-{id}"))
1418    }
1419}