feat(llm): bump model registry to Qwen3.5 + Qwen3.6 family (4 tiers)
Replaces the three older Qwen3 variants with a four-tier ladder spanning a wider hardware range: - Qwen3_5_2B_Q4 (Minimal, 8 GB RAM, ~1.3 GB download) - Qwen3_5_4B_Q4 (Standard, 16 GB RAM / 6 GB VRAM, ~2.7 GB) — DEFAULT - Qwen3_5_9B_Q4 (High, 32 GB RAM / 12 GB VRAM, ~5.7 GB) - Qwen3_6_27B_Q4 (Maximum, 64 GB RAM / 24 GB VRAM, ~17 GB) All four GGUFs sourced from unsloth's HF org with pinned commit SHAs. Sizes and SHA256 hashes verified against the live X-Linked-Etag / X-Linked-Size headers on the LFS CDN. Q4_K_M quantisation throughout (common sweet-spot for cleanup + task extraction). recommend_tier rewritten to span four bands; default_tier moves from the old 4B-Instruct-2507 to Qwen3.5 4B. The 27B Maximum tier honestly needs 64 GB RAM to run without partial offload — surfaced in the description string so the Settings UI can warn realistically. In-tree smoke tests (smoke.rs, content_tags_smoke.rs) updated to reference the new smallest tier so a developer's MAGNOTIA_LLM_TEST_MODEL points at the cheapest GGUF to download. Crate description in crates/llm/Cargo.toml refreshed to mention the new family. NOTE (out of scope; not fixed): the size_bytes / sha256 / hf_url methods could collapse into a single LlmModelMetadata table to remove four parallel match arms. Layer 2 cleanup, separate session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
name = "magnotia-llm"
|
name = "magnotia-llm"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Local LLM engine for Magnotia (Qwen3 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition"
|
description = "Local LLM engine for Magnotia (Qwen3.5 / Qwen3.6 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Default desktop build keeps the existing openmp + vulkan acceleration.
|
# Default desktop build keeps the existing openmp + vulkan acceleration.
|
||||||
|
|||||||
@@ -12,100 +12,119 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|||||||
#[allow(non_camel_case_types)]
|
#[allow(non_camel_case_types)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub enum LlmModelId {
|
pub enum LlmModelId {
|
||||||
#[serde(rename = "qwen3_1_7b")]
|
#[serde(rename = "qwen3_5_2b")]
|
||||||
Qwen3_1_7B_Q4,
|
Qwen3_5_2B_Q4,
|
||||||
#[serde(rename = "qwen3_4b_instruct_2507")]
|
#[serde(rename = "qwen3_5_4b")]
|
||||||
Qwen3_4BInstruct2507Q4,
|
Qwen3_5_4B_Q4,
|
||||||
#[serde(rename = "qwen3_14b")]
|
#[serde(rename = "qwen3_5_9b")]
|
||||||
Qwen3_14BQ5,
|
Qwen3_5_9B_Q4,
|
||||||
|
#[serde(rename = "qwen3_6_27b")]
|
||||||
|
Qwen3_6_27B_Q4,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LlmModelId {
|
impl LlmModelId {
|
||||||
pub fn default_tier() -> Self {
|
pub fn default_tier() -> Self {
|
||||||
Self::Qwen3_4BInstruct2507Q4
|
Self::Qwen3_5_4B_Q4
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => "qwen3_1_7b",
|
Self::Qwen3_5_2B_Q4 => "qwen3_5_2b",
|
||||||
Self::Qwen3_4BInstruct2507Q4 => "qwen3_4b_instruct_2507",
|
Self::Qwen3_5_4B_Q4 => "qwen3_5_4b",
|
||||||
Self::Qwen3_14BQ5 => "qwen3_14b",
|
Self::Qwen3_5_9B_Q4 => "qwen3_5_9b",
|
||||||
|
Self::Qwen3_6_27B_Q4 => "qwen3_6_27b",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display_name(&self) -> &'static str {
|
pub fn display_name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => "Qwen3 1.7B",
|
Self::Qwen3_5_2B_Q4 => "Qwen3.5 2B",
|
||||||
Self::Qwen3_4BInstruct2507Q4 => "Qwen3 4B Instruct 2507",
|
Self::Qwen3_5_4B_Q4 => "Qwen3.5 4B",
|
||||||
Self::Qwen3_14BQ5 => "Qwen3 14B",
|
Self::Qwen3_5_9B_Q4 => "Qwen3.5 9B",
|
||||||
|
Self::Qwen3_6_27B_Q4 => "Qwen3.6 27B",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn file_name(&self) -> &'static str {
|
pub fn file_name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => "Qwen3-1.7B-Q4_K_M.gguf",
|
Self::Qwen3_5_2B_Q4 => "Qwen3.5-2B-Q4_K_M.gguf",
|
||||||
Self::Qwen3_4BInstruct2507Q4 => "Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
Self::Qwen3_5_4B_Q4 => "Qwen3.5-4B-Q4_K_M.gguf",
|
||||||
Self::Qwen3_14BQ5 => "Qwen3-14B-Q5_K_M.gguf",
|
Self::Qwen3_5_9B_Q4 => "Qwen3.5-9B-Q4_K_M.gguf",
|
||||||
|
Self::Qwen3_6_27B_Q4 => "Qwen3.6-27B-Q4_K_M.gguf",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn size_bytes(&self) -> u64 {
|
pub fn size_bytes(&self) -> u64 {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => 1_107_409_472,
|
Self::Qwen3_5_2B_Q4 => 1_280_835_840,
|
||||||
Self::Qwen3_4BInstruct2507Q4 => 2_497_281_120,
|
Self::Qwen3_5_4B_Q4 => 2_740_937_888,
|
||||||
Self::Qwen3_14BQ5 => 10_514_570_624,
|
Self::Qwen3_5_9B_Q4 => 5_680_522_464,
|
||||||
|
Self::Qwen3_6_27B_Q4 => 16_817_244_384,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn minimum_ram_bytes(&self) -> u64 {
|
pub fn minimum_ram_bytes(&self) -> u64 {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => 8 * 1024_u64.pow(3),
|
Self::Qwen3_5_2B_Q4 => 8 * 1024_u64.pow(3),
|
||||||
Self::Qwen3_4BInstruct2507Q4 => 16 * 1024_u64.pow(3),
|
Self::Qwen3_5_4B_Q4 => 16 * 1024_u64.pow(3),
|
||||||
Self::Qwen3_14BQ5 => 32 * 1024_u64.pow(3),
|
Self::Qwen3_5_9B_Q4 => 32 * 1024_u64.pow(3),
|
||||||
|
Self::Qwen3_6_27B_Q4 => 64 * 1024_u64.pow(3),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn recommended_vram_bytes(&self) -> Option<u64> {
|
pub fn recommended_vram_bytes(&self) -> Option<u64> {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => None,
|
Self::Qwen3_5_2B_Q4 => None,
|
||||||
Self::Qwen3_4BInstruct2507Q4 => Some(8 * 1024_u64.pow(3)),
|
Self::Qwen3_5_4B_Q4 => Some(6 * 1024_u64.pow(3)),
|
||||||
Self::Qwen3_14BQ5 => Some(16 * 1024_u64.pow(3)),
|
Self::Qwen3_5_9B_Q4 => Some(12 * 1024_u64.pow(3)),
|
||||||
|
Self::Qwen3_6_27B_Q4 => Some(24 * 1024_u64.pow(3)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn description(&self) -> &'static str {
|
pub fn description(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => "Low tier for 8 GB RAM and CPU-heavy machines.",
|
Self::Qwen3_5_2B_Q4 => "Minimal tier for 8 GB RAM and CPU-heavy machines.",
|
||||||
Self::Qwen3_4BInstruct2507Q4 => {
|
Self::Qwen3_5_4B_Q4 => {
|
||||||
"Default tier for cleanup and task extraction on 16 GB systems."
|
"Standard tier for cleanup and task extraction on 16 GB systems."
|
||||||
|
}
|
||||||
|
Self::Qwen3_5_9B_Q4 => "High tier for 32 GB RAM with a 12 GB+ GPU.",
|
||||||
|
Self::Qwen3_6_27B_Q4 => {
|
||||||
|
"Maximum tier for 64 GB RAM with a 24 GB GPU; partial CPU offload below that."
|
||||||
}
|
}
|
||||||
Self::Qwen3_14BQ5 => "High tier for 32 GB+ RAM and larger GPUs.",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hf_url(&self) -> &'static str {
|
pub fn hf_url(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => {
|
Self::Qwen3_5_2B_Q4 => {
|
||||||
"https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/d7f544eead698dbd1f15126ef60b45a1e1933222/Qwen3-1.7B-Q4_K_M.gguf"
|
"https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/f6d5376be1edb4d416d56da11e5397a961aca8ae/Qwen3.5-2B-Q4_K_M.gguf"
|
||||||
}
|
}
|
||||||
Self::Qwen3_4BInstruct2507Q4 => {
|
Self::Qwen3_5_4B_Q4 => {
|
||||||
"https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/a06e946bb6b655725eafa393f4a9745d460374c9/Qwen3-4B-Instruct-2507-Q4_K_M.gguf"
|
"https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/e87f176479d0855a907a41277aca2f8ee7a09523/Qwen3.5-4B-Q4_K_M.gguf"
|
||||||
}
|
}
|
||||||
Self::Qwen3_14BQ5 => {
|
Self::Qwen3_5_9B_Q4 => {
|
||||||
"https://huggingface.co/unsloth/Qwen3-14B-GGUF/resolve/a04a82c4739b3ef5fa6da7d10261db2c67dd1985/Qwen3-14B-Q5_K_M.gguf"
|
"https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/resolve/3885219b6810b007914f3a7950a8d1b469d598a5/Qwen3.5-9B-Q4_K_M.gguf"
|
||||||
|
}
|
||||||
|
Self::Qwen3_6_27B_Q4 => {
|
||||||
|
"https://huggingface.co/unsloth/Qwen3.6-27B-GGUF/resolve/82d411acf4a06cfb8d9b073a5211bf410bfc29bf/Qwen3.6-27B-Q4_K_M.gguf"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sha256(&self) -> &'static str {
|
pub fn sha256(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Qwen3_1_7B_Q4 => {
|
Self::Qwen3_5_2B_Q4 => {
|
||||||
"de942b0819216caa3bfe487180dd1bb37398fa1c98cb42bb0bbac7ab7d6e8a12"
|
"aaf42c8b7c3cab2bf3d69c355048d4a0ee9973d48f16c731c0520ee914699223"
|
||||||
}
|
}
|
||||||
Self::Qwen3_4BInstruct2507Q4 => {
|
Self::Qwen3_5_4B_Q4 => {
|
||||||
"bf52d44a54b81d44219833556849529ee96f09da673a38783dddc2e2eaf17881"
|
"00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4"
|
||||||
|
}
|
||||||
|
Self::Qwen3_5_9B_Q4 => {
|
||||||
|
"03b74727a860a56338e042c4420bb3f04b2fec5734175f4cb9fa853daf52b7e8"
|
||||||
|
}
|
||||||
|
Self::Qwen3_6_27B_Q4 => {
|
||||||
|
"5ed60d0af4650a854b1755bd392f9aef4872643dc25a254bc68043fa638392a0"
|
||||||
}
|
}
|
||||||
Self::Qwen3_14BQ5 => "6f87abc471bd509ad46aca4284b3cfa926d8114bc491bb0a7a3a7f74c16ef95b",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,9 +140,10 @@ impl FromStr for LlmModelId {
|
|||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
match value {
|
match value {
|
||||||
"qwen3_1_7b" => Ok(Self::Qwen3_1_7B_Q4),
|
"qwen3_5_2b" => Ok(Self::Qwen3_5_2B_Q4),
|
||||||
"qwen3_4b_instruct_2507" => Ok(Self::Qwen3_4BInstruct2507Q4),
|
"qwen3_5_4b" => Ok(Self::Qwen3_5_4B_Q4),
|
||||||
"qwen3_14b" => Ok(Self::Qwen3_14BQ5),
|
"qwen3_5_9b" => Ok(Self::Qwen3_5_9B_Q4),
|
||||||
|
"qwen3_6_27b" => Ok(Self::Qwen3_6_27B_Q4),
|
||||||
other => Err(format!("Unknown LLM model id: {other}")),
|
other => Err(format!("Unknown LLM model id: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,9 +174,10 @@ pub enum DownloadError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALL_MODELS: &[LlmModelId] = &[
|
const ALL_MODELS: &[LlmModelId] = &[
|
||||||
LlmModelId::Qwen3_1_7B_Q4,
|
LlmModelId::Qwen3_5_2B_Q4,
|
||||||
LlmModelId::Qwen3_4BInstruct2507Q4,
|
LlmModelId::Qwen3_5_4B_Q4,
|
||||||
LlmModelId::Qwen3_14BQ5,
|
LlmModelId::Qwen3_5_9B_Q4,
|
||||||
|
LlmModelId::Qwen3_6_27B_Q4,
|
||||||
];
|
];
|
||||||
|
|
||||||
static ACTIVE_DOWNLOADS: LazyLock<Mutex<std::collections::HashSet<LlmModelId>>> =
|
static ACTIVE_DOWNLOADS: LazyLock<Mutex<std::collections::HashSet<LlmModelId>>> =
|
||||||
@@ -206,16 +227,15 @@ pub fn model_info(id: LlmModelId) -> LlmModelInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option<u64>) -> LlmModelId {
|
pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option<u64>) -> LlmModelId {
|
||||||
if total_vram_bytes.unwrap_or(0) >= 16 * 1024_u64.pow(3)
|
let vram = total_vram_bytes.unwrap_or(0);
|
||||||
&& total_ram_bytes >= 32 * 1024_u64.pow(3)
|
if vram >= 24 * 1024_u64.pow(3) && total_ram_bytes >= 64 * 1024_u64.pow(3) {
|
||||||
{
|
LlmModelId::Qwen3_6_27B_Q4
|
||||||
LlmModelId::Qwen3_14BQ5
|
} else if vram >= 12 * 1024_u64.pow(3) && total_ram_bytes >= 32 * 1024_u64.pow(3) {
|
||||||
} else if total_vram_bytes.unwrap_or(0) >= 8 * 1024_u64.pow(3)
|
LlmModelId::Qwen3_5_9B_Q4
|
||||||
|| total_ram_bytes >= 16 * 1024_u64.pow(3)
|
} else if vram >= 6 * 1024_u64.pow(3) || total_ram_bytes >= 16 * 1024_u64.pow(3) {
|
||||||
{
|
LlmModelId::Qwen3_5_4B_Q4
|
||||||
LlmModelId::Qwen3_4BInstruct2507Q4
|
|
||||||
} else {
|
} else {
|
||||||
LlmModelId::Qwen3_1_7B_Q4
|
LlmModelId::Qwen3_5_2B_Q4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,15 +409,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_path_contains_model_dir_and_filename() {
|
fn model_path_contains_model_dir_and_filename() {
|
||||||
let path = model_path(LlmModelId::Qwen3_1_7B_Q4);
|
let path = model_path(LlmModelId::Qwen3_5_2B_Q4);
|
||||||
assert!(path.to_string_lossy().ends_with("Qwen3-1.7B-Q4_K_M.gguf"));
|
assert!(path.to_string_lossy().ends_with("Qwen3.5-2B-Q4_K_M.gguf"));
|
||||||
assert!(path.starts_with(model_dir()));
|
assert!(path.starts_with(model_dir()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recommend_tier_prefers_mid_by_default() {
|
fn recommend_tier_prefers_mid_by_default() {
|
||||||
let tier = recommend_tier(16 * 1024_u64.pow(3), None);
|
let tier = recommend_tier(16 * 1024_u64.pow(3), None);
|
||||||
assert_eq!(tier, LlmModelId::Qwen3_4BInstruct2507Q4);
|
assert_eq!(tier, LlmModelId::Qwen3_5_4B_Q4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ fn extract_content_tags_returns_valid_pair() {
|
|||||||
|
|
||||||
let engine = LlmEngine::new();
|
let engine = LlmEngine::new();
|
||||||
engine
|
engine
|
||||||
.load_model(LlmModelId::Qwen3_1_7B_Q4, &model_path, true)
|
.load_model(LlmModelId::Qwen3_5_2B_Q4, &model_path, true)
|
||||||
.expect("load model");
|
.expect("load model");
|
||||||
|
|
||||||
let transcript = "Tomorrow I need to run through the grant application one more time \
|
let transcript = "Tomorrow I need to run through the grant application one more time \
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ fn llama_cpp_2_smoke_generates_and_wraps() {
|
|||||||
|
|
||||||
let engine = LlmEngine::new();
|
let engine = LlmEngine::new();
|
||||||
engine
|
engine
|
||||||
.load_model(LlmModelId::Qwen3_1_7B_Q4, &model_path, true)
|
.load_model(LlmModelId::Qwen3_5_2B_Q4, &model_path, true)
|
||||||
.expect("load model");
|
.expect("load model");
|
||||||
|
|
||||||
let completion = engine
|
let completion = engine
|
||||||
|
|||||||
Reference in New Issue
Block a user