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!({