diff --git a/scripts/add_hwfit_models.py b/scripts/add_hwfit_models.py
index b981379..6694735 100644
--- a/scripts/add_hwfit_models.py
+++ b/scripts/add_hwfit_models.py
@@ -43,7 +43,8 @@ _GENERIC_TAGS = {
"transformers", "safetensors", "conversational", "text-generation",
"image-text-to-text", "text-generation-inference", "endpoints_compatible",
"autotrain_compatible", "compressed-tensors", "gguf", "mlx", "vllm", "4-bit",
- "8-bit", "awq", "gptq", "fp8", "quantized", "chat",
+ "8-bit", "awq", "gptq", "fp8", "fp4", "nvfp4", "mxfp4", "nf4",
+ "quantized", "chat",
}
api = HfApi()
@@ -79,6 +80,20 @@ def _base_model_tag(tags):
def _quant_from_name(name):
n = name.lower()
+ if "nvfp4" in n:
+ return "NVFP4"
+ if "mxfp4" in n:
+ return "MXFP4"
+ if re.search(r"(^|[-_/])nf4($|[-_/])", n):
+ return "NF4"
+ if re.search(r"(^|[-_/])fp4($|[-_/])", n):
+ return "FP4"
+ if re.search(r"(^|[-_/])w4a16($|[-_/])", n):
+ return "W4A16"
+ if re.search(r"(^|[-_/])w8a8($|[-_/])", n):
+ return "W8A8"
+ if re.search(r"(^|[-_/])w8a16($|[-_/])", n):
+ return "W8A16"
is8 = "8bit" in n or "8-bit" in n or "int8" in n
if "awq" in n:
return "AWQ-8bit" if is8 else "AWQ-4bit"
@@ -93,7 +108,9 @@ def _quant_from_name(name):
if "fp8" in n:
return "FP8"
if "int4" in n or "4bit" in n or "4-bit" in n:
- return "AWQ-4bit"
+ return "INT4"
+ if "int8" in n or "8bit" in n or "8-bit" in n:
+ return "INT8"
return "Q4_K_M"
@@ -160,7 +177,10 @@ def _entry_from_modelinfo(mi, overrides):
rel = created.strftime("%Y-%m-%d") if created else datetime.utcnow().strftime("%Y-%m-%d")
# Rough RAM/VRAM hints (fit.py recomputes the real requirement from params+quant).
_BPP = {"AWQ-4bit": 0.58, "GPTQ-Int4": 0.58, "mlx-4bit": 0.55, "mlx-6bit": 0.85,
- "AWQ-8bit": 1.1, "GPTQ-Int8": 1.1, "mlx-8bit": 1.1, "FP8": 1.1, "NVFP4": 0.6, "Q4_K_M": 0.6}
+ "AWQ-8bit": 1.1, "GPTQ-Int8": 1.1, "mlx-8bit": 1.1, "FP8": 1.1,
+ "FP4": 0.58, "NVFP4": 0.58, "MXFP4": 0.58, "NF4": 0.58,
+ "INT4": 0.58, "INT8": 1.1, "W4A16": 0.58, "W8A8": 1.1, "W8A16": 1.1,
+ "Q4_K_M": 0.6}
bpp = _BPP.get(quant, 0.6)
vram = round(pb * bpp + 0.5, 1)
entry = {
diff --git a/services/hwfit/fit.py b/services/hwfit/fit.py
index fa5fa32..0a6b273 100644
--- a/services/hwfit/fit.py
+++ b/services/hwfit/fit.py
@@ -219,9 +219,9 @@ def _quant_bits(q):
Returns 0 when unknown (caller treats unknown as "don't filter")."""
qu = (q or "").upper().replace("-", "").replace("_", "").replace(" ", "")
# GGUF k-quants + float formats
- if qu.startswith("Q8") or "FP8" in qu:
+ if qu.startswith("Q8") or "FP8" in qu or "INT8" in qu or qu.startswith("W8"):
return 8
- if qu.startswith("Q4") or qu.startswith("IQ4"):
+ if qu.startswith("Q4") or qu.startswith("IQ4") or "FP4" in qu or "NF4" in qu or "INT4" in qu or qu.startswith("W4"):
return 4
if qu.startswith("Q2") or qu.startswith("IQ2"):
return 2
@@ -233,7 +233,7 @@ def _quant_bits(q):
return 6
if qu.startswith("F16") or qu.startswith("BF16") or qu.startswith("F32"):
return 16
- # Prequantized formats: pull the bit-width digit (AWQ4 / AWQ4BIT / GPTQ8 / 4BIT / INT8 …)
+ # Prequantized formats: pull the bit-width digit (AWQ4 / AWQ4BIT / GPTQ8 / 4BIT / INT8 ...)
m = re.search(r"(?:AWQ|GPTQ|MLX|EXL2|BNB|INT|W)(\d{1,2})", qu) or re.search(r"(\d{1,2})BIT", qu)
if m:
b = int(m.group(1))
@@ -282,15 +282,21 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None):
else:
effective_vram = gpu_vram
+ native_gpu_only = preq and not native_quant.startswith("mlx-")
+
# Determine which quant to evaluate at
+ native_quant_prefixes = (
+ "AWQ-", "GPTQ-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
+ "INT4", "INT8", "W4A16", "W8A8", "W8A16",
+ )
+
if preq:
- # AWQ/GPTQ/FP8/MLX come at a fixed bit-width. If the user picked a
- # GGUF quant tier (Q4/Q8/etc.), do not treat a same-bit AWQ/GPTQ build
- # as equivalent. "Q4" means llama.cpp/Ollama-style GGUF in this UI;
- # AWQ/GPTQ/FP8 are separate GPU-serving formats and must only appear
- # when explicitly selected or when no quant filter is applied.
+ # Native HF/vLLM quantized repos come at a fixed format. If the user
+ # picked a GGUF quant tier (Q4/Q8/etc.), do not treat same-bit
+ # AWQ/GPTQ/FP8/FP4 builds as equivalent; those formats are separate
+ # serving paths and only appear when explicitly selected or unfiltered.
if target_quant:
- if not any(target_quant.startswith(p) for p in ("AWQ-", "GPTQ-", "FP8", "NVFP4")):
+ if not any(target_quant.startswith(p) for p in native_quant_prefixes):
return None
_tb, _nb = _quant_bits(target_quant), _quant_bits(native_quant)
if _tb and _nb and _tb != _nb:
@@ -303,16 +309,7 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None):
# Default: Q4_K_M (user's stated preference)
quant_to_try = "Q4_K_M"
- result = _try_quant_at(model, quant_to_try, ctx, effective_vram, eff_ram)
-
- # If target quant doesn't fit and it's not pre-quantized, try lower quants
- if result is None and not preq and target_quant:
- from services.hwfit.models import QUANT_HIERARCHY
- idx = QUANT_HIERARCHY.index(target_quant) if target_quant in QUANT_HIERARCHY else -1
- for q in QUANT_HIERARCHY[idx + 1:]:
- result = _try_quant_at(model, q, ctx, effective_vram, eff_ram)
- if result:
- break
+ result = _try_quant_at(model, quant_to_try, ctx, effective_vram, 0 if native_gpu_only else eff_ram)
if result is None:
# Model doesn't fit on the user's current hardware. Surface it
@@ -447,8 +444,11 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
results.sort(key=sort_fn, reverse=(sort != "vram"))
return results[:limit]
- # If user picked a prequantized format (AWQ/FP8/GPTQ/NVFP4), filter to only those models
- filter_native = quant and any(quant.startswith(p) for p in ("AWQ-", "GPTQ-", "FP8", "NVFP4"))
+ # If user picked a native prequantized format, filter to only those models.
+ filter_native = quant and any(quant.startswith(p) for p in (
+ "AWQ-", "GPTQ-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
+ "INT4", "INT8", "W4A16", "W8A8", "W8A16",
+ ))
system_backend = (system.get("backend") or "").lower()
apple_silicon = system_backend in ("mps", "metal", "apple")
@@ -459,9 +459,9 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
if "nvfp4" in (m.get("name") or "").lower():
native_q = "NVFP4"
- # MLX is Apple Silicon only. Hide MLX rows on non-Mac hardware scans,
- # but leave them visible on Metal/MPS so Mac support is not broken.
- if not apple_silicon and (native_q.startswith("mlx-") or "mlx" in (m.get("name") or "").lower()):
+ # MLX needs the mlx_lm runtime, which Odysseus does not generate serve
+ # commands for. Hide it on every backend, including Metal.
+ if native_q.startswith("mlx-") or "mlx" in (m.get("name") or "").lower():
continue
# ROCm support for vLLM/SGLang quantized safetensors is too brittle to
@@ -479,20 +479,23 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
# default GGUF quant) and vLLM-only AWQ/GPTQ/FP8 builds alike. Without
# this the Cookbook recommends models the Mac can't run; on CUDA these
# stay visible because vLLM serves safetensors directly.
- is_mlx = native_q.startswith("mlx-") or "mlx" in (m.get("name") or "").lower()
- if apple_silicon and not (m.get("is_gguf") or m.get("gguf_sources") or is_mlx):
+ if apple_silicon and not (m.get("is_gguf") or m.get("gguf_sources")):
continue
- # Format filter: AWQ tab → only AWQ models, FP8 tab → only FP8 models
+ # Format filter: AWQ tab -> only AWQ models, FP4 tab -> FP4-family models, etc.
if filter_native:
if quant == "FP8" and native_q != "FP8":
continue
+ if quant == "FP4" and native_q not in ("FP4", "NVFP4", "MXFP4", "NF4"):
+ continue
if quant.startswith("AWQ") and not native_q.startswith("AWQ"):
continue
if quant.startswith("GPTQ") and not native_q.startswith("GPTQ"):
continue
if quant.startswith("NVFP4") and not native_q.startswith("NVFP4"):
continue
+ if quant in ("INT4", "INT8", "W4A16", "W8A8", "W8A16") and native_q != quant:
+ continue
if search:
name = m.get("name", "").lower()
diff --git a/services/hwfit/models.py b/services/hwfit/models.py
index b62184e..c6504e6 100644
--- a/services/hwfit/models.py
+++ b/services/hwfit/models.py
@@ -5,7 +5,9 @@ import re
QUANT_HIERARCHY = ["Q8_0", "Q6_K", "Q5_K_M", "Q4_K_M", "Q3_K_M", "Q2_K"]
QUANT_BPP = {
- "F32": 4.0, "F16": 2.0, "BF16": 2.0, "FP8": 1.0, "NVFP4": 0.5,
+ "F32": 4.0, "F16": 2.0, "BF16": 2.0, "FP8": 1.0,
+ "FP4": 0.50, "NVFP4": 0.50, "MXFP4": 0.50, "NF4": 0.50,
+ "INT4": 0.50, "INT8": 1.0, "W4A16": 0.50, "W8A8": 1.0, "W8A16": 1.0,
"Q8_0": 1.05, "Q6_K": 0.80, "Q5_K_M": 0.68,
"Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37,
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
@@ -14,7 +16,9 @@ QUANT_BPP = {
}
QUANT_SPEED_MULT = {
- "F16": 0.6, "BF16": 0.6, "FP8": 0.85, "NVFP4": 1.1,
+ "F16": 0.6, "BF16": 0.6, "FP8": 0.85,
+ "FP4": 1.15, "NVFP4": 1.15, "MXFP4": 1.15, "NF4": 1.10,
+ "INT4": 1.15, "INT8": 0.85, "W4A16": 1.15, "W8A8": 0.85, "W8A16": 0.85,
"Q8_0": 0.8, "Q6_K": 0.95, "Q5_K_M": 1.0,
"Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35,
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
@@ -23,7 +27,9 @@ QUANT_SPEED_MULT = {
}
QUANT_QUALITY_PENALTY = {
- "F16": 0.0, "BF16": 0.0, "FP8": 0.0, "NVFP4": 0.0,
+ "F16": 0.0, "BF16": 0.0, "FP8": 0.0,
+ "FP4": -3.0, "NVFP4": -3.0, "MXFP4": -3.0, "NF4": -4.0,
+ "INT4": -4.0, "INT8": 0.0, "W4A16": -4.0, "W8A8": 0.0, "W8A16": 0.0,
"Q8_0": 0.0, "Q6_K": -1.0, "Q5_K_M": -2.0,
"Q4_K_M": -5.0, "Q4_0": -5.0, "Q3_K_M": -8.0, "Q2_K": -12.0,
"AWQ-4bit": -3.0, "AWQ-8bit": 0.0,
@@ -32,7 +38,9 @@ QUANT_QUALITY_PENALTY = {
}
QUANT_BYTES_PER_PARAM = {
- "F16": 2.0, "BF16": 2.0, "FP8": 1.0, "NVFP4": 0.5,
+ "F16": 2.0, "BF16": 2.0, "FP8": 1.0,
+ "FP4": 0.5, "NVFP4": 0.5, "MXFP4": 0.5, "NF4": 0.5,
+ "INT4": 0.5, "INT8": 1.0, "W4A16": 0.5, "W8A8": 1.0, "W8A16": 1.0,
"Q8_0": 1.0, "Q6_K": 0.75, "Q5_K_M": 0.625,
"Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25,
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
@@ -40,14 +48,60 @@ QUANT_BYTES_PER_PARAM = {
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
}
-# Pre-quantized formats that should NOT go through the GGUF quant hierarchy
-PREQUANTIZED_PREFIXES = ("AWQ-", "GPTQ-", "mlx-", "FP8", "NVFP4")
+# Pre-quantized formats that should NOT go through the GGUF quant hierarchy.
+# These are native HF/vLLM-style repos, not llama.cpp GGUF quant tiers.
+PREQUANTIZED_PREFIXES = (
+ "AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
+ "INT4", "INT8", "W4A16", "W8A8", "W8A16",
+)
+
+
+def infer_quantization_from_name(name):
+ n = (name or "").lower()
+ if "nvfp4" in n:
+ return "NVFP4"
+ if "mxfp4" in n:
+ return "MXFP4"
+ if re.search(r"(^|[-_/])nf4($|[-_/])", n):
+ return "NF4"
+ if re.search(r"(^|[-_/])fp4($|[-_/])", n):
+ return "FP4"
+ if re.search(r"(^|[-_/])w4a16($|[-_/])", n):
+ return "W4A16"
+ if re.search(r"(^|[-_/])w8a8($|[-_/])", n):
+ return "W8A8"
+ if re.search(r"(^|[-_/])w8a16($|[-_/])", n):
+ return "W8A16"
+ is8 = "8bit" in n or "8-bit" in n or "int8" in n
+ if "awq" in n:
+ return "AWQ-8bit" if is8 else "AWQ-4bit"
+ if "gptq" in n:
+ return "GPTQ-Int8" if is8 else "GPTQ-Int4"
+ if "mlx" in n:
+ if "6bit" in n:
+ return "mlx-6bit"
+ return "mlx-8bit" if is8 else "mlx-4bit"
+ if "fp8" in n:
+ return "FP8"
+ if "int4" in n or "4bit" in n or "4-bit" in n:
+ return "INT4"
+ if "int8" in n or "8bit" in n or "8-bit" in n:
+ return "INT8"
+ return ""
+
+
+def _normalize_model_entry(model):
+ if not isinstance(model, dict):
+ return model
+ inferred = infer_quantization_from_name(model.get("name", ""))
+ if inferred and (model.get("quantization") in (None, "", "Q4_K_M") or model.get("_discovered")):
+ model["quantization"] = inferred
+ return model
def is_prequantized(model):
q = model.get("quantization", "")
- name = (model.get("name") or "").lower()
- return "nvfp4" in name or any(q.startswith(p) for p in PREQUANTIZED_PREFIXES)
+ return any(q.startswith(p) for p in PREQUANTIZED_PREFIXES)
def params_b(model):
@@ -168,7 +222,7 @@ def get_models():
data_path = os.path.join(os.path.dirname(__file__), "data", "hf_models.json")
try:
with open(data_path, encoding="utf-8") as f:
- _models_cache = json.load(f)
+ _models_cache = [_normalize_model_entry(m) for m in json.load(f)]
except (FileNotFoundError, json.JSONDecodeError):
_models_cache = []
return _models_cache
diff --git a/static/js/cookbook-hwfit.js b/static/js/cookbook-hwfit.js
index bccd760..98b2f16 100644
--- a/static/js/cookbook-hwfit.js
+++ b/static/js/cookbook-hwfit.js
@@ -827,7 +827,9 @@ export function _hwfitRenderList(el, models) {
const pcount = m.parameter_count || '?';
const ctx = m.context ? (m.context >= 1024 ? (m.context / 1024).toFixed(0) + 'k' : m.context) : '?';
const fitLabel = (m.fit_level || '').replace('_', ' ');
- const modeLabel = (m.run_mode || '').replace('_', '+');
+ const modeLabel = m.run_mode === 'cpu_offload'
+ ? 'cpu+offload'
+ : (m.run_mode || '').replace(/_/g, ' ');
const vramLabel = m.required_gb ? m.required_gb.toFixed(1) + 'G' : '?';
const moeBadge = m.is_moe ? 'MoE' : '';
const imgBadge = m.is_image_gen ? 'IMG' : '';
diff --git a/static/js/cookbook.js b/static/js/cookbook.js
index 329e0c2..2ef4e41 100644
--- a/static/js/cookbook.js
+++ b/static/js/cookbook.js
@@ -262,10 +262,10 @@ export function _detectBackend(model) {
const isRocm = sysBackend === 'rocm';
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
- if (!isAppleSilicon && (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX'))) {
+ if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) {
return { backend: 'unsupported', label: 'Unsupported' };
}
- const isAwqLike = /^AWQ|^GPTQ|^NVFP4/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8|nvfp4)\b/i.test(_nm);
+ const isAwqLike = /^AWQ|^GPTQ|^NVFP4/.test(q) || ['FP8', 'FP4', 'MXFP4', 'NF4', 'INT4', 'INT8', 'W4A16', 'W8A8', 'W8A16'].includes(q) || /\b(awq|gptq|fp8|fp4|nvfp4|mxfp4|nf4|int4|int8|w4a16|w8a8|w8a16)\b/i.test(_nm);
const isGgufLike = model.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf');
// Image gen models → diffusers
@@ -291,7 +291,7 @@ export function _detectBackend(model) {
}
// Apple Silicon (Metal) → llama.cpp (GGUF). vLLM/SGLang are CUDA/ROCm-only and
- // don't run on macOS; AWQ/GPTQ/FP8 (vLLM-only) models are already filtered out
+ // don't run on macOS; vLLM-native quantized models are already filtered out
// of metal Cookbook results, so llama.cpp is always the right engine here.
if (['metal', 'mps', 'apple'].includes(sysBackend)) {
return { backend: 'llamacpp', label: 'llama.cpp' };
@@ -1516,7 +1516,7 @@ function _renderRecipes() {
html += '';
html += '';
html += '';
- html += '';
+ html += '';
html += '';
// Engine filter: show only models whose serve engine matches. "llama.cpp"
// (GGUF) runs everywhere incl. consumer AMD/Apple; vLLM/SGLang are CUDA /
diff --git a/tests/test_hwfit_quant_formats.py b/tests/test_hwfit_quant_formats.py
new file mode 100644
index 0000000..20e9743
--- /dev/null
+++ b/tests/test_hwfit_quant_formats.py
@@ -0,0 +1,78 @@
+from services.hwfit.fit import analyze_model, rank_models
+from services.hwfit.models import (
+ get_models,
+ infer_quantization_from_name,
+ is_prequantized,
+)
+
+
+def _dual_5060ti_system():
+ return {
+ "has_gpu": True,
+ "backend": "cuda",
+ "gpu_name": "NVIDIA GeForce RTX 5060 Ti",
+ "gpu_vram_gb": 31.0,
+ "gpu_count": 2,
+ "available_ram_gb": 128.0,
+ "total_ram_gb": 128.0,
+ }
+
+
+def test_infers_native_hf_quant_formats_from_repo_names():
+ cases = {
+ "txn545/Qwen3.5-122B-A10B-NVFP4": "NVFP4",
+ "some/model-MXFP4": "MXFP4",
+ "some/model-NF4": "NF4",
+ "some/model-FP4": "FP4",
+ "some/model-W4A16": "W4A16",
+ "some/model-W8A8": "W8A8",
+ "some/model-W8A16": "W8A16",
+ "some/model-INT4": "INT4",
+ "some/model-8bit": "INT8",
+ }
+ assert {name: infer_quantization_from_name(name) for name in cases} == cases
+
+
+def test_nvfp4_catalog_quant_is_preserved():
+ catalog = {m["name"]: m for m in get_models()}
+ model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
+
+ assert model["quantization"] == "NVFP4"
+ assert is_prequantized(model)
+
+
+def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
+ catalog = {m["name"]: m for m in get_models()}
+ model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
+
+ fit = analyze_model(model, _dual_5060ti_system())
+ assert fit["quant"] == "NVFP4"
+ assert fit["run_mode"] != "cpu_offload"
+
+ results = rank_models(
+ _dual_5060ti_system(),
+ search="Qwen3.5-122B-A10B-NVFP4",
+ limit=10,
+ )
+ hit = next(r for r in results if r["name"] == "txn545/Qwen3.5-122B-A10B-NVFP4")
+ assert hit["quant"] == "NVFP4"
+ assert hit["run_mode"] != "cpu_offload"
+
+
+def test_selected_gguf_quant_is_strict_not_lower_quant_fallback():
+ model = {
+ "name": "local/Huge-GGUF",
+ "provider": "local",
+ "parameter_count": "100B",
+ "parameters_raw": 100_000_000_000,
+ "quantization": "Q4_K_M",
+ "context_length": 4096,
+ }
+
+ system = _dual_5060ti_system()
+ system["available_ram_gb"] = 80.0
+ system["total_ram_gb"] = 80.0
+ fit = analyze_model(model, system, target_quant="Q8_0")
+
+ assert fit["quant"] == "Q8_0"
+ assert fit["run_mode"] == "no_fit"