Studio: scale export GGUF size estimates from the real model size (#6418)

* Studio: scale export GGUF size estimates from the real model size

The Export page showed hardcoded, model-independent GGUF quant size
labels (Q8_0 ~8.2 GB, BF16 ~14.2 GB, ...) calibrated for an ~8B model.
For a 35B MoE model like Qwen3.6-35B-A3B (67 GiB bf16, Q8 ~34 GiB) the
picker wrongly reported Q8 ~8.2 GB. Only the displayed estimate was
wrong; the actual export via save_pretrained_gguf was always correct.

Add GET /api/models/export-size, which returns a model's MoE-aware
fp16/bf16-equivalent size and total params using the existing
estimate_fp16_model_size_bytes (safetensors -> config -> local -> vllm).
The result is memoized and degrades to nulls so a size hint can never
break the Export page.

The Export picker now scales each quant from that size
(bytes ~= fp16_bytes * bits_per_weight / 16, GiB units to match the
model selector), and renders no size when it is unknown rather than a
misleading fixed number. The Est. size summary in the page and dialog
is restored now that the value comes from the backend.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio export-size: address review feedback

- Run the size estimate off the event loop with asyncio.to_thread so a slow
  Hugging Face request cannot stall other API or SSE endpoints.
- Cache only successful estimates; a transient failure (offline, gated before
  credentials) is no longer pinned as unavailable until restart.
- Forward the HF token so private and gated models can be sized, and refetch
  when the token changes.
- Clamp the size formatter index so sub-1-byte values cannot pick an
  out-of-range unit.

* Studio export-size: address second review pass

- Send the HF token in an X-HF-Token header instead of the query string, so
  it never lands in URLs, logs, or browser history.
- Key the estimate cache by model id only (the fp16 size is token independent),
  so HF tokens are never retained in the cache.
- Restrict local-path sizing to known Studio roots (outputs/exports/cache/home)
  so an authenticated caller cannot trigger a scan of an arbitrary directory.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio export-size: fix CI (import-hoist + isolated-load test stubs)

- Import ExportSizeResponse from models.models in routes/models.py instead of
  re-exporting it through models/__init__.py, so the import-hoist lint does not
  flag a newly added but un-loaded re-export (models/__init__.py is unchanged).
- Add Header and ExportSizeResponse to the stubbed fastapi / models.models in
  test_export_absolute_paths.py, which loads routes/models.py in isolation.

* Studio: validate export-size local path before filesystem access

CodeQL flagged the export-size local-path guard as path injection: the
user-provided model path was resolved and stat-ed before it was checked
for containment under a Studio data root. Decide containment by lexical
normalization (normpath/abspath/expanduser, no filesystem access) and
only touch the filesystem once the path is proven to sit under a trusted
root, so an unvalidated value never reaches a filesystem call. Add a
direct containment unit test (under-root, root itself, missing, /etc,
and '..' traversal).

* Studio: trim export-size comments to be more concise

Shorten docstrings and comments on the export-size endpoint, helpers, tests,
and frontend size utilities; drop comments that just restate the code. Verified
code-identical (comments only) via AST/TS-compiler check. No behavior change.

* Studio: harden export-size local-path handling

Address review feedback on the export-size endpoint's local sizing:
- Resolve symlinks and re-verify containment in _is_sizable_local_path so a
  symlink inside a Studio root can't point the sizer outside it.
- Re-validate the resolved LoRA base before sizing, so a crafted adapter
  whose base_model points outside the roots can't redirect the scan.
- Skip nested checkpoint-*/global_step* snapshots when summing local weight
  sizes so a run dir's intermediate checkpoints don't inflate the estimate.
- Size the checkpoint directory for full fine-tune checkpoint exports (whose
  base may be a local/custom path), keeping base-model sizing for adapters.

Adds tests for the adapter-base escape, symlink escape, and nested-checkpoint
exclusion.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
This commit is contained in:
Daniel Han 2026-06-18 05:44:17 -07:00 committed by GitHub
commit 22e6d64493
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 648 additions and 42 deletions

View file

@ -53,6 +53,24 @@ class CheckpointListResponse(BaseModel):
)
class ExportSizeResponse(BaseModel):
"""Model fp16/bf16-equivalent size; size fields are null when unknown."""
model: str = Field(..., description = "Model id or path the estimate was computed for")
fp16_bytes: Optional[int] = Field(
None,
description = "Estimated FP16/BF16-equivalent on-disk size in bytes, or null if unknown",
)
total_params: Optional[int] = Field(
None,
description = "Estimated total parameter count (fp16_bytes // 2), or null if unknown",
)
source: str = Field(
"unavailable",
description = "How the estimate was derived (e.g. safetensors, config, local, vllm, unavailable)",
)
class ModelDetails(BaseModel):
"""Model configuration and metadata; used for both list and detail views"""

