diff --git a/docs/models-and-providers.md b/docs/models-and-providers.md index 064a7388..73b49535 100644 --- a/docs/models-and-providers.md +++ b/docs/models-and-providers.md @@ -128,7 +128,7 @@ In practice you rarely need to weigh any of this yourself: Discover's **Staff pi ### mmproj (vision) -A vision model needs a second file, the **multimodal projector** (`mmproj`), that turns an image into something the model can read. Thuki downloads it alongside the main model and passes it to the engine with `--mmproj`. Models with this companion show a **Vision** badge. +A vision model needs a second file, the **multimodal projector** (`mmproj`), that turns an image into something the model can read. Thuki downloads it alongside the main model and passes it to the engine with `--mmproj`. Models with this companion show a **Vision** badge. Projector files and other non-chat helpers (draft / MTP / dspark) never appear as standalone chat downloads in Browse all: only primary text weights are listed, and a matching projector is attached automatically when you install a brain from the same repo. ### Capabilities @@ -147,7 +147,7 @@ For a **Staff pick** the answer is baked in: the curated catalog records each mo For **Browse all** there is no curated answer, so Thuki derives the badges live from what Hugging Face returns for each search result, the GGUF metadata block and the repo's file list, with no extra downloads: - **Text**: every row you see is already a chat model. Browse all only lists repos whose Hugging Face task tag is a chat-style one (`text-generation` or `image-text-to-text`); image generators, embedders, and the like are filtered out before they reach you. That filter is what makes the Text badge a safe constant. -- **Vision**: Thuki scans the repo's file list for a multimodal projector (a `mmproj*.gguf` file). If one is present the model can read images, so it earns the Vision badge. +- **Vision**: Thuki scans the repo's file list for a multimodal projector (names like `mmproj-….gguf` or `…-mmproj-….gguf`). If one is present the model can read images, so it earns the Vision badge. - **Reasoning**: this is read from the model's **chat template** (the embedded recipe that formats your messages), the only reliable signal for _how_ a model reasons. Thuki runs the template through a small classifier that recognizes the reasoning families: a structural reasoning channel (gpt-oss / Harmony), an `enable_thinking` / `thinking` switch (Qwen3, GLM, Granite), or always-on `` / `` tags with no off switch (DeepSeek-R1, QwQ, Phi-4-reasoning). Match any of those and the model gets the Reasoning badge. A repo's **name** is deliberately not trusted on its own. Plenty of models put "Thinking" or "Reasoning" in their title as marketing while shipping an ordinary chat template with no reasoning machinery at all, and badging those off the name would be a false promise. The name is consulted only as a last-resort fallback for the rare repo that ships no chat template for Thuki to read. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a114ae1a..70e4d44a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -258,6 +258,13 @@ pub fn builtin_target( .to_string(), }); }; + // Refuse projector/helper rows left over from older installs or bypasses. + if let Err(message) = crate::models::validate_primary_install(&model.file_name, None) { + return Err(EngineError { + kind: EngineErrorKind::ModelUnsupported, + message, + }); + } let model_path = if model.parts.is_empty() { store.blob_path(&model.sha256) } else { @@ -446,7 +453,20 @@ fn is_os_incompatible(lower_detail: &str) -> bool { /// runtime. Shared by `stream_builtin_chat` and `resolve_llm_transport`. pub fn engine_start_error(detail: &str) -> EngineError { let lower = detail.to_ascii_lowercase(); - if lower.contains("unknown model architecture") || lower.contains("unknown architecture") { + // Projector GGUF loaded as `-m` (CLIP): clear product copy, not a vague start fail. + if lower.contains("architecture: 'clip'") + || lower.contains("architecture: \"clip\"") + || lower.contains("architecture: clip") + { + EngineError { + kind: EngineErrorKind::ModelUnsupported, + message: "This file is a vision projector, not a chat model.\nDownload a text model GGUF; Thuki attaches the projector automatically when the repo includes one.".to_string(), + } + } else if lower.contains("unknown model architecture") + || lower.contains("unknown architecture") + || lower.contains("unsupported model architecture") + || lower.contains("unsupported architecture") + { EngineError { kind: EngineErrorKind::ModelUnsupported, message: "Unsupported model\nThuki's engine doesn't support this model's architecture yet. Try another model; support expands as the engine updates.".to_string(), @@ -3173,6 +3193,19 @@ mod tests { ) } + #[test] + fn engine_start_error_clip_architecture_is_projector_copy() { + let err = engine_start_error( + "0.00.050.233 E llama_model_load: error loading model: unsupported model architecture: 'clip'", + ); + assert_eq!(err.kind, EngineErrorKind::ModelUnsupported); + assert!( + err.message.contains("vision projector"), + "got: {}", + err.message + ); + } + #[test] fn engine_start_error_unknown_architecture_is_model_unsupported() { let err = engine_start_error( @@ -5298,6 +5331,25 @@ mod tests { assert!(err.message.contains("Settings")); } + /// A leftover projector install cannot be resolved as a chat target. + #[test] + fn builtin_target_rejects_projector_file_name() { + let conn = crate::database::open_in_memory().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let store = crate::models::storage::ModelStore::new(dir.path().to_path_buf()).unwrap(); + let mut row = installed_model("org/repo:Bonsai-27B-mmproj-Q8_0.gguf", "sha_p", None); + row.file_name = "Bonsai-27B-mmproj-Q8_0.gguf".to_string(); + crate::models::manifest::insert(&conn, &row).unwrap(); + let err = builtin_target(&conn, &store, "org/repo:Bonsai-27B-mmproj-Q8_0.gguf", 4096) + .unwrap_err(); + assert_eq!(err.kind, EngineErrorKind::ModelUnsupported); + assert!( + err.message.contains("vision projector"), + "got: {}", + err.message + ); + } + #[test] fn builtin_target_manifest_read_error_is_other() { // A bare connection without the schema makes `manifest::get` fail. diff --git a/src-tauri/src/models/gguf.rs b/src-tauri/src/models/gguf.rs index 174ed115..9c0926d5 100644 --- a/src-tauri/src/models/gguf.rs +++ b/src-tauri/src/models/gguf.rs @@ -32,7 +32,7 @@ const GGUF_TYPE_STRING: u32 = 8; /// GGUF value type tag for an array (`elem_type(u32) | count(u64) | elements`). const GGUF_TYPE_ARRAY: u32 = 9; -/// Metadata extracted from a GGUF header. Either field is `None` when the +/// Metadata extracted from a GGUF header. Each field is `None` when the /// model does not carry it (or the reader stopped before reaching it). #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct GgufMetadata { @@ -40,13 +40,16 @@ pub struct GgufMetadata { pub chat_template: Option, /// The model architecture (`general.architecture`, e.g. `qwen3`, `gpt-oss`). pub architecture: Option, + /// File role hint (`general.type`, e.g. `model`, `mmproj`, `adapter`). + pub general_type: Option, } -/// Reads `general.architecture` and `tokenizer.chat_template` from a GGUF -/// stream. Returns `None` only when the stream is not a GGUF the reader -/// understands (bad magic, unsupported version, or a header too short to carry -/// the counts); a stream that is a valid GGUF but is truncated or malformed -/// partway through returns `Some` with whatever was decoded before the fault. +/// Reads `general.architecture`, `general.type`, and `tokenizer.chat_template` +/// from a GGUF stream. Returns `None` only when the stream is not a GGUF the +/// reader understands (bad magic, unsupported version, or a header too short to +/// carry the counts); a stream that is a valid GGUF but is truncated or +/// malformed partway through returns `Some` with whatever was decoded before +/// the fault. /// /// Generic over [`Read`] + [`Seek`] so it is driven by an in-memory /// [`std::io::Cursor`] in tests and a [`std::io::BufReader`] over the blob @@ -96,12 +99,26 @@ pub fn read_gguf_metadata(r: &mut R) -> Option { Some(s) => meta.architecture = Some(s), None => break, } + } else if value_type == GGUF_TYPE_STRING && key == b"general.type" { + match read_string_value(r) { + Some(s) => meta.general_type = Some(s), + None => break, + } } else if skip_value(r, value_type).is_none() { break; } - // Both targets found: no reason to walk the rest of the header. - if meta.chat_template.is_some() && meta.architecture.is_some() { + // Role-critical fields plus template: stop walking once we have enough + // for both reasoning classification and primary-vs-projector gating. + if meta.chat_template.is_some() + && meta.architecture.is_some() + && meta.general_type.is_some() + { + break; + } + // Projectors often lack a chat template; architecture alone (clip) or + // architecture + type is enough to stop after a reasonable scan. + if meta.architecture.as_deref() == Some("clip") && meta.general_type.is_some() { break; } } @@ -356,6 +373,47 @@ mod tests { assert_eq!(meta.chat_template, None); } + #[test] + fn extracts_general_type() { + let bytes = build_gguf( + 3, + &[ + kv_string("general.architecture", b"clip"), + kv_string("general.type", b"mmproj"), + ], + ); + let meta = read(&bytes).unwrap(); + assert_eq!(meta.architecture.as_deref(), Some("clip")); + assert_eq!(meta.general_type.as_deref(), Some("mmproj")); + } + + #[test] + fn stops_after_template_arch_and_type_ignoring_trailing_malformed() { + let bad_nested = kv_array("trailing.bad", GGUF_TYPE_ARRAY, 1, &[]); + let bytes = build_gguf( + 3, + &[ + kv_string("general.architecture", b"qwen3"), + kv_string("general.type", b"model"), + kv_string("tokenizer.chat_template", b""), + bad_nested, + ], + ); + let meta = read(&bytes).unwrap(); + assert_eq!(meta.architecture.as_deref(), Some("qwen3")); + assert_eq!(meta.general_type.as_deref(), Some("model")); + assert_eq!(meta.chat_template.as_deref(), Some("")); + } + + #[test] + fn general_type_string_too_large_stops_scan() { + let mut kv = enc_string(b"general.type"); + kv.extend_from_slice(&GGUF_TYPE_STRING.to_le_bytes()); + kv.extend_from_slice(&(MAX_GGUF_STRING_BYTES + 1).to_le_bytes()); + let bytes = build_gguf(3, &[kv]); + assert_eq!(read(&bytes), Some(GgufMetadata::default())); + } + #[test] fn stops_after_both_found_ignoring_trailing_malformed_kv() { // A nested-array KV (unsupported) AFTER both targets must not matter: diff --git a/src-tauri/src/models/gguf_role.rs b/src-tauri/src/models/gguf_role.rs new file mode 100644 index 00000000..eeca1a41 --- /dev/null +++ b/src-tauri/src/models/gguf_role.rs @@ -0,0 +1,314 @@ +/*! + * GGUF artifact role classification for browse/install/load gates. + * + * Hugging Face multi-file repos ship chat weights next to vision projectors + * (mmproj / CLIP) and optional helpers (draft / MTP / dspark). Filename denylists + * rot: `mmproj*.gguf` misses `Bonsai-27B-mmproj-Q8_0.gguf`. This module is the + * single policy for "what may be a primary chat model" vs companion vs helper. + * + * Order of signals: GGUF metadata (architecture / general.type) first when + * present; filename only as a soft fallback for list-time (no local blob yet). + * Draft/MTP/dspark are never auto-wired to the engine; they are only excluded + * from primary chat download/Ready. + */ + +use crate::models::gguf::GgufMetadata; + +/// Role of a GGUF file relative to Thuki's chat load path (`llama-server -m`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GgufRole { + /// Chat / completion weights eligible for `-m` when the engine supports them. + Primary, + /// Vision projector (`--mmproj`); never a standalone chat install. + Projector, + /// Non-chat companion (draft, MTP, dspark, adapter, imatrix, etc.). + Helper, +} + +/// Classifies a GGUF using optional header metadata plus the file name. +/// +/// Metadata wins when it clearly identifies projector or adapter roles. +/// Otherwise the file name is inspected with the soft heuristics in +/// [`role_from_filename`]. Unknown / empty metadata with a normal quant name +/// defaults to [`GgufRole::Primary`] so list-time and legacy brains still work. +pub fn classify_gguf_role(file_name: &str, meta: Option<&GgufMetadata>) -> GgufRole { + if let Some(m) = meta { + if let Some(role) = role_from_metadata(m) { + return role; + } + } + role_from_filename(file_name) +} + +/// True when `file_name` may appear as a Browse-all / paste-repo chat download. +/// +/// List-time has no local header: uses filename soft classification only. +pub fn is_chat_download_candidate(file_name: &str) -> bool { + matches!(role_from_filename(file_name), GgufRole::Primary) +} + +/// True when `file_name` is a vision projector companion candidate at list/resolve +/// time (filename soft signals). Used to auto-attach alongside a brain install. +pub fn is_projector_companion_name(file_name: &str) -> bool { + matches!(role_from_filename(file_name), GgufRole::Projector) +} + +/// Rejects non-primary roles before a blob becomes Ready / primary load. +/// +/// Returns `Ok(())` for [`GgufRole::Primary`]. Errors carry user-facing copy that +/// names projector vs helper so the UI is not a vague engine-start failure. +pub fn validate_primary_weights_role( + file_name: &str, + meta: Option<&GgufMetadata>, +) -> Result<(), String> { + match classify_gguf_role(file_name, meta) { + GgufRole::Primary => Ok(()), + GgufRole::Projector => Err(primary_role_error(GgufRole::Projector, file_name)), + GgufRole::Helper => Err(primary_role_error(GgufRole::Helper, file_name)), + } +} + +/// User-facing message when a non-primary artifact is treated as a chat model. +pub fn primary_role_error(role: GgufRole, file_name: &str) -> String { + match role { + GgufRole::Primary => String::new(), + GgufRole::Projector => format!( + "\"{file_name}\" is a vision projector (CLIP/mmproj), not a chat model. \ + Download a text model GGUF from the same repo; Thuki attaches the projector automatically." + ), + GgufRole::Helper => format!( + "\"{file_name}\" is a helper file (draft/MTP/dspark/adapter), not a chat model. \ + Download a text model GGUF instead." + ), + } +} + +/// Maps GGUF header fields to a role when they are decisive. +/// +/// Returns `None` when metadata is silent so the caller can fall back to the +/// file name. Follows the llama.cpp / Ollama convention: `clip` and +/// `general.type` of `mmproj`/`projector` are projectors; `adapter` is a helper. +fn role_from_metadata(meta: &GgufMetadata) -> Option { + let arch = meta + .architecture + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()); + let gtype = meta + .general_type + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()); + + if let Some(ref t) = gtype { + if t == "mmproj" || t == "projector" { + return Some(GgufRole::Projector); + } + if t == "adapter" || t == "lora" { + return Some(GgufRole::Helper); + } + } + if let Some(ref a) = arch { + if a == "clip" { + return Some(GgufRole::Projector); + } + } + // Explicit model type is a positive primary signal when present. + if gtype.as_deref() == Some("model") { + return Some(GgufRole::Primary); + } + None +} + +/// Soft role from the leaf file name only (list-time / missing header). +/// +/// Projector: `mmproj` as a path segment or substring (`mmproj-f16.gguf`, +/// `Bonsai-27B-mmproj-Q8_0.gguf`). Helper: draft/MTP/dspark/imatrix/lora markers. +/// Basename is lowercased; directory prefixes are stripped for matching. +fn role_from_filename(file_name: &str) -> GgufRole { + let leaf = file_name + .rsplit(['/', '\\']) + .next() + .unwrap_or(file_name) + .to_ascii_lowercase(); + if !leaf.ends_with(".gguf") { + // Non-gguf never reaches browse rows; treat as non-primary if misused. + return GgufRole::Helper; + } + if leaf.starts_with("mmproj") || leaf.contains("mmproj") { + return GgufRole::Projector; + } + // Speculative-decode / quant tooling companions are not chat weights. + if leaf.contains("dspark") + || leaf.starts_with("mtp-") + || leaf.contains("-mtp-") + || leaf.contains("mtp.") + || leaf.starts_with("draft-") + || leaf.contains("-draft-") + || leaf.contains("imatrix") + || leaf.contains("lora") + || leaf.ends_with(".gguf_file") + { + return GgufRole::Helper; + } + GgufRole::Primary +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta(arch: Option<&str>, gtype: Option<&str>) -> GgufMetadata { + GgufMetadata { + chat_template: None, + architecture: arch.map(str::to_string), + general_type: gtype.map(str::to_string), + } + } + + #[test] + fn brain_quant_is_primary_by_name() { + assert_eq!( + classify_gguf_role("Bonsai-27B-Q1_0.gguf", None), + GgufRole::Primary + ); + assert_eq!( + classify_gguf_role("gemma-4-26B-A4B-it-UD-Q3_K_XL.gguf", None), + GgufRole::Primary + ); + assert!(is_chat_download_candidate("model-Q4_K_M.gguf")); + } + + #[test] + fn prefix_mmproj_is_projector() { + assert_eq!( + classify_gguf_role("mmproj-BF16.gguf", None), + GgufRole::Projector + ); + assert!(is_projector_companion_name("mmproj-model-f16.gguf")); + assert!(!is_chat_download_candidate("mmproj-BF16.gguf")); + } + + #[test] + fn mid_name_mmproj_is_projector() { + assert_eq!( + classify_gguf_role("Bonsai-27B-mmproj-Q8_0.gguf", None), + GgufRole::Projector + ); + assert_eq!( + classify_gguf_role("gemma-4-E4B-it-mmproj.gguf", None), + GgufRole::Projector + ); + assert!(is_projector_companion_name("Bonsai-27B-mmproj-BF16.gguf")); + assert!(!is_chat_download_candidate("Bonsai-27B-mmproj-Q8_0.gguf")); + } + + #[test] + fn helper_names_are_not_chat_downloads() { + for name in [ + "Bonsai-27B-dspark-Q4_1.gguf", + "mtp-gemma-4-26B-A4B-it.gguf", + "MTP/mtp-gemma-4-26B-A4B-it-Q8_0.gguf", + "draft-small.gguf", + "model-lora.gguf", + "imatrix_unsloth.gguf", + ] { + assert_eq!(classify_gguf_role(name, None), GgufRole::Helper, "{name}"); + assert!(!is_chat_download_candidate(name), "{name}"); + } + } + + #[test] + fn metadata_clip_overrides_brain_like_name() { + let m = meta(Some("clip"), None); + assert_eq!( + classify_gguf_role("looks-like-Q4_K_M.gguf", Some(&m)), + GgufRole::Projector + ); + } + + #[test] + fn metadata_mmproj_type_is_projector() { + let m = meta(Some("clip"), Some("mmproj")); + assert_eq!( + classify_gguf_role("weights.gguf", Some(&m)), + GgufRole::Projector + ); + let m2 = meta(None, Some("projector")); + assert_eq!( + classify_gguf_role("weights.gguf", Some(&m2)), + GgufRole::Projector + ); + } + + #[test] + fn metadata_adapter_is_helper() { + let m = meta(Some("llama"), Some("adapter")); + assert_eq!( + classify_gguf_role("adapter.gguf", Some(&m)), + GgufRole::Helper + ); + } + + #[test] + fn metadata_model_type_is_primary() { + let m = meta(Some("qwen3"), Some("model")); + assert_eq!( + classify_gguf_role("anything.gguf", Some(&m)), + GgufRole::Primary + ); + } + + #[test] + fn validate_primary_rejects_projector_and_helper() { + assert!(validate_primary_weights_role("brain-Q4_K_M.gguf", None).is_ok()); + let err = validate_primary_weights_role("Bonsai-27B-mmproj-Q8_0.gguf", None).unwrap_err(); + assert!(err.contains("vision projector"), "{err}"); + let err = validate_primary_weights_role("Bonsai-27B-dspark-Q4_1.gguf", None).unwrap_err(); + assert!(err.contains("helper file"), "{err}"); + let clip = meta(Some("clip"), None); + let err = validate_primary_weights_role("renamed.gguf", Some(&clip)).unwrap_err(); + assert!(err.contains("vision projector"), "{err}"); + } + + #[test] + fn primary_role_error_covers_all_arms() { + assert!(primary_role_error(GgufRole::Primary, "x.gguf").is_empty()); + assert!(primary_role_error(GgufRole::Projector, "p.gguf").contains("vision projector")); + assert!(primary_role_error(GgufRole::Helper, "h.gguf").contains("helper file")); + } + + #[test] + fn metadata_lora_type_is_helper_and_silent_arch_falls_back_to_name() { + let lora = meta(Some("llama"), Some("lora")); + assert_eq!( + classify_gguf_role("weights.gguf", Some(&lora)), + GgufRole::Helper + ); + // Architecture alone without a decisive type falls through to filename. + let quiet = meta(Some("qwen3"), None); + assert_eq!( + classify_gguf_role("Bonsai-27B-Q1_0.gguf", Some(&quiet)), + GgufRole::Primary + ); + assert_eq!( + classify_gguf_role("Bonsai-27B-mmproj-Q8_0.gguf", Some(&quiet)), + GgufRole::Projector + ); + } + + #[test] + fn whitespace_only_metadata_is_ignored() { + let m = GgufMetadata { + chat_template: None, + architecture: Some(" ".into()), + general_type: Some("\t".into()), + }; + assert_eq!( + classify_gguf_role("brain-Q4_K_M.gguf", Some(&m)), + GgufRole::Primary + ); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index d38f9d8c..95d1eeab 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -17,6 +17,7 @@ pub mod download; pub mod gguf; +pub mod gguf_role; pub mod manifest; pub mod memory; pub mod reasoning; @@ -1707,20 +1708,25 @@ pub struct MmprojCompanion { pub size_bytes: u64, } -/// True when `name` is an `mmproj*.gguf` vision projection companion. The -/// presence of one is Thuki's ground-truth vision signal: llama.cpp cannot do -/// image input without it, regardless of how the base model is tagged. +/// True when `name` is a vision projector companion candidate (list/resolve). +/// +/// Delegates to [`gguf_role::is_projector_companion_name`]: matches `mmproj*.gguf` +/// and mid-name forms like `Bonsai-27B-mmproj-Q8_0.gguf`. Presence of such a +/// sibling is Thuki's ground-truth vision signal for browse installs. fn is_mmproj(name: &str) -> bool { - name.starts_with("mmproj") && name.ends_with(".gguf") + gguf_role::is_projector_companion_name(name) } /// Pure parse of an HF repo listing into the spec for one target `file`. -/// Capability rule for pasted repos: vision = an `mmproj*.gguf` sibling with -/// complete LFS metadata exists. The reasoning class is recorded in two stages: -/// [`repo_installed_model`] seeds `thinking` from the model name via -/// [`detect_thinking`], then `finalize_install` refines `thinking` and sets -/// `reasoning_always` from the downloaded GGUF's chat template (falling back to -/// the name guess when the template cannot be read). +/// Capability rule for pasted repos: vision = a projector companion sibling +/// with complete LFS metadata exists. The target file must be a chat-weight +/// candidate ([`gguf_role::is_chat_download_candidate`]); projectors and +/// helpers are rejected here so they never enter the download pipeline. +/// The reasoning class is recorded in two stages: [`repo_installed_model`] +/// seeds `thinking` from the model name via [`detect_thinking`], then +/// `finalize_install` refines `thinking` and sets `reasoning_always` from the +/// downloaded GGUF's chat template (falling back to the name guess when the +/// template cannot be read). pub fn resolve_listing(body: &[u8], file: &str) -> Result { let info: HfRepoInfo = serde_json::from_slice(body) .map_err(|e| format!("failed to decode Hugging Face API response: {e}"))?; @@ -1732,6 +1738,8 @@ pub fn resolve_listing(body: &[u8], file: &str) -> Result { return Err("Hugging Face API response carries no valid commit sha".to_string()); } + // Boundary: never start a primary install for projector/helper GGUFs. + gguf_role::validate_primary_weights_role(file, None)?; let target = info .siblings .iter() @@ -1776,15 +1784,18 @@ fn resolve_split_parts(siblings: &[HfSibling], file: &str) -> Vec { } /// Projects an HF sibling listing onto the grouped `.gguf` browser rows: keeps -/// LFS or plain-sized `.gguf` files, drops `mmproj*` companions, then collapses -/// split shards into one entry each via [`group_split_files`]. The single -/// sibling-to-row derivation, shared by [`parse_gguf_listing`] and +/// LFS or plain-sized `.gguf` files that are chat-weight candidates (drops +/// projectors and helpers via [`gguf_role::is_chat_download_candidate`]), then +/// collapses split shards into one entry each via [`group_split_files`]. The +/// single sibling-to-row derivation, shared by [`parse_gguf_listing`] and /// [`resolve_split_parts`] so the browser listing and the resolve path can never /// disagree about what is a split set. fn gguf_files_from_siblings(siblings: &[HfSibling]) -> Vec { let files = siblings .iter() - .filter(|s| s.rfilename.ends_with(".gguf") && !is_mmproj(&s.rfilename)) + .filter(|s| { + s.rfilename.ends_with(".gguf") && gguf_role::is_chat_download_candidate(&s.rfilename) + }) .map(|s| { let size_bytes = s.lfs.as_ref().and_then(|l| l.size).or(s.size).unwrap_or(0); let sha256 = s @@ -1903,9 +1914,10 @@ fn finish_split_group(mut parts: Vec<(u32, HfGgufPart)>, total: u32) -> Option Result, String> { let info: HfRepoInfo = serde_json::from_slice(body) .map_err(|e| format!("failed to decode Hugging Face API response: {e}"))?; @@ -2661,6 +2673,19 @@ fn resolve_reasoning_flags( reasoning_flags_from_metadata(template, architecture, repo, file_name) } +/// Ensures a completed download may become a Ready primary chat model. +/// +/// Uses on-disk GGUF metadata when the weights blob is readable; falls back to +/// the file name alone when the header cannot be read (same soft signals as +/// list-time). Pure relative to I/O: the blob read is injected via `meta` so +/// unit tests drive the shipped gate without a filesystem. +pub fn validate_primary_install( + file_name: &str, + meta: Option<&gguf::GgufMetadata>, +) -> Result<(), String> { + gguf_role::validate_primary_weights_role(file_name, meta) +} + /// Re-classifies installed built-in rows whose `reasoning_always` is `NULL` /// (rows written before the classifier existed) and persists the result so they /// stop appearing in [`manifest::list_unclassified`]. Best-effort: any list, @@ -3223,6 +3248,10 @@ fn finalize_install( model: &manifest::InstalledModel, ) -> Result<(), String> { let store = app.state::(); + // Never mark a projector/helper as Ready: re-check role against the blob + // header (and file name) before the manifest insert. + let meta = gguf::read_gguf_metadata_from_file(&store.blob_path(&model.sha256)); + validate_primary_install(&model.file_name, meta.as_ref())?; // Classify reasoning from the just-downloaded GGUF's chat template so the // picker badge and `/think` gate are correct the instant the install lands. // Curated starters keep their registry flags; a template that cannot be read @@ -5670,6 +5699,35 @@ mod tests { ); } + /// Bonsai-style multi-file repo: brains stay downloadable; mid-name mmproj, + /// dspark, and mtp helpers are not chat-download candidates. + #[test] + fn parse_gguf_listing_hides_mid_name_projector_and_helpers() { + let body = serde_json::json!({ + "sha": "c".repeat(40), + "siblings": [ + {"rfilename": "Bonsai-27B-Q1_0.gguf", + "lfs": {"sha256": "a".repeat(64), "size": 3_900_000_000u64}}, + {"rfilename": "Bonsai-27B-F16.gguf", + "lfs": {"sha256": "b".repeat(64), "size": 53_800_000_000u64}}, + {"rfilename": "Bonsai-27B-mmproj-Q8_0.gguf", + "lfs": {"sha256": "c".repeat(64), "size": 600_000_000u64}}, + {"rfilename": "Bonsai-27B-dspark-Q4_1.gguf", + "lfs": {"sha256": "d".repeat(64), "size": 1_800_000_000u64}}, + {"rfilename": "mtp-Bonsai-27B.gguf", + "lfs": {"sha256": "e".repeat(64), "size": 500_000_000u64}}, + ] + }) + .to_string(); + let files = parse_gguf_listing(body.as_bytes()).unwrap(); + let names: Vec<&str> = files.iter().map(|f| f.file.as_str()).collect(); + assert_eq!( + names, + vec!["Bonsai-27B-Q1_0.gguf", "Bonsai-27B-F16.gguf"], + "only chat brains should list: {names:?}" + ); + } + #[test] fn sanitize_context_length_trusts_only_sane_values() { assert_eq!(sanitize_context_length(None), None); @@ -5792,6 +5850,58 @@ mod tests { assert_eq!(mm.size_bytes, 200); } + /// Mid-name projector siblings (e.g. Bonsai) attach as companions when the + /// user installs a brain, not as primary weights. + #[test] + fn resolve_listing_attaches_mid_name_mmproj_companion() { + let body = serde_json::json!({ + "sha": "c".repeat(40), + "siblings": [ + {"rfilename": "Bonsai-27B-Q1_0.gguf", + "lfs": {"sha256": "a".repeat(64), "size": 1000}}, + {"rfilename": "Bonsai-27B-mmproj-Q8_0.gguf", + "lfs": {"sha256": "b".repeat(64), "size": 200}}, + ] + }) + .to_string(); + let r = resolve_listing(body.as_bytes(), "Bonsai-27B-Q1_0.gguf").unwrap(); + let mm = r.mmproj.expect("mid-name projector must attach"); + assert_eq!(mm.file, "Bonsai-27B-mmproj-Q8_0.gguf"); + assert_eq!(mm.sha256, "b".repeat(64)); + } + + #[test] + fn resolve_listing_rejects_projector_and_helper_as_primary() { + let body = serde_json::json!({ + "sha": "c".repeat(40), + "siblings": [ + {"rfilename": "Bonsai-27B-Q1_0.gguf", + "lfs": {"sha256": "a".repeat(64), "size": 1000}}, + {"rfilename": "Bonsai-27B-mmproj-Q8_0.gguf", + "lfs": {"sha256": "b".repeat(64), "size": 200}}, + {"rfilename": "Bonsai-27B-dspark-Q4_1.gguf", + "lfs": {"sha256": "d".repeat(64), "size": 300}}, + ] + }) + .to_string(); + let err = resolve_listing(body.as_bytes(), "Bonsai-27B-mmproj-Q8_0.gguf").unwrap_err(); + assert!(err.contains("vision projector"), "got: {err}"); + let err = resolve_listing(body.as_bytes(), "Bonsai-27B-dspark-Q4_1.gguf").unwrap_err(); + assert!(err.contains("helper file"), "got: {err}"); + } + + #[test] + fn validate_primary_install_rejects_clip_metadata() { + let meta = gguf::GgufMetadata { + chat_template: None, + architecture: Some("clip".into()), + general_type: Some("mmproj".into()), + }; + let err = validate_primary_install("renamed-Q4.gguf", Some(&meta)).unwrap_err(); + assert!(err.contains("vision projector"), "got: {err}"); + assert!(validate_primary_install("brain-Q4_K_M.gguf", None).is_ok()); + } + #[test] fn resolve_listing_rejects_invalid_json() { let err = resolve_listing(b"not json", "f.gguf").unwrap_err();