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>
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 map → LLM, 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 LlmModelIdpub struct LlmModelInfo(:152) — serde-camelCase serialised summary for the frontendpub 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>whereF: FnMut(u64, u64) + Send + 'static(:272)
- External deps that matter:
reqwestwithrustls-tlsandstreamfeatures (no native TLS, no compress),sha2,tokiofor async file IO,futures-util::StreamExt,lumotia-coreforpaths::app_paths(). - Tauri command that calls this (slice 2, best guess):
commands::models::download_model(src-tauri/src/commands/models.rs:516), wired viasrc-tauri/src/lib.rs:325. Pluscommands::llm::*callsmodel_manager::recommend_tieratsrc-tauri/src/commands/llm.rs:35andmodel_manager::download_modelatsrc-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:
vram >= 24 GiB && ram >= 64 GiB→Qwen3_6_27B_Q4vram >= 12 GiB && ram >= 32 GiB→Qwen3_5_9B_Q4vram >= 6 GiB || ram >= 16 GiB→Qwen3_5_4B_Q4- 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 bylumotia-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 bothmodel_pathandpartial_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:
- Acquire a
DownloadReservation. A process-globalLazyLock<Mutex<HashSet<LlmModelId>>>(:183) holds the set of in-flight downloads. The reservation'sDropreleases the slot. A second concurrent download for the same tier returnsDownloadError::Http("download already in progress for {tier}"). Different tiers can download in parallel. tokio::fs::create_dir_all(model_dir()). Idempotent. Surfaces IO errors.- If
destalready exists, verify SHA. A re-download of an already-correct file short-circuits toOk(()). A SHA mismatch deletes the file and falls through to a fresh download. This is what makesdownload_modelsafe to call from a frontend that does not know whether the file is present. - 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.partfile exists, we resume from its length. reqwestclient with a 30-second connect timeout,lumotia/0.1.0user-agent, no aggressive compression (streamfeature).Range: bytes={resume_from}-header whenresume_from > 0. If the server responds with anything other than 206 PARTIAL_CONTENT to a ranged request, we returnDownloadError::ResumeUnsupportedrather than starting over silently.- Total-size resolution. For a 200 OK response,
Content-Lengthis the total. For a 206, parseContent-Range: bytes start-end/totalto recover the underlying size; fall back tocontent_length() + resume_fromif the header is missing. - Hasher pre-feed. When resuming, the existing
.gguf.partcontent 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.partis opened withcreate + appendso 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 returnsDownloadError::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]sotokio::fserrors 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
DownloadReservationis 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::Httpand 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_bytesis informational, not enforced. It is shown in the UI before download starts. The download trustsContent-Length(or computed fromContent-Range) for actual progress, so a server that lies about size shows a misleading bar but still verifies SHA at the end.minimum_ram_bytesis 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: Nonefor 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_downloadeddoes not verify SHA. It only checks file existence. A corrupted file passes. The nextdownload_modelcall would catch it, but aLlmEngine::load_modelwould fail with a llama.cpp parse error first. Worth a follow-up to expose averify_model(id) -> boolif user-facing "model integrity check" UX surfaces.
See also
- LLM engine — load path and what consumes the file
- LLM Cargo features
- Slice README — model registry drift entry