From 22e6d644933c58d329eeb903bbde49101fdf7002 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 18 Jun 2026 05:44:17 -0700 Subject: [PATCH] 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 --- studio/backend/models/models.py | 18 ++ studio/backend/routes/models.py | 122 ++++++++- .../tests/test_export_absolute_paths.py | 2 + .../tests/test_export_size_estimate.py | 244 ++++++++++++++++++ studio/backend/utils/hardware/hardware.py | 12 +- .../src/features/export/api/export-api.ts | 25 ++ .../export/components/export-dialog.tsx | 15 +- .../export/components/quant-picker.tsx | 9 +- .../frontend/src/features/export/constants.ts | 124 +++++++-- .../src/features/export/export-page.tsx | 51 +++- .../export/hooks/use-export-size-estimate.ts | 68 +++++ 11 files changed, 648 insertions(+), 42 deletions(-) create mode 100644 studio/backend/tests/test_export_size_estimate.py create mode 100644 studio/frontend/src/features/export/hooks/use-export-size-estimate.ts diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index ff00363ff8..d1ef368eae 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -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""" diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 8eec7777c4..c17bb6fb57 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -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, + ) diff --git a/studio/backend/tests/test_export_absolute_paths.py b/studio/backend/tests/test_export_absolute_paths.py index d62c2a5e61..761ea08e3f 100644 --- a/studio/backend/tests/test_export_absolute_paths.py +++ b/studio/backend/tests/test_export_absolute_paths.py @@ -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", diff --git a/studio/backend/tests/test_export_size_estimate.py b/studio/backend/tests/test_export_size_estimate.py new file mode 100644 index 0000000000..6976187d83 --- /dev/null +++ b/studio/backend/tests/test_export_size_estimate.py @@ -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() diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py index 12baded14a..02e177baf3 100644 --- a/studio/backend/utils/hardware/hardware.py +++ b/studio/backend/utils/hardware/hardware.py @@ -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 diff --git a/studio/frontend/src/features/export/api/export-api.ts b/studio/frontend/src/features/export/api/export-api.ts index e6c51b1e36..5cd944e67b 100644 --- a/studio/frontend/src/features/export/api/export-api.ts +++ b/studio/frontend/src/features/export/api/export-api.ts @@ -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 { return parseJson(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 { + // Token in a header (not the query string) so it never lands in URLs/logs. + const headers: Record = {}; + if (hfToken) { + headers["X-HF-Token"] = hfToken; + } + const response = await authFetch( + `/api/models/export-size?model=${encodeURIComponent(modelId)}`, + { signal, headers }, + ); + return parseJson(response); +} + export async function loadCheckpoint(params: { checkpoint_path: string; max_seq_length?: number; diff --git a/studio/frontend/src/features/export/components/export-dialog.tsx b/studio/frontend/src/features/export/components/export-dialog.tsx index 2b40356a8c..c366f0cdeb 100644 --- a/studio/frontend/src/features/export/components/export-dialog.tsx +++ b/studio/frontend/src/features/export/components/export-dialog.tsx @@ -252,7 +252,7 @@ export function ExportDialog({ checkpoint, exportMethod, quantLevels, - estimatedSize: _estimatedSize, + estimatedSize, baseModelName, isAdapter, destination, @@ -576,11 +576,14 @@ export function ExportDialog({ )} - {/* TODO: unhide once estimated size comes from the backend API */} - {/*
- Est. size - {estimatedSize} -
*/} + {estimatedSize && ( +
+ Est. size + + {estimatedSize} + +
+ )} {/* Live export output panel */} diff --git a/studio/frontend/src/features/export/components/quant-picker.tsx b/studio/frontend/src/features/export/components/quant-picker.tsx index 3001cfc033..192cbeaeee 100644 --- a/studio/frontend/src/features/export/components/quant-picker.tsx +++ b/studio/frontend/src/features/export/components/quant-picker.tsx @@ -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; } -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) {
{QUANT_OPTIONS.map((q) => { const active = value.includes(q.value); + const sizeLabel = sizes?.[q.value] ?? ""; return (