View file

@ -3,6 +3,7 @@
"""Model management API routes."""
import asyncio
import hashlib
import json
import os
@ -10,7 +11,7 @@ import shutil
import sys
import uuid
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
from typing import List, Optional
import structlog
from loggers import get_logger
@ -149,6 +150,7 @@ from models import (
from models.models import (
BrowseEntry,
BrowseFoldersResponse,
ExportSizeResponse,
GgufVariantDetail,
GgufVariantsResponse,
ModelType,
@ -3008,3 +3010,121 @@ async def list_checkpoints(
event = "models.list_checkpoints_failed",
log = logger,
)
# Successful estimates only, keyed by model id (token-independent, never stored).
# Failures are not cached so a transient offline/gated error can recover later.
_EXPORT_SIZE_CACHE: dict[str, tuple[int, int, str]] = {}
def _is_sizable_local_path(model: str) -> bool:
"""True only for local paths under a Studio data root.
Containment is decided lexically (no filesystem access) before the path is
touched, then the path is symlink-resolved and re-checked so a symlink
inside a root can't point the sizer outside it. A user-controlled path thus
can't trigger a scan of an arbitrary dir.
"""
from utils.paths import outputs_root, exports_root, studio_root
from utils.paths.storage_roots import cache_root
def _lexical(p: str) -> str:
# Lexical only (no filesystem read); normpath collapses '..'.
return os.path.normpath(os.path.abspath(os.path.expanduser(p)))
raw_roots = [studio_root(), outputs_root(), exports_root(), cache_root()]
roots = []
for root in raw_roots:
try:
roots.append(_lexical(str(root)))
except (OSError, RuntimeError, ValueError):
continue
try:
candidate = _lexical(model)
except (OSError, RuntimeError, ValueError):
return False
for root in roots:
if candidate == root or candidate.startswith(root + os.sep):
# Contained lexically; resolve symlinks and re-verify the real path
# is still under a root before touching the filesystem.
try:
real = os.path.realpath(candidate)
except (OSError, RuntimeError, ValueError):
return False
for raw in raw_roots:
try:
real_root = os.path.realpath(str(raw))
except (OSError, RuntimeError, ValueError):
continue
if real == real_root or real.startswith(real_root + os.sep):
return os.path.exists(real)
return False
return False
def _export_size_cached(
model: str, hf_token: Optional[str]
) -> tuple[Optional[int], Optional[int], str]:
"""Estimate a model's fp16/bf16-equivalent size in bytes (+ total params).
Memoizes successful results by model id; never raises (failures return
(None, None, "unavailable") and are not cached). Blocking I/O; call off-thread.
"""
cached = _EXPORT_SIZE_CACHE.get(model)
if cached is not None:
return cached
try:
from utils.hardware.hardware import (
_resolve_model_identifier_for_gpu_estimate,
estimate_fp16_model_size_bytes,
)
# A local LoRA adapter is sized via its base model, which the sizer
# reads from the adapter config; re-validate that resolved base so a
# crafted adapter can't redirect the local scan outside the roots.
if is_local_path(model):
base = _resolve_model_identifier_for_gpu_estimate(model, hf_token = hf_token)
if is_local_path(base) and not _is_sizable_local_path(base):
return None, None, "unavailable"
fp16_bytes, source = estimate_fp16_model_size_bytes(model, hf_token = hf_token)
if not fp16_bytes or fp16_bytes <= 0:
return None, None, source or "unavailable"
result = (int(fp16_bytes), int(fp16_bytes) // 2, source)
_EXPORT_SIZE_CACHE[model] = result
return result
except Exception as e: # a size hint must never break export
logger.warning("Could not estimate export size for '%s': %s", model, e)
return None, None, "unavailable"
@router.get("/export-size", response_model = ExportSizeResponse)
async def get_export_size(
model: str = Query(..., description = "Base model id or local model path to size"),
hf_token: Optional[str] = Header(None, alias = "X-HF-Token"),
current_subject: str = Depends(get_current_subject),
):
"""Estimate a model's fp16/bf16-equivalent size for the Export page.
Returns nulls with HTTP 200 when the size can't be determined. The HF token
(for gated repos) comes from the X-HF-Token header so it never hits URLs/logs.
"""
if is_local_path(model):
if not _is_sizable_local_path(model):
return ExportSizeResponse(
model = model, fp16_bytes = None, total_params = None, source = "unavailable"
)
resolved = model
else:
resolved = resolve_cached_repo_id_case(model)
# Blocking network/disk I/O: run off the event loop.
fp16_bytes, total_params, source = await asyncio.to_thread(
_export_size_cached, resolved, hf_token
)
return ExportSizeResponse(
model = resolved,
fp16_bytes = fp16_bytes,
total_params = total_params,
source = source,
)

View file

@ -72,6 +72,7 @@ def _install_lightweight_backend_stubs(monkeypatch):
fastapi.APIRouter = lambda: _Router()
fastapi.Body = lambda default = None, **_kwargs: default
fastapi.Depends = lambda dependency = None, **_kwargs: dependency
fastapi.Header = lambda default = None, **_kwargs: default
fastapi.HTTPException = _HTTPException
fastapi.Query = lambda default = None, **_kwargs: default
fastapi.Request = object
@ -191,6 +192,7 @@ def _install_lightweight_backend_stubs(monkeypatch):
for name in (
"BrowseEntry",
"BrowseFoldersResponse",
"ExportSizeResponse",
"GgufVariantDetail",
"GgufVariantsResponse",
"ScanFolderInfo",

View file

@ -0,0 +1,244 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for GET /api/models/export-size (the Export page size estimate).
The endpoint must never raise and must degrade to nulls when size is unknown.
"""
import asyncio
import importlib.util
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
# Real Qwen3.6-35B-A3B: 35.95B params -> ~67 GiB bf16 (UI wrongly showed Q8 ~8.2 GB).
_QWEN35_PARAMS = 35_951_822_704
_QWEN35_FP16_BYTES = _QWEN35_PARAMS * 2
def _load_route_module(name: str, relative_path: str):
spec = importlib.util.spec_from_file_location(name, _BACKEND_ROOT / relative_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestExportSizeEndpoint(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.models_route = _load_route_module(
"models_route_module_for_export_size_test",
"routes/models.py",
)
def setUp(self):
self.models_route._EXPORT_SIZE_CACHE.clear()
def _call(self, model: str = "unsloth/Qwen3.6-35B-A3B"):
with (
patch.object(self.models_route, "is_local_path", return_value = False),
patch.object(self.models_route, "resolve_cached_repo_id_case", side_effect = lambda m: m),
):
return asyncio.run(
self.models_route.get_export_size(
model = model, hf_token = None, current_subject = "test-user"
)
)
def test_known_model_returns_bytes_and_params(self):
with patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
return_value = (_QWEN35_FP16_BYTES, "safetensors"),
):
resp = self._call()
self.assertEqual(resp.fp16_bytes, _QWEN35_FP16_BYTES)
self.assertEqual(resp.total_params, _QWEN35_PARAMS)
self.assertEqual(resp.source, "safetensors")
self.assertEqual(resp.model, "unsloth/Qwen3.6-35B-A3B")
def test_moe_via_config_fallback(self):
# MoE sized via the sizer's config path -> source "config".
with patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
return_value = (67 * (1024**3), "config"),
):
resp = self._call()
self.assertEqual(resp.fp16_bytes, 67 * (1024**3))
self.assertEqual(resp.total_params, (67 * (1024**3)) // 2)
self.assertEqual(resp.source, "config")
def test_unknown_size_returns_nulls_not_error(self):
with patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
return_value = (None, "unavailable"),
):
resp = self._call()
self.assertIsNone(resp.fp16_bytes)
self.assertIsNone(resp.total_params)
self.assertEqual(resp.source, "unavailable")
def test_zero_size_treated_as_unknown(self):
with patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
return_value = (0, "safetensors"),
):
resp = self._call()
self.assertIsNone(resp.fp16_bytes)
self.assertIsNone(resp.total_params)
def test_sizer_exception_is_swallowed(self):
with patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
side_effect = RuntimeError("boom"),
):
resp = self._call()
self.assertIsNone(resp.fp16_bytes)
self.assertEqual(resp.source, "unavailable")
def test_result_is_memoized_per_model(self):
with patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
return_value = (_QWEN35_FP16_BYTES, "safetensors"),
) as mock_sizer:
first = self._call()
second = self._call()
self.assertEqual(first.fp16_bytes, second.fp16_bytes)
self.assertEqual(mock_sizer.call_count, 1)
def test_failures_are_not_cached(self):
# A transient failure must not poison the cache; a later call recovers.
with patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
side_effect = [(None, "unavailable"), (_QWEN35_FP16_BYTES, "safetensors")],
) as mock_sizer:
first = self._call()
second = self._call()
self.assertIsNone(first.fp16_bytes)
self.assertEqual(second.fp16_bytes, _QWEN35_FP16_BYTES)
self.assertEqual(mock_sizer.call_count, 2)
def test_token_is_forwarded_to_sizer(self):
with (
patch.object(self.models_route, "is_local_path", return_value = False),
patch.object(self.models_route, "resolve_cached_repo_id_case", side_effect = lambda m: m),
patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
return_value = (_QWEN35_FP16_BYTES, "safetensors"),
) as mock_sizer,
):
asyncio.run(
self.models_route.get_export_size(
model = "unsloth/Private",
hf_token = "secret-token",
current_subject = "test-user",
)
)
self.assertEqual(mock_sizer.call_args.kwargs.get("hf_token"), "secret-token")
def test_arbitrary_local_path_is_not_scanned(self):
# Unsafe local paths must not be scanned -> unavailable.
with (
patch.object(self.models_route, "is_local_path", return_value = True),
patch.object(self.models_route, "_is_sizable_local_path", return_value = False),
patch("utils.hardware.hardware.estimate_fp16_model_size_bytes") as mock_sizer,
):
resp = asyncio.run(
self.models_route.get_export_size(
model = "/etc", hf_token = None, current_subject = "test-user"
)
)
self.assertIsNone(resp.fp16_bytes)
self.assertEqual(resp.source, "unavailable")
mock_sizer.assert_not_called()
def test_sizable_local_path_is_sized(self):
with (
patch.object(self.models_route, "is_local_path", return_value = True),
patch.object(self.models_route, "_is_sizable_local_path", return_value = True),
patch(
"utils.hardware.hardware._resolve_model_identifier_for_gpu_estimate",
side_effect = lambda m, **_kw: m,
),
patch(
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
return_value = (_QWEN35_FP16_BYTES, "local"),
),
):
resp = asyncio.run(
self.models_route.get_export_size(
model = "/root/.unsloth/studio/outputs/run",
hf_token = None,
current_subject = "test-user",
)
)
self.assertEqual(resp.fp16_bytes, _QWEN35_FP16_BYTES)
self.assertEqual(resp.source, "local")
def test_local_adapter_base_escaping_roots_is_rejected(self):
# A local adapter under a root whose resolved base points outside the
# roots (e.g. "/") must not be sized: the resolved base is re-validated.
adapter = "/root/.unsloth/studio/outputs/adapter"
with (
patch.object(self.models_route, "is_local_path", return_value = True),
patch.object(
self.models_route, "_is_sizable_local_path", side_effect = lambda p: p == adapter
),
patch(
"utils.hardware.hardware._resolve_model_identifier_for_gpu_estimate",
return_value = "/",
),
patch("utils.hardware.hardware.estimate_fp16_model_size_bytes") as mock_sizer,
):
resp = asyncio.run(
self.models_route.get_export_size(
model = adapter, hf_token = None, current_subject = "test-user"
)
)
self.assertIsNone(resp.fp16_bytes)
self.assertEqual(resp.source, "unavailable")
mock_sizer.assert_not_called()
def test_is_sizable_local_path_containment(self):
# Only paths under a trusted root are sizable; '..' can't escape.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "outputs"
inside = root / "run-1"
inside.mkdir(parents = True)
with (
patch("utils.paths.studio_root", return_value = root),
patch("utils.paths.outputs_root", return_value = root),
patch("utils.paths.exports_root", return_value = root),
patch("utils.paths.storage_roots.cache_root", return_value = root),
):
is_sizable = self.models_route._is_sizable_local_path
self.assertTrue(is_sizable(str(inside)))
self.assertTrue(is_sizable(str(root)))
self.assertFalse(is_sizable(str(root / "missing")))
self.assertFalse(is_sizable("/etc"))
self.assertFalse(is_sizable(str(root / ".." / "etc")))
# A symlink inside a root pointing outside it cannot escape.
escape = root / "escape"
os.symlink(tmp, escape)
self.assertFalse(is_sizable(str(escape)))
def test_local_weight_size_skips_nested_checkpoints(self):
# A run dir's intermediate checkpoint-*/global_step* snapshots must not
# be counted; only the model files at the root are summed.
from utils.hardware.hardware import _get_local_weight_size_bytes
with tempfile.TemporaryDirectory() as tmp:
run = Path(tmp)
(run / "model.safetensors").write_bytes(b"\0" * 1000)
for sub, size in (("checkpoint-60", 5000), ("global_step10", 7000)):
d = run / sub
d.mkdir()
(d / "model.safetensors").write_bytes(b"\0" * size)
self.assertEqual(_get_local_weight_size_bytes(str(run)), 1000)
if __name__ == "__main__":
unittest.main()

View file

@ -1111,10 +1111,18 @@ def _get_local_weight_size_bytes(model_name: str) -> Optional[int]:
return None
weight_exts = (".safetensors", ".bin", ".pt", ".pth")
# Skip intermediate training checkpoints: a run dir can hold several
# checkpoint-*/global_step* snapshots, but export loads only the model at
# the root, so counting them would multiply the estimate.
skip_prefixes = ("checkpoint-", "global_step")
total = 0
for file in model_path.rglob("*"):
if file.is_file() and file.suffix in weight_exts:
total += file.stat().st_size
if not file.is_file() or file.suffix not in weight_exts:
continue
rel = file.relative_to(model_path)
if any(part.startswith(skip_prefixes) for part in rel.parts):
continue
total += file.stat().st_size
return total if total > 0 else None

View file

@ -33,6 +33,13 @@ export interface CheckpointListResponse {
models: ModelCheckpoints[];
}
export interface ExportSizeEstimate {
/** Estimated FP16/BF16-equivalent on-disk size, or null when unknown. */
fp16_bytes: number | null;
total_params: number | null;
source: string;
}
export interface ExportOperationResponse {
success: boolean;
message: string;
@ -48,6 +55,24 @@ export async function fetchCheckpoints(): Promise<CheckpointListResponse> {
return parseJson<CheckpointListResponse>(response);
}
/** Estimate a model's fp16-equivalent size to scale the GGUF quant labels; nulls (not error) when unknown. */
export async function fetchExportSize(
modelId: string,
hfToken?: string | null,
signal?: AbortSignal,
): Promise<ExportSizeEstimate> {
// Token in a header (not the query string) so it never lands in URLs/logs.
const headers: Record<string, string> = {};
if (hfToken) {
headers["X-HF-Token"] = hfToken;
}
const response = await authFetch(
`/api/models/export-size?model=${encodeURIComponent(modelId)}`,
{ signal, headers },
);
return parseJson<ExportSizeEstimate>(response);
}
export async function loadCheckpoint(params: {
checkpoint_path: string;
max_seq_length?: number;

View file

@ -252,7 +252,7 @@ export function ExportDialog({
checkpoint,
exportMethod,
quantLevels,
estimatedSize: _estimatedSize,
estimatedSize,
baseModelName,
isAdapter,
destination,
@ -576,11 +576,14 @@ export function ExportDialog({
</span>
</div>
)}
{/* TODO: unhide once estimated size comes from the backend API */}
{/* <div className="flex justify-between">
<span>Est. size</span>
<span className="font-medium text-foreground">{estimatedSize}</span>
</div> */}
{estimatedSize && (
<div className="flex justify-between">
<span>Est. size</span>
<span className="font-medium text-foreground">
{estimatedSize}
</span>
</div>
)}
</div>
{/* Live export output panel */}

View file

@ -18,9 +18,11 @@ import { QUANT_OPTIONS } from "../constants";
interface QuantPickerProps {
value: string[];
onChange: (v: string[]) => void;
/** quant value -> "~X GB"; blank/missing when the model size is unknown. */
sizes?: Record<string, string>;
}
export function QuantPicker({ value, onChange }: QuantPickerProps) {
export function QuantPicker({ value, onChange, sizes }: QuantPickerProps) {
const toggle = (qv: string) => {
onChange(
value.includes(qv) ? value.filter((q) => q !== qv) : [...value, qv],
@ -66,6 +68,7 @@ export function QuantPicker({ value, onChange }: QuantPickerProps) {
<div className="flex flex-wrap gap-2 py-1 pl-1">
{QUANT_OPTIONS.map((q) => {
const active = value.includes(q.value);
const sizeLabel = sizes?.[q.value] ?? "";
return (
<button
key={q.value}
@ -85,7 +88,9 @@ export function QuantPicker({ value, onChange }: QuantPickerProps) {
/>
)}
{q.label}
<span className="text-[10px] opacity-60">{q.size}</span>
{sizeLabel && (
<span className="text-[10px] opacity-60">{sizeLabel}</span>
)}
{q.recommended && !active && (
<span className="rounded-full bg-emerald-100 px-1.5 py-0 text-[9px] font-semibold text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300">
rec

View file

@ -35,39 +35,123 @@ export const EXPORT_METHODS: {
},
];
export const QUANT_OPTIONS = [
{ value: "q2_k_l", label: "Q2_K_L", size: "~2.9 GB" },
{ value: "q3_k_m", label: "Q3_K_M", size: "~3.5 GB" },
{ value: "q4_k_m", label: "Q4_K_M", size: "~4.8 GB", recommended: true },
{ value: "q5_k_m", label: "Q5_K_M", size: "~5.6 GB" },
{ value: "q6_k", label: "Q6_K", size: "~6.6 GB" },
{ value: "q8_0", label: "Q8_0", size: "~8.2 GB" },
{ value: "bf16", label: "BF16", size: "~14.2 GB" },
{ value: "f16", label: "F16", size: "~14.2 GB" },
export const QUANT_OPTIONS: {
value: string;
label: string;
recommended?: boolean;
}[] = [
{ value: "q2_k_l", label: "Q2_K_L" },
{ value: "q3_k_m", label: "Q3_K_M" },
{ value: "q4_k_m", label: "Q4_K_M", recommended: true },
{ value: "q5_k_m", label: "Q5_K_M" },
{ value: "q6_k", label: "Q6_K" },
{ value: "q8_0", label: "Q8_0" },
{ value: "bf16", label: "BF16" },
{ value: "f16", label: "F16" },
];
/**
* llama.cpp effective bits-per-weight per quant; GGUF size ~= fp16_bytes * bpw / 16.
* K-quant values are published average bit-rates (Q2_K_L = Unsloth Q2_K + Q8_0
* embeddings). Approximate ("~"), not exact file sizes.
*/
export const GGUF_BPW: Record<string, number> = {
q2_k_l: 3.35,
q3_k_m: 3.91,
q4_k_m: 4.83,
q5_k_m: 5.67,
q6_k: 6.56,
q8_0: 8.5,
bf16: 16,
f16: 16,
};
const FP16_BPW = 16;
/**
* Human-readable base-1024 size ("67 GB"), matching the model-selector picker.
* Do NOT use the base-1000 hub formatBytes here -- it would disagree ("72 GB").
*/
export function formatModelSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "";
}
const units = ["B", "KB", "MB", "GB", "TB"];
// clamp: bytes < 1 would give a negative index
const i = Math.max(
0,
Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1),
);
const value = bytes / 1024 ** i;
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`;
}
/** Estimated on-disk bytes for one GGUF quant, scaled from the real fp16 size. */
export function estimateQuantBytes(
fp16Bytes: number | null | undefined,
quant: string,
): number | null {
if (!fp16Bytes || fp16Bytes <= 0) {
return null;
}
const bpw = GGUF_BPW[quant];
if (bpw == null) {
return null;
}
return fp16Bytes * (bpw / FP16_BPW);
}
/** "~X GB" label for a quant, or "" when the real model size is unknown. */
export function formatQuantSize(
fp16Bytes: number | null | undefined,
quant: string,
): string {
const bytes = estimateQuantBytes(fp16Bytes, quant);
return bytes == null ? "" : `~${formatModelSize(bytes)}`;
}
/** value -> "~X GB" for every quant option (blank when size unknown). */
export function buildQuantSizeLabels(
fp16Bytes: number | null | undefined,
): Record<string, string> {
const out: Record<string, string> = {};
for (const q of QUANT_OPTIONS) {
out[q.value] = formatQuantSize(fp16Bytes, q.value);
}
return out;
}
/**
* Estimated total export size for the summary line; scales from the model's
* real fp16 size, returns "" when unknown so the UI can hide a wrong number.
*/
export function getEstimatedSize(
method: ExportMethod | null,
quantLevels: string[],
) {
const sizeOf = (v: string) =>
QUANT_OPTIONS.find((q) => q.value === v)?.size ?? "—";
fp16Bytes: number | null | undefined,
): string {
if (method === "gguf" && quantLevels.length > 0) {
if (quantLevels.length === 1) {
return sizeOf(quantLevels[0]);
const perQuant = quantLevels.map((q) => estimateQuantBytes(fp16Bytes, q));
if (perQuant.some((b) => b == null)) {
return ""; // unknown -> blank
}
const total = quantLevels
.map((q) => Number.parseFloat(sizeOf(q).replace(/[^0-9.]/g, "")))
.reduce((a, b) => a + b, 0);
return `~${total.toFixed(1)} GB (${quantLevels.length} files)`;
let total = 0;
for (const b of perQuant) {
total += b ?? 0;
}
const label = `~${formatModelSize(total)}`;
return quantLevels.length === 1
? label
: `${label} (${quantLevels.length} files)`;
}
if (method === "merged") {
return "~14.2 GB";
return fp16Bytes && fp16Bytes > 0 ? `~${formatModelSize(fp16Bytes)}` : "";
}
if (method === "lora") {
// Adapter size is bounded by LoRA rank, not the base model size.
return "~100 MB";
}
return "—";
return "";
}
export const METHOD_LABELS: Record<TrainingMethod, string> = {

View file

@ -71,8 +71,10 @@ import { QuantPicker } from "./components/quant-picker";
import {
type ExportMethod,
GUIDE_STEPS,
buildQuantSizeLabels,
getEstimatedSize,
} from "./constants";
import { useExportSizeEstimate } from "./hooks/use-export-size-estimate";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { exportTourSteps } from "./tour";
@ -243,6 +245,26 @@ export function ExportPage() {
? selectedSourceModel ?? "—"
: baseModelName;
// For a full fine-tune checkpoint the weights live in the checkpoint dir
// itself (its base_model may be a local/custom path that can't be sized), so
// size that dir; for LoRA adapters the export merges into the base model.
const sizeTargetModel = useMemo(() => {
if (sourceMode === "checkpoint" && !isAdapter) {
const cp = checkpointsForModel.find((c) => c.display_name === checkpoint);
if (cp?.path) {
return cp.path;
}
}
return sourceBaseModelName;
}, [sourceMode, isAdapter, checkpointsForModel, checkpoint, sourceBaseModelName]);
// Real (MoE-aware) fp16 size, used to scale the GGUF quant estimates.
const { fp16Bytes } = useExportSizeEstimate(sizeTargetModel, debouncedHfToken);
const quantSizeLabels = useMemo(
() => buildQuantSizeLabels(fp16Bytes),
[fp16Bytes],
);
const {
results: hfResults,
isLoading: isLoadingHfModels,
@ -367,7 +389,7 @@ export function ExportPage() {
}
};
const estimatedSize = getEstimatedSize(exportMethod, quantLevels);
const estimatedSize = getEstimatedSize(exportMethod, quantLevels, fp16Bytes);
const selectedExportSource =
sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
const defaultSaveDirectory = useMemo(() => {
@ -1087,21 +1109,28 @@ export function ExportPage() {
<AnimatePresence>
{exportMethod === "gguf" && (
<motion.div {...collapseAnim} className="overflow-visible">
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
<QuantPicker
value={quantLevels}
onChange={setQuantLevels}
sizes={quantSizeLabels}
/>
</motion.div>
)}
</AnimatePresence>
<Separator />
<div className="flex items-center justify-end">
{/* TODO: unhide once estimated size comes from the backend API */}
{/* <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3.5"
/>
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
</div> */}
<div className="flex items-center justify-between">
{estimatedSize ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3.5"
/>
<span>Est. size: {estimatedSize}</span>
</div>
) : (
<span />
)}
<Button
data-tour="export-cta"
disabled={!canExport}

View file

@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect, useState } from "react";
import { type ExportSizeEstimate, fetchExportSize } from "../api/export-api";
export interface ExportSizeState {
data: ExportSizeEstimate | null;
loading: boolean;
/** Real FP16/BF16-equivalent bytes, or null when unknown. */
fp16Bytes: number | null;
}
const EMPTY: ExportSizeState = { data: null, loading: false, fp16Bytes: null };
// Export page's "no model" sentinel (em dash U+2014); char code keeps this file ASCII.
const EMPTY_MODEL_SENTINEL = String.fromCharCode(0x2014);
function normalizeModelId(modelId: string | null | undefined): string {
const id = (modelId ?? "").trim();
return id && id !== EMPTY_MODEL_SENTINEL ? id : "";
}
/**
* Fetch the selected model's fp16-equivalent size so the GGUF picker can scale
* its per-quant estimates. Null size on any failure; refetches on token change.
*/
export function useExportSizeEstimate(
modelId: string | null | undefined,
hfToken?: string | null,
): ExportSizeState {
const modelKey = normalizeModelId(modelId);
const token = hfToken?.trim() || "";
// Refetch when model or token changes; "|" can't appear in an id/token.
const key = modelKey ? `${modelKey}|${token}` : "";
const [state, setState] = useState<{ key: string; value: ExportSizeState }>(
() => ({ key: "", value: EMPTY }),
);
useEffect(() => {
if (!modelKey) {
setState({ key: "", value: EMPTY });
return;
}
const controller = new AbortController();
setState({ key, value: { ...EMPTY, loading: true } });
void fetchExportSize(modelKey, token, controller.signal)
.then((data) => {
if (controller.signal.aborted) {
return;
}
setState({
key,
value: { data, loading: false, fp16Bytes: data.fp16_bytes ?? null },
});
})
.catch(() => {
if (controller.signal.aborted) {
return;
}
setState({ key, value: EMPTY });
});
return () => controller.abort();
}, [key, modelKey, token]);
// Guard against a stale result rendering for a newer key.
return state.key === key ? state.value : EMPTY;
}