Files
Lumotia/docs/architecture-map/04-llm-formatting-mcp/llm-model-manager.md
Jake 26c7307607
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — docs, scripts, root config, residuals
Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.

transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
  -> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
  as immutable audit trail). Includes architecture-map references
  to magnotia_core::*, magnotia_storage::*, etc. now pointing at
  lumotia_*; dev-setup.md tracing output examples (lumotia_startup
  target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
  audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
  hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
  crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
  crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
  doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
  ("lumotia_task_sync"); magnotia_locale i18n localStorage key
  renamed + migration shim added; CSS keyframe names
  magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
  system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
  keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
  wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
  to "lumotia era" earlier — restored).

Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
  legacy detection strings in legacy_and_target_paths() so the
  migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
  — migration call sites reference the legacy magnotia keys
  deliberately.
- docs/handovers/ — historical audit trail.

cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:38:03 +01:00

11 KiB

name, type, slice, last_verified
name type slice last_verified
LLM model manager architecture-map-page 04-llm-formatting-mcp 2026/05/09

LLM model manager

Where you are: Architecture mapLLM, Formatting, MCP → 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: lumotia-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, lumotia-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 GiBQwen3_6_27B_Q4
  2. vram >= 12 GiB && ram >= 32 GiBQwen3_5_9B_Q4
  3. vram >= 6 GiB || ram >= 16 GiBQwen3_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()lumotia_core::paths::app_paths().llm_models_dir(). The on-disk root, owned by lumotia-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, lumotia/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)
  → lumotia_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 lumotia_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 Lumotia 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