Add Qwen3 GGUF support and synthesize missing bias tensors
- Support Qwen3 chat templates and metadata in GGUF export - Synthesize zero-filled Q/K/V bias tensors for Qwen3 models - Display fine-tune option only for safetensors models - Improve chat model loading logs and error handling
keypair34 committed
Apr 29, 2026 at 22:58 UTC
7bbcad392efa4f5804b7f19af4cea9a7448bd488
4 files changed
+193
-46
src/app.rs
+17
-6
@@ -326,6 +326,9 @@ pub struct App {
326
// adapters discovered on the model detail screen
327
pub adapter_list: Vec<AdapterEntry>,
328
pub adapter_cursor: usize,
329
+ /// `true` when the model on the detail screen has `config.json` (safetensors)
330
+ /// and can therefore be fine-tuned. GGUF-only models set this to `false`.
331
+ pub model_detail_can_finetune: bool,
332
// GGUF detail + upload
333
pub selected_gguf: Option<AdapterEntry>,
334
pub upload_running: bool,
@@ -407,6 +410,7 @@ impl App {
410
merged_model_dir: None,
411
adapter_list: Vec::new(),
412
adapter_cursor: 0,
413
+ model_detail_can_finetune: false,
414
selected_gguf: None,
415
upload_running: false,
416
upload_progress: None,
@@ -1248,17 +1252,28 @@ fn handle_key_downloads(
1252
}
1253
(Enter, _) if !app.downloads.is_empty() => {
1254
// Scan for existing adapters before entering the detail screen
1255
+ let can_finetune;
1256
if let Some(model) = app.downloads.get(app.downloads_cursor) {
1257
let resolved = resolve_hf_cache_path(&model.model_id);
1258
+ can_finetune = !resolved.is_empty()
1259
+ && std::path::Path::new(&resolved).join("config.json").exists();
1260
app.adapter_list = scan_adapters(&resolved);
1261
app.adapter_cursor = 0;
1262
+ } else {
1263
+ can_finetune = false;
1264
}
1265
+ app.model_detail_can_finetune = can_finetune;
1266
app.screen = Screen::ModelDetail;
1267
if app.adapter_list.is_empty() {
1268
app.status = Status::neutral("Model details.");
1269
} else {
1270
+ let ft_hint = if can_finetune {
1271
+ " f · fine-tune new"
1272
+ } else {
1273
+ ""
1274
+ };
1275
app.status = Status::success(format!(
1261
- "{} adapter{} found. Enter · merge & export f · fine-tune new",
1276
+ "{} adapter{} found. Enter · merge & export{ft_hint}",
1277
app.adapter_list.len(),
1278
if app.adapter_list.len() == 1 { "" } else { "s" }
1279
));
@@ -1385,15 +1400,11 @@ fn handle_key_model_detail(
1400
app.status = Status::error("No adapter selected.");
1401
}
1402
}
1388
- (Char('f'), KeyModifiers::NONE) => {
1403
+ (Char('f'), KeyModifiers::NONE) if app.model_detail_can_finetune => {
1404
if let Some(model) = app.downloads.get(app.downloads_cursor) {
1405
let resolved = resolve_hf_cache_path(&model.model_id);
1406
if resolved.is_empty() {
1407
app.status = Status::error("Model not downloaded locally.");
1393
- } else if !std::path::Path::new(&resolved).join("config.json").exists() {
1394
- app.status = Status::error(
1395
- "Fine-tuning requires a safetensors model (GGUF not supported).",
1396
- );
1408
} else {
1409
app.finetune_model_id = model.model_id.clone();
1410
app.finetune_model_dir = resolved;
src/chat.rs
+7
-1
@@ -168,13 +168,19 @@ pub async fn start_chat(
168
};
169
170
// ── 3. Build and load the model ──────────────────────────────────────────
171
+ log::info!("[chat] loading GGUF from dir={model_dir:?} file={file_name:?}");
172
let model = match GgufModelBuilder::new(&model_dir, vec![file_name.clone()])
173
.with_token_source(TokenSource::None)
174
+ .with_logging()
175
.build()
176
.await
177
{
176
- Ok(m) => m,
178
+ Ok(m) => {
179
+ log::info!("[chat] model loaded successfully");
180
+ m
181
+ }
182
Err(e) => {
183
+ log::error!("[chat] model load failed: {e}");
184
let _ = progress_tx.send(ChatProgress::Error(format!(
185
"Failed to load model \"{file_name}\": {e}"
186
)));
src/gguf.rs
+159
-32
@@ -83,6 +83,11 @@ struct ModelConfig {
83
max_position_embeddings: usize,
84
#[serde(default)]
85
tie_word_embeddings: bool,
86
+ /// HuggingFace model type, e.g. "qwen2" or "qwen3".
87
+ /// Kept for future architecture-specific logic.
88
+ #[allow(dead_code)]
89
+ #[serde(default = "default_model_type")]
90
+ model_type: String,
91
}
92
93
fn default_rms_norm_eps() -> f64 {
@@ -94,6 +99,9 @@ fn default_rope_theta() -> f64 {
99
fn default_max_position_embeddings() -> usize {
100
32768
101
}
102
+fn default_model_type() -> String {
103
+ "qwen2".to_string()
104
+}
105
106
#[derive(serde::Deserialize)]
107
struct TokenizerJson {
@@ -149,6 +157,17 @@ const GGUF_MAGIC: u32 = 0x46554747;
157
const GGUF_VERSION: u32 = 3;
158
const ALIGNMENT: u64 = 32;
159
160
+/// Standard Qwen ChatML template used as a fallback when `tokenizer_config.json`
161
+/// does not provide one. Matches the template shipped in Bartowski's Qwen GGUFs.
162
+const QWEN_CHATML_TEMPLATE: &str = concat!(
163
+ "{%- for message in messages %}",
164
+ "{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}",
165
+ "{%- endfor %}",
166
+ "{%- if add_generation_prompt %}",
167
+ "{{'<|im_start|>assistant\n'}}",
168
+ "{%- endif %}",
169
+);
170
+
171
// GGUF tensor type IDs
172
const GGUF_TYPE_F32: u32 = 0;
173
const GGUF_TYPE_F16: u32 = 1;
@@ -266,6 +285,11 @@ fn map_tensor_name(hf_name: &str) -> Option<String> {
285
"self_attn.k_proj.weight" => "attn_k.weight",
286
"self_attn.v_proj.weight" => "attn_v.weight",
287
"self_attn.o_proj.weight" => "attn_output.weight",
288
+ // Bias tensors (present in Qwen2/2.5, absent in Qwen3)
289
+ "self_attn.q_proj.bias" => "attn_q.bias",
290
+ "self_attn.k_proj.bias" => "attn_k.bias",
291
+ "self_attn.v_proj.bias" => "attn_v.bias",
292
+ // QK-Norm (Qwen3)
293
"self_attn.q_norm.weight" => "attn_q_norm.weight",
294
"self_attn.k_norm.weight" => "attn_k_norm.weight",
295
"mlp.gate_proj.weight" => "ffn_gate.weight",
@@ -459,6 +483,47 @@ fn run_gguf_export(
483
let tok: TokenizerJson =
484
serde_json::from_str(&tok_text).context("failed to parse tokenizer.json")?;
485
486
+ // Read the chat template from tokenizer_config.json.
487
+ // This is needed by mistral.rs / llama.cpp to format multi-turn conversations.
488
+ //
489
+ // The field can be either:
490
+ // - a plain string (Qwen2/2.5)
491
+ // - an array of {"name": "...", "template": "..."} objects (Qwen3)
492
+ //
493
+ // When it's an array, pick the "default" entry, or fall back to the first.
494
+ // If the file is missing or the field is absent, use the standard Qwen
495
+ // ChatML template so the GGUF is always loadable for chat.
496
+ let chat_template: String = {
497
+ let tok_config_path = config.model_dir.join("tokenizer_config.json");
498
+ let from_file = std::fs::read_to_string(&tok_config_path)
499
+ .ok()
500
+ .and_then(|text| {
501
+ let v: serde_json::Value = serde_json::from_str(&text).ok()?;
502
+ let ct = v.get("chat_template")?;
503
+
504
+ // Plain string
505
+ if let Some(s) = ct.as_str() {
506
+ return Some(s.to_string());
507
+ }
508
+
509
+ // Array of objects — pick "default", else first entry
510
+ if let Some(arr) = ct.as_array() {
511
+ let default = arr.iter().find(|entry| {
512
+ entry.get("name").and_then(|n| n.as_str()) == Some("default")
513
+ });
514
+ let chosen = default.or_else(|| arr.first());
515
+ return chosen
516
+ .and_then(|entry| entry.get("template"))
517
+ .and_then(|t| t.as_str())
518
+ .map(String::from);
519
+ }
520
+
521
+ None
522
+ });
523
+
524
+ from_file.unwrap_or_else(|| QWEN_CHATML_TEMPLATE.to_string())
525
+ };
526
+
527
// Build ordered token list sorted by ID
528
let vocab_size = model_cfg.vocab_size;
529
let mut id_to_token: Vec<(u32, String)> =
@@ -576,6 +641,47 @@ fn run_gguf_export(
641
});
642
}
643
644
+ // Synthesize zero-filled Q/K/V bias tensors for layers that lack them.
645
+ //
646
+ // mistralrs expects `blk.{i}.attn_q.bias` etc. for the qwen2 architecture.
647
+ // Qwen2/2.5 have these in the safetensors; Qwen3 does not. Bartowski's
648
+ // llama.cpp-converted Qwen3 GGUFs include zero-filled bias tensors.
649
+ // We do the same here so the GGUF loads correctly for both model families.
650
+ let num_layers = model_cfg.num_hidden_layers;
651
+ let num_heads = model_cfg.num_attention_heads;
652
+ let num_kv_heads_usize = model_cfg.num_key_value_heads.unwrap_or(num_heads);
653
+ let head_dim = model_cfg.hidden_size / num_heads;
654
+
655
+ for i in 0..num_layers {
656
+ let bias_specs: &[(&str, usize)] = &[
657
+ (&format!("blk.{i}.attn_q.bias"), num_heads * head_dim),
658
+ (
659
+ &format!("blk.{i}.attn_k.bias"),
660
+ num_kv_heads_usize * head_dim,
661
+ ),
662
+ (
663
+ &format!("blk.{i}.attn_v.bias"),
664
+ num_kv_heads_usize * head_dim,
665
+ ),
666
+ ];
667
+ for (gguf_name, num_elements) in bias_specs {
668
+ if !entries.iter().any(|e| e.gguf_name == *gguf_name) {
669
+ // 1-D bias → always F32
670
+ let data_size = tensor_data_size(*num_elements, GGUF_TYPE_F32);
671
+ entries.push(TensorEntry {
672
+ gguf_name: gguf_name.to_string(),
673
+ hf_name: String::new(), // sentinel: no safetensors source
674
+ dims: vec![*num_elements as u64],
675
+ ndim: 1,
676
+ num_elements: *num_elements,
677
+ gguf_type: GGUF_TYPE_F32,
678
+ data_size,
679
+ offset: 0,
680
+ });
681
+ }
682
+ }
683
+ }
684
+
685
// Compute offsets (relative to start of data section, 32-byte aligned)
686
let mut data_offset: u64 = 0;
687
for entry in &mut entries {
@@ -601,8 +707,16 @@ fn run_gguf_export(
707
GgufDtype::Q8_0 => 7,
708
};
709
710
+ // GGUF architecture string.
711
+ //
712
+ // In the llama.cpp / mistralrs GGUF ecosystem, Qwen3 is treated as a
713
+ // Qwen2-variant (same tensor layout, plus optional QK-norm). Bartowski's
714
+ // pre-built Qwen3 GGUFs all declare `general.architecture = "qwen2"`.
715
+ // Using "qwen3" would cause loaders to hang or reject the file.
716
+ let arch = "qwen2";
717
+
718
// Count metadata KV entries
605
- let num_metadata: u64 = 17; // all the keys listed below
719
+ let num_metadata: u64 = 18;
720
721
// Pre-compute header + metadata + tensor info byte size
722
let mut header_size: u64 = 0;
@@ -610,23 +724,24 @@ fn run_gguf_export(
724
header_size += 4 + 4 + 8 + 8;
725
726
// Metadata sizes
613
- header_size += kv_string_size("general.architecture", "qwen2");
727
+ header_size += kv_string_size("general.architecture", arch);
728
header_size += kv_string_size("general.name", "onde-finetuned");
729
header_size += kv_u32_size("general.file_type");
616
- header_size += kv_u32_size("qwen2.block_count");
617
- header_size += kv_u32_size("qwen2.embedding_length");
618
- header_size += kv_u32_size("qwen2.feed_forward_length");
619
- header_size += kv_u32_size("qwen2.attention.head_count");
620
- header_size += kv_u32_size("qwen2.attention.head_count_kv");
621
- header_size += kv_f32_size("qwen2.attention.layer_norm_rms_epsilon");
622
- header_size += kv_f32_size("qwen2.rope.freq_base");
623
- header_size += kv_u32_size("qwen2.context_length");
730
+ header_size += kv_u32_size(&format!("{arch}.block_count"));
731
+ header_size += kv_u32_size(&format!("{arch}.embedding_length"));
732
+ header_size += kv_u32_size(&format!("{arch}.feed_forward_length"));
733
+ header_size += kv_u32_size(&format!("{arch}.attention.head_count"));
734
+ header_size += kv_u32_size(&format!("{arch}.attention.head_count_kv"));
735
+ header_size += kv_f32_size(&format!("{arch}.attention.layer_norm_rms_epsilon"));
736
+ header_size += kv_f32_size(&format!("{arch}.rope.freq_base"));
737
+ header_size += kv_u32_size(&format!("{arch}.context_length"));
738
header_size += kv_string_size("tokenizer.ggml.model", "gpt2");
739
header_size += kv_string_array_size("tokenizer.ggml.tokens", &tokens);
740
header_size += kv_u32_array_size("tokenizer.ggml.token_type", &token_types);
741
header_size += kv_string_array_size("tokenizer.ggml.merges", &merges);
742
header_size += kv_u32_size("tokenizer.ggml.bos_token_id");
743
header_size += kv_u32_size("tokenizer.ggml.eos_token_id");
744
+ header_size += kv_string_size("tokenizer.chat_template", &chat_template);
745
746
// Tensor info sizes
747
for entry in &entries {
@@ -657,39 +772,47 @@ fn run_gguf_export(
772
write_u64(&mut w, num_metadata)?;
773
774
// Metadata KV pairs
660
- write_kv_string(&mut w, "general.architecture", "qwen2")?;
775
+ write_kv_string(&mut w, "general.architecture", arch)?;
776
write_kv_string(&mut w, "general.name", "onde-finetuned")?;
777
write_kv_u32(&mut w, "general.file_type", file_type_val)?;
778
write_kv_u32(
779
&mut w,
665
- "qwen2.block_count",
780
+ &format!("{arch}.block_count"),
781
model_cfg.num_hidden_layers as u32,
782
)?;
783
write_kv_u32(
784
&mut w,
670
- "qwen2.embedding_length",
785
+ &format!("{arch}.embedding_length"),
786
model_cfg.hidden_size as u32,
787
)?;
788
write_kv_u32(
789
&mut w,
675
- "qwen2.feed_forward_length",
790
+ &format!("{arch}.feed_forward_length"),
791
model_cfg.intermediate_size as u32,
792
)?;
793
write_kv_u32(
794
&mut w,
680
- "qwen2.attention.head_count",
795
+ &format!("{arch}.attention.head_count"),
796
model_cfg.num_attention_heads as u32,
797
)?;
683
- write_kv_u32(&mut w, "qwen2.attention.head_count_kv", num_kv_heads)?;
798
+ write_kv_u32(
799
+ &mut w,
800
+ &format!("{arch}.attention.head_count_kv"),
801
+ num_kv_heads,
802
+ )?;
803
write_kv_f32(
804
&mut w,
686
- "qwen2.attention.layer_norm_rms_epsilon",
805
+ &format!("{arch}.attention.layer_norm_rms_epsilon"),
806
model_cfg.rms_norm_eps as f32,
807
)?;
689
- write_kv_f32(&mut w, "qwen2.rope.freq_base", model_cfg.rope_theta as f32)?;
808
+ write_kv_f32(
809
+ &mut w,
810
+ &format!("{arch}.rope.freq_base"),
811
+ model_cfg.rope_theta as f32,
812
+ )?;
813
write_kv_u32(
814
&mut w,
692
- "qwen2.context_length",
815
+ &format!("{arch}.context_length"),
816
model_cfg.max_position_embeddings as u32,
817
)?;
818
write_kv_string(&mut w, "tokenizer.ggml.model", "gpt2")?;
@@ -698,6 +821,7 @@ fn run_gguf_export(
821
write_kv_string_array(&mut w, "tokenizer.ggml.merges", &merges)?;
822
write_kv_u32(&mut w, "tokenizer.ggml.bos_token_id", bos_id)?;
823
write_kv_u32(&mut w, "tokenizer.ggml.eos_token_id", eos_id)?;
824
+ write_kv_string(&mut w, "tokenizer.chat_template", &chat_template)?;
825
826
// Tensor info entries
827
for entry in &entries {
@@ -726,19 +850,22 @@ fn run_gguf_export(
850
name: entry.gguf_name.clone(),
851
});
852
729
- // Load tensor from safetensors and convert to f32 on CPU
730
- let tensor = safetensors
731
- .load(&entry.hf_name, &Device::Cpu)
732
- .with_context(|| format!("failed to load tensor {}", entry.hf_name))?;
733
- let tensor_f32 = tensor
734
- .to_dtype(candle_core::DType::F32)
735
- .with_context(|| format!("failed to convert {} to f32", entry.hf_name))?;
736
- let data = tensor_f32
737
- .flatten_all()
738
- .context("failed to flatten tensor")?;
739
- let data = data
740
- .to_vec1::<f32>()
741
- .context("failed to extract f32 data from tensor")?;
853
+ // Load tensor data. Synthetic entries (empty hf_name) get zeros.
854
+ let data: Vec<f32> = if entry.hf_name.is_empty() {
855
+ vec![0.0f32; entry.num_elements]
856
+ } else {
857
+ let tensor = safetensors
858
+ .load(&entry.hf_name, &Device::Cpu)
859
+ .with_context(|| format!("failed to load tensor {}", entry.hf_name))?;
860
+ let tensor_f32 = tensor
861
+ .to_dtype(candle_core::DType::F32)
862
+ .with_context(|| format!("failed to convert {} to f32", entry.hf_name))?;
863
+ let flat = tensor_f32
864
+ .flatten_all()
865
+ .context("failed to flatten tensor")?;
866
+ flat.to_vec1::<f32>()
867
+ .context("failed to extract f32 data from tensor")?
868
+ };
869
870
match entry.gguf_type {
871
GGUF_TYPE_F32 => write_tensor_f32(&mut w, &data)?,
src/ui.rs
+10
-7
@@ -1227,11 +1227,12 @@ fn render_model_detail(frame: &mut Frame, app: &App, area: Rect) {
1227
/// Render the selectable artifact list inside the Model Detail screen.
1228
fn render_model_detail_adapters(frame: &mut Frame, app: &App, area: Rect) {
1229
if app.adapter_list.is_empty() {
1230
- frame.render_widget(
1231
- Paragraph::new(" No artifacts found. Press f to fine-tune.")
1232
- .style(Style::new().fg(C_MUTED)),
1233
- area,
1234
- );
1230
+ let msg = if app.model_detail_can_finetune {
1231
+ " No artifacts found. Press f to fine-tune."
1232
+ } else {
1233
+ " No artifacts found. (GGUF models cannot be fine-tuned.)"
1234
+ };
1235
+ frame.render_widget(Paragraph::new(msg).style(Style::new().fg(C_MUTED)), area);
1236
return;
1237
}
1238
@@ -2576,8 +2577,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2577
Style::new().fg(C_MUTED),
2578
));
2579
}
2579
- keys.push(Span::styled("f", Style::new().fg(C_NEON)));
2580
- keys.push(Span::styled(" · fine-tune ", Style::new().fg(C_MUTED)));
2580
+ if app.model_detail_can_finetune {
2581
+ keys.push(Span::styled("f", Style::new().fg(C_NEON)));
2582
+ keys.push(Span::styled(" · fine-tune ", Style::new().fg(C_MUTED)));
2583
+ }
2584
keys.push(Span::styled("Esc", Style::new().fg(C_NEON)));
2585
keys.push(Span::styled(" · back ", Style::new().fg(C_MUTED)));
2586
keys.push(Span::styled("Ctrl+C", Style::new().fg(C_NEON)));