Commit graph

939 commits

Author SHA1 Message Date
Daniel Han
65c8a88fe4
Studio macOS: force anyio<4.14.0 via uv override (#6575)
The macOS-arm studio venv still installs anyio 4.14.0 despite the
constraints.txt cap from #6546. mlx-vlm / mlx-lm pull anyio>=4.14, which
conflicts with the anyio<4.14.0 constraint; a uv -c constraint loses that
conflict so 4.14.0 gets installed, reintroducing the cancel-scope
RuntimeError on Python 3.13 (#6483). UV_OVERRIDE is already applied on
macOS-arm via overrides-darwin-arm64.txt and a uv override wins the
conflict, so cap anyio there too. macOS-arm now resolves anyio 4.13.0.
2026-06-22 08:45:13 -07:00
Michael Han
7bd8e64921
Studio: honor custom HF_HOME for model download and load (#6510)
* Studio: honor custom HF_HOME for model download and load

_setup_cache_env always derived HF_HUB_CACHE and HF_XET_CACHE from
XDG_CACHE_HOME / ~/.cache, ignoring a user-set HF_HOME. Because it sets
HF_HUB_CACHE explicitly and that variable takes precedence over HF_HOME
in huggingface_hub, the hub cache was pinned to the standard location: a
model already present under a custom HF_HOME was detected but then
re-downloaded from scratch on load.

Seed HF_HUB_CACHE and HF_XET_CACHE from HF_HOME when the user set it
(HF's own default is $HF_HOME/hub and $HF_HOME/xet), and honor the legacy
HUGGINGFACE_HUB_CACHE alias. The hub download workers call
snapshot_download without a cache_dir for both the Xet and HTTP-fallback
paths, so they follow HF_HUB_CACHE; fixing it here unifies detection and
both transports on one root. Explicit HF_HUB_CACHE / HF_XET_CACHE stay
untouched. Adds tests for the custom-HF_HOME, default, explicit-override,
and legacy-alias cases. Fixes #5182.

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

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

* Studio: do not crash startup when a custom HF_HOME is not writable

Seeding HF_HUB_CACHE/HF_XET_CACHE from HF_HOME means _setup_cache_env now
mkdir's under a user-controlled path. A non-writable or not-yet-mounted
HF_HOME (typo, offline drive) would raise and crash startup, where the old
code silently fell back. Make the mkdir best-effort; the env var is still
set, so HF reports a clear error at download time. Adds a regression test.

* Studio: strip blank HF_HOME and isolate cache-env tests

Address review: a whitespace-only HF_HOME no longer derives " /hub";
strip it and fall back to the default (matches studio_root). Tests set
UNSLOTH_STUDIO_HOME to a tmp dir so _setup_cache_env's UV/VLLM mkdirs do
not touch the real ~/.unsloth/studio. Adds a whitespace regression test.

* [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>
2026-06-22 08:21:59 -07:00
Daniel Han
007a21235c
Generalize transformers tier selection by probing AutoConfig (#6550)
* Resolve the transformers tier by probing AutoConfig instead of guessing

When the only signal is a 5.x tokenizer class, get_transformers_tier guessed the
lowest 5.x sidecar (530). That misroutes models whose built-in config parser needs
a higher tier: dense NemotronH ships a 5.x tokenizer but its '-' (MLP) layer only
transformers 5.10 can parse, so 5.3/5.5 raise KeyError '-'. The config.json
transformers_version field records the saving version, not the minimum to load, so
it cannot drive routing either.

Replace the weak tokenizer->530 guesses (local and remote) with a probe: parse
config.json with the built-in parser (trust_remote_code=False) in each sidecar,
escalating 530->550->510, and pick the first that succeeds. This generalizes to any
architecture without hardcoded lists. Strong signals stay fast paths (no subprocess);
the probe runs only when the tier is otherwise ambiguous and is cached by (model,
commit sha). It never executes repo code, never downloads weights, never raises, and
falls back to the legacy 530 guess on a transient/auth/offline failure or when no
sidecar is available. UNSLOTH_DISABLE_TIER_PROBE restores the old behavior.

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

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

* Address review: tier probe fallbacks and cross-platform robustness

Codex:
- Never escalate to 510 on uncertainty. When every sidecar was probed and none
  parsed with the built-in parser, the model is a remote-code / custom model_type
  that loads via its own code; keep the legacy 530 route instead of jumping to
  510 (which would change the behavior of models that worked on the 5.3 stack).
- Only cache the 530 fallback when the result is conclusive (every tier actually
  probed). If a sidecar was missing/uninstallable the environment is incomplete,
  so return 530 uncached and retry on the next call.
- Do not pin the tier cache under an unknown revision: _resolve_commit_sha no
  longer memoizes a None sha (a transient Hub failure is retried), and _probe_tier
  only caches a tier when the commit sha is known.

Gemini:
- Wrap Path.exists() in the sha resolver in try/except OSError (a remote repo id
  can raise WinError 123 on Windows).
- Probe script writes the error to sys.stderr.buffer as UTF-8 bytes so a non-ASCII
  message cannot itself raise UnicodeEncodeError under cp1252.
- subprocess.run decodes stderr with errors="replace" to avoid UnicodeDecodeError
  on non-UTF-8 consoles.

Tests: 72 passed (added partial-sidecar uncached, sha-unresolved not cached,
all-failed stays 530 + cached, sha resolver retries None / handles OSError).

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

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

* Address review round 2: authenticate tier checks, stop memoizing local sigs

Codex:
- Thread hf_token through _check_config_needs_510/550 and
  _check_tokenizer_config_needs_v5 (and the underlying raw fetches). Previously a
  gated/private model whose only 5.x signal is tokenizer_config.json never reached
  the authenticated probe: the unauthenticated raw fetch failed and cached False,
  so the model fell through to the default 4.x tier. The per-check caches are now
  keyed by (model, token) so an unauthenticated miss cannot poison a later authed
  read, mirroring _load_config_json.
- _resolve_commit_sha no longer memoizes a local directory signature. A local
  signature is mutable (size/mtime of config/tokenizer), so a reused/overwritten
  checkpoint path would otherwise keep selecting the previous tier; it is now
  recomputed every call. Only the immutable remote commit sha is memoized.

Tests: 75 passed (added token-cache isolation + auth header, local signature not
memoized, token threaded into all checks/probe).

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

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

* Address review round 3: reach activation with the token, drop SHA tier cache

Codex round 3:
- Thread hf_token into the activation path that actually selects a sidecar. The
  token-aware tier checks added last round were unreachable:
  activate_transformers_for_subprocess called get_transformers_tier without a
  token, and the inference/training/export workers passed only the model name even
  though they hold a request-scoped hf_token. activate_transformers_for_subprocess
  now takes hf_token and the three workers forward config["hf_token"], so a
  gated/private model whose only 5.x signal is an authenticated config/tokenizer is
  routed to the right sidecar instead of falling to default 4.x.
- Stop importing huggingface_hub during tier detection. _probe_tier no longer
  resolves a commit sha, so it never pulls huggingface_hub into the worker before
  the sidecar venv is prepended to sys.path (activation only prepends, never
  purges), which would otherwise pin the default-env hub over the sidecar's
  pinned huggingface_hub==1.8.0.
- The tier cache is now keyed by model_name for the process lifetime (a model's
  required tier is a property of its architecture; cleared on restart). This drops
  the mutable-SHA memo that masked remote revision changes and the mutable
  local-signature memo, removing _resolve_commit_sha / _local_dir_signature /
  _probe_sha_cache entirely.
- Do not cache a probe success that depended on a skipped lower tier: if a lower
  sidecar was unavailable, the lowest valid tier may change once it installs, so
  the result is returned uncached and re-probed next call.

Tests: 73 passed (probe imports no hub; success uncached when a lower tier is
skipped; activation forwards the token).

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

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

* Trim comments to be more succinct

* Re-probe overwritten local checkpoints and authenticate the probe child

The AutoConfig tier probe cached its result under the bare model_name, so a
local checkpoint overwritten in place (same path, new config.json) kept serving
the stale sidecar. Fold a cheap config.json signature (size + mtime) into the
cache key for local paths; remote ids stay name-keyed so no huggingface_hub
import lands before the sidecar is activated.

The probe relies on the implicit HF_TOKEN env, so an inherited
HF_HUB_DISABLE_IMPLICIT_TOKEN=1 left it unauthenticated and a gated repo 401ed
into the 530 fail-safe. Clear that flag in the child env when a token is set.

* Keep tier probes off the log-only path and probe new 5.x archs default-first

- get_transformers_tier gains probe=True/False. needs_transformers_5 (a coarse
  4-vs-5 boolean used only for a spawn log and a vision-check branch) now passes
  probe=False, so a parent/log-only caller never spawns sidecar probes. The real
  activation path keeps probe=True and resolves the exact tier in the worker.
- A config.json saved by transformers 5.x but matched by no fast path is now probed
  default-first: _probe_tier gains include_default + floor, prepending the ambient
  4.57.x tier to the escalation. A model that still parses on the default is left on
  it (no mis-route onto a sidecar); only a config the default parser cannot read
  escalates to the lowest 5.x tier that parses. The transformers_version field is a
  cheap 'worth probing' hint only, read from the already-fetched config (no extra
  network); ordinary 4.x configs never probe.

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

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

* Separate probe cache by mode and keep version-field 5.x visible to needs_transformers_5

- _probe_tier cache was keyed only by config.json signature, so a default-first probe
  that returned 'default' could be handed back to a later tokenizer/known-5.x caller
  (floor=530), leaving a model with a 5.x-only tokenizer on transformers 4.x. Key the
  cache by probe mode (floor + include_default); the legacy 530 mode keeps the bare key.
- The version-field 5.x detection is a cheap config read, not a probe, so run it even
  when probe=False: a standard-tokenizer model whose only signal is transformers_version
  >= 5 now classifies as 5.x via needs_transformers_5 (returns '530' without spawning a
  probe), so the vision-routing fallback uses the 5.x subprocess instead of failing the
  default parser and marking it non-vision. The real activation path still probes
  default-first and may resolve 'default'.

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

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

* Don't treat local checkpoints as Hub ids, and fix stale activation test double

- _load_config_json / _check_tokenizer_config_needs_v5: a local checkpoint dir whose
  config.json / tokenizer_config.json is not yet present was being fetched from the Hub
  as if the path were a repo id, and the 404 miss was cached. A later call after the
  file is written (in-progress checkpoint) then served the stale miss, so a
  TokenizersBackend checkpoint fell through to the default tier. Skip the Hub fetch for
  local dirs and do not cache the miss, so the file is read once it appears.
- test_activate_transformers_version_or_warn_*: the worker now threads hf_token into
  _activate_transformers_version (model_name, hf_token); update the one-arg test doubles
  to the real two-arg signature so the silent-success path stays silent.

* Tighten comments in the AutoConfig probe and tier-selection paths

* Address review: canonical probe cache key and reuse _token_cache_key

- _probe_cache_key resolves config.json to its absolute realpath before
  keying, so a relative path or a changed cwd can't collide with or miss a
  prior probe result. Remote ids still fall back to the name (stat raises,
  caught).
- _cached_config_json reuses _token_cache_key instead of re-hashing the
  token inline, keeping the (model, token) key derivation in one place.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-22 08:20:06 -07:00
Sanat Bhargava
1fc8bf53c7
Add Hugging Face dataset streaming mode to Studio (#4946)
* Add HF dataset streaming mode to Studio

* Added default value for datasetStreaming in training-config-store.ts

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

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

* Handle None max_steps for streaming validation

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

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

* studio: fast-fail streaming validation and guard incompatible modes

Reject dataset_streaming at the API boundary when hf_dataset is empty,
the dataset is vision/audio, or max_steps is not set. Probe eval split
with get_dataset_split_names before the streaming load so typos fail
immediately instead of mid-training. Guard column_names=None after map
on iterables. Hide the UI toggle for non-text configurations and clear
the stale flag when config becomes incompatible.

* studio: add streaming dataset tests, iterable helper, and streaming template/format support (WIP)

Work-in-progress on top of feat/studio-dataset-streaming-mode (PR #4946):
- new test_training_streaming.py and iterable.py dataset helper
- streaming support in chat_templates.py and format_conversion.py
- additional streaming guards in trainer.py / models / routes
- frontend streaming wiring in params-section and training-config-store

Committed to preserve uncommitted work before merging latest main.

* studio: fix review-team findings for streaming + main merge

BLOCKER: streaming + raw-text/CPT crashed on len(IterableDataset). Guard it in the
start route (reject format_type=="raw" or training_type=="Continued Pretraining")
and in isStreamingSupported (datasetFormat !== "raw").

Also:
- models/training.py: validate hf_dataset/subset/split (charset+length, block ..//);
  cap dataset slice indices (le=1e9); note validator ordering
- chat_templates.py: guard _apply_custom_mapping .map() for streaming
- trainer.py: warn when packing+streaming
- training-config-store.ts: persist-migration bump to v11 (standalone datasetStreaming
  backfill); add isVisionModel to NON_PERSISTED; toast on silent streamingCompatiblePatch
  mutations in the 4 indirect setters
- tests: route rejections (max_steps, raw/cpt), slice cap, unsafe hf_dataset

* studio: enable raw-text/CPT dataset streaming + streaming UX polish

- raw_text: keep the lazy filter but skip len()-based row counting for
  IterableDatasets so raw-text / CPT can stream; guard the eval-size log
- routes/trainer: drop the raw/CPT streaming block; add a defensive
  not-streaming guard on the eval auto-split (train_test_split)
- dataset-section: streaming toggle is visible-but-disabled and lists the
  exact unmet requirement(s) in its tooltip; block embedding models
- training-start-overlay: show "streaming (no full download)" instead of a
  stuck download bar for streaming runs
- trim the streaming test suite to the high-value cases

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

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

* studio: address streaming review (MLX/embedding guards, sliced eval split, rehydrate timing)

- routes: reject dataset_streaming for embedding training and on Apple Silicon
  (MLX); both loaders materialize the full dataset instead of streaming
- trainer: validate the base eval split name so streaming eval accepts HF slice
  syntax such as "validation[:1000]"
- training-config-store: defer the onRehydrateStorage setState to a microtask so
  it doesn't hit the store's TDZ during synchronous hydration
- test: streaming start rejects embedding models

* studio: harden HF dataset streaming (column_names, split slicing, empty/eval bounds, gating)

Address a deeper streaming review:
- raw_text: resolve_column_names() guards IterableDataset.column_names=None
  (from_generator / unresolved features) so raw-text and CPT streaming no longer
  raise TypeError before training
- models/routes: reject HF slice syntax in train_split/eval_split when streaming
  (load_dataset(streaming=True) raises "Bad split"); reject mixed sources
  (local/S3) and embedding/MLX streaming at the API, not just in the UI
- trainer: an empty post-slice/filter stream fails preflight with a clear message;
  streaming eval is capped (STREAMING_EVAL_MAX_SAMPLES) so each eval terminates;
  the manual-slice shortcut falls back to a regular load when train_split is sliced
- format_conversion: streaming conversions preflight the first mapped row so
  format errors surface before training, not mid-iteration
- frontend: block streaming on Apple Silicon; clear datasetStreaming when a
  dataset is detected as image/audio at start

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

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

* studio: fix CI for streaming PR (lint blocker + no-torch sandbox + preflight test)

- trainer.py: drop unused `IterableDataset` import (hoist safety-net blocker).
- test_training_streaming.py: only select real classes (isinstance type) when
  locating the trainer class, so a MagicMock-stubbed global is never passed to
  object.__new__ (fixes TypeError on the Python 3.10-3.13 jobs).
- no-torch import sandboxes (test_e2e_no_torch_sandbox.py,
  test_studio_import_no_torch.py): teach the chat_templates/format_conversion
  exec stubs and the full-import-chain copy list about the new `.iterable`
  module so the AFTER/runtime cases import without torch again.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
2026-06-22 17:48:18 +03:00
Daniel Han
dbc13f02c9
Studio: fix Ctrl+C shutdown ordering (installer shell + uvicorn thread wait) (#6566)
* Installer: respect a declined Studio auto-start and keep Ctrl+C shutdown logs ordered

The `curl | sh` Studio auto-start prompt had two issues on Linux/macOS/WSL
(install.sh). install.ps1 already gates on input redirection, so Windows is
unaffected.

1. Typing n, or any closed/EOF /dev/tty, still launched Studio. The read
   fallbacks defaulted to "y" (read failure, and the no-tty branch), so any
   answer other than a cleanly delivered y/n line auto-started a blocking
   foreground server. Default those to "n"; a real Enter still counts as yes
   via ${_reply:-y}.

2. On Ctrl+C the shell prompt printed in the middle of Studio's shutdown logs.
   The non-interactive installer shell took the default SIGINT action and died
   before the child finished its graceful shutdown, so the prompt raced ahead
   of "All subprocesses cleaned up". trap '' INT in the installer shell so it
   waits for Studio's own graceful shutdown.

* Studio: wait for the uvicorn thread before the terminal returns on Ctrl+C

Builds on #6565 by @Imagineer99. The studio server runs uvicorn in a daemon
thread, so on Ctrl+C the process could return to the shell while that thread
was still writing its shutdown logs, interleaving them with the prompt.

Retain the uvicorn thread and join it (flushing stdout/stderr) before terminal
entrypoints return, from run.py's main shutdown path and the CLI shutdown paths.

Refinements over #6565:
- Bound the join at 5s (_SERVER_SHUTDOWN_JOIN_TIMEOUT, matching the existing
  _graceful_shutdown subprocess timeouts) so a stalled uvicorn shutdown cannot
  hang the terminal; the timeout warning branch is now reachable.
- Restore SIG_DFL for SIGINT/SIGTERM at the start of the signal handler so a
  second Ctrl+C force-quits, and drop the redundant in-handler wait (the
  post-loop wait already covers the signal path).

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>

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

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

* Address review: keep child Ctrl+C working and restore SIGBREAK

- install.sh: run studio in a subshell that resets INT to default
  (trap - INT; exec ...) so the foreground child does not inherit the
  installer shell's ignored SIGINT, which would otherwise swallow the
  studio process's own Ctrl+C and graceful shutdown.
- run.py: also restore SIGBREAK to SIG_DFL in the signal handler so a
  second Ctrl+Break force-quits on Windows, matching SIGINT/SIGTERM.

* install.sh: capture studio exit with || under set -e so the migration hint still prints

* Trim shutdown-fix comments to be terser (comments only, no code change)

* Dedup CLI shutdown-wait into finally blocks (review follow-up)

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-22 07:41:10 -07:00
Daniel Han
6254ab37c3
Studio: accept --not-secure as a back-compat alias for --no-secure (#6568)
* Studio: accept --not-secure as a back-compat alias for --no-secure

PR #6560 renamed the negative secure flag from --not-secure to --no-secure
to match argparse.BooleanOptionalAction. Re-add --not-secure as a hidden,
deprecated alias at both CLI layers so existing scripts and muscle memory
keep working, while --no-secure stays the documented spelling.

- studio/backend/run.py: extract the CLI parser into _build_arg_parser() so
  the flag wiring is unit-testable, and register --not-secure as a hidden
  store_false alias for --no-secure. Last flag wins, matching
  BooleanOptionalAction semantics.
- unsloth_cli/commands/studio.py: add a hidden --not-secure option to
  `unsloth studio` and `unsloth studio run`; it forces secure off and
  forwards the canonical --no-secure to the backend.
- Tests at both layers for the alias and its polarity.

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

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

* Studio: address review on --not-secure alias

- run.py: use argparse.SUPPRESS for the --not-secure default so the alias
  never contributes a namespace default (the canonical --secure owns it).
- studio.py: resolve --not-secure last-wins from argv via _resolve_secure()
  so `--not-secure --secure` keeps secure on, matching the backend's
  BooleanOptionalAction and how --secure/--no-secure already behave.
- Add a CLI last-wins test covering both flag orders.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-22 07:20:50 -07:00
Daniel Han
e2e8e5ab46
Studio: show tool-call progress for large GGUF tool arguments (#6484)
* Studio: show tool-call progress for large GGUF tool arguments

The GGUF agentic tool loop only surfaced an early provisional tool card
for render_html, so any other tool (python, terminal, ...) was invisible
in the UI while its arguments streamed. For a large argument such as a
full HTML or code file this left the chat sitting on "Generating..." with
zero progress for tens of seconds while the model was clearly working.

Generalize the provisional tool_start to any enabled tool once its
streamed arguments grow past a threshold (render_html still surfaces
immediately, small-argument tools are unchanged). The provisional and the
real tool_start share the tool_call_id so the frontend reconciles them
into one card. Close the provisional on no-op, denial, parallel-drop,
post-loop, and on stream errors so a card can never spin forever, surface
each parallel call, and skip the early card while a human confirmation
gate is active. Apply the same confirmation-gate guard to the safetensors
agentic loop.

Additional hardening:
- Only emit a provisional card once a real, non-empty tool_call_id is
  known. llama.cpp can stream a tool call with an empty id, and a card
  keyed by "" cannot reconcile with the real tool_start (the frontend
  mints its own id per event), so it would dangle.
- On a connection drop or other mid-iteration failure, close the dangling
  provisional card with an error result instead of an empty success so the
  UI renders it as failed rather than completed.
- Mirror the provisional cleanup in the safetensors loop: close a
  provisional render_html card if the model generator raises mid-stream or
  the controller turns the call into an internal no-op.

Adds regression tests for the empty-id guard, the error-result on a
dropped connection, and the safetensors mid-stream exception cleanup.

* [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: wasimysaid <wasimysdev@gmail.com>
2026-06-22 05:50:10 -07:00
Leo Borcherding
040858c382
Studio: fix tier detection for models loaded via custom folder path (#6396)
* Studio: detect transformers 5.3.0 tier from config.json for local checkpoints

A local safetensors folder whose config.json did not match the Gemma4 (510/550)
architecture signals short-circuited get_transformers_tier() to "default"
(transformers 4.57.x), never reaching the name-substring check that routes
Qwen3.5 to the 5.3.0 sidecar. So a local Qwen3.5 checkpoint (model_type
"qwen3_5", needs transformers >= 5.2.0) loaded with 4.57.x and failed with
"does not support Qwen3.5". The same model as a remote HF id worked, because it
has no local config.json to trigger the short-circuit.

Detect the 5.3.0 tier from config.json (model_type "qwen3_5" / architecture
Qwen3_5ForCausalLM) in the local-config branch, mirroring the existing Gemma4
510/550 handling. This is a positive config signal, so it fixes local Qwen3.5
without weakening the directory-name false-positive guard (a llama checkpoint
under a "gemma-4-12b-*" parent still resolves to default).

Adds tests for the config-based 530 detection and local-folder tier resolution.

* Studio: suppress false warning when config.json parse fails for sidecar-tier models

* Studio: generalize local-checkpoint tier detection for all 5.3.0 families

Expands the config.json-based tier detection to cover all known 5.3.0-tier
model families (Qwen3 MoE, GLM-4.7-Flash, LFM2.5-VL) and adds a _name_or_path
fallback so renamed local checkpoints with unrecognised model_type values still
route correctly via the HF ID embedded in their config.json.

- Expand _TRANSFORMERS_530_ARCHITECTURES / _MODEL_TYPES with verified entries
  from Qwen3MoeForCausalLM, Glm4MoeLiteForCausalLM, Lfm2VlForConditionalGeneration,
  and Qwen3_5ForConditionalGeneration (confirmed from local Qwen3.5-2B config.json)
- Extract _tier_from_name() helper, deduplicating the fast-substring logic used
  by both the remote-path branch and the new config _name_or_path fallback
- In the local-config branch: after architecture checks, resolve the tier from
  cfg._name_or_path / cfg.model_name before returning "default", preserving the
  existing directory-name false-positive guard
- 79 tests passing

* Studio: match 510/550 style for 530 config sets (no inline comments)

* Studio: use _resolve_base_model instead of reinlining _name_or_path lookup

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

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

* Studio: recurse into get_transformers_tier for resolved base model (Gemini suggestion)

* Studio: use _tier_from_name in local-config fallback to avoid network probes

Using get_transformers_tier(resolved) on the _name_or_path fallback would
trigger up to 3 network fetches (config.json + tokenizer_config.json, 10s
each) for every ordinary checkpoint whose _name_or_path is a plain HF ID
like meta-llama/Llama-3-8B. The fallback's purpose is name-based detection
on the resolved HF ID, _tier_from_name covers all known cases without I/O.

* Studio: add _check_config_needs_530 to slow HF-ID fallback path

Private or renamed HF repos whose model IDs lack a 5.3 substring were
silently routed to the default tier. _check_config_needs_530 mirrors the
existing 510/550 pattern: fetches config.json once, caches the result, and
is called after the 550 check in the slow path. Includes 5 unit tests.

* Studio: guard _tier_from_name fallback against local-path false positives

When _name_or_path in config.json is an absolute path to the same checkpoint
passed as a relative path, the textual resolved != model_name check passes
and _tier_from_name would scan the directory path for substrings. Split the
fallback: local directories recurse into get_transformers_tier (config check,
no network I/O); HF Hub IDs use _tier_from_name (name-based, no network).

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

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

* Studio: separator-norm aliases, model_name/_name_or_path fallback, tests

- _norm_separators(): collapse _ . whitespace to - so underscore/dot model
  ID variants (Qwen3_5, Qwen3_Next) match the canonical substring list
- _tier_from_name(): apply norm to both name and each substring so aliases
  resolve without duplicating the substring lists
- _resolve_base_model(): try model_name then _name_or_path separately so a
  self-referential Unsloth model_name doesn't hide the useful HF ID in
  _name_or_path
- Gate get_base_model_from_lora on adapter_cfg_path.is_file() to avoid
  eagerly importing transformers before the sidecar venv is on sys.path
- 17 new tests covering _norm_separators, separator-insensitive
  _tier_from_name, and the model_name/_name_or_path fallback

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

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

* Studio: only pre-resolve LoRA adapters in activation callers

activate_transformers_for_subprocess and ensure_transformers_version were
pre-resolving all local checkpoints via _resolve_base_model before calling
get_transformers_tier. After the model_name/_name_or_path fix, a full
checkpoint with a private/offline _name_or_path and no tier substring would
resolve to that HF ID, which can't be probed, bypassing the local config.json
model_type check entirely. Gate pre-resolution on adapter_config.json so full
checkpoints go straight to get_transformers_tier, which reads config.json
directly. LoRA adapters still pre-resolve as before.

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

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

* Studio: fix Qwen3.5 MoE/Qwen3.6 tier detection and dot-version false positives

- Add Qwen3.5 MoE (qwen3_5_moe / Qwen3_5MoeForConditionalGeneration) and
  Qwen3-Next to the 5.3.0 config sets, so renamed local checkpoints route to
  the sidecar instead of default transformers
- Let a 510/550 name match override a 530 config match, so Qwen3.6 (which
  reuses qwen3_5 / qwen3_5_moe config ids) still routes to the 5.5.0 sidecar
- Stop normalizing version dots to hyphens so size names like Qwen3-5B and
  Qwen3-6B are not promoted to a 5.x sidecar; underscore aliases still match
- Skip name matching for resolved values that look like stale local paths

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

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

* Studio: close remaining codex P2s: adapter-only LoRA + 530-override path-hint guard

- adapter_model-only LoRA: add import-light _is_lora_adapter_dir/_has_adapter_weights
  and gate activation/export pre-resolve on them, so LoRA dirs with
  adapter_model*.safetensors but no adapter_config.json still resolve to their base
  model (via _resolve_base_model's new unsloth_<model>_<ts> directory-name parse)
  instead of tiering off the adapter folder.
- 530 override: only treat a resolved value as a name hint when it is a real Hub id;
  a stale/renamed local path in model_name/_name_or_path can no longer flip a correct
  530 config to 550. Current folder basename still allowed.

Added 7 regression tests; suite at 116 passing.

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

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

* Studio: address review feedback on tier detection

- Add Qwen3.5 text-tower model types (qwen3_5_text / qwen3_5_moe_text) to the
  5.3.0 config set so text-only configs with stripped architectures still route
  to the sidecar
- Apply the Qwen3.6 name override on the remote slow path too, so a renamed or
  private repo whose config reuses qwen3_5 ids but names Qwen3.6 in
  _name_or_path selects 5.5.0 instead of 5.3.0
- Treat an existing local path (or empty value) as a path, not a Hub id, in
  _looks_like_hf_id so a real local checkpoint folder is not name matched
- Guard _resolve_base_model against non-string config values and compare paths
  by realpath so relative or absolute self references resolve correctly
- Keep the LoRA adapter is_file check inside the OSError guard

* Studio: harden tier detection against malformed configs and bad paths

- _config_matches_tier no longer raises TypeError when a malformed config.json
  carries a non-string model_type (e.g. a list) or non-list architectures; it
  fails open to no-match
- guard the model_name-derived is_file/is_dir probes with _safe_is_file /
  _safe_is_dir so a pathological or over-long path (e.g. a Windows long path)
  fails open to the default tier instead of raising OSError

No routing changes for any valid model; purely defensive. Verified by a
cross-platform simulation (POSIX + NT path semantics) and a before/after tier
matrix that is unchanged for all previously supported models.

* Studio: trim verbose comments in tier detection

Shorten/remove over-long comments and docstrings, mainly on internal helpers,
without changing behavior. Verified code-only via comment_tools.py check; suite
unchanged at 128 passing.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-06-22 05:40:39 -07:00
PChemGuy
52c2cf8b63
Correct wrong negative argparse.BooleanOptionalAction argument name (#6560)
studio.backend.run.__main__ adds "--secure" argument via argparse.BooleanOptionalAction, which automatically creates negative --no-secure, that is with **NO** prefix, instead of **NOT**.
2026-06-22 05:31:15 -07:00
Daniel Han
ab2717afe0
Studio: persistent per-user trust_remote_code approval cache (#6551)
* Studio: persistent per-user trust_remote_code approval cache

The consent gate pins each approval to a content fingerprint (sha256 over every
repo .py), but nothing was persisted, so the dialog reappeared on every fresh
load of the same unchanged repo. This adds an on-disk, per-user approval cache
that lets the gate skip the dialog when the same user reloads the same code,
while keeping the safety guarantees intact.

Two-tier validation, both must hold or the user is re-prompted:
- Commit SHA (cheap, one HfApi.model_info().sha, no download): a match means a
  byte-identical tree to the approved revision, so the scan/download is skipped.
- Content fingerprint (authoritative): used whenever the SHA is unavailable
  (local path / offline) and always recomputed on a SHA miss. A new or edited
  .py changes both the SHA and the fingerprint, so it is caught in every mode.

Safety:
- Keyed per subject; one user's approval never auto-runs code for another.
- CRITICAL is never stored or honored (guarded on both write and read), so a
  hand-edited store cannot smuggle in an auto-approval.
- The malware (HF unsafe-file) gate stays unconditional.
- Fail-safe: a corrupt store, an unresolvable SHA, or any error degrades to
  "ask again", never to "auto-approve". UNSLOTH_TRC_APPROVAL_CACHE_DISABLE=1
  turns the cache off entirely.

New module utils/security/remote_code_approvals.py holds the store
(studio_root()/security/remote_code_approvals.json, atomic write, 0600, RLock)
plus the SHA resolvers. Recording happens at the single gate chokepoint when the
caller supplies the matching fingerprint, so subject is just threaded through
inference/training/export (orchestrators, routes, workers). The scan endpoint
returns already_approved so the frontend can skip the dialog on a cache hit.

Tests: new tests/test_trc_approval_cache.py covers cache miss, SHA-match skip,
SHA-moved re-scan, new-file re-consent, CRITICAL never cached (write + forged
read), disable flag, subject isolation, combined adapter+base key, corrupt
store, and no-subject bypass. Full security suite: 101 passed.

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

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

* Address review: make the approval cache skip only the prompt, never the scan

Codex found that the SHA "no-scan" fast path could run untrusted code without
re-consent. Removed it; the gate now always re-scans and the cache only seeds the
authoritative fingerprint check, so it can skip the dialog but never the scan.

- CRITICAL is hard-blocked on every load (the scan always runs), so a hand-edited
  store that downgrades a CRITICAL repo's severity can no longer auto-run it
  (P2: do not trust editable severity for SHA approvals).
- The fingerprint covers external auto_map repos, so changed third-party code
  always re-prompts even when the primary commit SHA is unchanged; there is no
  longer a SHA path that bypasses the fingerprint (P1: external auto_map repos).
- resolve_commit_sha is resolved fresh on every call (no memoization), so a repo
  whose default branch moves after approval re-prompts instead of reusing a stale
  cached SHA (P1: revalidate mutable Hub SHAs). The SHA is now only a conservative
  secondary gate: a fresh resolvable SHA must match the approved revision, else the
  seed is withheld; a None (local/offline) falls back to the fingerprint.
- Approvals record the scanner ruleset version (SCAN_RULES_VERSION); the gate
  ignores approvals from an older ruleset so reclassified bytes are re-scanned and
  re-shown instead of silently auto-approved (P2: invalidate on scan-policy change).

Tests: test_trc_approval_cache.py rewritten around the prompt-skip semantics
(unchanged repo still scans; SHA move / changed code / scanner-version bump /
disable flag all re-prompt; forged downgraded severity still blocks CRITICAL).
105 passed with test_consent_gate.py.

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

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

* Trim comments to be more succinct

* Keep run-owner subject out of persisted config; serialize approval writes

Threading subject (the run owner's username / API-key id) into the training
config meant _sanitize_db_config persisted it into config_json, which
training-history GET returns to any authenticated user, leaking who started a run
in multi-user installs. Filter subject alongside the token fields; the worker
still receives it from the live config.

The approval store's RLock only guards one process, but approvals are recorded
from separate inference/export/training subprocesses, so concurrent writers could
clobber each other on os.replace and drop an approval (re-prompt). Hold a
best-effort cross-process file lock around the read-modify-write.

* Fail safe on a malformed approval store

A store with the right version but a non-dict shape (e.g. a hand-edited
"subjects": []) passed _load()'s check, then lookup chained .get() on a list and
raised, breaking every remote-code load until the file was removed. Validate that
subjects is a dict in _load(), and tolerate a non-dict per-subject entry in
lookup/record/forget, so a corrupt store fails safe (re-prompt) instead.

* Keep subject out of the MLX W&B run config

_run_mlx_training uploads the whole training config to W&B minus a sensitive set
that only listed hf_token/wandb_token/s3_config, so the authenticated subject
(username / API-key id) was sent to W&B as run config even though DB history
already strips it. Add subject to the W&B-sensitive filter, mirroring
training._sanitize_db_config.

* Tighten the W&B subject-filter comment

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-22 05:12:49 -07:00
Daniel Han
2a05426adb
Auto-install SSM kernels (causal-conv1d, mamba-ssm) for inference loads (#6535)
* Auto-install SSM kernels (causal-conv1d, mamba-ssm) for inference loads

Mamba/SSM hybrids (Nemotron-H/Nano, Falcon-H1, Granite-4.0-H, ...) lazily import
mamba_ssm / causal_conv1d during from_pretrained, so loading them for chat failed
with 'mamba-ssm is required by the Mamba model but cannot be imported'. The training
worker already wheel-first installs these before a fine-tune; the inference worker
did not. Add utils/ssm_runtime.ensure_ssm_runtime and call it from the inference load
path so the same models load for inference. Training worker is untouched; a drift
test keeps the shared detection and pinned versions in lockstep.

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

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

* ssm_runtime: invalidate import caches, skip MLX, cover LoRA base

- Invalidate importlib finder caches in _is_importable and after a successful
  wheel install, so a kernel installed earlier in this same process is actually
  importable when the modeling code lazy-imports it during from_pretrained.
- Skip the SSM kernel install entirely on the MLX (Apple Silicon) load path:
  these are CUDA/ROCm Torch kernels with no MLX use and no macOS prebuilt wheel,
  so the source build would fail before the MLX backend loads the model.
- For LoRA loads, also run detection over the resolved base model, since an
  adapter id like 'me/my-lora' won't match the SSM heuristics but its SSM base
  (Nemotron-H, ...) is what needs the kernels.

Adds tests for cache invalidation and the MLX-skip / LoRA-base worker wiring.

* Tighten SSM autoinstall comments

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

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

* ssm_runtime: verify wheel imports, HIP-aware source build, build heartbeat

Address review feedback:
- Verify a prebuilt wheel actually imports before trusting it; a CUDA/ABI-mismatched
  wheel now falls back to a source build instead of returning success and failing later
  with the cryptic lazy-import error.
- HIP-aware source build: require hipcc on ROCm, inject clang --gcc-install-dir, and use
  the 1800s timeout, mirroring the training worker (ROCm has no prebuilt wheel).
- Emit a status heartbeat every 60s during the source build so a long (ROCm) build does
  not trip the orchestrator's 300s inactivity timeout.

Tests cover the wheel-not-importable fallback and the missing-hipcc ROCm bail.

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

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

* Make causal-conv1d best-effort and harden the SSM source build

- causal-conv1d is a fast path: models that merely want it (Qwen3-Next, LFM2)
  fall back to torch, so a failed install must not reject an otherwise loadable
  chat model on Windows/CPU/macOS or an ABI without a wheel. Only a true SSM
  model's mamba-ssm requirement stays fatal, matching the training worker which
  treats causal-conv1d as best-effort.
- The source build is reached only when not importable, including a wheel that
  installed but failed to import; add --reinstall/--force-reinstall so it
  replaces the broken install instead of no-opping as already satisfied.
- Add --no-cache to the ROCm uv source build to avoid reusing stale artifacts
  from a partial HIP build, mirroring the training worker.

* Address review: install SSM kernels before transformers, harden import + Windows

Codex:
- Install the SSM kernels before importing transformers. run_inference_process
  imported core.inference.inference (which imports unsloth/transformers) before the
  load, and a sidecar transformers can evaluate its optional-backend gates against
  the import state; installing causal_conv1d/mamba_ssm afterwards left those gates
  unsatisfied and a Nemotron/Falcon/Granite load still failed with "mamba-ssm is
  required". The initial model's kernels are now installed in run_inference_process
  before the ML import, via a shared _ensure_ssm_kernels helper; _handle_load keeps
  calling it (idempotent) for a LoRA's base and for later in-process loads.
- _is_importable now treats any import failure as "not importable", not only
  ImportError. An ABI-incompatible native kernel (undefined symbol after a torch/CUDA
  upgrade) raises OSError/RuntimeError; letting those escape reported
  ssm_runtime_install_failed instead of falling back to reinstall/source build.
- Skip causal-conv1d on Windows (no prebuilt wheel), mirroring the training worker.
  A causal-conv1d-only model (Qwen3-Next/LFM2) no longer drops a chat load into a
  multi-minute untimed source build; it uses the torch fallback. mamba-ssm is still
  attempted for true SSM hybrids.

Tests: test_ssm_runtime.py +5 (broken-kernel exceptions read as not-importable;
causal-conv1d skipped on win32 while mamba-ssm still installs). 36 passed.

* Trim comments to be more succinct

* Run security gates before installing SSM kernels

The SSM kernel auto-install is name-based (model_is_ssm is a substring match, no
config fetch), so a model id merely containing an SSM substring triggered a
native-package install (possibly a slow source build) before the malware and
remote-code consent gates ran. Extract those gates into _run_security_gates and
call it before the kernel install in both the pre-import path of
run_inference_process and in _handle_load, so a blocked or nonexistent model is
refused before any build. The gates are metadata-only and do not import
transformers, so they are safe to run before the pre-import install.

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

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

* Resolve remote LoRA bases before importing transformers

_resolve_base_model only reads a local adapter_config.json, so a remote LoRA
adapter whose own id has no SSM substring but whose base is a Nemotron/Falcon/
Granite model had its base discovered only by ModelConfig in _handle_load, after
transformers was imported and its optional-backend availability snapshotted, so
the SSM kernel install there was too late. Add _remote_lora_base, a metadata-only
adapter_config.json fetch (no huggingface_hub / transformers import), and use it
in the pre-import path so the base is gated and its kernels pre-installed.

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

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

* Gate only loaded roots, tier on the resolved base, read offline LoRA cache

Three follow-ups to the pre-import resolution:

- The security gate reused the SSM target list, which for a local full fine-tune
  includes the config.json-recorded base. That base is never loaded, so scanning
  it could falsely block a safe local checkpoint. Gate only the model plus a
  genuine LoRA base (matching _handle_load's mc.is_lora), separate from the
  broader SSM-install list.

- Tier activation ran on the raw adapter id, so a remote LoRA whose base needs a
  sidecar transformers version imported the default and failed. Resolve the base
  once up front and activate on it.

- _remote_lora_base bailed on offline before checking the hub cache, missing a
  cached adapter's base. Read the cached adapter_config.json when offline or when
  the fetch fails.

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

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

* Keep the pre-import gate transformers-free; harden remote LoRA resolution

The pre-import security gate called security_load_subdirs, which imports
model_config and thus transformers, snapshotting optional-backend availability
before the SSM kernels are installed and defeating the ordering. Add
compute_subdirs to _run_security_gates and pass False in the preflight so it scans
from the root only (transformers-free); _handle_load still runs the authoritative
gate with full subdir scoping after the import.

_remote_lora_base now skips existing local relative paths (is_local_path) so a
checkpoint like outputs/run1 is never treated as a Hub repo, and distinguishes a
definitive 404 (not a LoRA -> None) from transient/offline failures (read the
cache), so a repo that is now a full model no longer resolves a stale cached base.

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

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

* Probe a real model id for SSM kernels; respect HF_ENDPOINT

model_is_ssm is a substring match, so an arbitrary name could false-match and
force a mamba-ssm install that fails the load for a non-SSM model:
- a LoRA adapter id like user/falcon-h1-lora (the SSM-relevant code is the base's);
- a local checkpoint under an SSM-named parent dir, e.g. /runs/falcon-h1/llama-ckpt.

Add ssm_probe_identifier, which resolves the base (or a bare local checkpoint's
basename) and feed that to ensure_ssm_runtime from both the pre-import path and
_handle_load, so detection runs against a real model id, never an adapter id or
parent folders.

_remote_lora_base now honors HF_ENDPOINT so enterprise/mirror deployments resolve
the adapter base instead of always hitting huggingface.co.

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

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

* Tighten comments in the pre-import SSM gate/install path

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
2026-06-22 04:48:29 -07:00
Daniel Han
aeb5075121
Route dense NemotronH models to the transformers 5.10 tier (#6541)
* Route dense NemotronH models to the transformers 5.10 tier

Dense NemotronH models (e.g. unsloth/NVIDIA-Nemotron-3-Nano-4B) describe their
layer stack with a hybrid_override_pattern that includes '-' (MLP) layers.
transformers only learned to parse that ('-' -> 'mlp' in pattern_mapping, 'mlp'
in valid_types and MIXER_TYPES) in 5.10; on 5.3/5.5 the config raises
KeyError: '-'. The model also ships auto_map remote code, so training and
inference that approve trust_remote_code load fine, but a native (TRC=False)
load such as export hits the built-in parser and fails with
'Failed to load checkpoint: -'.

Detect dense NemotronH from config.json (a '-' in hybrid_override_pattern, or
'mlp' in an expanded layers_block_type) and route it to the 5.10 tier, where the
model loads natively without remote code. Pure-MoE NemotronH configs are
unaffected and keep their existing tier.

Covers both the local config.json and the remote HF-id paths, and adds tests for
the detector and the resulting tier selection.

* Tighten _nemotron_h_needs_mlp_support docstring

* Detect dense NemotronH in nested, cached, and resolved-away configs

Three gaps could still route a dense NemotronH (MLP '-' layers) to a tier
below 5.10 and hit KeyError: '-':

- VL wrappers (e.g. NemotronH_Nano_VL_V2) keep the dense language model under
  llm_config/text_config; the detector only checked the top-level model_type.
  Recurse into nested language configs.
- Offline or blocked config fetches returned None for an already-downloaded
  repo. Read config.json from the HF hub cache before any network.
- A local checkpoint resolves to its base before tiering, so an offline/private
  base discarded the local config that revealed the dense pattern. Prefer the
  higher tier of the resolved base and the original path.

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

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

* Harden NemotronH tier detection follow-ups

Address review of the nested/cached/resolved-away detection:

- The local re-check ran the full tier detector on the original path, so a bare
  LoRA adapter under e.g. /runs/gemma-4-x/llama-lora could upgrade a default base
  via directory-name substrings. Gate the re-check on a real local config.json so
  it reads metadata, not path names.
- The HF hub cache was read before any network, so an online tier check could
  serve stale config.json after the repo changed upstream. Consult the cache only
  offline or after a failed fetch.
- Reading the cache imported huggingface_hub during tier detection, which runs
  before a sidecar venv is activated and could pin the default-env hub into
  sys.modules. Resolve the cache path with stdlib only.

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

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

* Trim comments to be more succinct

* Select newest hub-cache snapshot by mtime and retry transient config fetches

The HF cache fallback in tier detection picked the lexicographically-first
snapshot when refs/main was absent (commit-pinned downloads), which can be an
older SHA than the Hub would load. Sort snapshots by mtime instead.

A transient online fetch failure cached the hub-cache fallback under the normal
(model_name, token) key, so a long-lived worker kept serving stale metadata even
after connectivity recovered. Return the fallback without memoizing it so the
next call retries the network.

* Harden config.json tier detection against auth failures and transient blips

- _load_config_json: a 401/403/404 from the raw Hub request is a definitive access
  answer, not an outage. Return None instead of falling back to the HF hub cache, so
  an unauthenticated or wrong-token request can never read another caller's cached
  private metadata.
- _check_config_needs_510/550: only memoize the derived tier when the underlying
  config read was definitive (local file, offline cache, or a completed fetch).
  A transient fetch fallback is no longer pinned, so the tier is re-evaluated once
  connectivity returns instead of staying stuck on the lower tier.

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

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

* Tighten comments in tier-detection auth/cache paths

---------

Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-22 04:47:30 -07:00
Michael Han
44d6727c65
Studio: redesign Select model dropdown to match Hub design (#6364)
* Studio: redesign Select model dropdown to match Hub design

Make the chat Select model picker easier to scan by reusing the Hub
on-device card's visual language.

- Rows now split owner/name, add a param chip, a DotTag format pill,
  a tabular size, and a Loaded marker on the active model.
- Hub models / Fine-tuned tabs reuse the Hub's exact .hub-tab-toggle
  styling (selectors extended in hub.css to the selector menu).
- Add a Downloaded / Recommended / Custom section toggle on the Hub
  tab to filter the list.
- Widen the popover and nudge the scrollbar toward the edge.

* Studio: move section toggle below search, size tabs to label

Put Downloaded / Recommended / Custom under the search bar in their own
row so Hub models / Fine-tuned no longer wrap. The section toggle uses a
smaller font and sizes each tab to its label instead of equal widths.

* Studio: extract pure row-meta helpers into their own module

Move splitRepoLabel, classifyMetaToken, and parseMetaTokens out of
pickers.tsx into row-meta.ts. No behaviour change; keeps the presentation
logic free of React/DOM deps so it is easy to test in isolation.

* Studio: content-size the source tabs and add section icons

Size the Hub models / Fine-tuned tabs to their labels (with side
padding) like the section toggle, instead of stretching full width. Add
a leading download, star, and folder icon to Downloaded, Recommended,
and Custom.

* Studio: stop source tabs stretching and hide empty Fine-tuned tab

The popover is a flex column, so the fit toggle stretched full width;
add w-fit/self-start so it sizes to its content. Also hide the
Fine-tuned tab when there are no fine-tuned models, defaulting to Hub
models.

* Studio: keep only fine-tuned models in the Fine-tuned tab

Local models (LM Studio, Ollama, custom folders) carry source "local"
and already show in the Hub tab's Downloaded / Custom sections, so
exclude them from the Fine-tuned tab and from its visibility count.
Extract the tab rules into source-tabs.ts.

* Studio: show local providers under Downloaded, Recommended first

Show LM Studio and other local provider models in the Downloaded
section in all modes (was chat-only). Put Recommended first and make it
the default section. Add a little more space below the search bar.

* Studio: make Recommended a sortable live Unsloth listing

Replace the static Recommended list (and its collapse chevron) with a
sort dropdown over Unsloth's own models: Recommended, Trending, Most
likes, Downloads, Recently updated. Recommended shows recently uploaded
GGUF/MLX models that fit the device (hidden if they do not); the other
sorts list all Unsloth models, badged but never hidden. Adds a sort
option to useHfModelSearch and a pure recommended-fit helper.

* Studio: size Recommended models from the repo name when metadata is missing

GGUF and MLX repos rarely expose safetensors metadata, so a large model
with no size could pass the Recommended fit check because unknown size was
treated as fitting. Parse the parameter count from the repo id, including
the Gemma E series, and hide anything we still cannot size.

* Studio: detect model capabilities and family from HF tags

Thread tags and the pipeline tag through the model search results and add a
pure helper that infers vision, reasoning and audio plus the architecture
family, falling back to repo-name keywords when tags are absent.

* Studio: add row details and inline section sorting to Select model

Give each model row more detail and make the Hub sections easier to scan:

- Show vision, reasoning and audio badges plus the architecture family tag
  on each row, alongside the params, format and size.
- Drop the redundant unsloth/ prefix on the Recommended rows.
- Rename the Recommended section tab to Unsloth and enlarge the section tabs.
- Move the sort dropdown inline to the right of the tabs at a fixed width.
- Add Recent, Size and Downloaded sorting to the Downloaded and Custom tabs.
- Remove the header icons, pad the subheadings, and grow the list height.

* Studio: tune the Select model sort dropdown and trim row badges

- Recommended now lists the most recently created Unsloth repos.
- Narrow the sort dropdown, remove its border, and truncate long labels.
- Tighten the gap between the section tab icons and their labels.
- Remove the architecture family tag from rows since it repeats the name.

* Studio: extract the PillTabs toggle into a shared module

Move the segmented pill toggle out of the model selector into its own file so
the Hub picker can reuse it for a format filter without duplicating the markup.

* Studio: fix Recommended infinite scroll and add a format filter

- Re-attach the scroll observer on each loaded page so a filtered Recommended
  list keeps paging until the viewport fills instead of spinning forever with
  nothing new appearing.
- Add an All / GGUF / MLX / Safetensors toggle on the Unsloth listing that
  filters every sort.

* Studio: default Recommended to Trending, rename Downloaded to On Device, and fade the scroll edge

Sort: default the Recommended view to Trending and add a Name option to
the On Device / Custom sort. Recent now orders by last load time while
Downloaded orders by file date, tracked in localStorage (model-usage.ts).

Formats: show the format filter on all three tabs (Unsloth, On Device,
Custom), exclude mobile GGUF builds from Recommended, and flag GGUF rows
that exceed the device with the same OOM badge as safetensors.

Polish: download-icon badge on already-downloaded Recommended rows, the
hugeicons view stroke-rounded vision badge, Search all models placeholder,
matched popover padding, and a top-edge mask fade once the list scrolls.

* Studio: size GGUF repos from gguf metadata so large ones flag OOM

Repos with no <n>B token in the name (Kimi, MiniMax) had no param count
and so never showed an OOM badge. Request the gguf expand field from
Hugging Face and read gguf.total, so those repos get a param chip and an
OOM badge when they exceed the device budget.

Keep the row name full contrast when over budget (the OOM badge already
signals the fit), shorten the format and sort dropdowns, narrow the
popover, and rename Recently updated to Recent and All formats to All.

* Studio: address selector review feedback

Add WAI-ARIA roving tabindex and Arrow Left/Right navigation to the pill
toggle so only the active tab is in the tab order. Keep the chat-only
GGUF/MLX filter for every Recommended sort, not just Recommended, so
chat-only users do not see unrunnable checkpoints under Trending. Feed
both listings' GGUF hints into repo detection so a tag-only GGUF in
Recommended expands variants instead of loading as a checkpoint.

* Studio: scope Select model search per tab and add an MLX tag

Search is now per section. The Unsloth tab searches the Unsloth HF
listing only, On Device filters downloaded and LM Studio models by name,
and Custom filters custom-folder models, each with its own empty state.

MLX repos get an MLX pill mirroring the GGUF tag. Downloaded quants in
the Unsloth and search lists get the same delete action as On Device.

Also: revert the model name to normal weight, narrow the popover to
558px so the format and sort dropdowns sit one gap-2 from the tabs,
tighten the dropdown menus to match the Projects activity Select, and
make the empty On Device state name the active format filter.

* Studio: show local ./models on the On Device tab so they stay selectable

Models under the local models directory (source models_dir) flow in as local
models but were dropped from every list: filtered out of Fine-tuned and never
re-added by the Hub picker, which kept only LM Studio and custom-folder
sources. Capture them in the local refresh and render a Local models group on
the On Device tab, with the same format, search, and chat-only GGUF rules as
the other local groups.

* Studio: add a Hub button beside the Select model search bar

Adds a Hub button next to the search bar that opens the full Hub Discover
page to browse more models. Styled like the section tabs (rounded, no
border, soft shadow with a faint top layer) and darkens on hover. Also
nudges the format and sort dropdown chevrons a touch toward the edge.

* Studio: align Select model padding and tighten the format pills

Sizes the popover to the tab cluster so the left and right padding match,
and drops the top row below the rounded corner so the Hub button lines up
with the Trending dropdown. Gives the Hub button a fixed width, lets the
list scrollbar sit inside the box, and shrinks the format pill dot with a
tighter dot-to-label gap.

* Studio: label the Hub button Search Hub and match the dropdown width

Renames the button to Search Hub, sets its width to the format and sort
dropdown width so it lines up above them, and tightens the icon gap.

* Studio: drop the vision and reasoning row badges to declutter

Removes the vision and reasoning capability icons from the model rows so
they read cleaner. Audio is kept.

* Studio: add a safetensors pill, hide diffusion models, eye on Vision

Gives safetensors rows a format pill and size so their meta matches GGUF
and MLX, drops image and video diffusion models from the listing since they
cannot run in chat, and shows an eye icon next to the Vision tag. Also
removes the em dashes from the Projects export and import labels.

* Studio: gate recommended folders on real weights and polish the selector

Only show a Recommended chip once the well-known dir actually holds
weights, so an empty LM Studio or Ollama scaffold no longer suggests
itself. _dir_has_downloaded_model checks for a GGUF/safetensors file or
a non-empty Ollama manifests store, with a bounded walk.

Selector polish: round the popover and option menus a touch more,
lighten the OOM badge in dark mode, soften the inner dropdown shadow,
even out the padding, and lift the toggle track and field triggers so
their edges read against the popover.

Also catch CogVideoX in the diffusion name fallback.

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

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

* Studio: align the dark Select model panel with the sidebar

Match the popover, fields, dropdowns, tab toggle and row states to the
sidebar surface and accent so the dropdown reads as one piece in dark
mode. The active tab pill and Search Hub button sit a touch lighter
than the track, and the inner option menus drop their drop shadow for a
flatter look. Light mode is unchanged.

* Studio: re-derive the Select model tab on open

The picker remounts each time the dropdown opens, but the source tab
state did not, so a persisted fine-tuned or connected selection that
only lands in its list after an async load would reopen on Hub. Reset
the active tab to the selection-derived default on the open edge, while
still letting the user switch tabs freely within a session.

* Studio: fold Custom into On Device and polish the picker

Merge the Custom tab into On Device so custom folders sit right below
the downloaded models, with a folder shortcut on the group header.
Rename the first Hub tab to Recommended, give the format dropdown
colored dots, even out the tab row spacing, and tighten the popover
width. Align the folder browser with the app dialogs (soft surface,
roomier padding, green confirm, grey hover).

* Studio: fix On Device controls and nudge the folder browser close

The Hub redesign merge dropped the old Search Hub button styling, so the
On Device search row rendered flat. Point the search input and Search
Hub button at the shared .field-soft surface so they match the rest of
the Hub controls, and lift the folder browser close button slightly.

* Studio: run the Select model search on the Hub search stack

Point the picker at the Hub's useHubModelSearch and useHubInfiniteScroll
instead of its own useHfModelSearch/useInfiniteScroll, scoped to unsloth
so the listing matches the old one. Both the search and the recommended
feed now share the Hub implementation, so there is one search path. The
Hub result folds GGUF params into totalParams, so the dead ggufParams
fallback is dropped.

* Studio: trim the recommended sort to Recommended, Trending, Recent

Drop Downloads and Most likes from the sort dropdown.

* Studio: give the section tabs room off the rounded edge

The fit-mode toggle wrapped the tabs with no inset, so On Device sat
tight against the rounded-full edge. Add a small horizontal inset and
widen the popover a touch to fit it.

* Studio: drop the legacy HF search hooks for the Hub ones

Migrate the training model and dataset sections, export page, onboarding
steps and recipe dataset combobox off useHfModelSearch, useHfDatasetSearch
and useInfiniteScroll onto the Hub equivalents, scoped to unsloth so the
listings match. The picker reads recommended param counts off the search
results it already has instead of a separate fetch. Removes the duplicate
search stack: use-hf-model-search, use-hf-dataset-search,
use-hf-paginated-search, use-infinite-scroll, use-recommended-model-vram
and the old lib/hf-cache.

* Fix model selector section toggle proportions

Remove the fit-mode track inset so the active pill sits flush to the
track edge, matching the Hub's segmented controls.

* Tighten model selector width and tab padding

Reduce the popover width so the right edge aligns with the row, and
widen the fit-mode tab padding so On Device clears the track edge.

* Refine Recommended formats, sort width and tab padding

Recommended now suggests GGUF anywhere and MLX only on Mac, never
safetensors. Size the sort dropdown to its label so Recommended no
longer truncates, and match the On Device trailing gap to the active
pill's leading inset.

* Flush section toggle and match dropdown font to Search Hub

Drop the trailing track pad so the active pill fits the track exactly
at either end. Size the sort and format dropdown text to text-xs like
the Search Hub button, and clip long labels without an ellipsis.

* Fix sort menu checkmark overlap and lock dropdown widths

Keep the option's right padding so the selected checkmark no longer
overlaps the label, and let the open menu expand to fit it. Set the
format and sort triggers to a fixed width matching the Search Hub
button so they always line up.

* Keep section toggle and dropdowns on one row

Drop the wrap and size the Search Hub button, format and sort dropdowns
to a shared 100px so they stay equal width and fit on one row without
widening the box.

* Studio: pre-load inference settings dialog with native context

Add a gear on downloaded GGUF quant rows that opens a settings dialog
to adjust inference parameters before loading a model:

- Context length, KV cache dtype, speculative decoding and tensor
  parallelism, all written to the runtime store the load call reads.
- Settings can be remembered per model in localStorage.
- The context slider ceiling and "Model supports up to N tokens" come
  from the model's native context, read from GGUF metadata and returned
  by /api/models/gguf-variants once a variant is downloaded.

Also drop models Studio can't run for chat (diffusion, image, video)
from the recommended feed and Hub search, plus minor selector polish
on row hover padding, Search Hub and dropdown widths, and tab spacing.

* Studio: model selector polish and memory-aware load warning

Search and listing:
- Drop the "Recommended" and "Hugging Face" section labels while
  searching so results read as one list; keep the format and sort
  dropdowns visible so search results can still be sorted and filtered.
- Request gguf metadata in the Hub listing so GGUF repos report a
  parameter count, restoring the OOM badge for repos without a size
  token in the name (Kimi, MiniMax, GLM).

Load settings dialog:
- Warn when weights plus the KV cache at the chosen context exceed
  available memory. The KV size is sized by the backend's
  architecture-aware estimator via a new kv-cache-estimate endpoint;
  the budget uses VRAM plus system RAM. Best-effort, no warning on
  failure or on auto context.
- Context Length placeholder reads "auto"; dark background slightly
  lighter.

Other:
- Clicking the Custom Folders header opens the folder browser; its
  title now reads "Select folder to detect models".
- On Device sort lists Downloaded last.
- Smaller chat template editor font; rounded wrapper clips the prompt
  and template editor scrollbars so the right corners stay round.

* Studio: fix load dialog memory warning budget and KV dropdown width

- The memory warning never fired without a discrete GPU. useGpuInfo
  returned zero system RAM in that case, so the budget was always zero.
  Surface system RAM even when no GPU is present (Mac unified memory),
  and have the load dialog read memory directly instead of through props.
- Give the dialog fields shrink-0 so the KV Cache Dtype value (e.g.
  q8_0) is not squeezed and clipped by the row.

* Studio: fold fine-tuned models into On Device tab

Remove the Hub models and Fine-tuned source tabs. Fine-tuned models now
show as a section in the Hub tab's On Device view, above Custom Folders,
with the Train icon and a collapse toggle. The section only appears when
the user has fine-tuned models. With no external providers the lone Hub
tab hides its own toggle.

Also: tick-circle Show hidden checkbox and drop the divider above Eject;
keep run settings load params (KV cache dtype, speculative, tensor
parallel) from being clobbered by a mid-load status poll.

* Studio: stage load settings in the sidebar with a Load on selection toggle

Replace the pre-load settings popup with a staging flow in the Run settings
sidebar. The gear on a downloaded quant row now stages the model and opens
Run settings with Load model and Cancel buttons, so options like context
length, KV cache, speculative decoding and tensor parallelism are set before
the model loads. A "Remember these settings" tick reuses them next time.

Add a global Load on selection toggle in Settings, Chat tab (default on).
On: Unsloth auto-picks the best settings for your hardware and loads on
selection. Off: picking a model stages it in Run settings to customize first.
The gear always stages, regardless of the toggle.

Other polish in this change:
- Fine-tuned models live under the On Device tab, with a train icon on the
  header that jumps to the Fine-tuned section.
- Default to the On Device tab when downloads exist, otherwise the last used
  section.
- Standard Unsloth tooltips on the train, folder and gear icons.
- Request the gguf param count on every Hub listing fetch so Kimi, MiniMax
  and GLM show a size badge.
- Search Hub hover state, scrollbar position and minor spacing fixes.

Remove the old inference load settings dialog.

* Studio: always show the fine-tuned shortcut and smooth out the picker

- Fine-tuned section and its train shortcut now always show on On Device,
  with an empty state when no fine-tuned models exist yet.
- Folder icon on the header jumps to Custom Folders instead of opening the
  browse popup, matching the train shortcut.
- Folder browser keeps the list mounted and dims it while refetching, so
  toggling Show hidden or changing folders no longer flashes.
- Drop the tooltip hover grace area in the picker so moving between the
  train, folder and gear icons switches the tooltip at once.

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

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

* Studio: add quantization display options and drop the fine-tuned empty text

- Settings, Chat: 'Expand quantizations' toggle. On expands every On Device
  GGUF model's quantizations by default; off keeps them behind a click
  (default).
- Settings, Chat: 'Show all quantizations' toggle. On lists every quant
  including ones not downloaded (default); off shows downloaded only.
- Remove the empty-state line under the Fine-tuned header; the header still
  shows on its own.

* Studio: let expanded quantizations collapse on click and split the On/Off help

- With Expand quantizations on, clicking an On Device model now collapses or
  re-expands its quantizations. The collapse state is in memory only, so it
  resets on reload and when the setting is toggled.
- Put the Off sentence on its own line in the quantization setting descriptions.

* Studio: reorder chat settings and rename the model section

- Rename the Models section to Select model settings and move it above the
  Chat menu section.
- Trim the section and Load on selection descriptions.

* Studio: tighten the On/Off lines in the model setting descriptions

Use a line break instead of separate spans so the On and Off lines sit on
consecutive lines without the extra paragraph gap.

* Studio: top-align the Load on selection toggle

Add an alignTop option to SettingsRow and use it so the toggle sits at the top
of the row next to the label, not centered against the tall description.

* Studio: put the gear hint and example chip on one line

Move the gear example chip inline with its label so it reads as a single line
instead of wrapping onto its own row.

* Studio: move the New badge from API keys to Chat settings

Add the New badge to the Chat settings tab and drop it from API keys.

* Studio: line the Load on selection toggle up with the first description line

Offset the top-aligned control past the label row so it sits next to the On
line instead of the label.

* Studio: label the chat menu item Chat with Files (RAG)

Rename the Chat with Files entry in the chat menu settings to clarify it is RAG.

* Studio: drop the pill around the gear example so it fits on one line

Remove the background and padding from the gear example chip so it sits inline
with its label at a lower height.

* Studio: fold the gear example into the description line spacing

Render the gear example inline in the same text block so its line spacing
matches the On and Off lines instead of an extra flex gap.

* Studio: scope Show all quantizations to On Device only

Gate the downloaded-only filter on an onDevice flag so Recommended and other
browse lists always show every quant, and note On Device in the setting copy.

* Studio: tidy On Device GGUF rows

- Drop the redundant Quantizations subheading under On Device models.
- Relay GGUF vision support up to the model name as a Vision badge instead.
- Drop the repo size from On Device GGUF model rows since the quants already
  show their size.

* Studio: pin the eject button and tidy General settings

- Move Eject loaded model out of the scrollable list into a centered footer so
  it stays in view no matter how far the list is scrolled.
- Space out and center the gear example in the Load on selection description.
- General: drop the duplicate Unsloth version section, move llama.cpp
  notifications above Helper LLM, and note new models in its description.

* Studio: add left padding before the gear example

Nudge the gear example away from its label with a small left margin.

* Studio: make the eject footer a sticky bar over the list

Pin Eject loaded model to the bottom of the scroll area with the menu
background so rows scroll under it, and drop the divider line.

* Studio: drop the eject footer background, keep it a sticky button

Make the sticky eject a centered transparent button so it coexists with the
rows scrolling behind it. The wrapper ignores pointer events so only the button
is clickable.

* Studio: give the eject button a solid background

Add the menu background, a border and a soft shadow to the sticky eject button
so it reads as a floating button over the list.

* Studio: restore the eject footer block, keep hover on the button only

Bring back the full-width menu background behind the sticky eject footer, but
keep the button compact and centered so the hover stays on the button.

* Studio: show the vision badge on On Device rows without expanding

- cached-gguf listing reports has_vision (mmproj present), so the badge shows
  on the model name without opening the quantizations.
- Make the vision badge icon-only with a tooltip: "This model can process
  image inputs". Falls back to the expander-reported value on older backends.

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

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

* Make LM Studio and Local models sections collapsible

* Fade the eject footer instead of a solid block

* Wrap the vision badge in a bordered pill

* Taller model list with the eject footer pinned to the bottom

* Use purple for the vision badge to set it apart from GGUF

* Reduce the model list height

* Make the eject button inline with no background block

* Match the vision badge color to the Hub indigo tone

* Shorten the model list and square off the format tags

* Pin the eject button so it floats at the bottom of the list

* Give the floating eject button a tinted background

* Add bottom clearance so the list ends on white space under the eject button

* Match eject button to the menu background and unify the settings gear icon

* Move eject below the list and match its shadow and dark background

* Drop the min height so short model lists leave no white space

* Remove the eject button fill so it never covers the list

* Nest dropdown hover radius inside the menu corners

* Float the eject pill again and fix sort dropdown hover radius

* Make the eject button opaque in both themes on hover and dark

* Trim the model menu bottom padding so it stops clipping the last row

* Match dark eject background to the Search Hub button and pad row indicators

* Fade the model list bottom edge while rows sit below the fold

* Lift the eject button and trim the section toggle right padding

* Nudge the model list taller and run the bottom fade to the box edge

* Nudge the model list slightly taller

* Remove the eject button shadow

* Align the eject button to the right

* Widen the Search Hub and dropdowns and right-align them

* Seat the eject button at the base and restore On Device right padding

* Reduce the Search Hub and dropdown width by 4px

* Widen the model menu so the section toggle keeps its padding

* Make the eject button an icon-only button with shadow

* Tighten section tab padding to cut the grey between tabs

* Revert section tab padding back to px-3

* Remove the section toggle trailing padding

* Add an eject button beside the model selector trigger

* Shrink the in-list eject button to a smaller proportional size

* Raise the in-list eject button

* Make the trigger eject a bare icon next to the dropdown arrow

* Revert eject back to the labeled button on the right

* Place the format and sort dropdowns next to the section toggle

* Raise the eject button and shorten its label to Eject model

* Widen the gap between the toggle and dropdowns slightly

* Align Search Hub with the last dropdown via a shared-width grid

* Narrow the model menu for symmetric padding

* Stretch the search row so Search Hub lines up with the last dropdown

* Inset the list so the right padding matches the left

* Right-align dropdowns and full-width search so Search Hub meets the last dropdown

* Pack section toggle and dropdowns with a uniform gap

* Inset search row so Search Hub aligns with the Trending dropdown

* Trim model menu right padding to match the left

* Nudge model list scrollbar inward

* Move eject button to the bottom left with a light shadow

* Shorten show all quantizations description

* Keep eject button right-aligned, nudged in from the edge

* Move Connected into the section toggle as a cloud-icon tab

* Align eject button with the format tag edge

* Right-align Connected layout so Search Hub meets Trending

* Download selected models through the Hub download manager

* Add Other models section for non-Unsloth downloads

* Add directions icon and shortcut for Other models section

* Space out subheadings and gate Other models on non-Unsloth downloads

* Use direction-right icon for Other models

* Use flag icon for Other models

* Widen Connected menu so dropdowns align with Search Hub

* Model selector: truncate long quant labels and tidy layout

- Hub GGUF card: truncate long file-path quant labels with an ellipsis
  instead of overflowing the row.
- Connected layout: left-pack the dropdowns and size the box so the last
  dropdown's right gap matches the pill's left gap, with Search Hub on its edge.
- On Device: show MLX/Safetensors with the size on non-GGUF rows.
- Connected list rows use the same grey hover as the tabs; the selected
  section tab no longer shows a hover change.

* Model selector: drop stale custom section on restore

A persisted custom section value no longer maps to a tab, so restoring it
opened the picker to an empty view. Fall back to recommended instead.

* Model selector: align the non-connected search bar with the All dropdown

Nudge the non-connected box width so the search bar's right edge meets the
All dropdown, which lands Search Hub on the last dropdown's edge.

* Studio chat model selector: remember last tab, route non-GGUF downloads through Hub, stack overlays

- Restore the last Hub section (Recommended / On Device) on every open instead of always snapping to On Device when downloads exist.
- Route uncached non-GGUF repos (safetensors / MLX) through the Hub download manager via a snapshot download, so every model download shows in the bottom-right indicator and follows Load on selection like GGUF.
- Allow safetensors in Recommended on Mac (they run locally there now), and honor the Safetensors format filter instead of dropping it via the recommendation default.
- Stack bottom-right overlays in one column so the download panel and banners never overlap.
- Add evenly spaced divider lines between the On Device subheadings.
- Pad the bottom of the list so the floating Eject pill never covers the last row.

* Studio downloads panel: widen left padding on header and rows

Bump the left inset to pl-4 while keeping pr-3 so the collapse and cancel buttons stay put.

* Studio: update cached-gguf route tests for the has_vision field

list_cached_gguf now returns has_vision per row (vision badge on On Device);
the expected dicts were missing it. True for the mmproj vision repo, False elsewhere.

* Studio: keep MLX/safetensors selectable in chat-only Mac search

The empty Recommended view allows GGUF plus MLX/safetensors on Mac, but the
curated and HF search lists dropped non-GGUF in chat-only via a GGUF-only filter,
so typing a query hid runnable Mac models. Reuse isRecommendableFormat in both
lists so search matches the empty view (chat-only non-Mac stays GGUF-only).

* Model selector: restore global model search and fix GGUF/device-fit regressions

- Search: training, export and onboarding pickers searched only the unsloth org
  on a typed query. Restore the prior behavior (global Hub search with unsloth
  floated first when a query is typed, curated unsloth listing when empty).
- Recommended browse: the GGUF/MLX-only gate ran before the format filter, so
  the Safetensors filter and the Trending/Recent sorts always came back empty.
  Apply that gate only for the Recommended sort and chat-only mode.
- GGUF metadata: request the gguf expand field through listModels so repos with
  no size token in the name (Kimi, MiniMax, GLM) report a param count for the
  size and OOM badge.
- Local GGUF: custom-folder and standalone ./models/*.gguf files now load
  directly with the GGUF marker instead of dead-ending in the variant expander,
  and scanned GGUF folders are classified via a backend model_format hint.
- Device fit: use system RAM in the budget on unified-memory hosts, and keep MLX
  rows selectable on chat-only Macs.
- kv-cache-estimate: resolve the quant from the snapshot-relative path, skip MTP
  drafter files, and prefer the most complete snapshot (mirrors the variant
  scanner). Bound the Ollama manifest walk.

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

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

* Model selector: classify suffixless local GGUF folders consistently

Complete the model_format plumbing so a GGUF folder is detected and loaded
through the same GGUF path that the format filter already uses:

- _scan_models_dir: a config.json no longer disqualifies a folder whose only
  weights are .gguf, so HF GGUF repos shipping a config still classify as GGUF.
- _scan_lmstudio_dir: emit model_format for every GGUF row (LM Studio dirs
  rarely carry a -GGUF suffix), via a shared _dir_model_format helper.
- Custom Folders and LM Studio rows: use localModelIsGguf (the same helper the
  filter uses) so the row label, expand-vs-direct-load, and isGguf flag agree;
  a suffixless GGUF folder no longer filters as GGUF but loads as non-GGUF.

Adds tests/test_local_model_format.py covering the classification rule.

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

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

* Studio model selector: tighten section spacing

Trim each subheading's gap to its rows (pb-1.5 to pb-1) and pull the On Device
heading block tight to the controls while Recommended keeps a little top room.

* Hub: format filter fix, sort defaults, avatar and layout polish

- Format dropdown now filters the feed's Latest list too, so the default
  GGUF hides fp8/safetensors and picking a format changes the rows.
- Latest Unsloth Models sorts by newest created, not recently updated.
- Sort dropdown order: Newest, Trending, Most downloads, Recently
  updated, Most likes.
- Unsloth uploads with no upstream provider logo show the Unsloth avatar
  instead of a colored initial.
- Owner scope pill gets a little more room before the chevron.
- README detail column lines up with the top bar (both-edges gutter).
- Long file-path quant labels truncate instead of overflowing the row.
- Model list keyboard nav no longer clips the focus ring.
- Run settings sheet: restore the Remember settings toggle and larger
  Load/Cancel buttons on the staged load flow.

* Hub: hide the RAG embedding model from browse previews

The Hub discover feed and chat model selector pull from the Hugging Face
listing on the client, which the backend _is_hidden_model filter never
touches, so the RAG embedder (unsloth/bge-small-en-v1.5-GGUF) and the
llama.cpp validation probe leaked into the lists.

Added isHiddenModelId mirroring the backend needles and filtered it out of
the discover rows, the trending feed, and the selector's recommended and
Hugging Face search lists. Per-repo file and download views are untouched,
so the model is never deleted and a reinstall still shows it as already
downloaded.

* Studio: skip hidden dirs when checking a folder for downloaded models

_dir_has_downloaded_model walked the tree with rglob("*") bounded by
max_entries. rglob yields entries in arbitrary order and counts every one, so a
model directory that also holds a large hidden subtree (.git/.cache/venv) could
exhaust the budget before reaching the real weights and falsely report no model,
hiding a valid Recommended-folder chip. Replace the generic-weights pass with a
bounded BFS that skips hidden directories so their entries can't starve the walk.

Adds a regression test (50-entry .git beside the weights, max_entries=10).

* Fix/adjust model selector handling for PR #6364

* Studio: address codex review on the staging/recommended-folder paths

- chat-page auto-load: selectModel only clears pendingSelection on success, so a
  failed auto-load left the hidden stage (and its edited load knobs) behind.
  Abandon the stage when it still matches the failed pick.
- model picker: count fine-tuned rows in the On Device empty check so a
  fine-tuned-only tab no longer shows a false 'No models on device' message
  above the Fine-tuned section.
- general settings: add the remembered per-model load settings key to PREFS_KEYS
  so 'Reset all local preferences' actually clears it.
- recommended-folders: recognize PyTorch .bin weights (gated by the scanner's
  weight-name prefixes) so a .bin-only model folder still earns a chip; add tests.

* Studio: name-gate .bin weight detection and complete selector preference reset

Follow-up to the codex review on the model_format/recommended-folder paths:

- _dir_model_format and _scan_models_dir treated any .bin (incl. tokenizer.bin)
  as a non-GGUF weight, so a suffixless GGUF folder shipping a companion .bin was
  misclassified as a plain checkpoint and routed through the wrong load path.
  Factor the scanner's weight-name gating into shared _is_weight_bin /
  _has_non_gguf_weights helpers and use them everywhere (also in
  _dir_has_downloaded_model).
- PREFS_KEYS was missing the new 'Select model settings' keys (load on selection,
  expand/show-all quantizations), so 'Reset all local preferences' left them set.
- On Device cached search dropped the active format filter while a query was
  typed; keep matchesFormatFilter applied so the format dropdown stays consistent.

Adds tests for the tokenizer.bin vs weight-.bin classification.

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

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

* Studio: validate Ollama blobs, gate staged context, honor RAM budget on no-GPU hosts

- recommended-folders: only count an Ollama dir once its manifest resolves to an
  on-disk model blob, so a failed/pruned pull no longer surfaces an empty chip
- GGUF variant click: only seed the staged contextLength for already-downloaded
  picks, so choosing an undownloaded quant from a partially cached repo still
  starts its download (the staging effect short-circuits on a known context)
- device fit: classify GGUF variants against the system-RAM budget on no-GPU /
  unified-memory hosts instead of reporting everything as fits, and pass
  systemRamGb to every variant expander regardless of gpu.available

* Studio: scope Hub search to Recommended, fix staged non-GGUF settings, keep local MLX on Mac

- model picker: only run the Hub search hooks on the Recommended section. On
  Device / Connected render local data, so typing there no longer fires HF
  requests or a spinner and the local/offline flow is preserved
- chat settings: when a pick is staged, decide the GGUF-only controls from the
  staged model's type, not the currently loaded model's. A staged non-GGUF Hub
  repo no longer inherits a loaded GGUF's context/KV/speculative controls
- On Device: keep local MLX builds in ./models selectable on Mac (chat-only ran
  GGUF/MLX only, but the filter dropped MLX before the format toggle)

---------

Co-authored-by: shimmyshimmer <info@unsloth.ai>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-22 04:33:04 -07:00
Daniel Han
53e8de601d
studio: persist personalization (profile, avatar, theme) server-side (#6516)
* studio: persist personalization (profile + theme) server-side

Profile name/nickname/avatar and appearance (theme) were stored only in the
browser's localStorage, so every browser or device that connected to the same
Studio started from defaults and forgot the user's personalization.

Persist them server-side (single-account, stored as one JSON blob in
app_settings) so they follow the account:
- utils/personalization_settings.py + GET/PUT /api/settings/personalization,
  with validation (theme/shape enums, avatar must be an image data URL capped at
  512 KB) and a 'saved' flag.
- Frontend usePersonalizationSync (mounted in the root layout when signed in)
  hydrates the profile + theme stores from the server when a blob exists, and
  otherwise migrates the existing local settings up once so nothing is lost;
  later changes are written through, debounced. Writers keep using the local
  stores unchanged.

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

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

* Fix/adjust personalization sync for PR #6516

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

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

* Fix Studio personalization sync edge cases

* [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: wasimysaid <wasimysdev@gmail.com>
2026-06-22 04:09:48 -07:00
MUHAMED FAZAL PS
f10e47fc51
fix: pin anyio to <4.14.0 to fix RuntimeError on Python 3.13 (#6546)
* fix: pin anyio to <4.14.0 to fix RuntimeError on Python 3.13

Fixes #6483

anyio 4.14+ introduced cancel scope changes that cause
RuntimeError on Python 3.13. Pin to <4.14.0 until the issue is
resolved upstream.

---

If this helps, consider buying me a coffee: https://buymeacoffee.com/muhamedfazalps

* Cap anyio<4.14.0 in single-env constraints so the pin holds across all install steps (#6483)

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-06-22 03:50:57 -07:00
Daniel Han
e83d4ae072
Windows installer: fix DiskPart UAC mid-install, drive-root cache, and spurious unsloth.exe rename warning (#6296)
* Windows installer: fix DiskPart UAC, drive-root cache, spurious rename warning, CPU-base messaging

amd-smi gate (DiskPart UAC mid-install): the AMD torch wheel ships hipInfo.exe
inside the venv, and the bitsandbytes fix prepends that venv Scripts dir to PATH.
shutil.which("hipinfo") then found it and flipped _amd_smi_allowed() to True, so
the post-install AMD probe fell through to `amd-smi list` (the venv hipInfo failed
to report gcnArchName, which is why the arch came from the GPU-name table) and
amd-smi elevated, popping the DiskPart UAC. Fix: a hipinfo resolved inside the
active venv (sys.prefix) is the torch-wheel binary, not a HIP SDK, and must not
open the gate. Mirrored in install_python_stack.py, install_llama_prebuilt.py, and
backend utils/hardware/amd.py (the runtime VRAM poller had the same latent prompt).

TORCHINDUCTOR_CACHE_DIR: move from C:\tc to <StudioHome>\TORCHINDUCTOR_CACHE_DIR so
the inductor/Triton cache lives under the user's Studio home, not the system drive
root. Long paths are already enabled above so deep inductor paths still fit.

unsloth.exe rename: skip the rename (and its "pip may fail with WinError 32"
warning) when SKIP_STUDIO_BASE=1. In the install.ps1 flow base packages are not
reinstalled, so unsloth.exe is never rewritten; the self-rename only failed because
setup runs via unsloth.exe (the running launcher holds its own file). The
'studio update' flow still attempts it.

CPU PyTorch messaging: clarify that the CPU base is temporary and setup replaces it
with GPU ROCm wheels, and print an explicit "GPU ROCm PyTorch installed" line after
the AMD wheels land, so the log makes clear the final install is GPU-accelerated.

Adds two regression tests covering the venv-internal vs external hipInfo gate.

Verified end-to-end on a Strix Halo box (Radeon 8060S / gfx1151): install.ps1
--local from this branch completed exit 0 with no DiskPart prompt, no rename
warning, the cache under the Studio home, and "GPU ROCm PyTorch installed
(gfx1151)"; Studio then booted and detected "ROCm (HIP 7.13.99004) -- AMD Radeon
8060S Graphics".

* Windows installer: drop the unreliable unsloth.exe rename and its WinError 32 warning

setup.ps1 used to rename the running unsloth.exe out of the way before the
base-package upgrade so pip could replace it. That rename never actually
worked: setup runs *via* unsloth.exe, so renaming our own running
uv-trampoline launcher failed with a sharing violation (WinError 32) and only
printed a scary 'could not rename unsloth.exe; pip may fail with WinError 32'
warning on every Windows install and update.

It also was not needed. pip tolerates a running/locked console-script .exe: it
moves the old one aside and writes the new one. The base upgrade routes through
pip on Windows, so the upgrade succeeds (or, in the install.ps1 flow with
SKIP_STUDIO_BASE=1, the base is not touched at all) and unsloth.exe is left
intact either way.

Removing the rename block and its failed-install restore block removes the
false warning for all Windows devices in both the install and update flows.

* Windows installer: gate venv-internal hipInfo.exe in PowerShell amd-smi probe; harden venv path checks

Follow-up to PR #6296.

- install.ps1 and setup.ps1: ignore the AMD torch wheel hipInfo.exe that lives
  inside the Studio venv when probing for a HIP SDK, so amd-smi no longer reopens
  the DiskPart UAC during install/update. Mirrors _path_inside_venv in the Python
  installers, which already do this.
- amd.py, install_llama_prebuilt.py, install_python_stack.py: normcase the venv
  containment check (Windows paths are case-insensitive) and run the
  HIP_PATH/ROCM_PATH candidate through it too.
- setup.ps1: fall back to a short TORCHINDUCTOR cache dir when long paths are
  unavailable, and create the dir wildcard-safely.
- tests: isolate sys.prefix in the gate helper, add HIP_PATH/ROCM_PATH cases, and
  assert the PowerShell venv exclusion.

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

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

* Windows installer: install ROCm PyTorch directly for a known AMD arch

When the GPU arch is known (name-inferred from the GPU-name table) but ROCm
could not be probe-verified (no HIP SDK, no amd-smi), the bootstrap installed
a CPU PyTorch base that setup.ps1 then force-reinstalled as ROCm. The
repo.amd.com wheels bundle their own runtime (no HIP SDK required), which
setup.ps1 already relies on, so the CPU base was a pure wasted download/install.

- Gate the ROCm index on a known arch, not only on probe-verified ROCm, so a
  mapped arch installs ROCm torch directly. Unmapped arches and no-GPU hosts
  still get CPU (unchanged).
- Fall back to a CPU base if the ROCm-index install fails, so a transient
  repo.amd.com outage does not abort the install (setup.ps1 retries ROCm).
- Correct the stale comment that claimed ROCm wheels need a confirmed HIP SDK.
- Add a regression test for the arch-based gate.

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

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

* Windows installer: correct the unsloth.exe rename-removal comment

The comment claimed the base upgrade 'routes through pip on Windows' and that
pip 'moves the old unsloth.exe aside, then writes the new one'. That is not what
the code does. install_python_stack tries uv first; on a locked launcher uv
aborts and falls back to pip, but the pip fallback strips --upgrade-package and
base.txt lists only bare unsloth/unsloth-zoo, so pip finds them already
satisfied and no-ops. The running unsloth.exe is left intact at its current
version either way. Reword the comment to describe the real uv-first /
pip-fallback-no-op behavior. No functional change.

* Windows installer: close two gaps in the venv-internal hipinfo exclusion

Review follow-up. The amd-smi/DiskPart gate could still reopen in two cases:

- setup.ps1 ran the HIP probe long before $VenvDir is assigned, so without
  VIRTUAL_ENV (the `unsloth studio update` path) $venvRoots was empty and the
  venv-internal hipInfo.exe was not recognized. Seed the venv root from
  UNSLOTH_SETUP_PYTHON and the default Studio home too (both installers).
- The HIP_PATH/ROCM_PATH candidate was accepted without the venv filter, so an
  env var pointing into the venv (AMD wheel) still set $HipSdkInstalled. Run
  Test-HipinfoIsVenvInternal on the candidate as well (both installers).

Extend the PS gate test to assert both. Both .ps1 parse clean; install tests
pass (the venv-internal / HIP probe coverage at 359 passed).

* Windows installer: correct the CPU-base message for arches with no ROCm wheels

After gating the ROCm index on a known arch, a mapped arch sets $ROCmIndexUrl
and installs ROCm directly, so it no longer reaches the "temporary CPU base"
branch. That branch is now reached only by a name-inferred arch with no ROCm
wheels (e.g. RDNA2 gfx103X), where setup.ps1 does NOT install ROCm. The old
text ("setup replaces it with GPU ROCm wheels ... the final install IS
GPU-accelerated") was therefore always wrong there. Say plainly that PyTorch
stays on CPU for this GPU.

* Windows installer: seed the venv-internal hipInfo check from a custom Studio home

Test-HipinfoIsVenvInternal seeded the venv root from VIRTUAL_ENV, VenvDir, the
setup python, and the default %USERPROFILE% path only. A standalone
`unsloth studio update` with a custom UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias)
and none of those set would not recognize the venv hipInfo on PATH, reopening the
amd-smi/DiskPart gate. Seed the custom home too, in both installers, and assert
it in the gate test.

* Studio installer: resolve venv aliases and expand ~ in the hipInfo venv filter

Two review points on the amd-smi/DiskPart UAC gate:

1. _path_inside_venv compared os.path.abspath of sys.prefix and the hipInfo
   path, which does not resolve symlinks, junctions, or 8.3 short names. A venv
   reached through an aliased path then fails the check, so its bundled
   hipInfo.exe is mistaken for an external HIP SDK and amd-smi runs (the
   DiskPart prompt this fix exists to suppress). Switch to os.path.realpath in
   all three copies (amd.py, install_llama_prebuilt.py, install_python_stack.py).

2. setup.ps1's early venv-internal hipInfo probe seeded the venv root from a
   custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME) without expanding a
   leading ~, while the canonical resolver does. With a tilde form,
   [IO.Path]::GetFullPath kept the literal ~ relative to cwd, so the custom-home
   hipInfo escaped the filter and reopened the gate. Expand ~ in the probe the
   same way as the resolver.

tests/studio/install/test_pr5940_followups.py: 30 passed (adds a symlink
realpath case and a setup.ps1 tilde-expansion guard).

* Studio installer: mirror the hipInfo venv filter and ROCm wheel pins into install.ps1

Follow-up review on the same install.ps1 paths:

1. install.ps1's venv-internal hipInfo probe (Test-HipinfoIsVenvInternal)
   seeded the venv root from a custom Studio home without expanding a leading
   ~, unlike the canonical resolver and setup.ps1. A tilde form left
   [IO.Path]::GetFullPath with the literal ~ (relative to cwd), so the
   custom-home hipInfo escaped the filter and reopened the amd-smi/DiskPart
   gate. Expand ~ in the probe, matching the setup.ps1 fix.

2. The AMD ROCm path installed torchvision/torchaudio bare while pinning torch
   to below 2.12. AMD's per-arch index publishes the companions independently
   and may ship torchvision 0.27 (for torch 2.12) before removing 0.26, so a
   bare resolve can pick an ABI-incompatible set and fall back to CPU. Add
   torchvision/torchaudio floor maps and pass the pinned specs, mirroring
   setup.ps1 and install_python_stack.py.

3. The ROCm-to-CPU fallback torch install used Invoke-InstallCommand (no
   retry), the only torch step in the file without it. Switch to
   Invoke-InstallCommandRetry so the recovery path survives a transient index
   failure.

tests/studio/install/test_pr5940_followups.py: 33 passed (parametrized tilde
check over both installers, a torch/companion floor-map parity test, and a
CPU-fallback retry guard).

* Studio installer: scan all PATH hipinfo so the venv copy can't shadow a real HIP SDK

The amd-smi HIP-SDK probe used shutil.which("hipinfo") / Get-Command hipinfo,
which return only the first hit on PATH. The AMD torch wheel ships hipInfo.exe
inside the venv and the bnb fix (plus the Studio backend) prepend the venv
Scripts dir to PATH, so that venv-internal copy lands first. When a real HIP SDK
hipinfo sits later on PATH with HIP_PATH/ROCM_PATH unset, the first-hit probe
stopped at the venv copy, treated it as "not a HIP SDK", and closed the amd-smi
gate -- AMD users in that PATH-only SDK setup lost amd-smi telemetry and could
fall back to CPU. Scan every PATH entry and keep the first hipinfo that is not
venv-internal; only the venv copy is ignored, so the UAC/DiskPart suppression is
unchanged.

Applied to all three Python copies (install_llama_prebuilt.py,
install_python_stack.py, backend/utils/hardware/amd.py) via a new
_external_hipinfo_on_path helper, and both PowerShell callers (install.ps1,
setup.ps1) now use Get-Command hipinfo -All filtered by Test-HipinfoIsVenvInternal.

tests/studio/install/test_pr5940_followups.py: 36 passed (real-PATH scan tests, a
shadow-regression test for the exact venv-first ordering, and a parity check that
every Python copy uses the scanning helper).

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

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

* Studio uninstallers: fix leftovers (false "removed", shared icon, llama lock)

Auditing a dual native+WSL uninstall on a real device surfaced three leftovers:

1. uninstall.ps1 removed the data dir (which holds unsloth.ico) before the
   shortcuts that reference that icon, so Explorer's icon cache briefly held it
   open. Remove-Item -Recurse reported success yet left the locked file, and the
   dir was never re-attempted, so it orphaned with a false "removed" log.
   _RemovePath now verifies the path is actually gone (retrying transient locks)
   and reports honestly, and the data dir is re-swept after the shortcuts go.

2. install.sh writes a shared unsloth.ico to %LOCALAPPDATA%\Unsloth Studio for
   the WSL shortcut, but uninstall.sh never removed it, orphaning the icon (and
   dir) after a WSL uninstall. uninstall.sh now drops that icon and the dir when
   empty, in both the powershell.exe and drvfs-fallback paths.

3. ~/.unsloth/.llama.cpp.install.lock was never removed, so the rmdir of
   ~/.unsloth failed and the dir lingered. Both uninstallers now remove the lock.

Verified by running both uninstallers on a real dual install: device fully clean
(no install dirs, shortcuts, PATH/registry entries, shared icon, or lock left).

* install.sh: auto-route Strix Halo WSL to an existing Ubuntu 24.04

ROCm-on-WSL is the GPU runtime for Strix Halo and only targets Ubuntu
24.04. When the installer runs in a newer default distro (e.g. 26.04) it
cannot enable the GPU and silently falls back to CPU. If a 24.04 distro
already exists, re-run the install there and stop in the current one so the
GPU path is taken without the user having to know about the distro
requirement.

Runs before venv creation so the wrong distro is left untouched, guards
against re-route loops via UNSLOTH_WSL_REROUTED, leaves a working ROCm
distro alone (librocdxg present), and skips the GGUF-only / opt-out /
non-Strix cases. When no 24.04 distro exists we keep today's behaviour:
continue to CPU and print the `wsl --install Ubuntu-24.04` guidance, never
auto-downloading a distro.

Adds tests/sh/test_strixhalo_wsl_reroute.sh (hermetic: extracts the
function, rewrites its paths to fixtures, mocks wsl.exe) covering the full
decision matrix, wired into tests/run_all.sh.

* uninstall.ps1: keep shared unsloth.ico for a surviving WSL shortcut

A dual native+WSL install shares %LOCALAPPDATA%\Unsloth Studio\unsloth.ico:
install.sh points the WSL shortcut's icon there while the native install owns the
dir. The native uninstaller removed the whole dir unconditionally, so uninstalling
native while keeping WSL left the WSL shortcut with a blank icon. The old code only
avoided this when Explorer happened to hold the icon open, which is unreliable; on a
real dual install the dir was deleted and the WSL shortcut went blank.

_RemoveDataDirKeepingWslIcon now scans the Start Menu + Desktop for a surviving
"Unsloth Studio (WSL ...).lnk" and, if found, removes everything in the data dir
except unsloth.ico (keeping the dir) instead of deleting it; with no WSL shortcut it
removes the dir as before. uninstall.sh still drops the icon and the empty dir when
WSL itself is uninstalled, so every uninstall order ends clean.

Adds tests/studio/test_uninstall_dual_install_icon.ps1 (AST-extracts the helper and
runs it against a temp dir with controlled shortcut dirs) covering the dual,
native-only, empty, and missing-dir cases, wired into the windows-inference smoke
workflow. Verified on a real dual install: native uninstall now keeps unsloth.ico
and the WSL shortcut's icon stays intact.

* installer: condense AMD/ROCm code comments (no behavior change)

Tighten the comments added for the Strix Halo native+WSL installer work so
they are shorter and clearer without losing intent: the venv-internal hipInfo
amd-smi gate, the ROCm torch/companion floor maps, the WSL 24.04 reroute, and
the dual-install uninstall icon handling. Comment-only; code paths unchanged.
107 insertions, 166 deletions across 11 files.

* install.sh: run the Strix Halo WSL reroute before any STUDIO_HOME write

The reroute fired after mkdir -p "$STUDIO_HOME" and the legacy-venv migration,
so rerouting 26.04 -> 24.04 left an empty ~/.unsloth/studio stub in the origin
distro (and ran venv migration in the distro about to be abandoned). Move the
reroute ahead of the venv section so the origin distro is left untouched, matching
the function's own comment. Behavior is identical on every non-reroute path.

* installer: fix ROCm CPU-fallback, hipinfo gate edge cases, uninstall icon, WSL 22.04

- install.ps1: clear $ROCmIndexUrl/$ROCmTorchFloor after the CPU fallback so the
  flavor-repair block does not retry the failed ROCm index and abort the install;
  pin the ROCm companion specs ($visionSpec/$audioSpec) in the repair path too.
- install.ps1 + setup.ps1: skip a bare drive root in Test-HipinfoIsVenvInternal so a
  non-venv UNSLOTH_SETUP_PYTHON does not match the whole drive; iterate
  HIP_PATH/HIP_PATH_57/ROCM_PATH and take the first non-venv hipinfo.
- amd.py, install_llama_prebuilt.py, install_python_stack.py: strip surrounding
  quotes from PATH entries before probing for hipinfo.
- install.sh: pipefail the WSL reroute curl|sh; do not reroute supported Ubuntu 22.04.
- uninstall.sh: keep the shared unsloth.ico while any Unsloth shortcut (native or
  another WSL distro) still references it, in both the powershell and drvfs paths.
- tests: regression coverage for all of the above.

* installer: forward reroute options, guard ROCm bootstrap, harden hipinfo gate

- install.sh: forward the caller's --package/--python/--verbose/--tauri and a custom
  UNSLOTH_STUDIO_HOME into the WSL reroute (was a bare default install); bail on
  --local; run the reroute BEFORE dependency/uv install so the origin distro is left
  untouched; set UNSLOTH_SKIP_ROCM_WSL_SETUP after a failed reroute so the later
  ROCm-on-WSL bootstrap does not install into the unsupported origin distro.
- install.ps1 + setup.ps1: Get-Command hipinfo -CommandType Application so only real
  executables match (not an alias/function named hipinfo).
- uninstall.ps1: guard $env:APPDATA when building the default shortcut search dirs.
- tests: cover option forwarding, --local bail, the bootstrap guard, and the gate change.

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

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

* installer: guard origin ROCm bootstrap on every CPU-only fallback; harden ~ expansion

WSL reroute: the no-wsl.exe, no-24.04-target and --local fallbacks all tell the
user the install continues CPU-only, but only the failed-reroute branch set
UNSLOTH_SKIP_ROCM_WSL_SETUP=1. The later _maybe_bootstrap_rocm_wsl gate keys off
that flag, so the other three branches could still install ROCm into the
unsupported origin distro (e.g. 26.04). Set the skip guard on all of them.

Forward UNSLOTH_ROCM_WSL_AUTO into the reroute so a Tauri/consented GPU bootstrap
carries through to the rerouted 24.04 child instead of dropping to the prompt path.

install.ps1/setup.ps1: guard the venv-probe ~ expansion on a non-empty
$env:USERPROFILE so Join-Path does not throw on a profile-less service account.

Tests: add no-wsl.exe and UNSLOTH_ROCM_WSL_AUTO reroute cases, the USERPROFILE
guard assertion, and route shell-test fixtures through a single trap-cleaned root.

* installer: pin + soften Windows ROCm Python repair, reroute to 22.04, harden gates

install_python_stack.py: the Windows AMD ROCm repair in _ensure_rocm_torch()
installed bare torch/torchvision/torchaudio via the fatal pip_install -- the same
asymmetry already fixed on the PowerShell side. A transient repo.amd.com failure
could abort the whole install even after install.ps1/setup.ps1 fell back to CPU.
Pin companions per-arch (gfx120X/Strix -> the rocm7.2 trio, mirroring the PS floor
maps) and make the retry nonfatal: keep the existing build and let the user re-run
update to retry ROCm, so the chain install.ps1 -> setup.ps1 -> stack stays CPU-safe.

install.sh: reroute now targets an installed Ubuntu 24.04 OR 22.04 (24.04 preferred);
both are AMD-supported for ROCm-on-WSL, matching the leave-alone set, so a box with
only 22.04 reaches the GPU instead of staying CPU-only.

install.ps1/setup.ps1: a bare ~ for UNSLOTH_STUDIO_HOME left an empty Join-Path child
(PS 5.1 throws); fall back to USERPROFILE directly and only join a real remainder.

_path_inside_venv (amd.py + both installers): guard a root-dir sys.prefix so commonpath
can't classify every path on the drive as venv-internal (defensive; venv never at root).

uninstall.sh: guard an empty LOCALAPPDATA in the PS-interop icon cleanup (mirror APPDATA).

Tests: add 22.04-target reroute cases, Windows ROCm pin+nonfatal coverage (text +
behavioral), root-dir guard coverage, and bare-~/LOCALAPPDATA guard assertions.

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

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

* install.sh: match WSL reroute target by exact distro name, not substring

The 24.04/22.04 reroute target was chosen with grep -F (substring), so a custom
distro such as 'Ubuntu-24.04-test' (with no exact Ubuntu-24.04) was picked as the
target; the later 'wsl -d Ubuntu-24.04' then fails and the Strix Halo install stays
CPU-only. Match whole lines (grep -ixF) and reuse the matched name so only a real
Ubuntu-24.04/22.04 is targeted. Adds substring-rejection + exact-vs-custom tests.

* install.sh: keep the WSL reroute target to Ubuntu 24.04 (helper-supported only)

The ROCm-on-WSL bootstrap (scripts/install_rocm_wsl_strixhalo.sh) dies on any
VERSION_ID other than 24.04 and pins the noble repo, so treating 22.04 as
GPU-supported let the parent report a successful reroute while the child fell
back to CPU. Drop 22.04 from the supported set and the reroute target list;
24.04 stays the sole target (keeping the exact whole-line distro match). An
already-working ROCm on any other version is still left alone by the librocdxg
check above.

tests: reroute 22.04 cases updated to the 24.04-only behavior; make the
"no wsl.exe" case hermetic so a real host wsl.exe can't leak in on dev boxes;
stop the tauri exit-order check from mis-flagging the reroute helper's
[ "$TAURI_MODE" = true ] && ... --tauri one-liner.

* installer: tighten comment wording across the Strix Halo install/uninstall paths

Condense the verbose multi-line comment blocks (amd-smi hipinfo gate, ROCm
torch install + CPU fallback, WSL reroute, uninstall icon-keep) into fewer,
clearer lines. Comments and a few docstrings only; no code, logic, or
behavior change. Verified with bash -n, the PowerShell parser, and ast.parse,
and the installer test suite still passes.

* add AGPL-3.0 SPDX headers to the .sh/.ps1 scripts missing them

Every shell and PowerShell script under the Studio/installer surface now
carries the standard SPDX-License-Identifier: AGPL-3.0-only + copyright
header (after the shebang where present): the installer (install.sh,
install.ps1), build.sh, the .github and src-tauri scripts, the installer
test suite, and the moe kernel test. Header-only, line endings preserved;
bash -n, the PowerShell parser, and the installer tests all pass.

* installer: drop the duplicate AGPL header from install.sh and install.ps1

Both already carry an SPDX-License-Identifier: AGPL-3.0-only header below
their usage comment block; the prior header pass added a second one at the
top because it only scanned the first few lines. Remove the duplicate so each
file keeps a single original header.

* installer: force-reinstall CPU fallback torch; propagate Tauri NEED_SUDO from reroute

install.ps1/setup.ps1: when the AMD ROCm wheel install fails and we fall back to a
CPU base, force-reinstall the torch/vision/audio triplet. A failed ROCm install can
leave an unpinned ROCm torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still
satisfies the CPU torch>=2.4,<2.11.0 range, so without --force-reinstall uv keeps the
ROCm build and only swaps the companions -- a mismatched venv the flavor-repair block
won't fix. setup.ps1 scopes the forced reinstall to the ROCm-fallback path
() so the genuine CPU-only install stays fast.

install.sh: the Strix Halo WSL reroute treated every nonzero child exit as a reroute
failure and fell back to CPU. In --tauri mode the child uses exit 2 ([TAURI:NEED_SUDO])
to ask the desktop app to elevate for the target distro; capture the child's exit code
and propagate exit 2 in Tauri mode (the child already printed the NEED_SUDO line)
instead of masking it. CLI mode still falls back to CPU on a generic failure.

Tests: reroute Tauri exit-2 propagation (and non-Tauri CPU-fallback) cases;
run_func now preserves the child exit code; force-reinstall assertions for both
PowerShell installers.

Note: codex's _rr_q apostrophe finding is a false positive -- the helper already
emits POSIX-correct 'O'\''Brien' and round-trips under both sh and bash.

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

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

* setup.ps1: fix $cpuForce array collapse in the ROCm->CPU torch fallback

An if-expression assignment ($cpuForce = if ($ROCmCpuFallback) { @("--force-reinstall") })
collapses the single-element array to a scalar string, so @cpuForce splatting enumerated
it character-by-character into broken single-letter args (- - f o r c e ...), which made
uv/pip reject the install and aborted the whole Studio setup on the AMD ROCm->CPU fallback
path. Build $cpuForce as a real array assigned outside the if-expression so the splat passes
a single --force-reinstall arg. Genuine CPU-only installs stay fast (empty array, no flag).
Test now asserts the array-build form and rejects the if-expression form.

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

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

* uninstall: remove the isolated Node.js runtime (~/.unsloth/node)

The isolated Node.js runtime (install_node_prebuilt.py, added with the managed-Node
change) installs to ~/.unsloth/node in default mode -- a sibling of studio, so deleting
<studio> leaves it behind (~200MB orphaned after uninstall). Both uninstallers already
remove the other default-mode siblings (llama.cpp/.cache/.staging); add node alongside
them. uninstall.ps1 also adds it to the handle-lock sweep so a held node.exe can't block
the delete. Env/custom mode nests node under the custom root, removed with that root.

* [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>
2026-06-22 03:09:08 -07:00
Daniel Han
378e33c8a5
Studio macOS: faster startup, MLX self-heal, drop obsolete prebuilt pins (#6494)
* Studio: defer llama.cpp update probes and self-heal MLX on macOS

Two macOS startup problems shared one root area in the FastAPI lifespan:

- The llama.cpp capability + freshness probes ran inline before the server
  yielded, so a cold/slow/flaky network on the GitHub freshness check blocked
  'Application startup complete' (~34s on CI, longer in the field). Move both
  probes to a daemon thread; app.state stays None until ready (status routes
  already re-probe at request time). Opt out with UNSLOTH_DISABLE_UPDATE_CHECK=1.

- Train and Export were greyed out because mlx/mlx-lm/mlx-vlm arrive only
  transitively and a resolver backtrack silently drops them, so CHAT_ONLY stayed
  true. Add utils/mlx_repair.py: when Apple Silicon is detected without MLX,
  reinstall mlx/mlx-lm/mlx-vlm by name on a daemon thread and re-run hardware
  detection (opt out UNSLOTH_DISABLE_MLX_AUTOREPAIR=1). Surface a chat_only_reason
  in /api/health plus a sidebar tooltip so a greyed Train/Export explains itself
  instead of failing silently.

* Studio: guard model defaults against a None model name

load_model_defaults(None) called model_name.lower() with no guard, raising
'Error loading model defaults for None' before any model is selected. Return
an empty dict for a falsy/non-str name.

* Studio: drop obsolete upstream macOS + Windows Blackwell prebuilt pins

Both pins worked around gaps in ggml-org upstream prebuilts, but Studio now
routes every GPU host and all of macOS to the unslothai/llama.cpp fork
(published_repo_for_host), which ships the needed bundles, so both pins are
dead code on the default install path:

- macOS b9415: macOS always routes to the fork (its own macOS bundles), and
  host_supports_macos_minos() is the backstop. The pin only fired under an
  explicit --published-repo ggml-org override.
- Windows Blackwell b9360: Windows-NVIDIA routes to the fork, whose
  windows-x64-cuda13 bundle covers Blackwell (manifest max_sm 120, toolkit
  13.3), so the pin's self-disable check makes it dormant on every default
  install; it could only activate under the same upstream override on a
  13.0-13.2 driver.

Remove the pin constants, functions, and call sites. Keep the Blackwell
capability detection (_drop_blackwell_incapable_windows_cuda, _host_is_blackwell,
_windows_cuda_attempt_covers_blackwell) that still drops a non-sm_120 cuda-12.4
build on a Blackwell host. After this, an explicit --published-repo ggml-org
override on a Blackwell 13.0-13.2 host loses its GPU fallback and lands on CPU;
the default fork path is unaffected. Update the install selection-logic and
macOS-compat unit tests for the new no-pin behavior.

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

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

* Studio: walk back deeper on the macOS upstream prebuilt path

After removing the b9415 macOS pin, the explicit --published-repo ggml-org
upstream path still used the default 2-release fallback, so a pre-macOS-26 host
behind a run of macOS-26-only builds would exhaust two too-new plans (minos is
only checked post-download) and drop to a source build before reaching a
loadable older release. Walk back as deep as the fork macOS path
(DEFAULT_MAX_MACOS_RELEASE_FALLBACKS), turning the removed static pin into
dynamic discovery. Addresses review feedback on the macOS upstream fallback.

* Studio: pin transformers during MLX self-heal so it cannot break Studio

mlx-lm/mlx-vlm declare transformers>=5, but the single-env install pins
transformers==4.57.6. The self-heal used --upgrade with no constraint, so it
could upgrade transformers in the live venv and break the rest of Studio just to
make import mlx.core pass. Pin transformers to the installed version via a
constraint file: the resolver either finds an mlx build compatible with it or
fails (we stay chat-only), never upgrading transformers underneath Studio.
Addresses review feedback on the MLX repair install.

* Studio: harden MLX self-heal against an unsupported mlx-vlm

Pinning transformers alone made uv backtrack mlx-vlm to 0.3.9 (below unsloth-zoo's
mlx-vlm>=0.4.4), which imports but breaks VLM Train/Export -- so the self-heal
could clear chat-only onto a broken stack. Mirror the main installer: set
UV_OVERRIDE=overrides-darwin-arm64.txt so a current mlx-vlm coexists with the
transformers pin, require the same minimum versions unsloth-zoo declares, and
gate/validate on a full mlx_stack_available() check (not a bare import) so an
old or partial stack stays chat-only. Addresses PR review.

* Studio: filter Blackwell-incapable CUDA in resolve_upstream_asset_choice

resolve_upstream_asset_choice returned the first windows-cuda choice unfiltered,
so a Blackwell host could be handed an sm_120-incapable cuda-12.4 build while the
sibling planners drop it. Apply _drop_blackwell_incapable_windows_cuda here too
and fall through to the CPU bundle on a Blackwell host with no capable GPU asset.
Addresses PR review.

* Studio: re-poll health so MLX self-heal reaches an open UI

The sidebar cached the initial /api/health, so a successful background MLX
self-heal (chat_only flips false) did not re-enable Train/Export until a manual
reload. While chat-only for the recoverable mlx_unavailable reason, re-poll
/api/health and stop once Train/Export become available. Addresses PR review.

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

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

* Studio: make the disabled Train/Export tooltip reachable

The greyed Train/Export items pass a tooltip explaining why (e.g. MLX missing),
but a disabled <button> fires no pointer events and SidebarMenuButton only showed
tooltips while collapsed, so the explanation never appeared. Wrap a disabled
button in a focusable span and show its tooltip while expanded too; enabled items
keep the collapsed-only behavior. Addresses PR review.

* Studio: gate Train/Export on the full MLX stack, not bare mlx.core

detect_hardware enabled MLX training whenever `import mlx.core` worked, but the
MLX self-heal (utils/mlx_repair) treats a stack without mlx-lm/mlx-vlm at the
versions unsloth-zoo requires as inadequate. That asymmetry let the UI enable
Train/Export on exactly the partial/backtracked stack the self-heal is trying to
repair (greyed-in-but-broken VLM export). Gate on the same mlx_stack_available()
criterion so a partial stack stays chat-only (reason mlx_unavailable) and the
background repair restores it. Addresses PR review.

* Fix MLX repair and health auth for PR #6494

* Fix macOS upstream prebuilt fallback for PR #6494

* Fix MLX stack validation for PR #6494

* Fix MLX self-heal validation for PR #6494

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

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

* Review fixes: isolate hardware-state test, robust transformers pin

- test_chat_only_reason.py: detect_hardware() assigns module globals directly,
  which monkeypatch does not revert; the autouse fixture now saves and restores
  DEVICE/CHAT_ONLY/CHAT_ONLY_REASON/IS_ROCM so a chat-only verdict here cannot
  leak into other backend tests (e.g. test_utils.py) on a GPU host.
- mlx_repair.py: read the transformers version from importlib.metadata instead of
  importing transformers, so the install pin is not silently dropped when
  transformers has valid metadata but fails to import.

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

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

* Fix CI: model full MLX stack in dispatch tests, keep selection test offline

dispatch (macOS) job:
- detect_hardware now gates MLX on the full stack (mlx_stack_available imports
  mlx_lm/mlx_vlm and checks dist versions), so faking only mlx.core makes the
  apple_silicon_mlx profile resolve to CPU. The dispatch tests assert the routing
  decision when the stack IS usable, so model a complete stack:
  test_hardware_dispatch_matrix patches utils.mlx_repair.mlx_stack_available and
  test_is_mlx_dispatch_gate patches hardware._has_usable_mlx_stack. The stack
  predicate's own internals stay covered by test_mlx_repair.py.

Repo tests (CPU) job:
- test_no_cuda_attempt_on_published_path_for_13_1 fell through to a live
  github_release_assets() upstream fetch after the Blackwell filter dropped every
  published attempt, which the offline security scanner blocks. Stub that fetch so
  the walk-back deterministically finds no usable CUDA build and raises
  PrebuiltFallback without network.

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

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

* Harden MLX self-heal: prepare transformers constraint inside the try

attempt_mlx_repair runs on a daemon thread, but _transformers_constraint_args was
called before the try. A failure there (e.g. tempfile.mkstemp on a full disk or a
bad TMPDIR) would propagate unhandled and silently kill the self-heal thread.
Move the call inside the try and initialize constraint_path so any such failure
is caught and leaves Studio chat-only instead of crashing the thread.

---------

Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-22 02:20:08 -07:00
Daniel Han
4e8d0da8f9
Show model provider/org in the trust_remote_code consent dialog (#6537)
* Show model provider/org in the trust_remote_code consent dialog

The consent dialog showed only the trailing model name (modelName.split('/').pop()),
dropping the owner. The HF org/owner is the 'who do I trust' signal the prompt is
asking about, so render it: 'NVIDIA-Nemotron-3-Nano-4B from "unsloth"'. A null
provider for local paths and bare names leaves those renders unchanged. Applies to
the enable/blocked/malware variants (shared description block).

* Only show consent provider tag for a confident single Hub repo

Tighten parseModelDisplay so the 'from "<provider>"' tag is shown only for a
canonical owner/repo Hub id (exactly one slash, both segments non-empty) that is
not a local path and not part of a multi-repo scan. This avoids misattributing a
relative local directory name (models/llama/7b) or a LoRA base/external repo's
finding to the wrong publisher in a trust decision. Extract a ProviderSuffix
component so both description branches render the clause identically via &&.

* Tighten consent provider-tag comments

* Source the consent provider tag from the backend

The dialog inferred the provider client-side from the model id, using
scanCreatedRepos (a cleanup-only list) to detect multi-repo scope and a
regex that missed bare relative paths like a local owner/model dir. Both
could attribute the scanned code to the wrong publisher.

Move the decision to the backend, where locality and scan scope are
known: _consent_provider returns the owner only for a single, non-local,
canonical owner/repo Hub id, and the route returns it as payload[provider].
The frontend now renders scan.provider directly.

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

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

* Suppress consent provider tag when external auto_map code is scanned

A single Hub repo can declare an auto_map that loads code from another repo
(owner/other--module.Class). The scanner fingerprints that external repo's
Python, but security_targets still held only the primary, so the dialog
attributed the custom code to the primary publisher. Pass the external refs
collected during the scan to _consent_provider and return no provider when any
are present, so attribution is shown only for genuinely self-contained repos.

* Trim comments to be more succinct

---------

Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-22 02:11:18 -07:00
Daniel Han
1582d2854c
Harden trust_remote_code consent: scan GGUF-only auto_map and drop pre-set TRC defaults (#6478)
* Scan auto_map for GGUF-only repo ids in the consent gate

The trust_remote_code consent gate treated any repo classified GGUF-only
(ships .gguf, no transformers-loadable weight) as having no remote code,
so _config_has_auto_map returned False even when a config declared an
auto_map and the repo shipped the referenced .py. The evaluator then
skipped the scan/fingerprint for that target entirely.

GGUF-inertness is a property of the loader, not the repo. A GGUF
selection loads via llama.cpp, which never reads config.json/auto_map,
and that case is already short-circuited upstream by the caller's
is_gguf check (the inference route skips the remote-code preflight for a
GGUF load). Every path that reaches this helper (export, training,
non-GGUF inference) loads through transformers/Unsloth from_pretrained,
which DOES import auto_map even for a repo that only ships .gguf weights:
the custom module runs before from_pretrained fails on the missing
transformers weights. The export path has no is_gguf guard and passes the
source straight to FastLanguageModel.from_pretrained(trust_remote_code=True),
so the in-helper GGUF skip let a repo with config.json (auto_map) +
modeling_x.py + only a .gguf run unreviewed code during export.

Drop the redundant repo-level GGUF short-circuit (and the now-unused
_is_gguf_repo helper). A direct .gguf file reference stays inert via
_is_direct_gguf_file_ref because that genuinely is a single-file llama.cpp
load; repo ids are always scanned. A GGUF repo whose auto_map ships no .py
still allows via the existing empty-code path, so legitimate GGUF loads
are unaffected (and GGUF inference never reaches this helper at all). Only
a repo that actually contains a .gguf can change behavior here; non-GGUF
repos (safetensors, MLX) are byte-identical before and after.

Update the GGUF auto_map test to expect a scan, and add two regression
tests: a GGUF-only repo shipping auto_map Python is scanned and blocked,
and a transformers-style repo (safetensors / MLX .npz) with auto_map stays
scanned and blocked.

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

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

* Remove trust_remote_code config defaults; consent dialog is the only enabler

trust_remote_code is a per-load decision that must go through the remote-code
consent dialog, which scans the auto_map code and pins the exact version. Two
pre-set paths could still enable it without the user reviewing any code, and the
GGUF consent bypass rode one of them into the export flow:

- 4 model_defaults YAMLs shipped trust_remote_code: true (GLM-4.7-Flash,
  Nemotron-3-Nano-30B-A3B, PaddleOCR-VL, ERNIE-4.5-VL).
- The frontend consent hook silently enabled trust_remote_code on a clean scan
  whenever the caller flagged the model as needing it.

Remove every trust_remote_code key from the model_defaults YAMLs (the loaders
already default to False when the key is absent) and delete the frontend silent
auto-enable, so trust_remote_code is only turned on after the user approves the
scanned code in the dialog.

The three models that genuinely run custom code ship auto_map, which the consent
gate detects on its own via _config_has_auto_map, so the dialog still fires for
them in inference, training, and export (Nemotron is also re-granted by the
trusted-org auto-enable in the workers). GLM-4.7-Flash has no auto_map:
glm4_moe_lite is native in transformers 5.0+ and it loads with
trust_remote_code=False, so its YAML flag was a no-op.

Adds test_yaml_trust_remote_code_removed.py.

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

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

* Drop YAML sections emptied by trust_remote_code removal

Removing trust_remote_code from a model YAML whose section had no other key
left a bare `inference:` header, which PyYAML parses as None;
load_inference_config() then does `model_config.get("inference", {}).get(...)`
and crashes on the None. Drop those now-empty section headers (24 model
defaults, all the `inference:` section) so callers fall back to family/default
inference params, which is the same result those models had before (their only
inference override was trust_remote_code).

Strengthens test_yaml_trust_remote_code_removed.py to forbid any empty/None
top-level section and to load the affected models' inference config end to end.

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

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

* Add sweep asserting every model YAML loads via training + inference paths

Loads all model_defaults YAMLs through load_model_defaults (training) and
load_inference_config (inference) with the exact .get() access patterns the
routes use, so a malformed/None section that crashes either loader is caught.

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

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

* Assert ex-TRC auto_map models still surface the consent dialog

Removing the trust_remote_code YAML default must not suppress the dialog for the
models that genuinely run custom code. The dialog is driven by the repo's auto_map
(via preflight_remote_code_consent_for_targets -> _config_has_auto_map), not the YAML
flag, so Nemotron/PaddleOCR-VL/ERNIE-4.5-VL still require consent; GLM-4.7-Flash (no
auto_map) takes no dialog and loads natively. Mocks only the Hub config + .py reader.

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

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

* Tighten comments in consent-gate changes

* Trim comments to be more succinct

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
2026-06-22 02:10:35 -07:00
Daniel Han
cba73457df
Studio: self-heal unsloth namespace shadows; clearer failed-load messages (#6532)
* Studio: self-heal unsloth namespace-package shadows in all subprocess workers

A directory named `unsloth` (or `unsloth_zoo`) without an __init__.py on
PYTHONPATH/sys.path, a stray source checkout or a polluted PYTHONPATH, makes
`import unsloth` resolve to an empty namespace package, so a worker's
`from unsloth import FastLanguageModel` dies with a cryptic
"cannot import name ... (unknown location)".

The LLM training path already recovered from this via `_ensure_real_packages`
in trainer.py (PR #6269), but the inference, export, and embedding-training
subprocesses imported Unsloth directly with no guard. Extract that helper into
a shared, dependency-free core/import_guards.py and call it before the Unsloth
import in every subprocess: it drops the offending sys.path entries, imports
the real packages (unsloth before unsloth_zoo so the pre-zoo GPU fixes run),
then restores sys.path. trainer.py now imports the shared helper instead of its
local copy.

Covers both unsloth and unsloth_zoo and both namespace origin forms (None and
"namespace"). The existing PR #6269 test now exercises the shared helper.

* Studio: distinguish a failed model load from no model in the attach gates

A failed load never sets the checkpoint, so the image and audio attach gates
fell through to "Load a model before adding images/audio", which reads as if
the user simply forgot to pick a model rather than that the load errored. Add a
dedicated lastModelLoadError to the chat runtime store, set only when an actual
load attempt fails (not on refresh, list, status, or unload errors, which keep
using modelsError) and cleared when the next load starts. The image gate (all
three call sites) and the audio gate now use it to report a failed load and
point at the server logs, while still blocking in exactly the same cases.

* Tighten namespace-shadow guard and load-error comments
2026-06-21 22:43:31 -07:00
Daniel Han
9b5c94df32
CLI: stop unsloth connect from leaking Studio credentials to unverified servers (#6479)
* CLI: stop `unsloth connect` from leaking Studio credentials to unverified servers

`unsloth connect` (and `unsloth chat`) discovered a Studio base URL from
UNSLOTH_STUDIO_URL or the default localhost port after only an unauthenticated
/api/health probe, then sent credentials to it:

- keyless connect iterated every cached API key and sent each as a bearer token
  to {base}/v1/models, so a malicious or port-preempting endpoint could harvest
  all of them;
- with no cached key it self-issued a Studio JWT and POSTed it to
  {base}/api/auth/api-keys;
- unsloth chat sent the same self-issued JWT to the discovered base.

The key cache was a flat, global list with no binding to a server identity, so a
key minted for one Studio could be replayed to any other.

Changes:

- Scope the agent key cache per base URL so a key is only ever replayed to the
  exact server it was minted for. Pre-scoping flat caches are ignored rather
  than replayed (at most one extra local mint on the next launch).
- Gate every automatic credential flow to loopback bases. A non-loopback
  UNSLOTH_STUDIO_URL now requires an explicit --api-key and nothing is sent
  automatically. SSH-tunnelled Studios that land on 127.0.0.1 keep working.
- Mint the API key locally against the Studio auth DB instead of POSTing a
  self-issued JWT over the network, so no bearer token leaves the process on the
  local path.
- Apply the same loopback gate to connect_studio_server (used by unsloth chat).

Fully closing same-host loopback-port preemption needs a signed /api/health
handshake so the client can verify the server identity before sending anything;
that is tracked as a server-side follow-up.

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

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

* CLI: verify Studio server identity before auto-sending credentials

Adds a challenge-response so `unsloth connect` and `unsloth chat` can confirm a
discovered loopback endpoint is really this install's Studio (not a process
that preempted the port) before sending it a cached or freshly minted
credential. This closes the same-host loopback-preemption gap left open by the
previous commit, which could only limit the blast radius.

Server:

- storage.get_or_create_identity_secret(): a dedicated server-wide secret in
  app_secrets (kept separate from the per-user JWT secret), readable only by
  the same OS user.
- storage.compute_identity_proof(nonce) = HMAC-SHA256(identity secret, nonce).
- GET /api/auth/identity?nonce=<base64url>: unauthenticated, returns the proof.
  The nonce is opaque to the server and the proof reveals nothing about the
  secret, so answering is safe.

Client:

- verify_studio_identity(base): sends a fresh 32-byte nonce, recomputes the
  expected HMAC from the local same-user secret, and constant-time compares.
  Fails closed on any error.
- connect._agent_api_key gates the loopback cached-key replay and the local
  mint on it; connect_studio_server (used by unsloth chat) gates the
  self-issued JWT on it.

A server that cannot read this install's secret (a different OS user, or a
remote/fake endpoint) cannot produce a matching proof, so the client refuses
and falls back to an explicit --api-key.

Tests: studio/backend/tests/test_identity.py (proof determinism, secret
persistence and caching, route response and nonce validation) and additions to
test_connect.py (the verify gate refuses when unverified, an explicit key skips
the check, and an end-to-end client plus server handshake against a stub HTTP
server).

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

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

* CLI: mint through the verified server instead of the local auth DB

CodeQL (py/clear-text-storage-sensitive-data) flagged the API key written to
the per-server cache and the agent config files once it was sourced from
storage.create_api_key(); the original HTTP-minted key did not trip the query.

Now that the identity handshake cryptographically confirms the loopback
responder really is this Studio before anything is sent, minting through the
server's /api/auth/api-keys endpoint with a self-issued JWT is safe again and
restores the original, CodeQL-clean data flow. The local-DB mint path is
removed.

The security properties are unchanged: discovery is still loopback-gated and
identity-verified, the key cache is still scoped per server, and a credential
reaches the server only after the handshake has proven its identity. The only
difference from the previous commit is that the self-issued JWT is sent to the
already-verified loopback server rather than the key being minted in-process.

Tests updated to mint through the fake server again.

* CLI: address review feedback on connect credential handling

- Reuse a saved per-server key before the loopback/identity gate. Keys are
  scoped per base URL, so a key the user saved with --api-key for a remote or
  SSH-tunnelled Studio (whose identity secret the local handshake can't match)
  is replayed only to that exact server. The loopback + identity-handshake gate
  now guards just auto-minting (self-issuing a JWT and creating a new key),
  which is the path that needs a cryptographically verified local Studio. Fixes
  keyless reuse being impossible for remote/tunnelled Studios the user had
  saved a key for.

- connect_studio_server (unsloth chat / inference): when the user explicitly set
  UNSLOTH_STUDIO_URL but the server can't be safely attached (non-loopback, or
  identity unverifiable), fail with a clear message instead of silently loading
  the model locally. Opportunistic discovery of the local default still falls
  back to a local load.

- Harden cache parsing: tolerate a corrupt or hand-edited cache where a base
  maps to a non-list (which would otherwise iterate a string into
  single-character "keys"), and read the cache as UTF-8.

Tests updated and added: saved-key replay without the handshake for both local
and remote bases, keyless mint still refused when the loopback server is
unverified, and connect_studio_server erroring on an explicit remote while
falling back locally on default discovery.

* CLI: harden connect handshake against relay and gate cached minted keys

Addresses review feedback on the credential handshake:

- Refuse HTTP redirects on credential-bearing requests (the identity handshake,
  /v1/models, key minting, and the chat HTTP backend). A process squatting the
  discovered port could 302 /api/auth/identity to the real Studio and relay its
  valid proof, or bounce a bearer-token request to another base, and urllib
  follows redirects by default. A shared no-redirect opener now treats any 3xx
  as an error.

- Give cached keys provenance. Keys the user supplied with --api-key are "saved"
  and replay without the handshake (needed for remote or SSH-tunnelled Studios
  whose secret the local handshake can't match). Keys we auto-mint are "minted"
  and replay only after the identity handshake, so a port squatter can't collect
  a previously minted localhost key just by answering the health check. New cache
  shape: servers[base] = {"saved": [...], "minted": [...]}.

Known residual: a different-OS-user process that squats the port and can also
reach a genuine same-secret Studio elsewhere on loopback can still manually relay
the identity challenge. Fully closing that needs the proof bound to the server's
real listening port, or OS-level peer-credential checks; tracked as follow-up. A
same-user attacker is out of scope, since it can already read the 0600 key cache.

Tests: redirect rejection in the handshake, minted-cache requiring the handshake
while saved-cache bypasses it, and the existing suites updated for the new cache
shape. unsloth_cli (206) and test_identity.py (5) pass.

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

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

* CLI: keep urllib imports function-local in the no-redirect opener

The repo's import-hoist safety linter (scripts/verify_import_hoist.py) flags
hoisting urllib to module level because it re-points the 'urllib' name in the
pre-existing HttpChatBackend._request scope. Build the no-redirect opener lazily
with function-local urllib imports instead, matching this module's convention,
and restore the local 'import urllib.request' in _request and
verify_studio_identity.

* test(identity): skip route tests when routes.auth import chain is unavailable

The identity route tests build a TestClient from routes.auth, which pulls the
whole routes package (routes/__init__ -> inference -> llama_cpp, ...). In a
minimal test matrix without the heavy backend deps, or when another test in the
same process has already broken that import chain, importing it raised and the
two route tests hard-failed. Skip in that case instead: the proof crypto is
covered by the storage-level tests, and the full backend CI still exercises the
route. No behaviour change where the deps are present (5 passed in isolation).

* test(connect): make connect tests pass on native Windows

unsloth connect supports Windows: --no-launch prints PowerShell ($env:X =
"v" / Remove-Item Env:X) instead of POSIX (export/unset), and the launch
path bridges env into a Windows agent .exe over WSLENV. The tests hardcoded
the POSIX shell forms, so on a real windows-latest runner 12 of them failed on
the assertion string even though every command exited 0.

Add OS-aware assertion helpers (_assert_env_set / _assert_env_unset) that check
the right shell syntax for the host OS, and skip the two WSL-from-Linux shim
tests on native Windows (os.name is 'posix' inside WSL, so that path can't run
there). No change on Linux/macOS (57 passed); the connect command's behaviour
is untouched. Validated on a windows-latest staging runner.

* style(connect): tighten comments in the credential-leak fix

Condense the verbose explanatory comments and multi-line docstrings added by
this PR to one or two lines each, drop a few that just restated the code, and
keep the security rationale where it is load-bearing. Comment/whitespace only;
verified with unslothai/scripts comment_tools.py (check --strip-docstrings:
6/6 'code unchanged'). Tests unchanged: connect 57 passed, identity 5 passed.

* CLI/Studio: harden the identity handshake (review round)

Addresses the latest Codex/Gemini review of the handshake:

- Store the identity secret privately. sqlite3.connect created the auth DB
  world-readable under a 022 umask, so another OS user could read app_secrets
  and forge proofs, defeating the same-user assumption the handshake rests on.
  The auth dir and DB are now restricted to owner-only (0700/0600); the JWT
  secret and password hashes there get the same protection.

- Bind the proof to the server's listening port. The stateless HMAC(secret,
  nonce) was relayable: a process squatting the discovered port could proxy the
  challenge to the real Studio on another port and pass it back. The proof now
  covers the port the server actually listens on (from the socket, never the
  Host header) and the client checks it against the port it connected to, so a
  relayed proof from a different port no longer matches. Closes the manual-relay
  residual left after the redirect fix.

- Cap the identity response read (the server is still unverified at that point)
  and serve the identity route from a sync def so its first-call SQLite read
  runs in the threadpool instead of the event loop.

Tests: port-bound proof + relayed-proof rejection added; identity (5) and
unsloth_cli (58) suites pass. Verified end to end against a real backend
(auth DB owner-only, handshake + mint still succeed).

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

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

* CLI/Studio: bind the identity proof to the connection address, not just port

Follow-up to the port binding from the last review. Binding only to the port
left a cross-address relay: a squatter on a different loopback address but the
same port (for example localhost resolving to a squatter on ::1 while the real
Studio is on 127.0.0.1) could proxy the nonce to the real Studio and pass back
a proof that still matched, since both share the port.

The proof now covers the address and the port the connection landed on:

- Server: takes the address+port from request.scope, which uvicorn populates
  from getsockname, so it is the real local address the client reached even
  when Studio is bound to 0.0.0.0 (verified empirically), never the
  client-controlled Host header.

- Client: resolves the base host to one concrete IP, talks to exactly that IP,
  and binds the proof to (IP, port). A proof relayed from a Studio on a
  different address or port was computed for that other endpoint and no longer
  matches the one the client dialed.

Both sides normalise the address through ipaddress so equivalent forms compare
equal. Together with the private-secret and redirect fixes, this closes the
cross-user loopback relay an attacker can mount without reading the secret.

Tests: proof now bound to host+port; relayed-proof rejection retained; identity
(5) and unsloth_cli (58) suites pass. Verified end to end against a real backend
(handshake + mint still succeed).

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

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

* CLI: pick the loopback address at discovery so localhost does not regress

find_studio_server now resolves a bare localhost base to its concrete loopback
addresses and returns the first that answers /api/health, IPv4 127.0.0.1 first
(where unsloth studio binds by default). The whole flow (health probe, identity
check, credential send) then targets that one address instead of racing
IPv4/IPv6 resolution, where localhost could resolve ::1-first and hide a Studio
bound to 127.0.0.1. A literal IP or remote name is unchanged.

* [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>
2026-06-21 21:28:38 -07:00
Daniel Han
9f39cc2c39
Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm (#6533)
* Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm

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

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

* Studio: address Node isolation review (no-Node probe crash, PATH refresh, OXC provisioning, venv python, runtime node resolver)

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

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

* Fix/adjust Node isolation for PR #6533

* Studio Node: don't cache a negative node resolution; accept Node metadata in setup.sh ownership guard

- node_runtime: memoize only a version-adequate executable so a Node installed
  by a separate-process 'studio update' is picked up without a backend restart.
- setup.sh: _studio_owned_adoptable also accepts UNSLOTH_NODE_PREBUILT_INFO.json,
  matching the setup.ps1 Node ownership guard (custom-home parity).

* Studio setup.ps1: skip OXC npm install gracefully when npm is absent

Mirror setup.sh's `command -v npm` guard so a pip-installed Studio with no
system Node skips the OXC runtime install (validator degrades at runtime) instead
of exit 1 aborting the whole setup. Tighten test_node_probe_guard.ps1's probe
regex so it only matches the two system-version probes, not this new npm guard.

* Wire test_node_probe_guard.ps1 into Windows CI for PR #6533

* Harden isolated Node install and probes for PR #6533

- install_node_prebuilt.py: keep an existing, still-usable isolated Node
  when nodejs.org's dist index is unreachable instead of aborting the
  update on a transient outage (existing_install_usable + tolerant fetch).
- install_node_prebuilt.py: pin NPM_CONFIG_PREFIX/npm_config_prefix and
  drop NODE_PATH in _run_node so any npm -g stays inside the isolated
  prefix; Windows npm otherwise writes to %APPDATA%\npm.
- install_node_prebuilt.py: resolve tar hard-link targets against the
  archive root (symlink targets stay link-parent relative).
- setup.ps1: wrap the system node/npm probes in try/catch so a present
  but broken shim degrades to the bundled Node instead of aborting setup.
- setup.ps1: run the isolated Node install with the handed-off/venv Python
  (ReusedSetupPython); the main resolver runs later and bare python may be
  a Store stub this early.
- setup.sh: log when the OXC validator runtime is skipped for missing npm,
  matching setup.ps1.
- node_runtime.py: move the version-floor comment onto _version_meets_floor.
- Tests for the offline-reuse and broken-shim paths.

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

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

* Trim verbose comments across the Studio Node installer for PR #6533

Comments-only pass: collapse the multi-line section banners to single lines,
drop comments that restate obvious code, and tighten the remaining docstrings
and "why" notes without losing intent. No code changes (verified with an AST
comment-only check on the Python files and a non-comment-diff scan on setup.sh
and setup.ps1). Net 109 fewer lines; the install, decision, and probe-guard
suites stay green.

* Harden Node install from review: validated Python, version floor, legacy home, lock race

For PR #6533, addressing the latest review pass:

- setup.ps1: run the isolated Node install with the validated reused/venv Python.
  An incompatible reused interpreter (old venv, conda, stale UNSLOTH_SETUP_PYTHON)
  is no longer used; fall back to the resolved python instead.
- setup.ps1: a STUDIO_HOME/UNSLOTH_STUDIO_HOME override equal to the legacy default
  now uses the legacy sibling node dir (~/.unsloth/node), matching the runtime
  resolver and setup.sh, so OXC can find the Node it installed.
- install_node_prebuilt.py: reject an explicit --node-version below the floor
  (^20.19 || >=22.12 || >=23) instead of installing a Node the build cannot use.
- install_node_prebuilt.py: atomically rename a stale install lock before unlinking
  so two concurrent runs without filelock cannot both acquire it.

Tests added for the version floor (parametrized + explicit-below-floor rejection).
Full install suite: 937 passed, 1 skipped; setup.ps1 parses; decision tests green.

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

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

* Address latest review: armv7l + later-fetch offline reuse for PR #6533

- install_node_prebuilt.py: reject 32-bit ARM (armv7l) up front. Node 24 LTS
  ships no linux-armv7l build, so the old path failed late with a confusing
  "no sha256"; it now fails fast with a clear unsupported-architecture error.
- install_node_prebuilt.py: extend the offline-reuse fallback to the SHASUMS and
  archive fetches. If index.json resolves a newer Node but a later download fails
  and a usable isolated Node is already on disk, keep it instead of aborting a
  non-force update.

Tests added: armv7l/armhf are unsupported; a SHASUMS failure keeps an existing
usable Node and re-raises when none is present. Full install suite: 941 passed.

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

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

* Add UNSLOTH_STUDIO_HOME node-dir tests (install side + resolver) for PR #6533

* Add regression tests pinning the reuse path read-only and isolating installer writes

Lock in the two invariants behind the isolated-Node design: reusing a good
system Node never mutates the user's Node/npm, and the installer's own npm
calls only ever write inside its install_dir.

- tests/studio/install/test_install_node_prebuilt_logic.py: assert _run_node
  redirects NPM_CONFIG_PREFIX/npm_config_prefix into install_dir and drops an
  inherited NODE_PATH; assert _ensure_npm_floor scopes the npm self-upgrade to
  install_dir (never -g against the system) and is a no-op once npm meets the floor.
- tests/sh/test_system_node_readonly.sh (new, wired into studio-backend-ci.yml):
  the setup.sh NODE_SOURCE=system arm runs no global install and sets no
  NPM_CONFIG_PREFIX, with a positive control that the bundled arm does.
- tests/studio/test_node_decision.ps1: symmetric structural guard that the prefix
  pin and the only global install (bun) live in the bundled branch, not the system arm.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-21 21:17:29 -07:00
oobabooga
e6b4480832
Studio: simplify the inference backend (#6490) 2026-06-21 20:01:09 -03:00
Wasim Yousef Said
72254e0a81
Tighten comments for PR #6493 (#6539) 2026-06-21 05:49:07 -07:00
Daniel Han
01bc716708
Studio: fix llama.cpp update toast tag and reload hint (#6493)
* Studio: fix llama.cpp update toast tag and reload hint

The post-update toast used the job's to_tag, which is the bare bNNNN build
number (same as installed_tag), so it showed e.g. "b9726" instead of the full
release tag. Use status.latest_tag (e.g. b9726-mix-<sha>) to match the tag the
banner already shows, falling back to to_tag and then a generic label.

Also drop "Reload your model to use it." when there is nothing to reload: only
append it when a local model is loaded, since external-provider models do not
use llama.cpp.

* Fix/adjust llama update toast for PR #6493

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-21 05:40:49 -07:00
UmranPros
9e83399f9e
Studio: fix Gemma 4 separate-drafter MTP detection and fallback (#6459)
Recognise the Gemma 4 separate-drafter MTP family, auto-download the drafter with retry, fall back to n-gram with a clear reason when it cannot be resolved, and retry the download on reload. Gemma 3n (ships no drafter) and embedded-MTP models (Qwen) are unaffected.

Fixes #6406
2026-06-20 04:43:37 -07:00
Parvesh Saini
17e9714a98
studio: run /generate/stream's sync generator off the event loop to avoid blocking it (#6466)
* studio: run /generate/stream's sync generator off the event loop to avoid blocking it

* fix: close generator in finally on client disconnect in generate_stream

* Fix/adjust generate stream test for PR #6466

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

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

* Fix/adjust generate stream cancellation for PR #6466

* Fix/adjust generate stream cleanup for PR #6466

* fix: cancel incomplete generate stream cleanup

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
2026-06-19 14:19:04 +01:00
Daniel Han
52877bba05
Studio: gate the MTP target-KV reserve to MTP spec mode, not just MLA (#6449)
_estimate_mtp_overhead_bytes is also reached for the separate-drafter spec modes
(draft-simple / draft-eagle3) through _user_draft_via_extras. Those modes load a
small distinct drafter with its own KV -- already counted in the draft KV +
weights -- and keep no duplicated full target context; only MTP runs a second
context over the target model's own KV geometry (llama.cpp ctx_tgt). Charging the
~main-KV-sized f16 copy there over-reserved by tens of GiB on an MLA model and
needlessly shrank the advertised context, the same under-advertising #6312 set
out to fix.

Thread mtp_keeps_target_ctx through _estimate_mtp_overhead_bytes (True for MTP,
False for separate-drafter modes) and derive _engaged_is_mtp at the fit call site
so the target copy is added only when the engaged mode is actually MTP. MLA + MTP
(GLM-5.2 / DeepSeek / Kimi) is unchanged, so the GLM-5.2 OOM fix is preserved;
non-MLA and the draft-simple / draft-eagle3 paths no longer pay the copy.

test_mtp_mla_target_ctx.py adds a case asserting the separate-drafter reserve
collapses to the draft KV (no target copy) while the default MTP path keeps it.
2026-06-19 05:51:35 -07:00
Daniel Han
76a2b9edf1
Studio: Auto disables MTP for MLA models (GLM-5.2 et al.); UNSLOTH_MLA_MTP_ENABLED to re-enable (#6468)
* Studio: Auto disables MTP for MLA models (GLM-5.2 et al.); UNSLOTH_MLA_MTP_ENABLED to re-enable

Studio's Auto speculative mode promotes any embedded-MTP model >=3B to
--spec-type draft-mtp. For MLA models (GLM-5.2/DeepSeek/Kimi) that is a
regression: llama.cpp's MLA/DSA MTP path keeps a duplicated full target-KV
context and recomputes the sparse-attention indexer every draft step, so it
runs ~2x slower than no speculation (GLM-5.2 UD-IQ1_S bench: 27 vs 45 tok/s,
flat across draft depth 1..6 and 96-100% acceptance, on both prose and code).
vLLM/SGLang get a speedup from the same model, so this is a llama.cpp
implementation gap, not a model property.

Auto now drops embedded MTP for MLA models and falls back to ngram-mod (or
spec-off when the binary lacks ngram-mod), mirroring the existing sub-3B
fallback. The metadata separator is kv_lora_rank: it is present on MLA models
and absent on non-MLA embedded-MTP models (Qwen3.x-MTP), whose MTP module is
structurally identical but fast, so a "full layer" heuristic cannot tell them
apart. Qwen MTP, separate drafters (Gemma, --model-draft), and non-MTP models
are unchanged.

Explicit overrides still engage the slower MTP route: choosing MTP / MTP+Ngram
in Settings, or passing --spec-type in extra args. UNSLOTH_MLA_MTP_ENABLED=1
re-enables Auto promotion for MLA once the upstream path is optimized.

A new spec_fallback_reason value "mla_mtp_disabled" surfaces this as an
Auto-mode policy downgrade (not a binary/update problem), with a settings
banner that points users at the MTP override. It is deliberately kept out of
the "Update llama.cpp" affordance since updating does not help.

Tests: resolver-matrix rows for MLA->ngram-mod / MLA-no-ngram->off /
non-MLA-Qwen->draft-mtp / MLA-separate-drafter->draft-mtp /
non-MTP-MLA->default / forced mtp|mtp+ngram on MLA->draft-mtp / env flag;
kv_lora_rank metadata fixtures; and reload-skip coverage (Auto ngram-mod is
idempotent, forced mtp bounces a reload).

* [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>
2026-06-19 05:40:16 -07:00
oobabooga
420799b61e
Studio: add an Open button to reveal the models folder in the file manager (#6452)
* Studio: add an Open button to reveal the models folder in the file manager

* Studio: report models folder creation failures

---------

Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
2026-06-19 05:14:58 -07:00
Daniel Han
b552f2fbc8
studio: fix two backend CI test failures (capability dict + MTP recovery race) (#6464)
test_safetensors_capability_advertise: detect_reasoning_flags now returns a
reasoning_effort_levels key, so the none-template expectation must include it.

test_tensor_parallel::test_runtime_recovery_reloads_without_mtp: the assertion
raced the recovery thread, which sets _spec_fallback_reason just before its
finally clears _mtp_runtime_fallback_in_progress. Wait for the flag to clear
before asserting.

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-06-19 04:33:52 -07:00
oobabooga
b4fa48e1b3
Studio: simplify the inference orchestrator and worker (#6439) 2026-06-18 17:23:16 -03:00
Leo Borcherding
5be8835de5
Studio: skip tensor-parallel for vision models; fix MTP drafter VRAM reserve on Windows (#6416)
---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-06-18 17:21:57 -03:00
Daniel Han
1390e721cb
Studio: reserve the duplicated MTP target KV context for MLA models (GLM-5.2 OOM) (#6447)
* Studio: reserve the duplicated MTP target KV context for MLA models

GLM-5.2 UD-IQ1_S advertised its native 1,048,576-token context, loaded, then
crashed cublasCreate on the first generation with "CUDA error: the resource
allocation failed" on a 2x B200 box. The model loaded fine; the decode OOMed.

Cause: when MTP speculative decoding is engaged, llama.cpp keeps a second full
copy of the target model's KV context for draft verification (ctx_tgt=yes in the
spec log), at f16. On an MLA model that copy is ~the main KV again -- for GLM-5.2
at 1M ctx llama.cpp sized it at ~97.5 GiB -- but the auto-fit reserve only
counted the tiny embedded draft head (~2 GiB), 46x too low. So weights (~202 GiB)
+ main KV (~83 GiB) + a 2 GiB reserve looked like it fit in 2x182 GiB, when the
real footprint with the ~97 GiB MTP copy is ~382 GiB and overruns the cards.
Disabling speculative decoding removed the copy and the same context ran fine.

_estimate_mtp_overhead_bytes now adds the duplicated target context (the main KV
re-estimated at f16) for MLA models, so auto-fit backs the context off (or selects
more GPUs) instead of advertising one that OOMs. It is gated strictly on MLA
(kv_lora_rank present), which is exactly the family that keeps the extra copy
(GLM-5.x, DeepSeek, Kimi-K2); non-MLA MTP (Qwen, Gemma) is byte-for-byte
unchanged. The reserve stays deterministic from GGUF dims, matching #6312.

test_mtp_mla_target_ctx.py covers it: the MLA reserve includes the f16 target
copy and dominates the draft head, the copy is f16 regardless of the main cache
type and scales with context, non-MLA embedded heads keep overhead == draft KV,
and _fit_context_to_vram on the GLM-5.2 / 2x B200 budget now returns a context
below the requested 1M where the old draft-only reserve kept the full 1M.

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

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

* Studio: stub structlog/loggers in MTP-MLA test so it is import-order-independent

The new test imports core.inference.llama_cpp, which pulls in orchestrator ->
structlog. In the lightweight test env structlog is absent, so when this file is
collected before test_mtp_vram_budget.py (it sorts first) or run directly,
collection aborted with ModuleNotFoundError. Install the same loggers/structlog
(+ conditional httpx) stubs the sibling MTP tests use before the import, matching
the established per-file convention.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 10:24:33 -07:00
Daniel Han
0c1127cb08
Studio: Bypass Permissions menu fix, decimal GB sizes, and GLM-5.2 high/max/disabled thinking (#6444)
* Studio: fix Bypass Permissions menu freeze and show decimal GB for model sizes

Bypass Permissions freeze: the warning dialog lived inside the composer
"+"/More dropdown and kept the menu mounted via onSelect preventDefault,
so confirming or cancelling the dialog left both popovers frozen open.
Lift the dialog out of the menu into a store-driven
BypassPermissionsConfirmDialog mounted at a stable spot in the composer.
The menu item now closes normally on select and just toggles a new
bypassConfirmOpen store flag, so the popovers dismiss as expected.

Model search sizes: formatBytes divided bytes by 1024 but labelled the
result "GB", so unsloth/GLM-5.2-GGUF:UD-IQ1_S showed 201.8 GB where
Hugging Face reports 217 GB. Switch the search display to decimal
(base-1000) units to match what Hugging Face reports. The GPU-fit math
stays base-1024 since VRAM capacity is binary.

* Studio: address review feedback and add GLM-5.2 high/max/disabled thinking

Review feedback on the Bypass Permissions and size-format changes:

- Mount the Bypass Permissions warning dialog once at the chat-page root
  instead of inside each Composer. It is driven by global store state, so
  the per-composer mount meant Compare mode (multiple composers) rendered
  duplicate dialogs and the shared-composer menu had none. A single root
  mount fixes both.
- Defer opening the dialog past Radix's menu-close focus restoration with
  setTimeout(0), so the dropdown does not steal focus back and break the
  dialog's focus trap.
- Clamp the unit index in formatBytes so units[i] cannot go out of bounds
  past TB (and to absorb log() float error at exact powers of 1000).

GLM-5.2 reasoning levels:

GLM-5.2's template gates thinking with enable_thinking and also reads a
reasoning_effort level ('high' or 'max'), so it needs high / max /
disabled rather than the binary toggle it got before (its style was
detected as enable_thinking, which made 'high' unreachable). Add a new
reasoning style 'enable_thinking_effort' that reuses the effort dropdown
but, unlike gpt-oss, can be fully disabled:

- detect_reasoning_flags classifies a template that has both
  enable_thinking and reasoning_effort, extracting the discrete levels
  from the quoted effort literals it branches on. Templates with only one
  of the two (gpt-oss, Qwen3, DeepSeek, GLM-4.6) are unchanged.
- _request_reasoning_kwargs maps the new style to enable_thinking plus an
  in-range reasoning_effort; disabling sends enable_thinking=false. The
  gpt-oss reasoning_effort path is left untouched.
- The backend reports reasoning_effort_levels on the load/status response;
  the frontend carries them through to the effort dropdown and sends
  enable_thinking + reasoning_effort for this style.

Verified: backend reasoning kwargs render the real GLM-5.2 template to
"Reasoning Effort: High/Max" (thinking) and an empty <think></think>
(disabled); tsc, eslint, i18n parity and the production build all pass.

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

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

* Studio: address review feedback on reasoning effort and formatBytes

- chat-adapter localReasoningEffort: accept 'minimal' so a template that
  branches on it (extracted into reasoning_effort_levels) is sent through
  instead of being coerced to 'low' and then dropped by the backend.
- formatBytes: return '0 B' for non-finite / non-positive sizes (missing
  metadata -> NaN, Infinity, negatives) and clamp the unit index lower
  bound to 0, so sub-1-byte values can't produce a negative index.

* Studio: hybrid reasoning none gate and decimal GB in load progress

- _request_reasoning_kwargs: for enable_thinking_effort models, treat a
  raw reasoning_effort='none' (OpenAI 'no reasoning' sentinel) as the
  enable_thinking=false off gate, so a direct API caller can disable
  thinking even without passing enable_thinking. The frontend already
  sends enable_thinking=false; this only affects raw API callers.
- use-chat-model-runtime: the download / 'X of Y GB in memory' load
  progress divided bytes by 1024**3 but labelled GB, so it disagreed with
  the model picker and Hugging Face. Use decimal GB (1e9) to match.

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

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

* Studio: carry hybrid reasoning levels on all load paths and harden formatBytes

Review follow-ups on the enable_thinking_effort work:

- Every model-load path now copies reasoning_effort_levels and derives
  supportsReasoningOff, via a shared reasoningCapsFromLoad() helper. The
  shared/Compare composer load and the three chat-adapter auto-load paths
  previously set only reasoningStyle, so a GLM-style hybrid model loaded
  through Compare or first-chat auto-load fell back to the default
  low|medium|high and lost its Max / Off controls.
- The local send path clamps the effort to the loaded model's advertised
  levels (clampReasoningEffortToLevels) instead of a hard-coded list. A
  stale "max" carried over from an external provider no longer reaches a
  pure reasoning_effort (gpt-oss) model that only accepts none|low|medium|
  high, where the backend would have dropped it.
- formatBytes divides iteratively instead of via Math.log, which has float
  error at exact powers of 1000 (log(1e12)/log(1000) = 3.9999... would
  label 1 TB as "1000 GB"). Keeps the non-finite/non-positive guard.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 10:10:01 -07:00
Daniel Han
6d27160dcc
Studio: graceful recovery ladder when llama-server hard-crashes at startup (#6291)
* Studio: fall back to text-only when a vision projector hard-crashes llama-server

The text-only mmproj fallback (#6075) only fired when llama-server printed a
recognizable projector-format error ("Unknown projector type", exit -6). An
installed llama.cpp that predates a model's projector can instead SIGSEGV
(exit -11) with no parseable output, e.g. unsloth/Qwen3.5-4B-MTP-GGUF +
mmproj-F16 on an older gfx1151 prebuilt: llama-server crashes on load, the
--fit off retry crashes the same way, and load_model gives up with a hard 500
instead of dropping vision.

Generalize the decision: a vision (--mmproj) launch killed by a signal (POSIX
returncode < 0, e.g. -11 SIGSEGV / -6 SIGABRT; Windows 0xC0000000+ access
violation) is treated like a projector incompatibility, so the load retries
once text-only. The retry is skipped if a cancel/unload is pending, mirroring
the MTP guard. Clean non-zero exits (bad GGUF, port bind) and hung processes
keep their own handling; non-vision launches are unaffected.

Reproduced and verified on gfx1151 (Radeon 8060S, ROCm 7.2.1): a current
prebuilt (llama.cpp b9596) loads the exact model + args fine, confirming the
crash is a stale prebuilt. With a wrapper that SIGSEGVs on --mmproj, Studio now
recovers: the load returns 200 (is_vision=false) and serves at ~31 tok/s
text-only instead of failing. New _is_signal_crash helper plus tests pin the
decision.

Also normalize a few em-dashes to ASCII punctuation in existing comments.

* Studio: refine mmproj hard-crash fallback (signal scope + last argv)

- Limit _is_signal_crash to genuine program faults (SIGSEGV, SIGABRT,
  SIGILL, SIGFPE, SIGBUS) and Windows 0xC0000000+ statuses. SIGKILL,
  SIGTERM and SIGINT no longer count, so an OOM-killer, unload or
  supervisor kill is not masked as a projector incompatibility.
- Strip --mmproj from the last attempted argv so the text-only retry
  keeps --fit off / --spec-default instead of resurrecting the original
  spec flags (matters for MTP vision models on an older llama.cpp).
- Drop stray temp files committed by mistake and gitignore the "~" dir
  so they cannot be re-added.

* Studio: tighten comments in mmproj hard-crash fallback

* Studio: retry --flash-attn off before dropping vision on a startup crash

When llama-server hard-crashes at startup, the recovery chain now tries the
least-destructive mitigation first. Flash-attention kernels SIGSEGV at load on
some ROCm/GPU builds (often inside the vision tower's attention); disabling
flash attention keeps BOTH vision and MTP, so a hard program fault with
--flash-attn on now retries once with --flash-attn off before the MTP-drop or
the text-only (mmproj-strip) fallbacks. _is_signal_crash already gates this to
genuine faults (SIGSEGV/SIGABRT/SIGILL/SIGFPE/SIGBUS), so an OOM-kill or unload
(SIGKILL/SIGTERM/SIGINT) does not trigger a retry.

Field context: a gfx1151 user crashes loading a vision GGUF even on the latest
prebuilt, so an update cannot help, and the same model and args load fine on
another gfx1151 box, pointing at a runtime/flash-attn fault. New
_with_flash_attn_off helper plus tests. Verified on hardware with a wrapper
that SIGSEGVs on --flash-attn on: Studio recovers with is_vision=true (vision
and MTP intact) instead of failing or losing vision.

* Studio: name the OOM kill on a too-large model load

When the OS kills llama-server with no diagnostic output (SIGKILL/SIGTERM,
almost always the OOM killer, e.g. a BF16 model too large for the WSL VM's
RAM cap), the recovery ladder correctly does not retry an external kill, so
this is the message the user sees. It fell through to the generic "is the
GGUF valid / out of memory" text. Make it actionable: name the signal and
point at a smaller or more quantized GGUF, a lower context length, or raising
the WSL memory limit. Output-based diagnoses still win and a hard fault keeps
the generic fallback.

* Studio: refuse a model too large for system RAM on a unified-memory APU

On gfx1150/gfx1151 APUs the weights load into shared system RAM (GGML
unified memory). _get_gpu_free_memory reports the full ROCm/APU budget as
free (often ~100 GB), but under WSL the VM's RAM cap is the real ceiling.
Studio trusted the budget, spawned a load larger than RAM, and the OS killed
it mid-flight, taking the Studio process with it (a silent "Terminated" with
no error, the model resident in RAM not VRAM).

Add a pre-flight guard on the APU path: if the weights exceed available
system RAM (psutil, then /proc/meminfo), refuse before spawning with a clear
message (smaller/more-quantized GGUF, lower context, or raise the WSL memory
limit). Weights only so KV/context auto-reduction is not double-counted;
unknown RAM never refuses; non-APU and discrete-GPU paths are untouched.

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

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

* Studio: refine recovery ladder (keep diagnosed errors, flip all flash-attn)

Two review points on the hard-crash recovery ladder:

1. The signal-only text-only fallback stripped --mmproj on any hard fault,
   even when llama-server had already printed a non-projector cause (an OOM
   such as "cudaMalloc failed: out of memory", an unsupported architecture, or
   a tensor-parallel limit). That masked the real error and told the user to
   update llama.cpp for vision. New _output_has_nonprojector_diagnostic gates
   the signal path: it fires only when no such marker is present, so a bare
   SIGSEGV with no output still retries text-only, but a diagnosed OOM surfaces
   the real error instead of silently dropping vision.

2. _with_flash_attn_off only flipped the first --flash-attn. llama.cpp is
   last-wins, so a leftover enable from extra_args (--flash-attn on, -fa on, or
   the = form) could keep flash attention on and re-crash the retry. It now
   flips every occurrence and returns None only when nothing is flippable.

test_llama_cpp_mmproj_fallback.py and the classification/APU suites: 103 passed.

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

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

* Studio: pass the text-only retry's exit code to the failure classifier

When the text-only fallback retry itself fails, read its exit code before
_kill_process() clears it and forward it to _classify_llama_start_failure, so an
OS-killed retry surfaces the actionable out-of-memory message instead of the
generic one (matching the primary failure path).

* Studio: scope APU RAM guard to selected GPUs, count MTP drafter, neutral SIGTERM

Three refinements to the startup recovery work in this PR:

- The unified-memory APU RAM guard fired whenever any visible GPU was a
  gfx1150/gfx1151 APU, so on a mixed APU+dGPU host it could refuse a valid
  load placed on the discrete GPU. Scope _amd_apu_wants_unified_memory to the
  selected gpu_indices (physical ids, mapped via CUDA_VISIBLE_DEVICES like
  _is_datacenter_gpu); None still means every visible GPU. Applied to both the
  RAM guard and the GGML_CUDA_ENABLE_UNIFIED_MEMORY env set.
- The RAM guard counted only the main GGUF plus mmproj, so a separate MTP
  drafter (also resident in unified system RAM, even when offloaded to CPU)
  could push the load past the RAM cap and still get OS-killed mid-load. Add
  the drafter weights to the APU RAM total.
- The startup classifier reported SIGTERM (-15) as 'most likely out of memory',
  but SIGTERM is also how an unload/cancel or a supervisor stops the server.
  Keep the OOM wording for SIGKILL (-9, the OOM killer) and report -15
  neutrally.

Tests updated/added accordingly.

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

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

* Studio: address review on the APU guard and decode-probe ladder

- Map APU physical ids via the active ROCm mask (HIP, then ROCR, then CUDA),
  mirroring _get_gpu_memory, so a HIP_VISIBLE_DEVICES-selected APU is matched.
- Only add the MTP drafter to the APU RAM total when MTP will actually engage,
  so a stale LLAMA_ARG_SPEC_DRAFT_MODEL cannot refuse a non-MTP load.
- After an MTP first-decode hard fault, retry --flash-attn off (keeps MTP)
  before dropping speculative decoding, matching the startup rung.
- Fold the --flash-attn= / -fa= rewrite into one branch.

Tests: tensor-parallel decode-probe assertion updated for the FA-off rung.

* Studio: tighten two comments in the APU guard and RAM preflight

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

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

* Studio: refine flash-attn retry and APU RAM guard per review

- _with_flash_attn_off now decides on the effective last-wins value: it returns
  None when FA is already off (no wasted retry), and neutralizes a bare
  --flash-attn / -fa (which llama.cpp reads as on) so the retry cannot re-enable
  it. Length is preserved so downstream index slices stay valid.
- _amd_apu_wants_unified_memory uses 'gpu_indices is not None' so an empty
  selection is respected (not treated as all-visible).
- The APU RAM refusal now checks the base model only (main + mmproj); an
  optional MTP drafter is dropped by the existing MTP-drop fallback rather than
  causing a hard pre-spawn refusal of an otherwise loadable model.

Tests: bare-flag / effective-off / empty-selection / HIP-mask cases added.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 09:07:25 -07:00
Daniel Han
c42c1d56e8
Studio: free chat model VRAM at training start only when the GPU is tight (#6243)
* Studio: free chat model VRAM at training start only when the GPU is tight

The training start route unconditionally tore down the transformers/MLX
inference subprocess before training, and never stopped the llama.cpp GGUF
server at all, so a loaded GGUF chat model kept holding VRAM for the whole
run. Conversely the HF model was always unloaded even when there was plenty
of room to keep it.

Make the unload VRAM aware and cover every inference backend:

- Add routes/training_vram.py with summarize_resident_chat(),
  can_keep_chat_during_training() and free_chat_models_for_training(). The
  keep/unload decision reuses the same estimator and live per device free
  VRAM reader the training GPU selection already uses (auto_select_gpu_ids,
  estimate_required_model_memory_gb, get_visible_gpu_utilization), so the
  probe agrees with the placement computed later in start_training.
- When a chat model is resident and training fits alongside it with a
  conservative margin (required_gb * 1.15 + 4 GB), keep it loaded so the
  user can train and chat at the same time; on a multi GPU box training
  lands on a different GPU and both coexist. Otherwise unload the HF/MLX
  orchestrator and the llama.cpp GGUF server before training starts.
- The export subprocess shutdown stays unconditional and now runs first so
  its freed VRAM is reflected in the decision.

Default deny: non CUDA backends, unestimable models, or any probe error
fall back to the previous always unload behavior.

Adds tests/test_training_vram_coexistence.py and updates two existing route
tests in test_gpu_selection.py.

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

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

* Studio: per-GPU floor for explicit GPU lists + don't unload chat on invalid gpu_ids

Address review feedback on the chat coexistence probe:

- Explicit gpu_ids mode now enforces a per-GPU floor in addition to the
  aggregate free-VRAM check, mirroring auto_select_gpu_ids' min_per_gpu_N.
  Without it, an uneven split such as free [45, 10] for a 40 GB job passed
  the aggregate threshold and kept chat loaded even though the 10 GB GPU
  could not hold its training shard, risking an OOM.
- Invalid explicit gpu_ids (ids outside the visible set, or a UUID/MIG
  mask) make resolve_requested_gpu_ids raise. That request is rejected with
  a 400 before training starts, so leave the resident chat model untouched
  instead of unloading it.
- Tighten the target_modules / gpu_ids type hints to List[str] / List[int].

Adds tests for the per-GPU floor (uneven split unloads, even split keeps)
and for invalid gpu_ids keeping the chat model loaded.

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

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

* Studio: only free chat VRAM once training will start; handle in-flight and CPU-only chat

Address the second review pass on the chat-coexistence path:

- Run the chat/export VRAM teardown as a before_spawn hook inside
  TrainingBackend.start_training, fired only after the start guards pass.
  Previously the route freed chat VRAM before calling start_training, so a
  refused start (e.g. a lingering pump thread) would tear down the resident
  chat model even though no training job began.
- Treat an in-flight HF chat load (loading_models set, no active model yet)
  as not safely sizeable: free it rather than risk both OOMing as the load
  keeps allocating after training starts.
- Do not count or tear down a GGUF llama-server confirmed to run entirely on
  CPU (_gpu_offload_active is False): it holds no VRAM, so killing it cannot
  help training fit.

Adds tests for the before_spawn hook (runs on start, skipped when a
subprocess is alive or a pump thread will not die, survives a hook error),
the in-flight load flag, and the CPU-only GGUF exclusion in both the resident
summary and the unload path.

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

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

* Studio: treat any in-flight chat load (HF swap / mid-start GGUF) as unsafe to keep

Tighten the in-flight detection in summarize_resident_chat so the keep check
never sizes a load that is still allocating:

- Flag loading on ANY non-empty loading_models, not only when active_model_name
  is empty. load_model adds the new model to loading_models before clearing the
  old active_model_name, so a replacement load during a swap was previously
  sized as a normal resident and could OOM as the new model finishes loading.
- Flag a GGUF server that is active but not yet healthy (is_loaded False) as
  in-flight: it is still mmaping/offloading layers, so its final VRAM footprint
  is unknown.

Consolidates the signal into a single resident["loading"] flag; the route frees
the chat model whenever it is set. Adds tests for the replacement HF load and
the mid-start GGUF cases.

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

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

* Studio: tighten comments in chat/training VRAM coexistence (comments only)

* Studio: run before_spawn VRAM hook only after GPU-selection validation

Reviewers found the before_spawn hook fired before prepare_gpu_selection
validated gpu_ids (and before config build), so a refused start (invalid
gpu_ids -> 400, or a bad grad-clip value) could still tear down chat/export
VRAM. Move the hook to immediately before proc.start(), once all synchronous
validation and process construction have passed. This also fixes the route's
in-flight-chat loading branch, since that teardown runs inside the same hook.

Add test_hook_skipped_when_gpu_selection_rejects.

* Studio: recompute GPU auto-selection after the before_spawn VRAM hook

Codex P2: with before_spawn moved after prepare_gpu_selection, placement was
frozen against the pre-teardown VRAM state while the hook freed export/chat
afterward. Auto-selection could pin training onto a GPU the hook then cleared
(or onto a kept chat model). Split validation from placement: explicit gpu_ids
are still validated before the hook (raise -> 400, no teardown; explicit
placement is VRAM-independent), but VRAM-dependent auto-selection now runs
after the hook so it sees the freed memory.

Add test_auto_placement_runs_after_hook and test_explicit_placement_validated_before_hook.

* Studio: allow chatting during training (lift sidebar gate + VRAM-aware load guard) (#6335)

* Studio: allow chatting during training (lift sidebar gate + VRAM-aware load guard)

The sidebar disabled New Chat, project, and home navigation while a training
run was active, so users could not chat during training even though the backend
serves inference fine alongside a run. This removes that gate and adds a backend
guard so the one genuinely risky operation, loading a new local chat model
mid-training, is refused with a clear 409 when it would not fit beside the run.

Frontend (app-sidebar.tsx): drop the chatDisabled = isTrainingRunning gate and
its consumers. Navigation triggers no model load on its own, so chat stays
usable during training.

Backend (routes/training_vram.py, routes/inference.py): add
can_load_chat_during_training plus a load/validate guard that sizes the same
effective load the loader performs (LoRA 4-bit to 16-bit resolved first, HF auto
placement via auto_select_gpu_ids, explicit multi-GPU per-GPU floor, GGUF sized
from on-disk shards and companions or the selected remote variant). It is a
no-op when training is inactive, never blocks external providers or
already-resident models, and default-denies only on a CUDA sizing failure so a
load can never OOM the run. Validate refuses early with the real settings so the
frontend does not unload the resident chat model for a load that would be
rejected.

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

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

* Studio: address review feedback for chat-during-training load guard

- Run the load/validate VRAM guard via asyncio.to_thread so the sync
  nvidia-smi + HF metadata work never blocks the event loop.
- Size the GGUF KV cache at the requested context (_estimate_gguf_kv_gb)
  and add it to the local GGUF estimate so large-context picks are not
  under-counted.
- Keep the requested quantization when adapter_config.json is malformed
  (not a JSON object) instead of raising in _effective_load_in_4bit.

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

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

* Studio: size the training load guard at the launcher's effective GGUF context

The GGUF KV-cache estimate used max_seq_length only, but the llama.cpp
launcher honors a user --ctx-size/-c in llama_extra_args. A load such as
max_seq_length=4096 with --ctx-size 131072 was sized against a 4k cache
while the server allocates 131k, so the guard could approve a long-context
GGUF load that then OOMs training. Size the guard's KV at the larger of
max_seq_length and the parsed --ctx-size (reusing the launcher's own
parse_ctx_override), keeping the conservative f16 cache so the estimate is
never smaller than what the server allocates.

The chat model picker also validated with the raw max_seq_length while
/load sizes with resolveLoadMaxSeqLength, so validate could pass, unload
the current model, then have /load reject the native-context load. Validate
now uses the same effective context; the load path is unchanged.

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

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

* Studio: size the GGUF training guard at the server parallel-slot count

The KV-cache estimate assumed a single slot, but llama-server allocates the
cache across --parallel slots (app.state.llama_parallel_slots). On a Studio
launched with --parallel N>1 the guard under-sized the cache N-fold and could
approve a GGUF chat load that then OOMs training. Thread the same slot count
the loader uses into the guard's KV estimate; default 1 leaves single-slot
setups unchanged.

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

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

* Trim comments for chat-during-training guard

* Studio: keep chat generation alive across navigation; Train spinner + Return to Chat

Hoist the base chat runtime above the routed outlet so navigating to Train (or any tab) no longer aborts an in-flight generation; only an explicit Stop cancels. Add a Train sidebar spinner and swap New Chat to Return to Chat while a run is active, with a lightweight completion watch so the spinner clears from any tab. Also respawn a chat llama-server killed mid-session and guard unreadable HF cache dirs that 500'd the hub model list.

* Studio: show Return to Chat on the Train tab whenever a chat is live

Previously the top sidebar item only swapped to Return to Chat while training was running; on the Train tab with an idle/just-finished run it stayed New Chat, which started a fresh thread and cancelled an in-flight generation. Show Return to Chat (and navigate back, preserving the run) whenever a generation is running or its thread is still active, or training is in progress.

* Studio: keep a running chat alive when starting a New Chat

Starting a New Chat (or switching threads) while a generation was in flight
remounted the single-chat runtime provider, which detached the in-flight run
and cut the previous chat off (it showed up frozen / empty when reopened).

Key the single-chat view by project instead of by thread or new-chat nonce so
the provider stays mounted and assistant-ui switches to a fresh thread in place.
The previous generation keeps streaming in the background and autosaves on
completion, and returning to that thread reattaches the live run instead of
reloading a half-saved one.

Also:
- "Return to Chat" now lands on the thread that is still generating rather than
  the empty new chat that became active after New Chat.
- Skip the explicit /inference/cancel POST when an abort comes from a runtime
  detach (navigation / background switch) rather than an explicit Stop, so a
  backgrounded generation is never cancelled behind the scenes.

* Studio: make model export non-blocking and inline

The Export tab opened a full-screen modal that trapped focus, could not be
closed or cancelled while running, and showed no progress. It also stopped
training and unloaded the chat model before loading, so export could not run
alongside them.

Export now mirrors the training runtime pattern:

- Inline panel embedded where the Export Model button was, with no modal or
  backdrop, so the rest of the UI stays usable during an export.
- Global export runtime store plus an app-root lifecycle hook, so a run keeps
  going and streaming across navigation and is reflected on the Export nav item
  from any tab.
- The worker log stream now stays connected across the load to export phase
  boundary instead of stranding on "Waiting for worker output".
- Progress bar driven by phase and quant index (quant N of M for GGUF), with
  elapsed time and a working Cancel.
- load-checkpoint no longer stops training or unloads inference; export loads in
  its own subprocess in parallel and surfaces out-of-memory as a clear error.
- Add POST /api/export/cancel and is_export_active on /api/export/status.

* Studio: show Return to Chat on the Export tab too

Extend the New Chat to Return to Chat swap to the Export route so leaving a
running chat for Export offers a way back to the live generation, matching the
Train tab.

* Studio: smooth out Export animations and polish the panel

- Drop the height-based reveal animations (source switch, run panel, quant
  picker, hub fields) that caused flashing and reflow; use instant swaps and
  quick opacity fades instead.
- Method and quant cards now transition colors only, with no transition-all or
  hover lift, so selecting a method or quant is crisp instead of jumpy.
- Auto-scroll the export panel into view when it opens and add a scroll-to-bottom
  button when its output is below the fold, like Chat.
- Show Return to Chat on the Export tab while an export is running, matching how
  training drives it on the Train tab.
- Surface the current phase or stage in the live output before the first worker
  line arrives so the panel never looks stuck while progress is advancing.

* Studio: show Return to Chat on every non-chat tab

Generalize the Return to Chat swap from just Train/Export to any non-chat route
(Recipes, Projects, Hub, ...) so a running or active chat is always one click
away, instead of showing New Chat there.

* Studio: stream export logs over the Cloudflare tunnel; drop janky export animations

Exporting over a --secure Cloudflare quick tunnel showed "connecting..." with no
logs while the progress bar advanced. Cloudflare buffers text/event-stream and
only flushes when the stream closes, so the SSE log stream never reached the
browser during the run (direct localhost is unaffected, which is why this only
showed up over the tunnel).

Add a tunnel-safe JSON poll fallback (GET /api/export/logs?since=) that the
runtime lifecycle hook polls while a run is active. Short JSON responses are not
buffered by the proxy, so logs show up in near real time over the tunnel. It
shares the orchestrator's monotonic seq cursor with the SSE stream and the store
de-dupes by seq, so the two transports run together (SSE on localhost, poll over
the tunnel) without double-printing. A successful poll marks the panel
"streaming" instead of leaving it stuck on "connecting...".

Also remove the framer-motion AnimatePresence reveals from the export config and
run panel (quant picker, hub fields, the inline run panel, and the live log
section). The expand/slide animations flashed and felt clunky; the sections now
render in place.

* Studio: recover export over the Cloudflare tunnel when the blocking POST times out (524)

A model export over a --secure Cloudflare quick tunnel showed "Request failed
(524)" even though the export succeeded on the backend (the GGUF was written).
Cloudflare returns 524 when a single request takes longer than ~100s to respond,
and a GGUF conversion routinely runs for minutes, so the blocking per-method
export POST is cut off while the backend keeps going.

Confirm completion via short status polls instead of relying on the long POST
response (the same approach that fixed log streaming):

- The orchestrator records each finished op's outcome (status / output_path /
  error) with a monotonic seq, exposed on GET /api/export/status.
- parseJson now preserves the HTTP status; a 524/520/522/523/502/503 or a
  status-less network drop is classified as a recoverable transport error.
- runExport wraps each phase (load, every export method, each GGUF quant): on a
  recoverable failure it keeps the run alive (logs keep streaming, the panel
  shows "reconnecting...") and polls status until the still-running op finishes,
  then settles from the recorded result, recovering the output path for the
  success banner. A real 4xx still fails immediately; localhost still uses the
  fast POST response. applyBackendStatus also settles a reloaded run from the
  last-op record.

Verified over the tunnel: a 3m14s gemma-4-E4B-it GGUF export now ends on the
success banner with the output path instead of 524.

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

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

* Studio: keep the export method + logs visible after navigating away mid-export

While an export was running, navigating to another tab and back to Export
remounted the page and reset the local form state (exportMethod, quant levels),
so the method card showed unselected and the run panel's log area was hidden
until the card was re-clicked. The run itself lives in the global store and was
unaffected.

Seed exportMethod / quantLevels from the active run's summary via lazy useState
initializers on (re)mount, and gate the panel's log area on the live run
(isExporting / logLines / the run's method) rather than only the local form
selection. The card stays selected and the logs/progress stay visible across
navigation; nothing changes when no run is active.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* Studio: address export/training review findings

- Export: guard Start against an empty GGUF quant selection so an inline-panel
  run with no quant can't settle as success with no file produced.
- Export: thread the source HF token into the background load so gated/private
  HF source exports (and gated bases) authenticate, matching the consent path.
- Export: only settle a recovered (non-owned) run as a finished export when the
  last backend op was an export, not a standalone load_checkpoint.
- Training: free the export subprocess whenever an export is active, not only
  once a checkpoint is loaded, so an in-flight export load can't race training
  for VRAM (current_checkpoint is unset during the load phase).

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 09:04:01 -07:00
Daniel Han
48a3a78703
Studio: fail fast on an invalid first training batch (base VLM empty chat template) (#6358)
* Studio: fail fast on an invalid first training batch

Training a base vision-language model (e.g. Qwen/Qwen2-VL-7B or
unsloth/Qwen2-VL-7B) on a conversational image dataset crashed on the first
step with 'Expected ... Long, Int; but got torch.cuda.FloatTensor (embedding)'.
Root cause: the base model's chat template is a flat, media-only template that
renders to an empty string for role-based messages, so UnslothVisionDataCollator
hands the processor empty text, the processor returns empty input_ids, torch
defaults the empty tensor to float32, and the embedding lookup rejects it.

Add a preflight that runs one real batch through the trainer's own tokenization
and collation right before train(), and stops the run with an actionable message
when input_ids is empty or non-integer (pointing to the instruction-tuned variant
for the base-model case). Faithful across text, vision and audio-VLM paths, and
never blocks a run whose first batch is valid.

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

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

* Trim comments in the training preflight

* Stub unsloth/trl in preflight test so backend CI collection passes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 06:36:33 -07:00
Daniel Han
8d804c9413
Studio: show an actionable message when the GGUF runtime is missing (#6327)
* Studio: show an actionable message when the GGUF runtime is missing

Selecting a GGUF model with no llama-server installed surfaced a generic
"Invalid model" in the UI, because validate_model's catch-all discarded the
real cause. Add LlamaServerNotFoundError (a RuntimeError subclass) raised by the
GGUF preflight in ModelConfig.from_identifier, and catch it in the validate
route so users get an actionable message: run `unsloth studio setup` to
download the prebuilt llama.cpp runtime. Other validation failures keep the safe
generic message. Adds a regression test.

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

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

* Studio: also map missing GGUF runtime to a 400 in load_model

validate_model already surfaces the actionable 'install the runtime'
message for LlamaServerNotFoundError; load_model fell through to the
generic 500 'Failed to load model'. Catch it there too so a GGUF load
without llama-server gives the same install hint instead of a 500.

* Trim comments for PR #6327

* Studio: fix stale validate test after #6398 and surface missing GGUF runtime on /load

- test_other_runtime_errors_do_not_get_gguf_message: after merging #6398,
  validate_model surfaces a RuntimeError's own message, so a plain RuntimeError
  no longer returns "Invalid model". Assert it does not receive the GGUF
  install message instead (the prior assertion was stale after the main merge).
- Raise LlamaServerNotFoundError (not a plain RuntimeError) at the backend
  load-time missing-binary branch, after diffusion routing, so /load returns the
  actionable 400 like remote validation, instead of a generic 500.
- Share LLAMA_SERVER_NOT_FOUND_DETAIL between the from_identifier preflight and
  the load-time raise so the message stays in sync.
- Add a propagation regression test for the non-tensor load path.

* [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>
2026-06-18 06:00:00 -07:00
Daniel Han
ce193c243d
Keep server-side tools enabled under --secure (#6403)
* Keep server-side tools enabled under --secure and on every bind

--secure binds loopback and exposes Studio only through an authenticated
Cloudflare HTTPS tunnel, but it was grouped with a raw 0.0.0.0 bind and
force-disabled all server-side tools (web search, Python, terminal). The
process tool policy overrode the client's enable_tools request, so the
model was never told the tools existed and answered in plain text. The
plain 'unsloth studio' command had no way to re-enable and printed nothing.

Tools now default on for every bind. The bind host and --secure no longer
change the tool policy; only an explicit --enable-tools/--disable-tools
forces it on or off. Both 'unsloth studio' and 'unsloth studio run' accept
the flags and the startup banner states the resolved policy.

- run.py: replace _apply_default_tool_policy(host, secure) with
  _apply_cli_tool_policy(enable_tools); add an enable_tools kwarg to
  run_server and --enable-tools/--disable-tools to the argparse.
- _tool_policy.py: resolve_tool_policy defaults to on for every host and
  no longer prompts on a network bind.
- studio.py: drop the secure-as-public tool gating, add the flags to the
  plain command, and reword the startup banner.
- Update and extend the secure-flag and tool-policy tests.

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

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

* Add tool-policy notice to plain server banner and refresh run --help

Follow-up to PR review:
- run.py: the plain 'unsloth studio' / --secure / direct run.py path went
  through _emit_startup_output without any tool-policy line, so a
  network-reachable launch was silent about code execution now that tools
  default on. Thread enable_tools through _emit_startup_output /
  _emit_secure_startup_output and print a one-line policy notice, followed by
  a single stop hint.
- studio.py: the 'unsloth studio run' --enable-tools/--disable-tools and --yes
  help still described the removed loopback-on/network-off default and the
  confirmation prompt; reword to match the new policy.
- Add tests for the banner notice and the refreshed help text.

* Update CI tool-policy resolver tests for default-on behavior

tests/python/test_unsloth_run_tool_policy_resolver.py still asserted the
removed network-bind policy (0.0.0.0 and LAN IP default off, explicit enable
prompts and aborts on a declined prompt), so it failed the Python CI jobs.
Rewrite the truth table: every bind defaults on, explicit on/off always wins,
and the resolver never prompts (yes/silent/prompt kept for compatibility).

* Trim comments for the tool-policy change

Shorten the verbose docstrings and block comments added for --secure tool
handling; keep the security-relevant intent. Verified comment-only via an AST
diff (code unchanged).

* Add deterministic test that server-side tools execute under --secure

Drive the GGUF agentic tool loop with a fake llama-server stream and let the
real execute_tool run: python counts 1..100, terminal returns a UTC datetime,
and web_search runs through real _web_search with only the ddgs network
boundary mocked. A policy assertion pins that the post-fix --secure path
(policy None + per-request enable_tools) is what keeps these executions
reachable. No model, GPU, or live network; runs in the existing backend CI.

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

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

* Align _emit_startup_output banner test with the moved stop hint

The tool-policy notice now prints between the access banner and the stop
hint, so the stop hint is emitted once at the end instead of inline in the
banner (include_stop_hint is False and print_studio_stop_hint runs once).
Update the plain-localhost case to match; the mismatch and wildcard cases
already asserted this wiring.

---------

Co-authored-by: Michael Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 05:52:40 -07:00
Daniel Han
7fecce4e49
Studio: cross-session backstop to reap a leftover llama-server on startup (#6431)
* Reap Studio child processes when the parent dies abnormally

Standalone `unsloth studio` launches orphaned cloudflared and llama-server when
the parent exited without running the cooperative shutdown path (terminal-window
close, Task Manager End Task, SIGKILL): the children reparented to init and kept
running, leaving an authenticated Cloudflare tunnel up for days.

Add utils/process_lifetime.py: a parent-owned Windows Job Object
(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, children auto-inherit) plus Linux
PR_SET_PDEATHSIG, behind a best-effort helper that mirrors the desktop app's
windows_job.rs. initialize_parent_lifetime() runs at the top of run_server;
long-lived spawns (cloudflared, llama-server, RAG embedder, llama.cpp updater)
get the PDEATHSIG preexec, multiprocessing workers are adopted into the job, and
_graceful_shutdown plus atexit gain a terminate_all() backstop sweep. The
cooperative shutdown path is otherwise unchanged.

Verified on Linux: killing the parent now reaps cloudflared and llama-server
within ~2s instead of orphaning them.

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

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

* test: add real Windows kill-on-job-close integration test

Spawn a parent that installs the job and a child that inherits it, terminate
the parent, and assert the child is reaped. Skipped off Windows. Also make the
liveness probe Windows-safe (os.kill(pid, 0) terminates on Windows).

* Fix Win64 handle truncation in the Job Object calls

Set explicit argtypes so the 64-bit job/process handles are not marshaled as
c_int (which truncated them on Win64, failing AssignProcessToJobObject). Assert
install success in the Windows integration test.

* Bind multiprocessing workers to parent death; harden the sweep

Review follow-ups:
- Multiprocessing workers (inference/export/training/data-recipe/Xet) cannot be
  given a preexec_fn by the parent, so adopt_pid alone left them orphanable on a
  Linux SIGKILL. They now bind themselves with PR_SET_PDEATHSIG at startup via
  bind_current_process_to_parent_lifetime(), wired into the shared
  run_without_native_path_secret entrypoint and the Xet child entry.
- Wire the previously-missed data-recipe worker through adopt_pid.
- terminate_all now honors its timeout: SIGTERM, wait, then SIGKILL the
  survivors, so cooperative children can exit cleanly.
- Track adopted pids with a /proc starttime identity and add forget_pid, so the
  shutdown sweep never signals a recycled pid.

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

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

* Studio: cross-session backstop to reap a leftover llama-server on startup

Builds on the parent-lifetime reaper: the Windows Job Object / PR_SET_PDEATHSIG
path kills children when the parent dies, and terminate_all() sweeps the living
parent's children. Neither covers an orphan left by an already-dead Studio:
terminate_all()'s registry is in-memory, PR_SET_PDEATHSIG has no macOS
equivalent, and both are best-effort.

This records the spawned llama-server PID to a pidfile under the active studio
root (removed on _kill_process). The startup reaper kills that exact PID first,
verifying it is still a llama-server to guard against PID reuse, then clears the
pidfile. It is path-independent, so it also catches an orphan the install-root
match would miss; the pidfile only ever names a Studio-spawned server, so
unrelated user processes (vllm, games) are never candidates. The existing
root-gated enumeration stays as a further fallback.

Adds tests: kills a recorded live server (real subprocess, verifies the actual
SIGKILL), skips a reused non-llama PID, cleans a stale/missing pidfile, and
clears the pidfile on kill.

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

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

* Studio: only reap a recorded llama-server when it is a true orphan

Harden the pidfile cross-session reaper so it cannot kill a live server
and does not crash on Windows.

- Reap the recorded PID only when its parent is gone (a genuine orphan),
  so constructing a second LlamaCppBackend in-process (the helper and
  advisor paths each build one) can never kill the active chat server.
  The check is topology independent: it holds whether the sweep runs in
  the main process or a worker.
- Record pid:starttime and verify the start-time identity before killing,
  so a PID recycled to another process is never reaped.
- Fall back to SIGTERM when signal.SIGKILL is undefined (Windows), where
  os.kill maps it to TerminateProcess, instead of raising and leaving the
  orphan alive while clearing the record.

Update and extend the pidfile tests: a live server with a running parent
is spared and its record kept, an identity mismatch is skipped, the
record-to-reap round trip kills a matching orphan, and the Windows
SIGKILL fallback uses SIGTERM.

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

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

---------

Co-authored-by: Michael Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 05:52:18 -07:00
Daniel Han
8e0d082c92
Reap Studio child processes when the parent dies abnormally (#6425)
* Reap Studio child processes when the parent dies abnormally

Standalone `unsloth studio` launches orphaned cloudflared and llama-server when
the parent exited without running the cooperative shutdown path (terminal-window
close, Task Manager End Task, SIGKILL): the children reparented to init and kept
running, leaving an authenticated Cloudflare tunnel up for days.

Add utils/process_lifetime.py: a parent-owned Windows Job Object
(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, children auto-inherit) plus Linux
PR_SET_PDEATHSIG, behind a best-effort helper that mirrors the desktop app's
windows_job.rs. initialize_parent_lifetime() runs at the top of run_server;
long-lived spawns (cloudflared, llama-server, RAG embedder, llama.cpp updater)
get the PDEATHSIG preexec, multiprocessing workers are adopted into the job, and
_graceful_shutdown plus atexit gain a terminate_all() backstop sweep. The
cooperative shutdown path is otherwise unchanged.

Verified on Linux: killing the parent now reaps cloudflared and llama-server
within ~2s instead of orphaning them.

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

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

* test: add real Windows kill-on-job-close integration test

Spawn a parent that installs the job and a child that inherits it, terminate
the parent, and assert the child is reaped. Skipped off Windows. Also make the
liveness probe Windows-safe (os.kill(pid, 0) terminates on Windows).

* Fix Win64 handle truncation in the Job Object calls

Set explicit argtypes so the 64-bit job/process handles are not marshaled as
c_int (which truncated them on Win64, failing AssignProcessToJobObject). Assert
install success in the Windows integration test.

* Bind multiprocessing workers to parent death; harden the sweep

Review follow-ups:
- Multiprocessing workers (inference/export/training/data-recipe/Xet) cannot be
  given a preexec_fn by the parent, so adopt_pid alone left them orphanable on a
  Linux SIGKILL. They now bind themselves with PR_SET_PDEATHSIG at startup via
  bind_current_process_to_parent_lifetime(), wired into the shared
  run_without_native_path_secret entrypoint and the Xet child entry.
- Wire the previously-missed data-recipe worker through adopt_pid.
- terminate_all now honors its timeout: SIGTERM, wait, then SIGKILL the
  survivors, so cooperative children can exit cleanly.
- Track adopted pids with a /proc starttime identity and add forget_pid, so the
  shutdown sweep never signals a recycled pid.

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

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

---------

Co-authored-by: Michael Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 05:51:22 -07:00
Daniel Han
22e6d64493
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>
2026-06-18 05:44:17 -07:00
Daniel Han
3bfc83781d
Runtime MTP fallback for tensor parallelism (try MTP, recover if it crashes) (#6324)
* Disable MTP speculative decoding under tensor parallelism

Follow-up to #6040 (Studio tensor-parallel support).

MTP-draft speculative decoding plus --split-mode tensor crashes the CUDA
flash-attn kernel at decode time. The startup /health probe only checks that
llama-server comes up, so the existing MTP-drop fallback (keyed on startup
health) never fires and the server dies on the first generation instead.

Gate MTP off when a tensor attempt actually engages: this runs before the
VRAM planner (so no drafter memory is reserved) and before the speculative
flag build (so no --model-draft / --spec-type is emitted). Ngram modes use no
draft model and are kept, and mtp+ngram degrades to ngram rather than off. The
layer-split fallback re-runs with tensor_parallel False and restores MTP.

The reason is surfaced as spec_fallback_reason "tensor_parallel" so the
settings sheet explains why MTP is off instead of prompting a llama.cpp update.

Verified on unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_XL across 4x B200: the
load now emits --split-mode tensor with no MTP flags and generation completes
without the prior decode crash.

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

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

* Make tensor-parallel MTP gate test format-independent

The assertion pinned the multi-line `speculative_type = (` form, but ruff
collapses it onto one line, so match `speculative_type =` instead.

* Recover from MTP+tensor-parallel crashes at runtime instead of banning MTP

MTP-draft speculative decoding under --split-mode tensor usually works, but can
crash llama-server's CUDA flash-attn kernel at decode time (the prompt-cache
checkpoint-restore path). The earlier fix statically disabled MTP whenever
tensor parallelism was on, which is not future-proof and gives up the MTP
speedup even though it normally works.

Replace the static ban with a try/recover, mirroring the existing load-time
MTP-drop fallback:

- Load-time decode probe: after the server passes /health under tensor +
  MTP, run one tiny /completion to exercise the draft path. A failure flips
  the load unhealthy so the existing fallback respawns with --spec-default.
  Catches a hard incompatibility that crashes on the first decode.

- Generation-time recovery: snapshot the load kwargs after a healthy load,
  and if llama-server exits mid-generation while MTP + tensor parallelism were
  active, quietly reload the same model with speculative decoding off (one
  single-flight background reload) and surface spec_fallback_reason=runtime_error.
  Catches the rare mid-generation crash the probe and load-time fallback miss.

No persistent ban: a later fresh load re-tries MTP, so this self-heals if a
future llama.cpp supports the combo. Verified on gemma-4-26B-A4B + 4x B200:
MTP runs normally, and killing llama-server mid-generation reloads it without
MTP and serves the next request cleanly.

* Address review feedback on the MTP runtime fallback

- Authenticate the decode probe: direct-stream mode runs llama-server with
  --api-key, so the unauthenticated /completion probe got a 401 and falsely
  dropped MTP. Attach the same bearer auth the other internal requests use.
- Re-check the cancel flag inside the recovery thread after the death poll,
  so an /unload that races the reload can't resurrect the dropped model.
- Schedule the no-MTP recovery on the connection-error paths it was missing:
  generate_chat_completion's ConnectError branch, the OpenAI passthrough
  typed (RemoteProtocolError/ReadError/CloseError) stream catch, and the
  Anthropic passthrough generic stream catch. Previously a server that died
  before reconnect, or a typed mid-stream error, skipped the reload.

* Cover every request path with the MTP+tensor crash recovery via a watchdog

The runtime MTP-crash recovery only fired from request handlers that
observed the failure, so the direct llama-server proxy endpoints
(/v1/completions, /v1/responses, the OpenAI/Anthropic passthrough
transports) -- and a crash with no request in flight -- could leave a
dead server. Add a single background watchdog, armed only on a healthy
MTP + tensor-parallel load, that polls the subprocess and routes an
unexpected death into the existing single-flight no-MTP reload. It is
stopped inside _kill_process (the one deliberate-termination chokepoint)
so a planned reload/unload is never mistaken for a crash, and re-checks
the stop flag after a detected exit to close the kill-vs-poll race. The
reload turns MTP off, so the replacement server arms no watchdog and the
fallback cannot loop; a later fresh load still re-tries MTP.

* Harden MTP+tensor crash recovery: stale-load race, pass-through MTP, requested mode

Address review findings on the runtime MTP-crash recovery:

- Stale-load race: the recovery thread snapshotted the crashed load, waited up
  to 5s for the process to confirm dead, then only checked the cancel flag
  before replaying load_model. A concurrent user load clears that flag, so the
  stale snapshot could reload the old model over the user's new one. Make the
  load lock re-entrant and run the staleness check (cancel + same process +
  unchanged snapshot) under it, atomically with the reload.

- Pass-through MTP: MTP can also be requested via a user --spec-type in
  extra_args or LLAMA_ARG_SPEC_TYPE, where Studio emits no spec flags and
  _speculative_type stays unset, so the probe/watchdog/recovery never engaged.
  Track _mtp_runtime_fallback_active from the actual launched config and gate on
  it; on the no-MTP reload, append a last-wins --spec-default so the replay drops
  MTP regardless of source (and the load-time fallback does the same).

- Requested mode: the off-reload reset _requested_spec_mode to off, so after a
  status refresh the UI showed a bare Off with the runtime-error note suppressed
  and would not retry MTP. Restore the original requested mode after the reload,
  matching the startup MTP fallback.

- Snapshot the extra_args list by value so a caller mutating it cannot corrupt
  the recovery snapshot.

Tests: test_tensor_parallel.py + test_llama_server_args.py green (303 passed).

* Trim verbose comments in the MTP+tensor crash recovery

Tighten the docstrings and inline comments added for the runtime MTP recovery
(watchdog, probe, reload, gating) to succinct one/two-line forms; no code
change (verified comment-only).

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 05:42:21 -07:00
Daniel Han
0533efe3f8
Harden model fetching (#6391)
* Harden model fetching: consent gate for trust_remote_code

Add a load-path consent gate that scans a model's auto_map repository code
before it executes and blocks CRITICAL/HIGH findings unless the user pins
approval of that exact code version. Capability detection stays code-free,
reading raw config.json instead of AutoConfig.

- Scan config.json and tokenizer_config.json auto_map, nested local helpers,
  and external owner/name--module repos; fail closed on partial downloads.
- Gate inference, training, and export workers, including the MLX path and a
  LoRA's base model, and report requires_trust_remote_code from the raw config
  so chat and auto-load surface the dialog.
- Verify trusted-org auto-enable against the Hub with the request token and key
  the verdict cache by token; reject local-path and spoofed names.
- Add a consent dialog showing the flagged file, line, and surrounding code.
- Thread hf_token through the scan and load paths for gated repos.

* Address review: token handling, tokenizer/LoRA scan coverage, rollback

- Send the HF token for remote-code scans in the POST body, not the URL, so it
  never lands in a log or browser history.
- Collect tokenizer_config.json auto_map files directly instead of relying only
  on the repo file listing.
- Resolve a LoRA's base model for the validate flag and the scan endpoint so the
  dialog scans the code the workers actually gate.
- Pass the request token to the training YAML trusted-org auto-enable.
- Resend a previously approved fingerprint when rolling back to a custom-code
  model after a failed switch.

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

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

* Consent UX: drop legacy chat toggle, fix decline copy, purge declined downloads

The per-model consent dialog is now the single approval path for custom
(auto_map) code in chat, so three leftovers from before it existed are removed:

- Remove the "Enable custom code" switch from Chat Settings and stop persisting
  trust_remote_code, so a previously saved blanket-on cannot linger and load a
  model without going through per-version review. The flag stays as an internal
  YAML/preset default (e.g. first-party auto-enable); the load path still gates
  every custom-code load on a fingerprint only the dialog produces.
- Reword the decline message and the auto-load toast to describe approving the
  model's code from the dialog, not a missing settings toggle.
- On decline, purge the repo the scan downloaded so untrusted code is not left
  on disk. A new /api/models/discard-remote-code endpoint deletes only a
  metadata-only cache entry the scan created; it refuses local paths, loaded
  models, and any repo with weight files cached, so a model the user already had
  or pre-downloaded is always left untouched. The frontend only calls it when
  the scan reported created_by_scan.

Adds discard-endpoint tests (delete metadata-only, refuse on weights/gguf,
refuse local, no-op when not cached) and a created_by_scan payload assertion.

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

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

* Export: remove the user-facing trust remote code toggle

The Export page kept a "Trust remote code" switch (default on) next to the HF
token field. Like chat, custom (auto_map) code should be approved per model
through the load-time review dialog, not a persistent blanket switch, so the
toggle is removed. The export load path already routes through the same consent
dialog: an HF source now starts with trust_remote_code off and only enables it
when the user approves the scanned code in the dialog (a local checkpoint the
user exported stays trusted by default). With the dialog unreachable and no
approval, an HF source loads with trust_remote_code off, which fails closed
rather than running unreviewed code.

* Block loads of repos with unsafe files using Hugging Face's security scan

The trust_remote_code consent gate covers one load-time RCE vector (a repo's
auto_map Python). It does not cover the other: a malicious pickle inside a weight
file (pytorch_model.bin, *.pkl, *.dat) deserializes during from_pretrained even
with trust_remote_code False, so a repo with a normal config plus a poisoned
pickle slips past the existing gate.

Add a metadata-only malware gate that uses Hugging Face's own scan (picklescan +
ClamAV), read via model_info(securityStatus=True).security_repo_status. It never
downloads, opens, or unpickles the flagged files; it only reads the Hub's verdict
and surfaces the flagged file names. New evaluate_file_security runs
unconditionally (independent of trust_remote_code) in every load path (inference,
training SFT/MLX, export), blocking the load when a file is flagged
unsafe/suspicious/malicious. The /remote-code-scan preflight and the validate
endpoint also report the result so the consent dialog opens as a hard block (no
override) listing the flagged files, even for a repo with no custom code.

Policy: hard block with no user override; fail open when the scan is unavailable
(offline/unscanned) so legitimate loads are not broken; no first-party exemption
(a poisoned pickle in a compromised trusted repo still blocks); local paths and
GGUF are skipped (no Hub scan, non-pickle format). Blocking does not gate on
scansDone, since that is often false for clean repos and a file already flagged
unsafe is unsafe regardless.

Adds test_file_security.py covering the block/allow/fail-open/skip matrix.

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

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

* Address review: scan list-form tokenizer auto_map, gate unsafe files on all load paths

Fixes from a 10-reviewer pass on the model-fetching hardening:

- The remote-code scanner skipped tokenizer auto_map encoded as a [slow, fast]
  list (transformers' standard tokenizer shape, e.g.
  {"AutoTokenizer": ["owner/repo--tokenization_x.Slow", null]}). External
  tokenizer code in that form was never fetched, scanned, or fingerprinted, so an
  AutoTokenizer(trust_remote_code=True) load could run it. _auto_map_refs now
  flattens string, list, and nested values. Adds a regression test.

- Compare-mode chat loads and background auto-load only gated on
  requires_trust_remote_code, so a repo flagged unsafe by the Hub scan but with no
  custom code skipped the hard-block dialog. Both now also gate on
  requires_security_review, matching the main chat path.

- The /remote-code-scan and /validate routes collapsed a LoRA adapter to its base
  before the malware scan, so unsafe files in the adapter repo itself were missed
  in the pre-load review (the workers already scan both). Both routes now run the
  file-security scan over the adapter and the base.

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

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

* Require approval for all HIGH remote code, fail closed when unscannable

Tighten the load-time security gates based on review:

Consent gate
- HIGH-severity auto_map code now requires explicit, per-version approval for
  every repo, including first-party unsloth/nvidia. The org is no longer a
  blanket bypass: a compromised first-party repo with HIGH code still warrants
  review. CRITICAL stays a hard block; clean code still loads after the consent
  prompt.
- Fail closed when auto_map code is present but cannot be fully fetched or
  listed to scan (gated, offline, transient, or a repo-listing failure that
  could hide an imported helper). We cannot fingerprint code we cannot see, so
  this is a non-approvable block, retryable once the repo is reachable.
- Scan auto_map from every config that can carry one (model, tokenizer, image
  and feature processor, processor, video processor), not just config.json and
  tokenizer_config.json, so a custom-processor model is not missed. The file
  list is the single source of truth in remote_code_scan and is pinned to the
  transformers filename constants by a guard test.
- Distinguish a genuine 404 (config truly absent) from a transient error: only
  the latter forces a scan, so a repo with no config is correctly a no-op.

Malware gate
- Scan a remote repo even when its name ends in .gguf; only local paths skip the
  Hub scan, so a repo cannot dodge the scan by naming itself "*.gguf".
- Correct the docstring: a file already flagged unsafe blocks regardless of
  scansDone; the only fail-open path is an unavailable scan.

Coverage
- Resolve a remote LoRA adapter's base model (not just local directories) so the
  base, where the code and weights actually execute, is scanned in validate,
  the scan route, and the training and export workers.
- Gate the embedding training path (FastSentenceTransformer) with the malware
  and consent checks, matching the other load paths.

Tests updated and added for each change.

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

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

* Scope malware gate to the load-path vector; stop false-blocking first-party models

Follow-up hardening from a second review pass + a broad live model matrix
(unsloth/* , nvidia/* , third-party, and the eicar malware repo).

Malware / unsafe-file gate
- Scope the block to the actual RCE vector: a root-level file in a code-executing
  format. from_pretrained deserializes weight files at the repo ROOT, so a flag is
  only a load-path pickle vector there. Two exclusions, because neither is loaded:
  inert formats (safetensors is tensor-only, gguf is non-pickle, configs/text/
  images) and files in subdirectories. This keeps eicar blocked (its *.pkl/*.dat/
  eicar_test_file sit at the repo root) while no longer false-blocking legitimate
  first-party repos: nvidia/Nemotron-H-8B-Base-8K ships root safetensors plus NeMo
  pickle checkpoints under nemo/ that the loader never touches, and the Hub flags
  both; the gate previously hard-blocked it.
- Unknown / future non-"safe" levels now fail closed (block) instead of being
  silently allowed, so Hub schema drift cannot introduce a bypass; in-progress
  ("pending"/"scanning"/"error") levels stay non-blocking to avoid false blocks.

Consent gate
- Ignore a STALE own-repo auto_map target that is absent from the repo listing (an
  older config pointing at a file the repo no longer ships) instead of failing the
  whole repo closed as unscannable. The present .py are still fully scanned, which
  is the stronger coverage, and a file that is not there cannot execute. This
  unblocks first-party models like unsloth/PaddleOCR-VL (its tokenizer_config.json
  names processing_ppocrvl.py while the repo ships processing_paddleocr_vl.py). A
  referenced .py that IS present but cannot be fetched, and a repo-listing failure,
  still fail closed.

Remote LoRA base resolution
- Distinguish a genuine 404 (not a LoRA / repo absent -> None) from a transient
  error: the transient case is retried once, then logged as a WARNING (a missed
  base is scanned by neither gate) rather than silently skipped.

Discard endpoint
- Treat .onnx and .ckpt as weights so a repo whose only heavy artifact is one of
  those is never eligible for the declined-download purge.

Tests added for each: load-path scoping (safetensors/subdir/Nemotron-H shapes,
unknown-level fail-closed, pending non-block), stale own-repo auto_map ref, remote
LoRA transient retry, and the empty-config-list (all-404 -> []) semantics.

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

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

* Make LoRA-base transient-warning test robust to logging backend

Assert on the logger object directly instead of capsys, so the test does not
depend on whether the real structlog logger or the module-stub logger is active
(which varies with test collection order).

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

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

* Allow a repo with auto_map but no executable code (e.g. GGUF) instead of blocking

A config can declare an auto_map yet the repo ship NO executable .py -- most
commonly a GGUF repo whose config.json carries an auto_map copied from the original
model (e.g. unsloth/Llama-3_1-Nemotron-Ultra-253B-v1-GGUF references
modeling_decilm.py, which the GGUF-only repo does not contain). A GGUF model loads
through llama.cpp, which never executes auto_map, and transformers cannot run a file
that is not present, so there is nothing to scan and trust_remote_code is a no-op.

The fail-closed change treated this empty result the same as "code is present but we
could not fetch it" and hard-blocked the load. Distinguish the two: repo_remote_code_files
now RAISES RemoteCodeUnscannable when code is present but cannot be fully fetched or
listed (offline / gated / transient / a present .py that 404s / a listing failure),
and returns an empty dict only when the listing succeeded and the repo genuinely ships
no executable .py. The consent gate blocks on the exception (fail closed) and allows the
empty case as a no-op. Real unscannable code still hard-blocks; eicar and CRITICAL/HIGH
custom code are unaffected.

Verified against all 37 unsloth/*Nemotron* models (two GGUF repos were false-blocked,
now load) and the existing matrix (eicar still blocks; DeepSeek-OCR / NVLM-D-72B still
prompt approvable consent). Tests updated to expect the raise for unscannable cases and
added for the no-executable-code no-op.

* Ignore vestigial auto_map in GGUF repos (llama.cpp never runs it)

A GGUF repo's config.json is often copied verbatim from the original
transformers model, auto_map and all, but a GGUF load goes through
llama.cpp which never executes auto_map, so the config is inert. Treat
a direct .gguf reference, and a repo that ships .gguf weights with no
.safetensors, as having no remote code so the consent flow is never
triggered. A mixed repo with both .gguf and .safetensors is still gated,
since the safetensors variant would load through transformers where
auto_map does run. The check sits behind the existing auto_map-present
gate so normal models pay no extra repo listing.

* Add scanner-result copy to the remote-code consent dialog

Make the consent dialog state the scan outcome in plain language for
every model. When the static scan finds nothing, reassure the user with
'Our automatic scanner did not flag any worrying files, but please
double check.' (shown only for the clean, approvable case). When the
scan flags custom code or unsafe files, label the list with 'Our
automatic scanner flagged issues including:'. The Hugging Face
attribution for unsafe files stays in the dialog description.

* Close GGUF-suffix consent bypass for repo ids ending in .gguf

The .gguf short-circuit in _config_has_auto_map skipped the scan for any
model name ending in .gguf, including a bare two-segment repo id like
'evil/model.gguf'. Such a repo can still ship safetensors plus auto_map
Python that transformers would execute, so skipping the scan was an
asymmetric bypass (file_security already scans those repos). Restrict the
short-circuit to genuine direct GGUF file references via
_is_direct_gguf_file_ref: a local .gguf path, or a remote repo_id plus
filename (three or more segments). A two-segment repo id named *.gguf now
falls through to the config scan and _is_gguf_repo file inspection, so it
only skips consent when it actually ships .gguf weights and no safetensors.

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

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

* Align consent dialog body with the title and fix narrow-width overflow

The scan results (the 'Our automatic scanner...' label, finding/unsafe
cards, and the clean-scan reassurance) sat at the dialog's left padding
while the title and description were indented past the status icon, so
the body did not line up under the description. Move the title,
description and results into one column to the right of the icon so they
share a left edge, and let that column fill its width so the description
no longer wraps early.

Also stop a wide code snippet from pushing the dialog off-screen on
narrow viewports: AlertDialogHeader is a grid with place-items-center,
which sized the content row to its content; give the row w-full so it
fills the track, and add min-w-0 down the results chain so the snippet
scrolls inside its card instead of widening the dialog. Verified aligned
and contained from mobile portrait through ultrawide.

* Treat a repo as GGUF-only only when it ships no transformers weights

_is_gguf_repo excluded only .safetensors, so a repo with a .gguf and a
pytorch_model.bin (or .pt/.pth/.h5/.msgpack/.onnx/.ckpt) and no
safetensors was treated as GGUF-only and skipped the consent scan, even
though transformers can load that weight set and execute the repo's
auto_map code. Require the absence of ANY transformers-loadable weight
before treating the repo as a llama.cpp-only GGUF load. A genuine
GGUF-only repo (only .gguf) is still inert; a mixed repo with any pickle
or safetensors weight is gated. Adds a regression test across all the
non-safetensors weight formats.

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

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

* Block flagged subdir weight shards referenced by a root index

The malware gate treated every subdirectory file as non-loadable, but
from_pretrained deserializes a subdir shard a root index references
(pytorch_model.bin.index.json -> shards/...-00001-of-00002.bin). Read the
root weight indexes and block a flagged subdir pickle the weight_map
points at; a flagged subdir pickle no index lists (NeMo nemo/*.distcp)
stays non-blocking, and an inconclusive index lookup fails closed.

* Pass hf_token to the export checkpoint load

ExportBackend.load_checkpoint scanned with hf_token in the worker but
loaded the weights unauthenticated, so a gated/private checkpoint passed
preflight then 401'd at from_pretrained. Add hf_token to load_checkpoint
and forward token to every from_pretrained branch; the worker passes the
command's hf_token.

* Scope created_by_scan to every HF cache the discard searches

created_by_scan used get_cache_path (active HF_HUB_CACHE only) while
/discard-remote-code deletes across active, legacy, and default caches. A
repo the user already had in a legacy/default cache was marked
scan-created and deleted on decline. Check all three caches for the repo
dir before declaring the scan created it.

* Scan the full .py closure of external auto_map repos

An auto_map cross-repo ref (owner/name--module.Class) only had its entry
file downloaded, but transformers also fetches that file's relative
imports from the same repo, so a dangerous helper.py was left outside the
scanned fingerprint. List each external repo's .py and scan the whole set
(plus the referenced entry files); fail closed if the repo cannot be
listed or fetched.

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

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

* Fail closed when a weight index cannot be fully read

_indexed_shard_paths treated a partial result as definitive: if one weight
index read cleanly but another failed transiently, it returned the shard
paths it did see. A flagged subdirectory pickle listed only by the index we
could not read would then be classed as "not a load input" and skipped,
re-opening the very fail-open this guard was added to close.

Return None whenever any index read is inconclusive, even if another read
cleanly, so the caller blocks the already-flagged subdir pickle. A repo that
ships no index files raises EntryNotFoundError for each (never inconclusive)
and still returns an empty set.

* Match cached repos case-insensitively in the created_by_scan guard

_repo_in_any_hf_cache resolved casing only against the active cache and then
probed every cache with an exact directory name. A case-variant already
present in a legacy or default cache (models--Unsloth--Foo for a scan of
unsloth/foo) was missed, so the repo was marked created_by_scan and deleted
on decline -- but discard_remote_code_download deletes case-insensitively,
so that delete would hit the user's pre-existing cache entry. Detect
case-insensitively too, mirroring the deletion path.

* Skip remote-code and security review for selected GGUF variants

validate_model ran the trust_remote_code and Hugging Face security-scan
preflight against the repo even when the selected artifact is a .gguf. A
GGUF loads through llama.cpp, which never executes the repo's auto_map
Python and never deserializes root pickle weights, so repo-level Transformers
artifacts (a config.json with auto_map, or an unsafe pytorch_model.bin next
to the .gguf in a mixed repo) are inert for that load. Gating the GGUF on
them is a false positive. Run both preflights only for non-GGUF loads.

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

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

* Scope the malware gate to actual load roots and serialized files

Two fixes to evaluate_file_security so it neither misses a load-path pickle nor
false-blocks an inert file:

- Honor subdirectory load roots. Spark-TTS / BiCodec call from_pretrained on the
  snapshot's LLM subdirectory, so a flagged pickle directly under it is a
  root-level load artifact there. A new load_subdirs parameter (set from the
  model's audio type via security_load_subdirs) reclassifies those files relative
  to the load root and looks for weight indexes under it, so a flagged shard in
  that subdir is no longer skipped as "not root-level".
- Exempt source files. A root .py is never deserialized by from_pretrained;
  executable repo code runs only through auto_map, which the remote-code consent
  gate scans. Flagging a Python helper here would false-block a repo that merely
  ships a build or train script.

* Scan a LoRA adapter and base as one consent unit, and gate MEDIUM code

A LoRA load runs both the adapter's and the base's repo code. The consent gate
scanned them separately and pinned one fingerprint per repo, so an adapter that
shipped its own auto_map code was either never shown in the dialog (which only
saw the base) or impossible to approve with the base's fingerprint.

evaluate_remote_code_consent_for_targets now scans all of a load's repos as a
single combined unit and pins ONE fingerprint over the union of their code, so
approving the load approves every repo's code together. evaluate_remote_code_consent
becomes a thin single-target wrapper, and an unscannable target fails the whole
load closed.

Also gate MEDIUM findings: like HIGH they now block pending pinned approval, so a
direct API caller cannot run flagged code by setting trust_remote_code=True
without consenting. Only a clean scan loads without a fingerprint.

* Preflight a LoRA load's adapter and base as one combined consent scan

scan_model_remote_code rewrote a LoRA adapter to its base and scanned only the
base for remote code, so the dialog never surfaced an adapter's own auto_map
code. Scan the adapter and base together through
preflight_remote_code_consent_for_targets, which pins one combined fingerprint
the worker gate accepts. The malware preflight is also scoped to each target's
load subdirectories.

* Apply combined consent and subdir-aware malware scan in load workers

Each load worker (inference, export, training) evaluated remote-code consent
once per target with a single shared fingerprint, so a LoRA adapter that ships
its own auto_map code could not be approved by the base's fingerprint. They now
scan the adapter and base together via evaluate_remote_code_consent_for_targets,
which pins one combined fingerprint over the union of their code. The malware
scan in each worker is also scoped to the model's load subdirectories so a
flagged pickle under a from_pretrained load subdir is not missed.

* Report a consistent trust_remote_code requirement after a model loads

validate_model reports requires_trust_remote_code from the YAML default OR the
raw auto_map, but the load, already-loaded, and status responses reported only
the YAML default. A custom-code model approved and loaded via auto_map was then
reported as not requiring trust_remote_code, so the frontend stored false and a
later retry or rollback sent trust_remote_code=false and failed.

A shared resolver reports the same requirement for a loaded model (a value
stored at load time, else the trust_remote_code the load used, else the YAML
default, else the raw auto_map check), and the load response persists it so the
status and already-loaded paths stay consistent. The selected-GGUF security
review is also scoped to the model's load subdirectories.

* Run the consent gate on training resume and for YAML-only trust_remote_code

Three frontend gaps left a model loading without the trust_remote_code it needs:

- The shared consent helper returned early when the scan found no auto_map and no
  unsafe files, dropping a requirement that comes from a model's Studio YAML
  default (e.g. GLM-4.7-Flash). It now grants the caller's requirement with an
  empty pin instead of sending trust_remote_code=false.
- Resume-from-history called startTraining directly with no consent gate, so a
  resumed run whose model needs custom code (or an old run with no approved
  fingerprint) hit the worker block with no dialog. It now runs the same gate as
  a fresh start.
- HF export passed requiresTrustRemoteCode=false for every HF source, so a
  YAML-only model could not flip the flag before export. It now signals the
  requirement for HF sources.

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

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

* Cover both LoRA repos in validate, report GGUF as inert, purge all declined repos

Three follow-on gaps from the combined adapter+base consent work:

- validate_model resolved requires_trust_remote_code from the base alone, so a
  LoRA adapter that ships its OWN auto_map code (with a plain base) was reported
  as not needing trust_remote_code and the consent dialog never opened. It now
  checks the [adapter, base] target set, matching the scan route and the workers
  (which already gate both) and the security review already running over both.

- The already-loaded, loaded, and status responses for a selected GGUF reported
  requires_trust_remote_code from the model's YAML default. A GGUF loads through
  llama.cpp, which never executes the repo's auto_map Python, so the requirement
  is inert for that load. They now report False, matching validate_model (which
  already skips both gates for GGUF) so a status refresh cannot flip the flag
  back on.

- The remote-code scan downloads both the adapter's and the base's config, but
  created_by_scan tracked only the primary, so a base the scan was first to pull
  into the cache was left on disk when the user declined. The scan now reports
  scan_created_repos (every repo it newly cached) and the decline cleanup purges
  each; created_by_scan stays for older clients. The frontend falls back to the
  primary flag when the list is absent.

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

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

* Scan the repo the load fetches, purge external code on decline, harden consent pins

Six follow-on hardening fixes from a fresh review pass over the gate:

- The malware gate scanned the literal "Spark-TTS-0.5B/LLM" alias, but the trainer
  downloads it as unsloth/Spark-TTS-0.5B and loads LLM/, so the alias 404'd and
  failed open, missing a flagged LLM/ pickle. evaluate_file_security now resolves
  the alias to the repo the loader fetches and scans LLM/ as a load root.

- security_load_subdirs relied only on tokenizer detection, which fails on an
  unresolved alias or offline; it now also honors the Studio YAML audio_type
  default, so a BiCodec LLM/ load root is not missed.

- The remote-code scan downloads external auto_map repos (owner/name--module.Class),
  but the decline cleanup tracked only the model/adapter/base, leaving the external
  untrusted code cached. The scan now enumerates external auto_map repos and reports
  the ones it created in scan_created_repos, so a decline purges them too.

- External auto_map refs failed the whole load closed on a stale or mis-derived
  dotted ref (sub.mod.py vs the real sub/mod.py) even though the actual file was
  present and scanned. They now drop such refs when the repo listing is real, exactly
  like the own-repo path; an empty/incomplete listing still fetches and fails closed.

- The combined consent fingerprint keyed code by the raw target string, so the scan
  endpoint's canonicalized casing and a worker's raw user input produced different
  pins for identical code, rejecting a valid approval. Hub repo ids are now folded to
  lowercase in the key (local paths stay case-sensitive), so the pin tracks the code.

- Export threaded hf_token into the weight load but not into detect_audio_type /
  is_vision_model, so a gated multimodal base 404'd in detection and fell through to
  the text loader. Both probes now use the same token.

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

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

* Thread the token through check-vision and guard the gate's parallel sites

The /check-vision endpoint classified a model without the hf_token, so a gated or
private vision model 404'd in the probe and was reported as a plain text model --
the same dropped-token shape as the export probes, at a sibling site. It now passes
the token like the neighboring /check-embedding endpoint.

Add deterministic consistency guards (tests/test_security_gate_consistency.py) that
enumerate the gate's parallel sites mechanically instead of relying on a review to
spot a missed sibling: every is_vision_model / is_embedding_model / detect_audio_type
caller under routes/ and core/ must thread the token, every GGUF response must report
trust_remote_code via the resolver or False (never the raw YAML default), and every
load worker that runs the malware or consent gate must resolve the LoRA base. A new
site that drops the token or mis-reports the requirement now fails CI directly.

* Narrow the LLM alias rewrite and make audio detection token-aware

Three fixes from the confirmatory review, one a regression from the previous round:

- _load_scan_target rewrote EVERY remote repo ending in "/LLM" to unsloth/<parent>,
  so a real third-party repo named "<owner>/LLM" was scanned as unsloth/<owner>
  while the loader still fetched the real repo -- a fail-open hole introduced when
  the Spark-TTS alias handling was added. It now rewrites only a registry-known
  bicodec alias; every other "/LLM" repo is scanned as itself.

- detect_audio_type cached results under the bare model name, so an unauthenticated
  probe of a gated/private repo cached None and poisoned a later authenticated call
  with the token. The cache is now keyed by (normalized_name, token_fingerprint),
  matching the vision cache.

- The training fallback /check-vision call dropped the hf_token, misclassifying a
  gated/private VLM when the config endpoint failed. It now passes the token, like
  the getModelConfig call it falls back from; checkEmbeddingModel takes the token too.

Extend the consistency guards: every capability cache must be keyed by a tuple
including the token, so a cache re-declared as Dict[str, ...] fails CI.

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

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

* Document the broad .py scan as deliberate and enforce it with a test

The remote-code scanner scans every .py in a repo once an auto_map exists, not
just the auto_map entry's static import closure. This is intentional: the entry
module can reach a sibling via an absolute import, importlib, or exec, none of
which a static relative-import closure follows, so closure-only scanning would be
a real bypass of a load-time RCE gate. The broad scan never under-scans; the cost
is that an unrelated benign script can over-block, which is the safe failure
direction (HIGH stays approvable; only CRITICAL hard-blocks).

Spell this out at both the local and remote scan sites so the choice reads as
deliberate, and add a test asserting an unrelated, never-imported .py is still
scanned -- so a future narrowing to the static closure fails CI.

* Purge a declined remote LoRA adapter the scan downloaded

scan_model_remote_code probed the created-by-scan state AFTER resolving the base,
but get_base_model_from_lora_identifier downloads a remote adapter's own
adapter_config.json, so the adapter looked already-cached and was dropped from
scan_created_repos. On decline the adapter -- including the auto_map .py the
preflight fetched -- was left on disk, defeating the "untrusted code is not left
on disk" guarantee for the adapter itself.

Snapshot the primary's cache state BEFORE base resolution and use it when marking
the adapter scan-created; on any probe error treat it as pre-existing so a decline
never deletes it. The base and external repos are unaffected (their configs are not
downloaded before their own probe). Add a test that models the mid-scan download
side effect, which the prior static-stub tests did not.

* Clear remote-code approval when the training model changes

Switching the training model from an approved custom-code model to a clean one
kept the previous model's trust_remote_code=true and approved fingerprint in the
store: setSelectedModel reset visionImageSize on a true switch but not the
remote-code approval. The clean model then trained with trust_remote_code=true,
which bypasses the compiler and disables fused cross-entropy.

Reset trustRemoteCode and approvedRemoteCodeFingerprint on a true model switch.
The new model's own YAML default is re-applied by loadAndApplyModelDefaults, and a
custom-code model still re-opens the consent dialog before training starts, so the
only change is that a clean model no longer inherits a stale approval.

* Trim verbose comments across the model-fetching hardening changes

Condense the explanatory comments and docstrings introduced across the
trust_remote_code consent gate, the malware/unsafe-file gate, the remote-code
scanner, the load workers, the model routes, and the security frontend into
fewer, tighter lines while preserving every security rationale (fail-open vs
fail-closed direction, the deliberate broad-scan anti-bypass note, the
empty-vs-unscannable distinction, stale-ref handling, and the alias-rewrite
spoof guard).

Comments and docstrings only. No code, logic, identifiers, or test behaviour
changed; verified comment-only via the AST/TypeScript checker (40/40), with the
backend test suite and frontend tsc green.

* Do not cache transient audio-detection failures

detect_audio_type cached _detect_audio_from_tokenizer's result
unconditionally, so a transient read failure (network error or 5xx,
returned as None) poisoned the cache and the later successful probe never
ran. Mirror the vision cache: _detect_audio_from_tokenizer now returns
(audio_type, definitive) and the caller caches only definitive results.

A read that succeeds with no audio tokens, or clean 404s for every
tokenizer path, stays a cacheable None; only a genuine transient failure
(connection error, timeout, 5xx, malformed body) skips the cache so the
next call retries.

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 05:39:52 -07:00
Michael Han
93572648b6
Studio Hub: default downloads to Xet transport (#6433)
* Studio Hub: default downloads to Xet transport

Model and dataset downloads defaulted to HTTP; flip the default to Xet for faster parallel chunked transfers.

- Frontend: DEFAULT_TRANSPORT_MODE is now Xet, so a user with no saved preference starts on Xet. effectiveTransportMode() already downgrades to HTTP and warns when hf_xet is unavailable, so this degrades gracefully.
- Backend: DownloadModelRequest.use_xet and DownloadDatasetRequest.use_xet default to True, keeping the API in step with the UI. Set use_xet=False for sequential HTTP Range-resume.
- Align the internal _spawn_download_worker default so no caller silently falls back to HTTP.

Inference and training model loads were already Xet-first with an HTTP stall fallback, so this brings explicit downloads in line with the rest of Studio.

* Studio Hub: gracefully fall back to HTTP when Xet is unavailable

With Xet now the default, an omitted or explicit use_xet=True from a non-UI API caller would 400 on installs without hf_xet, since resolve_transport raises when the transport is unavailable.

Add resolve_effective_use_xet(), which downgrades a Xet request to HTTP (with a warning) when hf_xet is missing, mirroring the frontend's own downgrade. Both the model and dataset flows now derive a single effective use_xet and feed it to resolve_transport and spawn_worker, so the recorded transport and the worker env can never disagree. The UI is unaffected: it already resolves availability and passes use_xet explicitly.

* Add tests for resolve_effective_use_xet Xet to HTTP fallback

* Trim comments for PR #6433

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-06-18 04:39:07 -07:00
Daniel Han
5f822c2e90
Studio: harden GPU startup detection and clarify multi-GPU listing (#6427)
* Studio: harden GPU startup detection and clarify multi-GPU listing

* Log swallowed CUDA device property probe failures at debug level
2026-06-18 02:35:13 -07:00
Daniel Han
1b697ed6fc
Fix _kill_process AttributeError when _stats_logger is unset (#6417)
* Fix _kill_process AttributeError when _stats_logger is unset

LlamaCppBackend._kill_process references self._stats_logger in its finally
block, but __init__ only sets self._stats_logger = None partway through. If
__init__ raises before that line, or the backend is built via __new__ (as the
kill-path unit test does), teardown crashes with AttributeError instead of
cleaning up the process.

Guard the reference with getattr, matching the existing
hasattr(self, '_chat_template_file') guard in the same finally block. Fixes
test_kill_process_records_timestamp_on_actual_kill.

* Also guard _stdout_thread in _kill_process teardown

Review follow-up: the same finally block also reads self._stdout_thread, which
is unset on a partially-built / __new__ backend. Guard it with getattr like
_llama_log_fh below, and add a test that _kill_process tolerates a backend with
those optional attrs unset. Trim the _stats_logger comment.

---------

Co-authored-by: Michael Han <michaelhan2050@gmail.com>
2026-06-18 00:05:43 -07:00
Daniel Han
9ccdc1a365
test: add regression guards for SecurityHeadersMiddleware pure-ASGI (#6424)
#6394 converted SecurityHeadersMiddleware to pure ASGI but did not lock the
property in. Add three guards: it must not be a BaseHTTPMiddleware (which would
re-wrap streaming responses in an anyio task group and break is_disconnected),
it must forward the ASGI receive channel untouched, and a StreamingResponse that
polls is_disconnected must unwind cleanly on client disconnect with headers
still applied.
2026-06-17 22:53:43 -07:00
Leo Borcherding
f91460113a
Studio: pin CUDA_DEVICE_ORDER=PCI_BUS_ID and list GPUs at startup (#6353)
* Studio: pin CUDA_DEVICE_ORDER=PCI_BUS_ID and list GPUs at startup

On a mixed-GPU host, Studio could load a model onto a different physical
GPU than the one it selected. The free-VRAM probe numbers GPUs via
nvidia-smi (PCI-bus order), but CUDA defaults to FASTEST_FIRST ordering,
so a selected index written into CUDA_VISIBLE_DEVICES resolved to the
wrong card. Example: 5090 + RTX PRO 6000, the picker chose the emptier
RTX PRO 6000 (nvidia-smi index 1) but CUDA read index 1 as the 5090.

Pin CUDA_DEVICE_ORDER=PCI_BUS_ID at import (before any CUDA context is
created) in both the Studio entrypoint and the hardware module, so torch,
nvidia-smi, and CUDA_VISIBLE_DEVICES share one index space. setdefault
keeps an explicit user override intact. Child processes inherit it via
os.environ.

Also list every detected CUDA GPU with its index at startup instead of
naming only device 0, matching nvidia-smi -L and making the selected
index unambiguous on multi-GPU hosts.

* Studio: make CUDA_DEVICE_ORDER tests exercise module import and respect user override

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

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

* Studio: guard full _print_cuda_device_list body and fix test PYTHONPATH trailing separator

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

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

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-17 22:40:04 -07:00