Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ The table below also lists the baked-in safety limits that govern Thuki's commun
| `MAX_GGUF_KV_COUNT` | `4096` | No | Defense-in-depth bound on a downloaded GGUF's metadata-key count. A corrupt or hostile `metadata_kv_count` could otherwise drive an unbounded scan; real models carry a few dozen entries, so 4096 never truncates legitimate metadata. | — | The most GGUF metadata key-value pairs the reasoning classifier scans when reading a downloaded model's chat template. Scanning stops at the cap. |
| `MAX_GGUF_KEY_BYTES` | `1 KiB` | No | Defense-in-depth bound on a downloaded GGUF's metadata-key length. Keys are short dotted identifiers (`tokenizer.chat_template`); capping the length stops a corrupt length field from forcing a large allocation. | — | The longest GGUF metadata key the reasoning classifier will read. A longer key stops the scan. |
| `MAX_GGUF_STRING_BYTES` | `4 MiB` | No | Defense-in-depth bound on a downloaded GGUF's string values. Real chat templates run a few KB to ~100 KB; 4 MiB never truncates one while bounding the memory a corrupt length field can demand. | — | The largest GGUF string value (the chat template or architecture) the reasoning classifier will materialize. A larger value stops the scan and the model relies on the runtime backstop instead. |
| `DENIED_PRIMARY_ARCHES` | `bert, t5, clip, …` | No | Defense-in-depth bound on attacker-influenced GGUF headers, and pin-scoped: a GGUF's `general.architecture` is editable, so a known non-chat family is refused as a primary chat model rather than handed to the engine, and an engine bump may add families that should stay out of the primary load path. Missing architecture stays soft (filename path) so an incomplete header never bricks an install. | — | Architectures that may never load as a primary chat model. A downloaded or pasted GGUF whose header reports one of these non-chat families (embedding, encoder-only, audio tokenizer) is rejected at finalize/load with a clear "not a chat model" message; a chat arch not on the list still passes. |
| `HF_API_TIMEOUT_SECS` | `15 s` | No | Protocol cap on a hung remote service so the download UI cannot stall on metadata resolution; 15 s is generous for a small metadata call over the internet. | — | How long Thuki waits for a Hugging Face API metadata call (repo file listing) to respond before giving up. Applies to resolving pasted repo ids and listing a repo's GGUF files, not to the model download itself. |
| `HF_BASE_URL` | `https://huggingface.co` | No | Single origin for model metadata and downloads. Provenance comes from the pinned repo revisions in the curated starter registry, and those pins are only meaningful against the canonical Hub; an arbitrary mirror could serve different content under the same revision ids. | — | The Hugging Face origin Thuki uses for all model metadata calls and blob downloads. Every starter in the registry pins a repo at an exact revision and carries a compiled-in sha256 digest checked after download; the digest catches truncation, bit rot, and resume corruption, while the pinned revision on the canonical Hub is what fixes which content is fetched. |
| `HF_SEARCH_LIMIT` | `30` | No | The per-page step for the in-app model browser. The "Load more" control raises the requested page size in multiples of this value, so it is a layout step rather than a user preference. | — | How many GGUF model repos the first page of an in-app Hugging Face search returns, most-downloaded first. |
Expand Down
6 changes: 4 additions & 2 deletions docs/models-and-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ 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. 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.
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 / LoRA / embedders) never appear as standalone chat downloads in Browse all: only primary text weights with complete LFS metadata are listed, and a matching projector is attached automatically when you install a brain from the same repo. When a repo ships several projectors, Thuki ranks them like llama.cpp (shared path with the brain, then closest quant bit-width).

At install and load time Thuki also re-reads the GGUF header: CLIP/mmproj roles, embedding architectures (bert, t5, …), and other non-chat helpers are rejected as primary chat models so they never become Ready. Missing architecture stays soft (filename heuristics). The denylist is pin-scoped: an engine bump may need a list update when new non-chat families appear.

### Capabilities

