Five-slice navigable map of the entire codebase under
docs/architecture-map/. Each slice is a self-contained
breadcrumbed sub-tree:
01-frontend (16) Svelte/SvelteKit UI
02-tauri-runtime (26) src-tauri commands + lifecycle
03-audio-transcription (16) audio + transcription crates
04-llm-formatting-mcp (19) llm, ai-formatting, mcp, cloud
05-core-storage-hotkey-build core, storage, hotkey, workspace,
(26) CI, dev glue
Plus master README.md and data-flow-end-to-end.md tracing
audio bytes from microphone to FTS5 search to MCP read.
Generated by 5 parallel subagents on 2026/05/09 against
HEAD 3c47000. Each page has YAML frontmatter, file:line code
refs, sibling cross-links, plain-English summaries.
Aggregated debt surfaced (full lists in master README):
RB-08 macOS power assertion, schema head drift v14 vs v15,
VAD blocked on ort version conflict, streaming primitives
not wired into live.rs, no prompt versioning, MCP has no
auth, cloud-providers in-memory keystore, SettingsPage
2 484 LOC, commands/live.rs 1 737 LOC, dual theme system,
brand rename to Lumenote pending across the codebase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
11 KiB
Markdown
153 lines
11 KiB
Markdown
---
|
|
name: LLM model manager
|
|
type: architecture-map-page
|
|
slice: 04-llm-formatting-mcp
|
|
last_verified: 2026/05/09
|
|
---
|
|
|
|
# LLM model manager
|
|
|
|
> **Where you are:** [Architecture map](../README.md) → [LLM, Formatting, MCP](README.md) → Model manager
|
|
|
|
**Plain English summary.** The four-tier Qwen registry, on-disk paths, resumable HTTP download, SHA-256 verification, hardware-tier recommendation, and on-disk delete. Single source of truth for every model fact: file name, size, expected SHA, Hugging Face URL, RAM and VRAM minimums, and human-friendly description.
|
|
|
|
## At a glance
|
|
|
|
- Crate: `magnotia-llm`
|
|
- Path: `crates/llm/src/model_manager.rs`
|
|
- LOC: 486
|
|
- Public surface:
|
|
- `pub enum LlmModelId { Qwen3_5_2B_Q4, Qwen3_5_4B_Q4, Qwen3_5_9B_Q4, Qwen3_6_27B_Q4 }` (`crates/llm/src/model_manager.rs:14`)
|
|
- `LlmModelId::default_tier`, `as_str`, `display_name`, `file_name`, `size_bytes`, `minimum_ram_bytes`, `recommended_vram_bytes`, `description`, `hf_url`, `sha256` (one per tier)
|
|
- `impl Display for LlmModelId`, `impl FromStr for LlmModelId`
|
|
- `pub struct LlmModelInfo` (`:152`) — serde-camelCase serialised summary for the frontend
|
|
- `pub enum DownloadError` (`:164`)
|
|
- `pub fn all_models() -> &'static [LlmModelId]` (`:213`)
|
|
- `pub fn model_info(id: LlmModelId) -> LlmModelInfo` (`:217`)
|
|
- `pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option<u64>) -> LlmModelId` (`:229`)
|
|
- `pub fn model_dir() -> PathBuf` (`:242`)
|
|
- `pub fn model_path(id: LlmModelId) -> PathBuf` (`:246`)
|
|
- `pub fn partial_download_path(id: LlmModelId) -> PathBuf` (`:250`)
|
|
- `pub fn is_downloaded(id: LlmModelId) -> bool` (`:254`)
|
|
- `pub fn delete_model(id: LlmModelId) -> io::Result<()>` (`:258`)
|
|
- `pub async fn download_model<F>(id: LlmModelId, on_progress: F) -> Result<(), DownloadError>` where `F: FnMut(u64, u64) + Send + 'static` (`:272`)
|
|
- External deps that matter: `reqwest` with `rustls-tls` and `stream` features (no native TLS, no compress), `sha2`, `tokio` for async file IO, `futures-util::StreamExt`, `magnotia-core` for `paths::app_paths()`.
|
|
- Tauri command that calls this (slice 2, best guess): `commands::models::download_model` (`src-tauri/src/commands/models.rs:516`), wired via `src-tauri/src/lib.rs:325`. Plus `commands::llm::*` calls `model_manager::recommend_tier` at `src-tauri/src/commands/llm.rs:35` and `model_manager::download_model` at `src-tauri/src/commands/llm.rs:70`.
|
|
|
|
## What's in here
|
|
|
|
### The four tiers
|
|
|
|
Each entry in `LlmModelId` carries:
|
|
|
|
| Tier | File name | Size | Min RAM | Rec VRAM | Description |
|
|
|---|---|---|---|---|---|
|
|
| `Qwen3_5_2B_Q4` | `Qwen3.5-2B-Q4_K_M.gguf` | ~1.28 GB | 8 GiB | n/a (CPU) | Minimal tier for 8 GB RAM and CPU-heavy machines. |
|
|
| `Qwen3_5_4B_Q4` | `Qwen3.5-4B-Q4_K_M.gguf` | ~2.74 GB | 16 GiB | 6 GiB | Standard tier for cleanup and task extraction on 16 GB systems. (default tier) |
|
|
| `Qwen3_5_9B_Q4` | `Qwen3.5-9B-Q4_K_M.gguf` | ~5.68 GB | 32 GiB | 12 GiB | High tier for 32 GB RAM with a 12 GB+ GPU. |
|
|
| `Qwen3_6_27B_Q4` | `Qwen3.6-27B-Q4_K_M.gguf` | ~16.82 GB | 64 GiB | 24 GiB | Maximum tier for 64 GB RAM with a 24 GB GPU; partial CPU offload below that. |
|
|
|
|
All Q4_K_M GGUF, all from `unsloth/Qwen3.5-*-GGUF` and `unsloth/Qwen3.6-*-GGUF` HF repos. URLs pin a specific revision hash so re-uploads do not silently change the file behind us. SHA-256 values pin the exact bytes.
|
|
|
|
### `default_tier` (`crates/llm/src/model_manager.rs:26`)
|
|
|
|
Returns `Qwen3_5_4B_Q4`. Used by `LlmEngine::load(&Path)` when no tier is specified.
|
|
|
|
### `recommend_tier(total_ram_bytes, total_vram_bytes)` (`crates/llm/src/model_manager.rs:229`)
|
|
|
|
Tier selection logic, ordered most-capable first:
|
|
|
|
1. `vram >= 24 GiB && ram >= 64 GiB` → `Qwen3_6_27B_Q4`
|
|
2. `vram >= 12 GiB && ram >= 32 GiB` → `Qwen3_5_9B_Q4`
|
|
3. `vram >= 6 GiB || ram >= 16 GiB` → `Qwen3_5_4B_Q4`
|
|
4. otherwise → `Qwen3_5_2B_Q4`
|
|
|
|
`vram` defaults to 0 when the option is `None`, so a CPU-only machine takes the OR branch on the third rule. Test at `crates/llm/src/model_manager.rs:418` asserts a 16 GiB RAM machine with no GPU gets the 4B tier.
|
|
|
|
### Path helpers (`crates/llm/src/model_manager.rs:242-256`)
|
|
|
|
- `model_dir()` → `magnotia_core::paths::app_paths().llm_models_dir()`. The on-disk root, owned by `magnotia-core` (slice 5).
|
|
- `model_path(id)` → `model_dir().join(id.file_name())`. The final destination once a download completes.
|
|
- `partial_download_path(id)` → `model_path(id).with_extension("gguf.part")`. Where in-flight downloads accumulate.
|
|
- `is_downloaded(id)` → `model_path(id).exists()`. Cheap check; does not validate SHA.
|
|
- `delete_model(id)` → removes both `model_path` and `partial_download_path` (each only if present). Sync — runs on a regular thread.
|
|
|
|
### `download_model` and `download_impl` (`crates/llm/src/model_manager.rs:272`, `:307`)
|
|
|
|
The flagship entry point. Steps:
|
|
|
|
1. **Acquire a `DownloadReservation`.** A process-global `LazyLock<Mutex<HashSet<LlmModelId>>>` (`:183`) holds the set of in-flight downloads. The reservation's `Drop` releases the slot. A second concurrent download for the same tier returns `DownloadError::Http("download already in progress for {tier}")`. Different tiers can download in parallel.
|
|
2. **`tokio::fs::create_dir_all(model_dir())`.** Idempotent. Surfaces IO errors.
|
|
3. **If `dest` already exists, verify SHA.** A re-download of an already-correct file short-circuits to `Ok(())`. A SHA mismatch deletes the file and falls through to a fresh download. This is what makes `download_model` safe to call from a frontend that does not know whether the file is present.
|
|
4. **Call `download_impl(url, expected_sha, dest, on_progress)`.**
|
|
|
|
`download_impl` internals:
|
|
|
|
- **Resume detection.** `resume_from = tokio::fs::metadata(&tmp).await.ok().map(|m| m.len()).unwrap_or(0)`. If a `.gguf.part` file exists, we resume from its length.
|
|
- **`reqwest` client** with a 30-second connect timeout, `magnotia/0.1.0` user-agent, no aggressive compression (`stream` feature).
|
|
- **`Range: bytes={resume_from}-` header** when `resume_from > 0`. If the server responds with anything other than 206 PARTIAL_CONTENT to a ranged request, we return `DownloadError::ResumeUnsupported` rather than starting over silently.
|
|
- **Total-size resolution.** For a 200 OK response, `Content-Length` is the total. For a 206, parse `Content-Range: bytes start-end/total` to recover the underlying size; fall back to `content_length() + resume_from` if the header is missing.
|
|
- **Hasher pre-feed.** When resuming, the existing `.gguf.part` content is read once and fed into the SHA hasher so the final hash covers the entire file, not just the new chunks.
|
|
- **Append-mode write.** The `.gguf.part` is opened with `create + append` so a resumed write naturally lands at the end.
|
|
- **Progress callback.** `on_progress(downloaded, total)` is called once per chunk. The user-supplied closure typically forwards to a Tauri event for the frontend progress bar.
|
|
- **SHA verification.** After the stream ends, `format!("{:x}", hasher.finalize())` is compared against the expected hex string. A mismatch deletes the partial file and returns `DownloadError::ShaMismatch { expected, actual }`. There is no retry — the caller decides whether to re-attempt.
|
|
- **Atomic rename.** `tokio::fs::rename(&tmp, dest)` is the final step. Only after SHA passes does the file appear at its real path.
|
|
|
|
### `DownloadError` (`crates/llm/src/model_manager.rs:164`)
|
|
|
|
Variants:
|
|
|
|
- `Http(String)` — anything from "DNS failed" to "server replied 500".
|
|
- `Io(io::Error)` — `#[from]` so `tokio::fs` errors lift cleanly.
|
|
- `ShaMismatch { expected: String, actual: String }` — caller-actionable.
|
|
- `ResumeUnsupported` — the server does not support range requests; rare but possible if HF or a CDN changes behaviour.
|
|
|
|
### `LlmModelInfo` (`crates/llm/src/model_manager.rs:152`)
|
|
|
|
Serde camelCase struct mirroring the per-tier metadata for frontend consumption. Every field is `&'static str` or `u64` so cloning is cheap. `model_info(id)` builds one on demand.
|
|
|
|
## Data flow
|
|
|
|
Download path:
|
|
|
|
```
|
|
Tauri command (commands::models::download_model)
|
|
→ magnotia_llm::model_manager::download_model(id, on_progress)
|
|
→ DownloadReservation::acquire(id) (Http error if duplicate)
|
|
→ tokio::fs::create_dir_all(model_dir())
|
|
→ if dest.exists(): sha256_file(dest); short-circuit if match, else delete
|
|
→ download_impl(hf_url, expected_sha, dest, on_progress)
|
|
→ resume_from = part-file size (if exists)
|
|
→ reqwest GET with Range header (resume) or plain (fresh)
|
|
→ hasher pre-feed from existing partial file
|
|
→ stream chunks → write_all → hasher.update → on_progress
|
|
→ SHA finalise + compare
|
|
→ tokio::fs::rename(part → final)
|
|
→ DownloadReservation drops, releases slot
|
|
→ returns Ok(()) or DownloadError
|
|
```
|
|
|
|
Tier-recommend path:
|
|
|
|
```
|
|
sysinfo or magnotia_core::system → (ram_bytes, Option<vram_bytes>)
|
|
→ recommend_tier(ram, vram) → LlmModelId
|
|
→ load_model(id, model_path(id), use_gpu)
|
|
```
|
|
|
|
## Watch-outs
|
|
|
|
- **`DownloadReservation` is process-local.** A user running two Magnotia processes against the same on-disk directory could race. We do not file-lock the `.gguf.part`. Realistically rare — but if it ever happens, the SHA check catches a corrupt result.
|
|
- **No retry, no exponential backoff.** A flaky network surfaces as `DownloadError::Http` and the frontend has to ask the user to retry. Resume keeps that retry cheap (only the missing tail re-fetches).
|
|
- **HF revision pinning is manual.** `hf_url()` includes the commit hash. Updating to a new upstream revision means changing the URL and the SHA in lockstep. No tooling enforces that the SHA still matches the URL — manual verification at upgrade time.
|
|
- **`size_bytes` is informational, not enforced.** It is shown in the UI before download starts. The download trusts `Content-Length` (or computed from `Content-Range`) for actual progress, so a server that lies about size shows a misleading bar but still verifies SHA at the end.
|
|
- **`minimum_ram_bytes` is advisory.** Nothing in the loader checks RAM at runtime. A user with 4 GB of RAM picking the 9B tier will see a llama.cpp OOM. The frontend should gate selection on this number.
|
|
- **`recommended_vram_bytes: None` for the 2B tier means "no GPU recommended", not "GPU optional".** The 2B tier is the CPU-only path; the others can run partially-offloaded but get warnings from llama.cpp.
|
|
- **`is_downloaded` does not verify SHA.** It only checks file existence. A corrupted file passes. The next `download_model` call would catch it, but a `LlmEngine::load_model` would fail with a llama.cpp parse error first. Worth a follow-up to expose a `verify_model(id) -> bool` if user-facing "model integrity check" UX surfaces.
|
|
|
|
## See also
|
|
|
|
- [LLM engine](llm-engine.md) — load path and what consumes the file
|
|
- [LLM Cargo features](llm-cargo-features.md)
|
|
- [Slice README — model registry drift entry](README.md)
|