From e116b80e080a52322d3872be1d578472d2573c99 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Sat, 18 Jul 2026 11:58:34 +1000 Subject: [PATCH] fix(desktop): dedupe mesh models across @main/plain id forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A serving node advertises the same model under two id strings — `org/model@main:Q4` (serveTargets[].modelId) and `org/model:Q4` (available_models). `dedupe_models` keyed on the raw id, so BOTH survived and the model picker listed the same model twice. The `@main` ref-qualifier was already stripped at selection time (`pick_serve_target_for_model`), so picking worked — but the display dedup was never canonicalized, leaving the duplicate in the list. Canonicalize `@main` once in `dedupe_models` (shared helper `canonical_model_id`) so both forms collapse to a single entry. Adds a regression test with the exact two-string shape. --- desktop/src-tauri/src/mesh_llm/mod.rs | 15 ++++++++++- desktop/src-tauri/src/mesh_llm/mod_tests.rs | 28 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 80d887bec7..4ec3adaab3 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -636,11 +636,24 @@ fn push_model(out: &mut Vec, id: &str, name: Option) { }); } +/// Canonical model id for equality/dedup. A serving node advertises the same +/// model under two strings — `org/model@main:Q4` (serveTargets[].modelId) and +/// `org/model:Q4` (available_models) — so keying on the raw string leaves BOTH +/// in the picker. Strip the `@main` ref-qualifier (matching the selection-time +/// rule in `pick_serve_target_for_model`) so the two collapse to one entry. +pub(super) fn canonical_model_id(value: &str) -> String { + value.trim().replace("@main", "") +} + pub(super) fn dedupe_models(models: Vec) -> Vec { + // Key by canonical id so `@main` / non-`@main` forms of the same model + // dedup together. Display the canonical (stripped) id so the UI shows one + // stable label; selection still matches because the picker canonicalizes + // both sides too. let mut by_id = BTreeMap::>::new(); for model in models { by_id - .entry(model.id) + .entry(canonical_model_id(&model.id)) .and_modify(|name| { if name.is_none() { *name = model.name.clone(); diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index e169af8886..4c0c835efe 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -53,6 +53,34 @@ fn sdk_ready_models_are_parsed_from_real_status_shape() { ); } +#[test] +fn dedupe_models_collapses_at_main_and_plain_forms() { + // Regression: a serving node advertises the SAME model under two strings — + // `org/model@main:Q4` (serveTargets[].modelId) and `org/model:Q4` + // (available_models). Keying on the raw id left BOTH in the picker (the + // "two model listing" bug). They must dedup to a single canonical entry. + let models = vec![ + super::MeshModelOption { + id: "unsloth/gemma-4-E4B-it-GGUF@main:Q4_K_M".to_string(), + name: None, + }, + super::MeshModelOption { + id: "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string(), + name: Some("Gemma 4".to_string()), + }, + ]; + + let deduped = super::dedupe_models(models); + assert_eq!( + deduped, + vec![super::MeshModelOption { + id: "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string(), + name: Some("Gemma 4".to_string()), + }], + "@main and plain forms of the same model must collapse to one entry" + ); +} + #[test] fn requested_model_is_not_ready_while_sdk_is_in_standby() { let payload = json!({