Expand Down Expand Up @@ -164,7 +166,7 @@ First it estimates the model's **resident size** at runtime:
estimate = model file size + ~2 GB overhead
```

The ~2 GB is a baseline for the engine's runtime buffers and KV cache. (The KV cache also grows with your context window, covered separately in the context-window guide.) For a vision model, a curated Staff pick folds its projector (`mmproj`) into the size; a Browse-all or installed-Library row counts only the single GGUF file you see on the row.
The ~2 GB is a baseline for the engine's runtime buffers and KV cache. (The KV cache also grows with your context window, covered separately in the context-window guide.) For a vision model, Thuki folds the projector (`mmproj`) into the size when known: Staff picks use the curated projector size, and installed Library / load-gate paths use the on-disk projector blob (or the curated size when the blob length is not yet available).

Then it compares that estimate against your Mac's **total physical memory**, read straight from the system (`hw.memsize`). Apple Silicon uses unified memory shared between the CPU and GPU, so a model competes for RAM with everything else you are running:

Expand Down
94 changes: 87 additions & 7 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,15 +258,18 @@ 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) {
// Refuse projector/helper/denied-arch rows: re-read the local blob header
// so a renamed companion or embedding GGUF cannot load as chat primary.
let weights_blob = store.blob_path(&model.sha256);
let meta = crate::models::gguf::read_gguf_metadata_from_file(&weights_blob);
if let Err(message) = crate::models::validate_primary_install(&model.file_name, meta.as_ref()) {
return Err(EngineError {
kind: EngineErrorKind::ModelUnsupported,
message,
});
}
let model_path = if model.parts.is_empty() {
store.blob_path(&model.sha256)
weights_blob
} else {
store
.materialize_split_shim(&model.parts)
Expand All @@ -292,7 +295,7 @@ pub fn builtin_target(
/// pure [`crate::models::memory::decide_load_gate`] needs and delegates every
/// decision to it (the single source of the block decision, shared with the
/// frontend fit affordance so the two can never drift, issue #296):
/// - the target's weights bytes from the manifest,
/// - the target's weights bytes from the manifest (plus mmproj when known),
/// - live available memory from the mach VM statistics,
/// - the currently-resident model's path (so a same-model reload is a no-op and
/// a different resident model's footprint is credited back, since the engine
Expand All @@ -317,9 +320,14 @@ pub(crate) fn preflight_memory_gate(
// Read the live engine status once: it feeds both the already-loading bypass
// and the resident-credit path, both applied inside `decide_load_gate`.
let status = engine.current_status();
// Cannot size the target -> do not block on a database hiccup.
// Cannot size the target -> do not block on a database hiccup. Fold mmproj
// blob length (or registry hint) when present so vision loads estimate true
// footprint.
let target_weights = match crate::models::manifest::get(conn, model_id) {
Ok(Some(row)) => memory::model_weights_bytes(&row),
Ok(Some(row)) => {
let mm = crate::models::memory::mmproj_bytes_for_model(store, &row);
memory::model_load_bytes(&row, mm)
}
_ => return memory::MemoryGate::Proceed,
};
// Map every installed row to (weights_bytes, weights blob path) so a
Expand All @@ -328,8 +336,9 @@ pub(crate) fn preflight_memory_gate(
.unwrap_or_default()
.into_iter()
.map(|row| {
let mm = crate::models::memory::mmproj_bytes_for_model(store, &row);
(
memory::model_weights_bytes(&row),
memory::model_load_bytes(&row, mm),
store.blob_path(&row.sha256),
)
})
Expand Down Expand Up @@ -462,6 +471,12 @@ pub fn engine_start_error(detail: &str) -> 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 is_mmproj_mismatch(&lower) {
// Wrong projector paired with text weights (n_embd / dim mismatch).
EngineError {
kind: EngineErrorKind::EngineStartFailed,
message: "Vision projector mismatch\nThis model's projector does not match the text weights. Re-download the model so Thuki can pick a matching mmproj, or choose another vision model.".to_string(),
}
} else if lower.contains("unknown model architecture")
|| lower.contains("unknown architecture")
|| lower.contains("unsupported model architecture")
Expand All @@ -484,6 +499,29 @@ pub fn engine_start_error(detail: &str) -> EngineError {
}
}

/// True when llama.cpp stderr indicates a vision projector mismatch.
///
/// Requires the failure to actually name a projector: `mmproj`, `projector`, or
/// `clip` appear in the engine's stderr only when a vision projector (`--mmproj`)
/// is being loaded. Gating on that keeps a text-only load's metadata dump (which
/// always prints an `n_embd = N` line) or a dyld / OS start failure from being
/// mislabeled a projector mismatch and shadowing the unknown-architecture and OS
/// arms below. Within a projector load, only an explicit incompatibility (never
/// the bare `n_embd` dump line, never a lone `wrong`) marks a genuine mismatch;
/// any other projector failure falls through to the raw engine reason.
fn is_mmproj_mismatch(lower: &str) -> bool {
let mentions_projector =
lower.contains("mmproj") || lower.contains("projector") || lower.contains("clip");
if !mentions_projector {
return false;
}
lower.contains("failed to load mmproj")
|| lower.contains("mismatch")
|| lower.contains("does not match")
|| lower.contains("incompatible")
|| lower.contains("not compatible")
}

/// Runs the built-in-engine stage of a chat turn: mark activity, ensure the
/// engine serves `target`, then stream via the `/v1` client at the engine's
/// port. An engine activity guard is held for the whole turn (ensure,
Expand Down Expand Up @@ -3206,6 +3244,48 @@ mod tests {
);
}

#[test]
fn engine_start_error_projector_mismatch_positive() {
// Each projector-token arm (mmproj / projector / clip) paired with each
// incompatibility verb maps to the fixable projector-mismatch copy.
for detail in [
"failed to load mmproj: bad projector file",
"clip: n_embd (2048) does not match model n_embd (4096)",
"projector tensors are incompatible with the model",
"mmproj embedding mismatch detected",
"vision projector is not compatible with these weights",
] {
let err = engine_start_error(detail);
assert_eq!(err.kind, EngineErrorKind::EngineStartFailed, "{detail}");
assert!(
err.message.contains("Vision projector mismatch"),
"detail={detail} got={}",
err.message
);
}
}

#[test]
fn engine_start_error_projector_mismatch_needs_projector_and_verb() {
// A text-only failure never names a projector, so its ubiquitous
// `n_embd`/`mismatch` metadata dump must not be mislabeled a projector
// problem; and a projector load that fails for another reason (out of
// memory) or with only the dropped bare `wrong` token also falls through
// to the raw engine reason rather than the projector copy.
for detail in [
"n_embd values mismatch between tensors",
"clip_model_load: loaded; ggml backend: out of memory",
"clip loaded; wrong number of tensors",
] {
let err = engine_start_error(detail);
assert!(
!err.message.contains("Vision projector mismatch"),
"detail={detail} got={}",
err.message
);
}
}

#[test]
fn engine_start_error_unknown_architecture_is_model_unsupported() {
let err = engine_start_error(
Expand Down
28 changes: 28 additions & 0 deletions src-tauri/src/config/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,34 @@ pub const MAX_GGUF_KEY_BYTES: u64 = 1024;
/// corrupt or hostile length field can demand.
pub const MAX_GGUF_STRING_BYTES: u64 = 4 * 1024 * 1024;

/// GGUF architectures that must never load as a primary chat model under the
/// pinned engine (consumed by `models::gguf_role`).
///
/// Pragmatic denylist of known non-chat families (embedding, encoder-only,
/// audio tokenizers). Chat arches (llama*, gemma*, qwen*, phi*, mistral*,
/// deepseek*, gpt-oss, ...) are intentionally absent so they pass. When a
/// GGUF's architecture is missing, validation skips this list and keeps the
/// soft filename path, so an incomplete header never bricks an install.
///
/// Baked-in: a defense-in-depth bound on attacker-influenced GGUF headers, and
/// pin-scoped (an engine bump may add families that should stay out of the
/// primary chat load path). Lives here so the one place the repo documents all
/// baked-in constants stays authoritative.
pub const DENIED_PRIMARY_ARCHES: &[&str] = &[
"bert",
"nomic-bert",
"jina-bert-v2",
"jina-bert-v3",
"jina-bert",
"t5",
"t5encoder",
"clip",
"wavtokenizer-dec",
"wavtokenizer",
"roberta",
"jina-code-embeddings",
];

/// Authoritative allowlist of `(section, key)` pairs the Settings GUI is
/// permitted to write via the `set_config_field` Tauri command.
///
Expand Down
Loading