Skip to content

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

Merged
danielhanchen merged 10 commits into
mainfrom
export-size-fix
Jun 18, 2026
Merged

Studio: scale export GGUF size estimates from the real model size#6418
danielhanchen merged 10 commits into
mainfrom
export-size-fix

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Summary

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

This scales the estimate from the model's real, MoE-aware fp16 size.

Changes

Backend

  • Add GET /api/models/export-size, returning a model's fp16/bf16-equivalent size and total params, reusing the existing MoE-aware 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.
  • Add the ExportSizeResponse schema and tests/test_export_size_estimate.py.

Frontend

  • Replace the static QUANT_OPTIONS sizes with a bits-per-weight map; each quant scales from the real fp16 size (bytes ~= fp16_bytes * bpw / 16), formatted in GiB to match the model selector.
  • New useExportSizeEstimate hook fetches the size when a checkpoint or model is selected; sizes are hidden when unknown rather than shown as a fixed number.
  • Restore the Est. size summary in the page and dialog now that the value comes from the backend.

Verification

  • New backend test passes on Linux, macOS and Windows.
  • Estimate checked against unsloth/Qwen3.6-35B-A3B: Q8 ~36 GiB (was ~8.2 GB), within ~3.5% of the published GGUF; Q4_K_M within ~2%.
  • Real end-to-end MoE export (Qwen1.5-MoE-A2.7B to Q8_0): actual 14.18 GiB vs estimate 14.17 GiB, confirming the export is correct and the estimate predicts it.

Before/after for Qwen3.6-35B-A3B:

quant before after
Q8_0 ~8.2 GB ~36 GB
Q4_K_M ~4.8 GB ~20 GB
BF16 ~14.2 GB ~67 GB

danielhanchen and others added 2 commits June 18, 2026 02:23
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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a dynamic model-size estimation feature for the Export page, replacing hardcoded estimates with real, MoE-aware size calculations scaled from the model's FP16/BF16-equivalent size. On the backend, a new /api/models/export-size endpoint and caching mechanism were added, along with a suite of unit tests. On the frontend, a custom hook fetches these estimates to dynamically calculate and display human-readable sizes for each GGUF quantization level. The review feedback highlights three key improvements: changing the backend endpoint from async def to a synchronous def to prevent blocking the FastAPI event loop, updating the corresponding tests to call the endpoint synchronously, and clamping the index calculation in formatModelSize to a minimum of 0 to avoid out-of-bounds errors when formatting sizes less than 1 byte.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.



