1use 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}