@router.get("/export-size", response_model = ExportSizeResponse)
async def get_export_size(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The get_export_size endpoint is defined as an async def but calls _export_size_cached, which performs blocking synchronous network and disk I/O (e.g., querying Hugging Face Hub via huggingface_hub and loading configurations via transformers). Because there are no await expressions inside this endpoint, defining it as async def will block the main asyncio event loop, preventing FastAPI from handling other concurrent requests.

Changing the endpoint definition to a standard synchronous def allows FastAPI to automatically run it in an external thread pool, keeping the event loop responsive.

Suggested change
async def get_export_size(
def get_export_size(

Comment on lines +53 to +57
model = model, hf_token = None, current_subject = "test-user"
)
)

def test_known_model_returns_bytes_and_params(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since get_export_size is being changed to a synchronous function to prevent blocking the event loop, calling it with asyncio.run in the test suite will raise a TypeError because it is no longer a coroutine. Update the test helper to call the endpoint synchronously.

            return self.models_route.get_export_size(
                model = model, hf_token = None, current_subject = "test-user"
            )

Comment on lines +86 to +89
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If bytes is less than 1 (which can happen with fractional byte values or extremely small sizes), Math.floor(Math.log(bytes) / Math.log(1024)) will evaluate to a negative integer (e.g., -1). This results in an out-of-bounds index for the units array, returning a string like "512.0 undefined".

Clamping the index i to a minimum of 0 ensures that small byte values are formatted correctly as "B".

  const i = Math.max(
    0,
    Math.min(
      Math.floor(Math.log(bytes) / Math.log(1024)),
      units.length - 1,
    ),
  );

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3807fd1e7b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

signal?: AbortSignal,
): Promise<ExportSizeEstimate> {
const response = await authFetch(
`/api/models/export-size?model=${encodeURIComponent(modelId)}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the HF token when estimating gated models

When the selected source is a private/gated Hugging Face model, this request only sends model, even though the backend endpoint accepts hf_token and the user can enter one on the Export page. In that scenario estimate_fp16_model_size_bytes cannot read safetensors/config metadata and returns null, so the new GGUF size estimates disappear for exactly the models that require authenticated access; include the token and make the hook refetch when it changes.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/models.py Outdated
resolved = model
if not is_local_path(model):
resolved = resolve_cached_repo_id_case(model)
fp16_bytes, total_params, source = _export_size_cached(resolved, hf_token)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move export-size estimation off the event loop

When the selected model is not already resolved locally, _export_size_cached can perform blocking Hugging Face metadata/config requests via estimate_fp16_model_size_bytes; calling it directly from this async route ties up the server event loop for the duration of a slow Hub request, delaying unrelated API calls and live export/log endpoints. Run the estimate in a worker thread (for example with asyncio.to_thread) so a size hint cannot stall the rest of Studio.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/models.py Outdated
)


@lru_cache(maxsize = 128)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid caching unavailable export-size results

Because the whole helper is memoized, transient failures are cached the same as successful estimates: if the first request for a model hits offline Hugging Face, a gated response before credentials are available, or any temporary exception, the (None, None, "unavailable") result is returned for subsequent selections of the same model/token until eviction or process restart. Cache only positive estimates (or use a short TTL for failures) so the Export page can recover once metadata becomes available.

Useful? React with 👍 / 👎.

- 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8757a1a71

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// The token lets the sizer read safetensors/config metadata for private or
// gated repos, which would otherwise return no estimate.
const tokenParam = hfToken
? `&hf_token=${encodeURIComponent(hfToken)}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep HF tokens out of export-size URLs

When a user sizes a private or gated model, this puts their Hugging Face token directly into the request URL (hf_token=...). Query strings are routinely exposed in browser/devtool network logs and backend or proxy access logs, so this new size-estimation call can leak credentials; send the token in a request body or an internal header instead of appending it to the GET URL.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/models.py Outdated

Blocking (HF metadata / config / disk); call via a worker thread.
"""
key = (model, hf_token)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid retaining HF tokens in the export-size cache

When a private or gated model estimate succeeds, this module-global cache stores the full Hugging Face token as part of the key for the lifetime of the backend process. The cache only needs to separate credential scopes, so retaining raw secrets in long-lived memory unnecessarily exposes them in debug dumps or memory snapshots; key by a token fingerprint or another non-secret discriminator instead.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/models.py Outdated
``estimate_fp16_model_size_bytes`` (safetensors -> config -> local -> vllm).
"""
resolved = model
if not is_local_path(model):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict local paths before sizing exports

When an authenticated caller passes an existing broad local directory such as / or a home directory, this branch leaves it as the model to size, and _export_size_cached ultimately reaches the local weight sizer that recursively walks the directory looking for weight files. A single malformed export-size request can therefore burn I/O over the whole filesystem and tie up worker threads; restrict this endpoint to known model directories or reject overly broad local paths before invoking the sizer.

Useful? React with 👍 / 👎.

danielhanchen and others added 2 commits June 18, 2026 03:50
- 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ecf1699a9f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/routes/models.py Outdated
root_resolved = root.resolve()
except (OSError, RuntimeError, ValueError):
continue
if target == root_resolved or target.is_relative_to(root_resolved):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject Studio roots before recursive sizing

When model is exactly studio_root(), outputs_root(), exports_root(), or cache_root(), this equality branch returns true; _export_size_cached then calls the local weight sizer, which recursively walks the whole tree. A malformed authenticated /api/models/export-size?model=$STUDIO_HOME or cache-root request can still burn I/O over all Studio outputs/caches instead of one model directory. Fresh evidence: the new allowlist still admits the broad root itself via target == root_resolved; require a specific model directory before sizing.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/models.py Outdated
return False
if not target.exists():
return False
for root in (studio_root(), outputs_root(), exports_root(), cache_root()):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include known local model roots in sizing allowlist

When a user directly exports a local model that Studio already discovered from LM Studio or a registered custom scan folder, the frontend sends that local path to /export-size, but this allowlist only accepts Studio roots. Those selections can still export, yet the endpoint returns unavailable, so the new quant-size labels and summary disappear for a supported local-model flow; include the same trusted LM Studio/custom roots used by the local model picker, while still rejecting arbitrary paths.

Useful? React with 👍 / 👎.

Comment on lines +2818 to +2820
cached = _EXPORT_SIZE_CACHE.get(model)
if cached is not None:
return cached

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Partition cached estimates by credential scope

When a private or gated model is first sized with X-HF-Token, the successful result is cached only by model, so a later request for the same repo without that token (or from another authenticated subject) returns the privileged cached size instead of the unavailable result the sizer would produce. That leaks private model metadata and makes the UI show estimates under credentials that cannot access the repo; key successful remote estimates by a non-secret credential scope such as subject plus token fingerprint, or only reuse unauthenticated successes globally.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/models.py Fixed
danielhanchen and others added 2 commits June 18, 2026 05:44
- 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.
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).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1ceaa82d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


// Real (MoE-aware) fp16 size, used to scale the GGUF quant estimates.
const { fp16Bytes } = useExportSizeEstimate(
sourceBaseModelName,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Size full-finetune checkpoints from the checkpoint path

When exporting a checkpoint from a full fine-tune whose base_model is a local/custom path outside the Studio roots (or has since been moved), this hook asks /export-size to size sourceBaseModelName rather than the selected checkpoint directory. That makes the endpoint return unavailable even though the checkpoint itself is under outputs_root() and could be sized locally, so GGUF quant labels and the summary disappear for a supported checkpoint export flow; use the selected checkpoint path for non-adapter checkpoint exports, keeping the base-model estimate for LoRA adapters.

Useful? React with 👍 / 👎.

try:
from utils.hardware.hardware import estimate_fp16_model_size_bytes

fp16_bytes, source = estimate_fp16_model_size_bytes(model, hf_token = hf_token)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-validate adapter base paths before sizing

When model is a local LoRA adapter inside an allowed Studio root, this call can still end up sizing an arbitrary path because estimate_fp16_model_size_bytes first resolves adapters through base_model_name_or_path and then the local weight sizer recursively walks that resolved base. A malformed adapter under outputs/cache with a base like / or a home directory bypasses _is_sizable_local_path and can burn I/O over the filesystem; re-check the resolved identifier before allowing local sizing.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/models.py Outdated
Comment on lines +2800 to +2802
if candidate == root or candidate.startswith(root + os.sep):
# Contained in a trusted root; safe to touch the filesystem.
return os.path.exists(candidate)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve symlinks before accepting local size paths

When an allowed Studio path is itself a directory symlink to somewhere outside the Studio roots (for example outputs/link -> /), this lexical prefix check still accepts it and the subsequent local weight sizer follows that symlink while recursively walking the candidate. That bypasses the local-path allowlist and lets a malformed export-size request scan arbitrary filesystem trees; compare resolved real paths or reject symlinked directory candidates before sizing.

Useful? React with 👍 / 👎.

try:
from utils.hardware.hardware import estimate_fp16_model_size_bytes

fp16_bytes, source = estimate_fp16_model_size_bytes(model, hf_token = hf_token)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid counting nested checkpoints in local estimates

When a user directly sizes a Studio training-run directory that contains checkpoint-* subdirectories, this passes the run root to the estimator; its local fallback recursively sums every weight file below that root and then prefers the larger local byte count over the config-derived size. The GGUF estimate can therefore be multiplied by the number of saved intermediate checkpoints even though export loads only the model at the selected root, so local full-finetune direct exports show inflated quant sizes; size only the selected model files/snapshot instead of the whole tree.

Useful? React with 👍 / 👎.

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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@danielhanchen danielhanchen merged commit 22e6d64 into main Jun 18, 2026
33 of 34 checks passed
@danielhanchen danielhanchen deleted the export-size-fix branch June 18, 2026 12:44
except (OSError, RuntimeError, ValueError):
continue
if real == real_root or real.startswith(real_root + os.sep):
return os.path.exists(real)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants