Commit graph

5,682 commits

Author SHA1 Message Date
Lee Jackson
820b5c2e20
Studio: Always show API usage examples and docs links (#5270)
* chore: always show API usage examples and docs links

* chore: add colon to API setup docs label

* settings: move help links, add API shortcut, and update API copy
2026-05-04 17:29:26 +04:00
Roland Tannous
dbea77e347
Studio: forward llama-server args from unsloth studio run , activate unsloth run , and allow passing model:quant to load models (#5271)
* Studio: forward unknown CLI args directly to llama-server

`unsloth studio run --model X --top-k 20 --chat-template-file foo.jinja`
now passes the unknown flags through to the llama-server subprocess.
Adds a denylist for flags Studio manages (port, -m, -c, --api-key, -ngl,
--flash-attn, --no-context-shift, --jinja, GPU-fit, model-identity, ...)
that returns HTTP 400 on collision. HTTP callers can supply the same
list via LoadRequest.llama_extra_args.

* Studio: accept `--model org/repo:variant` shorthand in `unsloth studio run`

Mirrors llama.cpp's `-hf <repo>:<quant>` and ollama's pull syntax so
`unsloth studio run --model unsloth/gpt-oss-20b-GGUF:UD-Q4_K_XL` is
equivalent to `--model unsloth/... --gguf-variant UD-Q4_K_XL`. Local
paths and Windows drive letters are preserved verbatim. If both an
embedded variant and an explicit `--gguf-variant` are given and they
disagree, the command fails with a clear error.

* Studio: register `unsloth run` as alias for `unsloth studio run`

Top-level `unsloth run --model ...` is now equivalent to
`unsloth studio run --model ...`. Same context_settings, so unknown
flags continue to pass through to llama-server.

* Studio: let users override soft-managed llama-server flags from CLI

Trims the denylist to flags Studio fundamentally cannot share with
the user (model identity, --host/--port/--path/--api-prefix,
--api-key, --ssl-*, --webui, --models-*). Soft-managed flags --
-c/--ctx-size, --parallel, --flash-attn, --no-context-shift,
--jinja, -ngl, -t/--threads, --fit* -- now pass through and override
Studio's auto-set version via llama.cpp's last-wins CLI parsing.

Lets users tune their run on the spot:
  unsloth run --model X -c 131072 --parallel 1 --threads 32

* Studio: accept `-hf` / `-hfr` / `--hf-repo` as aliases for `--model`

Matches llama-server's `-hf <repo>:<quant>` spelling so users coming
from llama.cpp can use the same flag. Typer claims the aliases before
the pass-through validator runs, so the HTTP-API denylist on those
flags is unaffected.

  unsloth run -hf unsloth/gpt-oss-20b-GGUF:UD-Q4_K_XL
2026-05-04 17:08:04 +04:00
Wasim Yousef Said
e35cbfb454
Add native GGUF intake to Studio (#5246)
* feat(studio): add Tauri native GGUF intake

* feat(studio): polish native GGUF intake

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

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

* fix(studio): load backend helpers during local setup

* fix(studio): acquire native load lease before unload

* Studio: harden native path lease verification and Tauri intake

- Wrap path.resolve(strict=True) and Path.stat() in NativePathLeaseError so a deleted or unmounted GGUF returns 400 instead of leaking the full filesystem path through the generic load_model/validate_model handler.
- Re-apply _reject_network_or_device_path to the resolved canonical path for defense in depth after symlink resolution.
- Replace try/except ValueError pattern in the device-path guard with Path.is_relative_to; the previous shape silently swallowed NativePathLeaseError (which subclasses ValueError) so /dev,/proc,/sys were never actually rejected.
- Broaden the lease redaction regex and dict-key check (Python and Rust diagnostics) to cover both native_path_lease and nativePathLease so the camelCase form emitted by Tauri/frontend payloads is also redacted.
- Hoist the redact_native_paths import to module top in loggers/handlers; the recursive filter no longer pays a per-record import lookup.
- Persist activeNativePathToken in the chat runtime store so the rollback branch can mint a fresh lease and reload the previous native GGUF when a new load fails after unload; clear it in clearCheckpoint and overwrite it on each successful load.
- use-native-drop: read options through a ref so the Tauri onDragDropEvent listener is registered once and stays attached across option changes; reject ambiguous multi-file drops up front instead of silently registering only the first GGUF.
- pick_native_model: use an async pick_file with a tokio oneshot channel instead of blocking_pick_file so the Tokio worker is not held for the duration of the OS dialog.
- registerNativeModelPath: drop the duplicate sourceKind argument; the Rust command parameter is source_kind.
- install_python_stack: insert the script directory (studio/) on sys.path; the previous insert pointed at studio/backend/ which does not satisfy `from backend.utils.wheel_utils import ...`.

* install_python_stack: keep _BACKEND_DIR on sys.path

Restore the studio/backend insertion. Although the immediately following `from backend.utils.wheel_utils import (...)` is satisfied by studio/ already being on sys.path[0] when invoked as `python studio/install_python_stack.py`, wheel_utils itself runs `from utils.native_path_leases import ...`, which requires studio/backend/ to be importable. Without the backend insertion, the existing tests/python/test_install_python_stack.py collection fails with ModuleNotFoundError: No module named 'utils'.

* Studio: tighten native path lease lifecycle and Tauri intake IPC

- register_native_model_path now hardcodes NativePathSourceKind::Drop on the Rust side and the frontend stops sending source_kind. The previous JS payload (source_kind only) never reached the Rust deserializer because Tauri's default ArgumentCase::Camel maps the Rust parameter source_kind to the JS key sourceKind, so drag/drop registration silently failed. Hardcoding the source kind also keeps audit metadata trustworthy on this command.
- Add native_path_secret_removed_for_child_start context manager and wrap multiprocessing.Process.start() at the inference, export, training, and data-recipe job spawn sites. The previous wrapper-only scrub left UNSLOTH_STUDIO_NATIVE_PATH_LEASE_SECRET visible to spawn-platform import-time worker code. The wrapper run_without_native_path_secret stays as defense-in-depth inside the child.
- Stop passing exc_info=True from the native-grant load/validate error logs in routes/inference.py. The structlog filter_sensitive_data processor runs before the renderer, so ConsoleRenderer formatted tracebacks bypassed redaction; the redacted str(e) preserves the message text.
- Replace the os.path.normcase string equality on the resolved canonical path with Path.samefile (with a normcase fallback) so Windows leases that differ only in extended-length \\?\ prefix or short-name spelling are accepted.
- Wrap consumeNativePathToken in its own try/catch in the chat runtime rollback. If the previous native-model token has aged out of TOKEN_TTL we now surface a clear modelsError instead of silently swallowing the rollback inside the outer catch.
- Reject non-ASCII lease strings in _split_lease and convert UnicodeEncodeError / binascii.Error / ValueError raised by _b64decode into NativePathLeaseError so verify_native_path_lease never escapes raw exceptions to the route handler.
- Tighten dropStateForPaths to mark multi-file payloads invalid so the overlay matches the post-fix drop handler that rejects the same payload.
- Replace the one-shot fetch in useNativePathLeasesSupported with a delayed-retry loop so the picker/drop becomes available once the backend is up rather than staying disabled for the rest of the session after a transient failure.
- Drop the unused setActiveNativePathToken setter; the value is set via setState directly in use-chat-model-runtime.
- Add a toast on auto-load failure in use-native-drop so a collapsed model selector does not hide the error.
- Burn the lease nonce before _validate_current_stat so a stat-failed lease is single-use even if a later state change happens to match the original size/mtime.

* Studio: cache lease secret, harden native path stat checks, polish intake UX

- Cache the decoded UNSLOTH_STUDIO_NATIVE_PATH_LEASE_SECRET on first verify and validate that it is base64-decodable and at least 32 bytes. Subsequent _decode_secret calls return from the cache and never touch os.environ, so concurrent /api/inference/load and /api/health requests no longer race with native_path_secret_removed_for_child_start scrubbing the env. native_path_leases_supported now wraps _decode_secret so the health flag matches what verify_native_path_lease actually accepts.
- Replace path.is_file()/is_dir() + path.stat() with os.lstat() in _validate_current_stat and explicitly reject S_ISLNK; size and mtime checks now refer to the link itself, closing the same-size+same-mtime symlink-swap window that the prior follow-symlink stat() left open.
- Add an issued_at_ms < expires_at_ms sanity check in _validate_payload to reject internally inconsistent (HMAC-protected) lease payloads.
- Sort _NATIVE_PATH_REDACTIONS by length (descending) before iterating in redact_native_paths so a longer registered path is replaced before a shorter prefix path; otherwise logs containing /foo/X.gguf.bak after only /foo/X.gguf was registered would leak the .bak suffix.
- classify_existing_path now re-checks the canonical path with symlink_metadata after canonicalize, so a regular file that is replaced with a symlink in the small canonicalize window is rejected at registration.
- ModelSelector renders the local file picker as its own block (not in the eject ternary), so a user with an active model can still replace it via the picker rather than only via drag/drop.
- useNativePathLeasesSupported caps the readiness probe at MAX_READINESS_POLLS (60 = ~5 minutes) and aborts the in-flight fetch on unmount via AbortController, so a permanently-disabled backend stops generating sustained traffic and hot-reload no longer leaks open connections.
- useChooseNativeModel returns a stable useCallback closure and guards the OS dialog with a useRef so rapid double-clicks cannot open multiple dialogs and orphan Rust tokens.
- Branch the multi-file drop toast: if no GGUF was present we say "Only .gguf model files can be dropped here." and otherwise "Drop a single .gguf model file." so users dropping non-GGUF attachments get an accurate explanation.

* native_path_leases: lstat the signed canonical path before resolving

The earlier change to lstat inside _validate_current_stat operates on grant.canonical_path, which is the post-resolve target. If the user atomically replaces the originally-signed file with a symlink to a different file of identical size and mtime, path.resolve(strict=True) follows the symlink, samefile returns True (both ends share the new inode), and the lstat in _validate_current_stat sees the regular target file rather than the symlink, so the swap goes undetected.

Add an os.lstat on the signed canonical path before path.resolve(strict=True), and reject S_ISLNK there. The lstat in _validate_current_stat stays as defense-in-depth for swaps that occur strictly between resolve and stat.

* Studio: scrub native lease secret before mp.Queue spawn and tighten lease lifecycle

- Move _CTX.Queue / _CTX.Event / _CTX.Process construction inside native_path_secret_removed_for_child_start at the inference, export, training and data-recipe spawn sites. The first Queue creation lazily spawns Python's multiprocessing.resource_tracker child, so when it ran outside the scrub context the tracker process inherited the lease secret. Reproduced via the proc filesystem environ entry; the wrapped order keeps the tracker clean.
- native_path_secret_removed_for_child_start now refcounts entries: the env var is popped on the first entry and restored only when the last context exits. Concurrent training/inference/export starts no longer serialize on the env lock across the entire proc.start yield, while still guaranteeing the env stays empty for the duration of every overlapping spawn.
- run_without_native_path_secret now also nulls the module-level cached lease secret. With the existing spawn-only multiprocessing context the cache is irrelevant in practice, but a future fork caller would otherwise inherit the in-memory secret even though the env var was scrubbed.
- filter_sensitive_data now applies the native lease key check on the top-level event_dict, not only on nested dicts, so a logger call that includes a lease value as a top-level keyword field actually redacts it (the bare value does not match the prefix-anchored regex).
- chat-page loadNativeModelIntent now passes intent.id to clearModelIntent so a second drag-drop during an in-flight first auto-load is not wiped from the chip area when the first resolves.
- Bump useNativePathLeasesSupported's MAX_READINESS_POLLS from 60 to 720 so first-run installs that compile llama.cpp from source or download large CUDA wheels (well past 5 minutes) don't permanently disable the native picker.

* native_path_leases: serialize first-decode against scrub context

_decode_secret used a separate _SECRET_INIT_LOCK from the env scrub's _NATIVE_PATH_ENV_LOCK, so the very first decode (before the cache is populated) could race a concurrent native_path_secret_removed_for_child_start and read os.environ during the env-empty window, raising "Native path grants require the managed desktop backend." Subsequent calls hit the cache and were already safe.

Acquire _NATIVE_PATH_ENV_LOCK around the env read inside _SECRET_INIT_LOCK and fall back to _SCRUB_SAVED_SECRET when the scrub has temporarily popped the env var. Lock ordering (init then env) is consistent with no other caller, so no deadlock.

* Studio: surface native model load errors and harden native path label cache

- Native model load and validate now bubble up the actual exception (with
  paths redacted) and apply the same friendly-error rewrite the non-native
  path uses, so users see "CUDA OOM", "trust_remote_code required", etc.
  instead of a generic "Failed to load native model: <label>".
- run_without_native_path_secret now also nulls _SCRUB_SAVED_SECRET so a
  forked grandchild that imports native_path_leases cannot recover the
  secret via the scrub-aware fallback in _decode_secret.
- _NATIVE_PATH_LABELS now has its own 10000-entry cap independent of the
  100-entry redaction list, so display_label_for_native_path no longer
  falls back to returning the raw canonical path after 101 native paths
  in one session. Redaction list keeps the 100-entry cap for log-scan
  performance.
- _validate_payload now also rejects null bytes in display_label, which
  is echoed back in HTTP responses and log lines.

* Studio: harden native path lease validation and chained native rollback

- child_env_without_native_path_secret now copies os.environ under
  _NATIVE_PATH_ENV_LOCK so a concurrent scrub-context env pop cannot
  raise RuntimeError: dictionary changed size during iteration in a
  background hardware scan or other env reader.
- _validate_payload and grant construction route every signed numeric
  field (version, issued_at_ms, expires_at_ms, size_bytes, modified_ms)
  through new _required_int / _optional_int helpers that wrap raw int()
  ValueError into NativePathLeaseError. The single upstream catcher
  produces 400 instead of 500 for malformed signed payloads.
- verify_native_path_lease now runs _validate_current_stat before
  _consume_nonce, so a transient stat error on the canonical path no
  longer permanently burns the nonce. Concurrent verifies still
  serialize through _consume_nonce, so single-use is preserved.
- Chained native model rollback now restores activeNativePathToken in
  the chat runtime store after a successful rollback loadModel. Without
  this, a second consecutive failed switch could not re-roll-back
  because the store token had been overwritten by the failed attempt.
- validate_model now applies the same not_supported_hints friendly
  rewrite to native model errors that load_model already does, so a
  native .gguf that fails validation with an upstream "is not supported"
  message gets the same actionable wording as the non-native branch.

* Studio: harden native path log redaction, status disclosure, and chip lifecycle

- structlog processor chain now runs format_exc_info before
  filter_sensitive_data so traceback strings are produced (and then
  redacted) rather than passed through as untouched (type, value, tb)
  tuples that the JSON or console renderer formats after the redaction
  filter has already finished.
- native_path_secret_removed_for_child_start clears _CACHED_LEASE_SECRET
  in addition to popping the env var, so a fork during the scrub window
  cannot inherit the cached bytes via the parent's heap. Parent verify
  calls during the window keep working through the existing scrub-aware
  fallback in _decode_secret.
- load_model's except ValueError handler now redacts native paths and
  uses the native model log label when native_grant_backed is true.
  Previously a ValueError raised after lease verification (e.g. from
  ModelConfig.from_identifier or downstream GGUF parsing) returned the
  raw exception string in the HTTP response body.
- llama_cpp_backend now records the native display label at GGUF load
  time, and /api/inference/status prefers it over the redaction store.
  After a Python backend restart the redaction store is empty; the
  attribute keeps the friendly label, and an absolute model_identifier
  with no other label source falls back to the basename so the canonical
  path no longer appears in active_model.
- reveal_path_token uses native "reveal and select" commands on macOS
  (open -R) and Windows (explorer /select,) so the file is highlighted
  in the file manager. Linux keeps the existing parent-directory open.
- Native model rollback that fails because the previous token cannot be
  consumed now throws a rollback-specific Error, and the outer empty
  catch was replaced with one that re-throws the rollback error. The
  rollback-specific message now reaches the user instead of being
  overwritten by the original load error message.
- NativeModelChip tracks the Rust token's expiresAtMs on a single
  setTimeout, disables the Load button at expiry, and relabels it
  "Select again" with an explanatory tooltip so users do not click into
  a guaranteed-failure path after the 15-minute TTL elapses.

* Studio: tighten native artifact policy, mmproj sibling check, and intake UX

- is_open_safe_artifact no longer grants Open for directories. Reveal
  already handles directory navigation, so the change closes the
  attack surface where a macOS .app artifact could be launched via
  open_path_token + open::that_detached.
- Display labels are sanitized in classify_existing_path. Control
  characters in filenames (newlines, tabs, NUL et al.) are replaced
  with spaces and the label is trimmed and capped, so a file named
  with embedded newlines cannot inject forged log lines or scramble
  the UI status panel.
- validate_entry_path skips the size_bytes/modified_ms equality check
  when the operation is Reveal or Open. Cloud-sync agents (Dropbox,
  iCloud Drive, OneDrive) routinely rewrite extended-attribute
  metadata which bumps mtime, and the user expects Reveal/Open to
  remain available for files in synced folders.
- llama_cpp_backend gains a _native_grant_backed flag at GGUF load
  success. /api/inference/status only applies the absolute-path
  basename fallback when that flag is true, so a non-native absolute
  local GGUF still reports its canonical model_identifier and unload
  by identifier keeps working.
- Native vision GGUFs now run through _validate_native_mmproj_companion
  before llama-server starts: the companion mmproj must be a regular
  file, not a symlink, and must live in the same resolved directory as
  the granted GGUF. This stops a hostile sibling or symlinked mmproj
  from being loaded under a single-file lease.
- Chained native rollback restructured: the rollback loadModel + state
  + refresh runs inside its own try/catch that swallows so the outer
  throw error surfaces the ORIGINAL load failure. The native-token
  consume-failure case still throws the rollback-specific message
  early, before the inner block runs, so its actionable guidance is
  preserved.
- Loading-model state and the duplicate-load guard in the chat runtime
  hook now compare both the model id and the native path token. Two
  drops or picks with the same basename in different folders no longer
  silently dedup; the second token is honored.
- chat-page loadNativeModelIntent awaits selectModel before clearing
  the pending intent. If selectModel returns early via dedup or
  throws, the chip and its token stay so the user can retry instead
  of losing the selection.
- NativeModelChip's Reveal button is disabled when the lease has
  expired (Rust would reject it anyway), and the Load button label
  reads "Expired" instead of "Select again" so the disabled element
  no longer promises an action it cannot perform.

* [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: Daniel Han <danielhanchen@gmail.com>
2026-05-04 11:46:18 +02:00
Roland Tannous
35ab5da93c
Default Studio host to 127.0.0.1 and prompt before auto-start (#5267)
Studio bound to 0.0.0.0 by default and the installer silently auto-started
a server at end of install, exposing it on the network without consent and
contradicting the privacy-first / local-only guarantee.

- studio/backend/run.py: run_server() and argparse --host default to 127.0.0.1
- unsloth_cli/commands/studio.py: studio_default() and run() --host default to 127.0.0.1
- install.sh: drop -H 0.0.0.0 from generated launcher template; replace silent
  auto-start with a [Y/n] prompt; add cloud/network note to manual hint
- install.ps1: drop -H 0.0.0.0 from PowerShell launcher template; replace
  silent auto-start with a Read-Host [Y/n] prompt; add cloud/network note
- studio/setup.sh: drop -H 0.0.0.0 from launch hint; add cloud/network note
- README.md: simplify launch examples to `unsloth studio -p 8888`; note
  -H 0.0.0.0 is available for cloud/LAN use

Tests:
- studio/backend/tests/test_host_defaults.py
- tests/studio/test_cli_studio_defaults.py
- tests/sh/test_install_host_defaults.sh
2026-05-04 13:03:16 +04:00
LFdev
e1b00854a5
Fix check for libcurl hearders in install.sh (#5251)
* Fix check for libcurl hearders in install.sh

Checking for `dpkg` has no relation to libcurl headers at all. If the package is installed, then an executable `curl-config` is available in Ubuntu/Debian as it can be seen here:

https://ubuntu.pkgs.org/26.04/ubuntu-main-amd64/libcurl4-openssl-dev_8.18.0-1ubuntu2_amd64.deb.html

This also fix the installation in ArchLinux, provided the needed packages are installed previously as shown in the error message when a package is missing.

* Fix check for libcurl hearders in studio/setup.sh

Use the same check as install.sh.

---------

Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-04 00:43:18 +04:00
Lee Jackson
00b607267a
Studio: Polish spacing and profile input radius (#5222)
* UI: polish studio spacing and profile input radius

* chore: use standard rounded radius for profile input

---------

Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-04 00:38:19 +04:00
Lee Jackson
2de17c0a96
Studio: Add checkpoint resume for stopped training runs (#5255)
* feat: add checkpoint resume for stopped training runs

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

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

* fix:add resume checkpoint helpers

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

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

* fix: use checkpoint parent as resume output dir

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

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

* fix: save optimizer and scheduler state on stop-and-save

Use Trainer._save_checkpoint instead of save_state so resume restores
optimizer momentum and LR-schedule position via the checkpoint-NNN/
subdir written by HF's official path.

* fix: clean up resume training history and startup progress

* fix: preserve resume output dirs

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

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

* fix: tighten resume run lookup

* fix: remove stale output-dir lookup

* fix: preserve startup download progress

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-04 00:34:46 +04:00
Lee Jackson
8cbd16786b
Studio: Enable deleting fine-tuned chat models (#5234)
* feat: enable deleting fine-tuned chat models

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

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

* fix: tighten fine-tuned model delete guards

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

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

* fix: harden fine-tuned model deletion edge cases

* fix: reject gguf export delete without variant; surface 503 on backend probe failure

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

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

* fix: narrow loaded-model delete guard by gguf_variant

* fix: clear inference state when cancelling model load

* fix: verify deletion completed and prune empty parent dirs

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
2026-05-04 00:28:58 +04:00
Lee Jackson
4d9a6ac63a
Studio: Chat thread autosave persistence (#5256)
* fix: chat thread autosave persistence

* fix: guard autosave deletion race

* fix: let run-start autosave persist chats

* fix: scope chat autosave to event thread

* fix: clean up tombstoned chat append rows
2026-05-03 22:25:23 +04:00
Roland Tannous
456a49a350
Add Qwen3.6 support (#5257)
* qwen3.6 unsloth studio support

* Add qwen3.6 causal-conv1d detection

* Update model_mappings.py

moved qwen3.6-27B to thinking train on completion template

* [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-05-02 23:30:57 +04:00
Lee Jackson
874e4605ef
Studio: Add dataset upload dropzone and update preserve think copy (#5253)
* fix: improve training dataset upload affordance

* chore: update preserve think

* fix: guard dataset dropzone drag target
2026-05-02 20:55:24 +04:00
DoubleMathew
7d227ed708
Fix/windowsprebuilt (#5241)
* update prebuilt logic

* Add test case

---------

Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-02 09:42:19 +04:00
Roland Tannous
5262d93b58
studio: add --local to setup.sh + overlay unsloth-zoo from git main (#5252)
* studio: add --local to setup.sh + overlay unsloth-zoo from git main

setup.sh now accepts --local, which exports STUDIO_LOCAL_INSTALL=1 and
STUDIO_LOCAL_REPO=$REPO_ROOT. install_python_stack.py overlays unsloth-zoo
from git main on top of the editable unsloth checkout in both local_repo
branches (no-torch and with-torch).

The Colab notebook now invokes ./studio/setup.sh --local so the cloned
repo is used in editable mode and unsloth-zoo tracks main, matching the
behavior of install.sh --local on a VM. install.sh --local is unchanged:
it still sets SKIP_STUDIO_BASE=1, which short-circuits the local_repo
branches in install_python_stack.py, so the overlay is not run twice.

* studio: make --local overlays visible + guard empty arg parsing

- setup.sh: gate the --local flag loop on $# > 0 (defensive against any
  shell that surfaces unset $@ under set -u) and emit a substep when local
  mode is detected so the user can confirm the flag was parsed.
- install_python_stack.py: emit explicit _step lines before each overlay
  pip_install in both local_repo branches so overlays appear in the static
  log instead of being overwritten by the in-place progress bar.
2026-05-02 08:51:56 +04:00
Lee Jackson
05f46686de
Studio: Fix chat template disappearing after browser refresh (#5209)
* fix: preserve chat template on refresh

* chore: simplify chat template status lookup
2026-05-01 08:19:09 -07:00
Roland Tannous
e4e89f41c1
install: overlay unsloth-zoo from git main on --local (#5242)
When --local is passed, also overlay unsloth-zoo from the upstream main
branch (--no-deps --reinstall-package) on top of the PyPI install. This
keeps the editable unsloth checkout paired with the latest unreleased
unsloth-zoo, mirroring the existing -e $_REPO_ROOT --no-deps overlay.

Applied to all four --local paths in install.sh (migrated, fresh no-torch,
fresh with-torch, auto-torch fallback) and the three corresponding paths
in install.ps1.
2026-05-01 11:15:22 +04:00
Datta Nimmaturi
329f99d3ad
MROPE for VLM GRPO (#5198)
* MROPE for VLM GRPO

* [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: pluesclues <136766175+pluesclues@users.noreply.github.com>
2026-05-01 10:00:21 +05:30
Wasim Yousef Said
265d16e742
Center Tauri windows and remove resize animation (#5235)
* Fix Tauri window centering and resize transition

* Guard Tauri window layout updates
2026-04-30 09:40:40 -07:00
Wasim Yousef Said
507417579f
Fix Studio desktop tray installer and titlebar and bux fixes (#5179)
* fix(tauri): dedupe tray and brand nsis installer

* feat(tauri): add linux windows custom titlebar

* Fix desktop auth gate after backend startup

* Fix desktop installer assets and setup script skew

* Scope setup failure exit to Tauri installer

* fix desktop updater production channel

* fix desktop auth runtime installer regressions

* fix desktop dev cors retry

* fix tauri process generation race

* feat desktop diagnostics support report

* fix tauri apt update best effort

* Fix Windows desktop NSIS installer upgrades

* Start managed backend after desktop install

* Improve NSIS installer branding resolution

* Fix assistant-ui internal import

* Fix desktop release workflow

* Keep desktop auth retry on cached backend

---------

Co-authored-by: wasimysaid <wasimysaid@users.noreply.github.com>
2026-04-30 08:40:39 -07:00
Anish Umale
11c04ed632
Fix local model scanner to handle ollama cloud models (#5220)
* fix _scan_ollama_dir to handle ollama cloud models correctly

* [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: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-04-30 15:13:20 +01:00
Lee Jackson
4ab5378d28
Studio: Pin assistant-ui core for fresh installs (#5229)
* fix(studio): pin assistant-ui core for fresh installs

* fix(studio): use assistant-ui internal export
2026-04-30 13:50:23 +02:00
Datta Nimmaturi
4f9c8321a2
Fix DPO trainer multi process hang (#5199)
* Fix DPO trainer multi process hang

* Fix datacollator error

* further dpo vision changes

* cleanup

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

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

* Harden DPO vision row processing and source rewrites

- dpo_trainer_vision_signature_columns: also match TRL 0.22.x layout
  (image_sizes followed by ref_chosen_logps), so vision keys are not
  stripped via remove_unused_columns on the originally-affected version.
- dpo_trainer_concatenated_inputs: fall back to inserting after the
  image_sizes block when no token_type_ids anchor follows it.
- Apply the same vision model_kwargs forwarding rewrite to
  _compute_loss_liger via dpo_trainer_compute_loss_liger so the Liger DPO
  path does not drop pixel_position_ids/image_position_ids/
  mm_token_type_ids when args.use_liger_loss is true.
- dpo_trainer_vision_process_row:
  - guard chosen/rejected EOS append with tokenizer.eos_token_id is not None
  - use features.get("images") and features.get("prompt") to match the
    existing get on line 164 and avoid KeyError on rows without those keys
  - drop the torch.is_tensor gate so list-form pixel_position_ids/
    image_position_ids returned without return_tensors are still aliased
  - skip the loop entry for image_position_ids when it was already
    promoted to pixel_position_ids, so the output dict no longer carries
    both keys with identical data
- dpo_trainer_data_collator_vision_keys: switch from pad_sequence to
  trl.trainer.utils.pad with padding_side='left' (matches the DPO
  collator's prompt left-pad) and padding_value=-1 for *_position_ids
  keys (sentinel for padded patches), 0 otherwise. Skip the key when not
  every example carries it. Falls back to pad_sequence if trl.pad is
  unavailable or the tensor rank is too high.
- dpo_trainer_prepare_dataset: keep TRL's writer_batch_size=10 when
  popping num_proc; removing it defaults to 1000 and reintroduces the
  vision OOM risk that writer_batch_size=10 was set to avoid.

* DPO vision row: keep upstream-facing keys and fix patch padding

- dpo_trainer_vision_process_row: no longer aliases image_position_ids
  to pixel_position_ids. Each upstream-emitted vision key is forwarded
  under its own name. Gemma4 ForConditionalGeneration.forward accepts
  image_position_ids directly and renames it to pixel_position_ids only
  at the vision-tower call site, so aliasing in the row helper hid the
  kwarg the model actually consumes.
- dpo_trainer_vision_process_row: extract pixel_values via "in"
  membership instead of unconditional indexing. With the missing-images
  path returning [] to the processor, modern processors no longer emit
  a pixel_values key, and the previous indexing raised KeyError.
- dpo_trainer_data_collator_vision_keys: pick padding_side per key
  family. *_position_ids tensors are patch-aligned to pixel_values
  (TRL's DataCollatorForPreference right-pads pixel_values), so pad
  them right with the -1 sentinel; mm_token_type_ids is token-aligned
  to prompt_input_ids (left-padded by TRL), so pad it left with 0.

* DPO vision: handle multi-image prompts and arbitrary-rank collator pad

- dpo_trainer_vision_process_row: when a prompt is missing vision
  placeholders, insert one placeholder per missing image instead of
  always inserting a single token. Multi-image rows now satisfy the
  processor's token-vs-image count check rather than under-inserting
  and tripping the placeholder/feature mismatch.
- dpo_trainer_data_collator_vision_keys: drop the dim()<=2 gate around
  trl.trainer.utils.pad. trl.pad handles arbitrary rank correctly,
  while the previous fallback to torch.nn.utils.rnn.pad_sequence
  raised RuntimeError on rank-3 patch-position tensors with mismatched
  non-leading dimensions. The pad_sequence path remains as a degraded
  fallback only when trl.pad is unavailable or raises.

* DPO vision row: support scalar images and align prompt-aligned aux ids

- dpo_trainer_vision_process_row: type-aware normalization of the
  features['images'] column instead of a truthiness/len check that
  raised on single image objects (PIL.Image has no __len__) and on
  numpy ndarrays (truthiness ambiguous). Lists/tuples count as their
  length, scalar image objects count as one, None counts as zero, and
  the original value is forwarded to the processor.
- dpo_trainer_vision_process_row: when max_prompt_length truncates
  prompt_input_ids, also slice token_type_ids and mm_token_type_ids
  by the same [-max_prompt_length:] suffix. Those keys are 1:1 token
  aligned to prompt_input_ids (Gemma 4 vision attention keys off
  mm_token_type_ids per modular_gemma4.py), so leaving them at the
  original length silently misaligned the multimodal mask.

* DPO vision row: stop synthesizing vision-token placeholders

Pass features['prompt'] and features['images'] straight to the
processor without inserting any extra placeholder tokens. The previous
helper used processing_class.image_token, which is the right prompt
placeholder for Gemma 4 but the wrong one for Gemma 3 (whose prompt
placeholder is boi_token while image_token is the inner expansion
target). Synthesizing that token also broke multi-image rows: text
ended up with N placeholders while the row helper only forwarded the
first image's pixel_values via the standard [0] indexing that mirrors
upstream TRL process_row, so token vs image-feature counts diverged.
Removing the synthesis matches stock TRL behavior; users provide the
correct placeholders for their processor in the prompt.

* Add tests for DPO vision row processor passthrough

* [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: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-04-29 04:15:34 -07:00
Lee Jackson
146295eeca
Studio: Fix clipped model selector text descenders (#5210)
* fix: clipped model selector text descenders

* Studio: Fix image-only chat requests failing validation (#5212)

* fix: allow image-only chat messages

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

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

* test: deduplicate empty content validation coverage

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* Fix descender clipping in sidebar user account section

Replace `leading-none` with `leading-tight` on the parent div wrapping
`displayTitle` and the "Studio" label inside `SidebarMenuButton`. The
child spans use `truncate` (overflow: hidden), so `line-height: 1`
clipped descenders (g, p, q, y, j) on user names. Same root cause and
fix as the model selector trigger.

* Add tests for studio text descender clipping

* [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: Daniel Han <danielhanchen@gmail.com>
2026-04-29 02:51:25 -07:00
Datta Nimmaturi
c4597298be
Patch checkpoint reload init functions to strip unsupported args (#5167)
* Patch checkpoint reload init functions to strip unsupported args

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

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

* Try adding attrs back if possible

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

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

* import_fixes: harden peft weight converter compatibility shim

Three small fixes to patch_peft_weight_converter_compatibility:

- Restore peft_config=None default and @functools.wraps on the
  build_peft_weight_mapping wrapper so the upstream coordinated
  signature (weight_conversions, adapter_name, peft_config=None) is
  preserved. Without the default, callers using the documented
  two-argument form raise TypeError after import unsloth.
- Serialize the temporary class-init patch/restore behind a
  threading.RLock. The previous unsynchronized window let two
  concurrent build_peft_weight_mapping calls (e.g. dynamic LoRA
  serving) re-expose the original distributed_operation TypeError
  when one thread restored a class while another was still inside
  original_build.
- Hand _patch_weight_converter_ctors a caller-owned accumulator
  list and append in place. If signature inspection ever raises
  mid-loop, the finally block now sees the partial list and
  restores already-patched classes instead of leaving them with
  the compat init permanently installed.

* Add tests for peft weight converter compatibility shim

* [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: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-04-29 02:50:49 -07:00
Daniel Han
a5615426a5
Studio: fix 7 failing studio_unit_tests on main (#5216)
* Studio: fix 4 failing studio_unit_tests on main

Three of the failing tests had drifted from production:

1. test_health_response_reports_desktop_capability_fields stubbed
   `routes` with a SimpleNamespace that omitted `inference_studio_router`,
   so importing studio.backend.main raised ImportError. Add the missing
   router stub.

2. test_local_recipe_token_preserves_desktop_marker and
   test_local_recipe_token_keeps_web_marker_absent decoded the local
   provider's api_key as a JWT, but _inject_local_providers now mints
   a unified sk-unsloth-* internal API key (not a forwarded JWT), so
   jwt.decode raised "Not enough segments". Renamed and rewrote both
   tests to validate the API-key contract: starts with
   storage.API_KEY_PREFIX and authenticates via get_current_subject as
   the real admin user. The web vs desktop distinction is irrelevant
   at this layer because the unified API-key path does not carry
   session flags.

The fourth failure was a real production bug:

3. test_github_validate_skips_live_access_with_honest_note expected
   github-seed validation to return valid=True per
   _GITHUB_VALIDATE_NOTE ("GitHub access and rate limits are checked
   when the run starts"). The validate route called
   build_config_builder which lazy-imports the optional data_designer
   module; when it is missing, the bare except blocked the recipe.
   Catch ImportError specifically and treat it as a deferred check,
   matching the documented intent.

Verified all 4 tests pass and the rest of studio/backend/tests still
pass (608 total, with the only remaining failures being environment
specific: 4 GPU-aware tests on a no-GPU host and 1 Anthropic-API
smoke test, both unrelated).

* Studio: fix 3 test_gpu_selection route tests after load_model signature change

`routes/inference.load_model` gained a `fastapi_request: Request`
positional argument (used to read `app.state.llama_parallel_slots`
inside the GGUF path), but the three TestRouteErrors cases that
exercise the early validation path were not updated and failed with
`TypeError: load_model() missing 1 required positional argument:
'fastapi_request'`.

Pass a SimpleNamespace mock that satisfies the attribute path the
production code reads. The validation under test fires before the
mock is consumed, but supplying the realistic shape protects against
regressions if the validation order changes.

Affected tests:
- test_inference_route_rejects_gpu_ids_for_gguf
- test_inference_route_returns_400_for_invalid_gpu_ids
- test_inference_route_returns_400_for_uuid_parent_visibility_gpu_ids

* Studio: address review feedback on validate.py ImportError handling

Two reviewers flagged the ImportError bypass added in b0d33cf:

- chatgpt-codex-connector[bot]: catching bare ImportError marks recipes
  as valid even when build_config_builder fails for unrelated import
  problems (broken internal imports, missing transitive deps after a
  version bump), hiding real regressions until run start.
- gemini-code-assist[bot]: silent pass discourages troubleshooting;
  the deferred-validation case should be logged at debug level.

Tighten the bypass to ModuleNotFoundError where the missing module name
starts with "data_designer". Other ImportErrors propagate to the outer
handler and surface as validation failures, restoring the visibility
the reviewers asked for. Add a debug-level log entry that names the
missing module so operators can trace why validation deferred.
2026-04-28 22:43:44 -07:00
Lee Jackson
ff759ba7e4
Studio: Fix image-only chat requests failing validation (#5212)
* fix: allow image-only chat messages

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

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

* test: deduplicate empty content validation coverage

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-04-28 14:49:13 -07:00
Lee Jackson
975a5c354f
Studio: Refine chat preset and group built-in presets (#5159)
* UX: Refine chat preset and group built-in presets

* fix: reuse built-in preset names and unify GGUF state reads

* fix: built-in chat preset save and refresh behavior

* Add chat preset invariant tests

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

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

* fix: decouple chat presets from model-specific settings

Limit chat preset compare/apply/save behavior to temperature, topP, topK, minP, repetitionPenalty, presencePenalty, maxTokens, and systemPrompt.

Preserve legacy stored preset data on load for backwards compatibility, but stop treating model-specific settings such as checkpoint, trustRemoteCode, and maxSeqLength as part of preset identity.

Also align legacy prompt migration dedupe with the new preset semantics and add invariant coverage for preset-owned config comparisons.

* fix: detect built-in preset edits from param changes

* fix: correct built-in preset dirty state and speculative select values

* fix: preserve default preset sync and keep qwen think pristine

---------

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-04-28 02:40:15 -07:00
Lee Jackson
2469ac885b
UX: single chat header error placement and selector alignment (#5173)
* UX: single chat header error placement and selector alignment

* fix: centre model dropdown chevron

* fix: revert view mode single

* fix: allow model selector label to truncate in narrow headers
2026-04-28 02:39:59 -07:00
Lee Jackson
230d58872d
Studio: Preserve transparency in uploaded profile avatars (#5200)
* fix: preserve transparency in uploaded profile avatars

* fix: guard unsupported canvas mime fallback
2026-04-28 02:39:48 -07:00
Etherll
daf0889804
Fix Windows install when paths contain spaces or Python 3.14 is on PATH (#5201)
* fix(studio): use py.exe to detect supported Python on Windows

  Description:
  The previous detection looked at `python --version` on PATH and
  hard-failed if the resolved Python wasn't 3.11-3.13. On systems
  where Python 3.14 sits ahead of 3.13 in PATH order, this aborted
  the installer even though a supported interpreter was installed.

  Prefer the py.exe launcher and probe `py -3.13`, `py -3.12`,
  `py -3.11` in turn. Fall back to `python --version` only when py.exe
  is absent, and surface a clearer error when no supported version
  can be found via either path.

* Studio: consolidate Windows studio overlay into single Tauri-gated block

  Replace the in-file sentinel hotfix and the unconditional file-copy
  overlay with a single block gated on $TauriMode. Hash-compare makes
  re-runs no-ops, removing the sentinel-clobbering bug that occurred
  when the second copy path overwrote the marker without re-adding it.

  Non-Tauri --local installs no longer need a copy overlay: the
  editable install above (uv pip install -e $RepoRoot --no-deps) makes
  _PACKAGE_ROOT in unsloth_cli/commands/studio.py resolve to the repo
  source tree via PEP 660 __file__-relative resolution, so
  `unsloth studio setup` finds the local setup.ps1 and
  install_python_stack.py without any file copying.

  Plain PyPI installs invoked from a checked-out repo directory are
  also no longer silently overlaid from cwd.

* fix(studio): work around uv space-in-path truncation on Windows

  uv 0.11.x truncates `-c <path>` and `-r <path>` arguments at the
  first space, breaking installs on Windows when the venv or repo
  sits under a path containing spaces (e.g. C:\Users\First Last\...).

  Pass paths through GetShortPathNameW to convert to 8.3 short form
  before handing them to uv. Plain pip is unaffected and keeps the
  original long path. No-op on Linux/Mac (gated on IS_WINDOWS and
  on the path actually containing a space).

* Refactor Python stack overlay logic in install.ps1

Refactor overlay logic for Python stack installation and improve handling of missing target directories.

* Update Python installation logic in setup.ps1
2026-04-28 01:10:47 -07:00
pre-commit-ci[bot]
df3a205726
[pre-commit.ci] pre-commit autoupdate (#5204)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.15.11 → v0.15.12](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.11...v0.15.12)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-04-27 14:17:03 -07:00
Leo Borcherding
efed5c3739
fix(studio): use endswith for mmproj F16 variant selection (#5184)
"f16" in filename matched BF16 files because "bf16" contains "f16"
as a substring. Switch to endswith("-f16.gguf") for an exact match.
2026-04-25 16:49:05 -07:00
Daniel Han
b09aa82a3a
Studio: add github_repo seed reader and GitHub Support Bot recipe (#5169)
* Studio: add github_repo seed reader and GitHub Support Bot recipe

Adds a first-party Data Designer seed reader that scrapes GitHub issues,
pull requests, and commits from one or more repositories via the GraphQL
API, and a learning recipe (GitHub Support Bot) that turns those rows into
synthetic support Q&A pairs for fine-tuning.

Backend (new plugin studio/backend/plugins/data-designer-github-repo-seed):
* GitHubRepoSeedSource config: repos, token (falls back to GH_TOKEN /
  GITHUB_TOKEN env var), item_types (issues / pulls / commits),
  per-resource limit (0 means all), max_comments_per_item.
* Rate-limit-aware GraphQL client (GitHubClient + RepoScraper) shared
  across repos; flattens each item into a uniform row with columns
  item_type, repo, number, title, body, state, author, created_at,
  closed_at, url, labels, comments.
* Registered via the data_designer.plugins entry point.

Frontend:
* New seed_github block variant so the seed node card shows
  "GitHub repositories" instead of the generic "Document file"
  placeholder, with its own icon and inline summary (repo count +
  item-type list).
* Rewritten seed dialog github_repo form: repos textarea pre-filled with
  unslothai/unsloth + unslothai/unsloth-zoo, password input for the GH
  token, items-per-repo number with an "All" toggle, and the noisier
  options (item types, max comments, include comments) tucked under an
  Advanced collapsible.
* Local model auto-load on Run: if a recipe uses an is_local provider
  and the inference server is not already serving that model, the
  executions hook calls /api/inference/load first. Removes the "open
  /chat to load a model" prerequisite that users kept tripping on.
* Honor the recipe's run.rows value in the Run dialog (previously the
  store reset to 5 regardless of what the template shipped).

Recipe (studio/frontend/src/features/data-recipes/learning-recipes/
github-support-bot.json):
* Defaults to the Local Model provider + unsloth/gemma-4-E2B-it-GGUF.
* Scrapes unslothai/unsloth and unslothai/unsloth-zoo, issues and pulls,
  up to 100 items per resource.
* Two LLM blocks: normalized_question (llm-text) rewrites each thread
  into a clean support question, support_answer (llm-structured)
  produces JSON with answer / diagnosis_questions / cites / confidence.
* Run defaults to 10 rows for a quick smoke test.

Verified end-to-end on a running Studio: card renders, source-data
dialog is pre-populated, All toggle disables the limit input, the
recipe executes and produces rows against a loaded local GGUF.

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

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

* fix: improve GitHub recipe support

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

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

* Studio: speed up GitHub scraper and harden the support-bot recipe

Addresses a perf issue found while demoing the github_repo seed reader:

Scraper is too slow at scale. The PRs GraphQL query pulls deeply nested
fields (reviewThreads, reviews, commits, timelineItems, etc.) so the
page size was pinned at 3 to stay under GitHub's node-count ceiling. 100
PRs meant 34 serial round trips. Added lighter query variants
(PRS_PAGE_QUERY_LIGHT, ISSUES_PAGE_QUERY_LIGHT) that drop the fields the
Studio flatten layer does not use (it only reads title, body, state,
author, labels, comments). With the light query PR pages can safely go
to 25 per page and issues to 50. The plugin scraper now passes
light=True to RepoScraper so Studio always uses the fast path; the heavy
query remains available for other callers.

Recipe defaults are now demo-ready with production knobs called out:
- max_parallel_requests: 1 and max_tokens: 800 so small local models
  stay stable when running the support_answer structured column.
- support_answer prompt trimmed to 80-200 words so gemma-4-E2B GGUF can
  actually comply with the schema. The canonical 150-300 word codex
  prompt is still documented in the node3 markdown note for
  production upgrades.

* Studio: rename GitHub recipe to 'GitHub Scraper' and add Easy mode

Changes the recipe framing from a single-purpose 'Support Bot' pipeline
to a general-purpose scraper that produces {user_request,
grounded_response} training pairs. Aligns with the canonical
github_data_gatherer dataset (11 enrichment tasks mirrored in pr_requests_20
/ issue_requests_20 on the input side and explain_pr / issue_fix_plan /
issue_solution on the output side).

Recipe JSON changes:
- columns[0] renamed normalized_question -> user_request, prompt now
  inverts a GitHub thread into a realistic user ask instead of
  normalising it.
- columns[1] renamed support_answer -> coauthor_response, emits
  {response, followups, cites, task, confidence} and branches on
  issue vs PR thread type.
- Notes rewritten to document the 11-task catalog and the canonical
  production prompt to paste in for a full dataset backfill.

Frontend: Easy mode for github_repo recipes. The drag-and-drop canvas is
hidden behind an 'Advanced' tab; Easy mode is the default for any recipe
whose seed_source_type is github_repo. The Easy form reuses the existing
GithubRepoSeedForm (promoted to exported), adds a rows input bound to
previewRows, a model field bound to the model_config, and a single Run
button that calls runPreview() directly (no modal). Non-github recipes
see the same Editor / Runs tabs as before.

View mode persists per-recipe-id in localStorage under
recipe-studio:view-mode:<recipeId>.

* Studio: auto-detect server GH_TOKEN and widen Easy-mode detection

The GitHub seed form now fetches /api/data-recipe/seed/github/env-token
on mount and, when the server exposes a GH_TOKEN / GITHUB_TOKEN env var
and the token field is blank, shows a small 'Using server env var' badge
and swaps the placeholder text. The token value itself is never returned
to the UI.

Widens Easy-mode detection in recipe-studio-page.tsx so that recipes
saved before ui.seed_source_type was persisted also get the Easy tab:
falls back to recipe.seed_config.source.seed_type, which is always
present for github_repo seeds.

* fix: polish GitHub recipe UI

* Studio: default llama-server --threads to -1 (auto)

Previously we passed --threads only when the caller set an explicit
value, which meant llama-server fell back to its internal default.
That default has varied across llama.cpp builds (some versions use
hardware concurrency including hyperthreads, which hurts throughput on
CPU-heavy inference). Always passing --threads -1 pins the behaviour
to llama.cpp's auto-detect (physical cores).

Caller-supplied n_threads still wins when non-None.

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

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

* Studio: auto-switch Easy mode to Runs pane on run start

Easy mode had no progress island or canvas overlay, so after clicking Run
the only visible state was the button label flipping to "Running..." while
the screen otherwise stayed identical. This reads as stuck even though the
job is progressing.

Wire an onExecutionStart callback from recipe-studio-page.tsx through to
useRecipeExecutions so that when a run is kicked off from easy mode, the
page flips to the executions view where the Runs sidebar, progress bar,
rate/ETA panel, and live log are rendered. Advanced/editor mode keeps its
existing behavior and stays on the canvas (it already has the floating
ExecutionProgressIsland).

* fix: clean up GitHub scraper layout

* Studio: forward llm-structured output_format as llama-server response_format

Local GGUF runs of llm-structured columns used to generate the full
max_tokens budget before the prompt-level "return JSON in a ```json
fence" instruction got parsed. Small models (e.g. gemma-4-E2B-it)
routinely broke format, so each row took ~65s and frequently failed
with "No parsable JSON structure within ```json markdown fence".

For any local-provider model_config referenced by an llm-structured
column, clone the model_config and inject response_format into the
clone's inference_parameters. Uses llama.cpp server's flat shape
(tools/server/README.md):

    {"type": "json_schema", "schema": <output_format>}

Not the OpenAI-nested form; data_designer's OpenAI adapter forwards
response_format verbatim via facade._COMPLETION_REQUEST_FIELDS, and
llama-server's documented schema path expects the flat variant.

The clone is per (model_alias, column) so:
- llm-text / llm-judge columns that share the same alias keep
  free-form sampling.
- Each structured column gets its own schema, so columns with
  different output_formats don't collide.

Effect on gemma-4-E2B-it demos: every row parses cleanly, and the
model terminates immediately after the closing brace instead of
running to max_tokens. Net wall-clock is usually faster even though
grammar-constrained sampling is slightly slower per token.

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

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

* Studio: flip Easy to Runs pane before validation scrape, not after

Previously onExecutionStart fired inside runExecution, which runs AFTER
validateRecipe() -- and validation re-invokes the seed reader. For the
github_repo reader that is a full GraphQL scrape, so the user sat on a
"Running..." button with an otherwise unchanged Easy form for 10-15s
before anything moved.

Call onExecutionStart at the top of runWithValidation, right after we
have a payload to send. The view flips immediately; ensureLocalModelLoaded
+ validateRecipe now run against the Runs pane instead of a frozen Easy
form. runExecution still calls onExecutionStart downstream, but the
callback is idempotent (the page's easy -> executions guard skips the
second call), so no behaviour change for runs that pass validation.

If validation fails the toast + runErrors path still fires; the Easy
form's error banner still reads runErrors when the user switches back.

* Studio: unify data-recipe workflow auth on sk-unsloth-* keys

The previous commit (a61b4cc9) assumed storage.create_api_key(..., internal=True)
and storage.revoke_internal_api_key(key_id) existed, but those helpers were
only in the working tree, never committed. Recipe runs in local-model mode
were therefore crashing with 500 when _inject_local_providers tried to mint
a workflow key. This commit ships the missing pieces.

auth/storage.py:
- api_keys schema gains is_internal INTEGER DEFAULT 0 (with a guarded
  ALTER TABLE migration so existing auth.db files upgrade in place).
- create_api_key takes an internal=False kwarg; internal keys are flagged
  so they can be hidden from user-facing listings.
- list_api_keys takes include_internal=False so UIs never see workflow keys.
- New revoke_internal_api_key(key_id): id-only revoke for keys minted by
  non-user subjects (the JobManager does not know a username).

core/data_recipe/jobs/manager.py:
- JobManager.start accepts internal_api_key_id and stores it on Job so
  lifecycle handlers can revoke eagerly.
- _handle_event revokes on EVENT_JOB_COMPLETED / _ERROR / _CANCELLED.
- _pump_loop subprocess-died fallback also retires the key so a crashed
  worker cannot leak a live sk-unsloth-* beyond its TTL.
- Revocation is best-effort (swallow exceptions) -- the 24h TTL is the
  safety net if storage hiccups.

core/data_recipe/jobs/types.py:
- Job dataclass gains internal_api_key_id: int | None = None.

Replaces the bespoke 24h JWT path that jobs.py used to mint for local
providers. One mint/revoke/verify surface for every API key the server
issues, and revocation is now eager (seconds, not 24h) instead of TTL-only.

* Studio: plug workflow-key leak on unexpected create_job errors

Review follow-up on the sk-unsloth-* workflow-key lifecycle in
create_job. Previously the revoke handlers wrapped mgr.start(...) but
only caught RuntimeError and ValueError, and get_job_manager() sat
outside the try block entirely. Any other exception type (TypeError
from a mismatched kwarg, OSError from the queue write, etc.) would
bubble up to FastAPI and leave the minted key live until its 24h TTL.

Fix: one try block covers both get_job_manager() and mgr.start(), with
a trailing except Exception that revokes and re-raises. The
RuntimeError -> 409 and ValueError -> 400 paths are unchanged so
specific client-facing status codes still surface. Revocation is still
best-effort (_revoke_internal_api_key_safe swallows errors) because we
never want revoke failures to mask the original crash.

Severity is low -- the key can't bootstrap longer access and the 24h
TTL bounds the window -- but the reviewer's point stands: eager revoke
on every failure path is the right invariant.

* Studio: nest response_format under extra_body so pydantic accepts it

The previous commit dropped response_format at the top level of a cloned
model_config's inference_parameters, which BuilderConfig rejected with:

  ValidationError: Extra inputs are not permitted [type=extra_forbidden]
  data_designer.model_configs.1.inference_parameters.response_format

data_designer's BaseInferenceParams is a pydantic model with extra=forbid
and only a fixed set of fields (temperature, top_p, max_tokens,
max_parallel_requests, timeout, extra_body). The pass-through path for
anything the schema doesn't know about is `extra_body`, which the
OpenAI SDK spreads into the chat-completions request body at the top
level -- which is exactly where llama-server reads response_format from.

Inject under extra_body (merging with any existing extra_body contents)
so the clone validates. llama-server still receives
{"type": "json_schema", "schema": <output_format>} at the top level of
the request body, which is the flat shape llama.cpp's server expects.

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

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

* Studio: forward response_format to llama-server and fence-wrap the reply

Two-part fix for the llm-structured data-recipe path:

(1) The /v1/chat/completions proxy was dropping response_format. The
route's passthrough branch only triggered on tools / tool messages, so
requests carrying a JSON schema fell into the non-passthrough GGUF path
which calls generate_chat_completion (no response_format kwarg). The
schema never reached llama-server, so guided decoding was a no-op and
the model emitted free-form text that happened to parse a fraction of
the time. Widen the passthrough trigger and teach _build_passthrough_payload
to forward response_format so llama-server's GBNF grammar actually runs.

Guided decoding does not require supports_tools, so split the condition:
a request is now passthrough-routed if it carries tools/tool messages
(existing behavior) OR carries response_format (new). The vision guard,
streaming fork, and tools-choice defaulting are unchanged.

(2) data_designer's llm-structured parser looks for a ```json ... ```
markdown fence and discards anything else. Guided decoding emits only
the JSON object (the GBNF grammar has no fence tokens), so a
100%-valid schema-constrained run still ended up 0 ok / N failed with
"No parsable JSON structure within ```json markdown fence". In
_openai_passthrough_non_streaming, wrap each choice's content in the
expected fence when the caller asked for guided decoding. Already-fenced
content is left alone so other clients that prefer raw JSON are not
affected; the wrap is scoped to requests that carried response_format.

Net effect on the GitHub Support Bot recipe on a local GGUF: schema
actually binds during sampling, content arrives wrapped in the fence
data_designer expects, and generation terminates immediately after the
closing brace instead of running out to max_tokens.

* Studio: Easy mode runs a full run, capped at the user's row count

Easy mode used to call runPreview, which produces a test run: no
artifact persisted, reduced progress tracking, and framed in the Runs
pane as "Test run". The whole point of the form is to let a user kick
off a real dataset build with one click, so wire it to runFull instead
and bind the Rows input to fullRows (not previewRows).

runFull requires a non-empty fullRunName. The Easy form has no run-name
input, so seed a default on mount whenever Easy is active and
fullRunName is still empty. Uses `<recipe name> <iso-timestamp>` so
each Easy run gets a stable-ish default that still sorts chronologically
in the Runs pane. User can override it from the Advanced run dialog
before clicking Run.

Rename GithubScraperEasyView's rows props from previewRows/setPreviewRows
to rows/setRows so the view stays agnostic to which hook state the page
chooses to bind. Loading indicator now follows fullLoading.

* Studio: clamp GitHub scrape page size and memoize the materialization

Two wins for the "before Generating fires" gap on small previews:

(1) scrape_{issues,prs,commits} hardcoded per_page (50 / 25 / 100) and
only checked the trial limit AFTER the page was written, so a 1-row
Easy run still asked GitHub for a full 50-issue + 25-PR page, wrote
them all to JSONL, and then stopped because total_new already exceeded
the trial cap. Cap per_page at min(page_cap, trial_limit) so
github_limit=1 actually asks for first:1.

(2) GitHubRepoSeedReader.get_dataset_uri used to scrape fresh on every
invocation. data_designer calls the seed reader multiple times per
recipe job (validation, preview, per-column sampling), so a 2-repo
Easy preview ran the full GraphQL scrape three times back-to-back,
burning ~15s of dead air before any LLM generation began.

Added a module-level in-process cache keyed on
(repos, item_types, limit, include_comments, max_comments_per_item,
sha256(token)[:16]) that stores the JSONL path of the first
materialization. Subsequent calls with the same signature return the
cached path, guarded by a staleness check that drops the entry if the
file was tmp-cleaned. Raw token values never land in the key.

Net effect on a 1-row Easy run, 2 repos, limit=1: 2 GraphQL round
trips instead of ~12, and the first-to-Generating gap collapses from
~15s to roughly 2-3s.

* Studio: make Easy mode Rows input editable instead of snapping to 1

The Rows to generate input used type="number" with value bound directly
to the rows state and an onChange that coerced any non-positive parse
result back to 1. The moment the user pressed backspace to clear the
field, the parent re-rendered with value=1 and the caret jumped, making
it impossible to change the value without arrowing the browser's +/-
spinner.

Switch to a text input with inputMode="numeric" and pattern="[0-9]*"
(so mobile still shows a numeric keyboard, and the browser drops the
spinner buttons the user did not want). Add a local rowsText buffer so
the field can hold transient empty / partial digit strings while
editing without fighting the parent state; the canonical rows value
only advances when the buffer parses to a valid integer in [1, 10000],
and onBlur clamps back to 1 or 10000 if the user left it out of range.

No behavior change for valid numeric edits - the downstream runFull()
still sees a clean positive integer.

* Studio: expand dataset cells horizontally by column on click

Click a long cell to expand that whole column. Click again to collapse.
Replaces the prior row-level vertical expansion which made it hard to
compare cells across columns. State is scoped per execution and per
column; the row itself is no longer a click target.

* Studio: force expanded dataset column to grow wide enough to read

* Studio: disable thinking for local recipe inference and plumb the kwarg

Reasoning-capable models (gemma-3n, qwen3.5, etc.) emit a
<think>...</think> preamble ahead of the answer by default, which
roughly doubles the generated token count per row on a local GGUF
and pushes the actual answer past data_designer's json-fence regex
on llm-structured columns. Recipes want the terse answer, not the
scratchpad.

Two halves of the fix:

(1) routes/data_recipe/jobs.py: when _inject_local_providers walks
the recipe's model_configs to point them at the local endpoint, also
stash chat_template_kwargs={"enable_thinking": false} under each
config's inference_parameters.extra_body. OpenAI SDK spreads
extra_body into the top-level request body, so llama-server and the
Studio /v1/chat/completions route both see it.

(2) routes/inference.py: the chat-completions route previously
dropped chat_template_kwargs on the floor because the whitelist
body builder only forwarded known fields.

    - At the top of openai_chat_completions, lift
      chat_template_kwargs.enable_thinking from payload.model_extra
      onto the typed payload.enable_thinking field when the caller
      did not set the latter, so the non-passthrough GGUF path's
      generate_chat_completion(...) call honors the override.
    - Teach _build_passthrough_payload to forward a
      chat_template_kwargs dict, and have _build_openai_passthrough_body
      derive that dict from payload.enable_thinking so
      response_format requests (structured columns) also land at
      llama-server with the reasoning preamble suppressed.

Net effect on a 10-row support-bot run with gemma-4-E2B-it-GGUF:
responses arrive without <think> tags, wall-clock per call drops
roughly in half, and structured columns stop leaking reasoning
tokens through the GBNF-constrained output.

* Studio: update GitHub Support Bot learning recipe with maintainer layout

Replace the template with the hand-laid-out export from the maintainer
so note nodes ship with real x/y positions (scattered around the
graph instead of all stacked at x=480) and the edges / canvas pan look
correct on first load. Also picks up the maintainer's prompt tweaks and
output schema names (coauthor_response / user_request / followups / task /
cites / confidence).

Diff is mostly ui.nodes positions and prompt bodies; runtime shape is
unchanged (seed_config / columns still target model_1 against the Local
Model provider).

* Studio: auto-size dataset sample columns; wide text gets a wide column

Drop the per-column click-to-expand toggle and the 180-char truncation.
Every column now renders its full value. Columns with long text get a
min-w of 48rem so the text is readable without wrapping into a tall
block; narrow-content columns get a 12rem min-w. The table wrapper
already has overflow-x-auto, so wide-column totals cause a horizontal
scrollbar instead of cramming everything into the viewport.

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

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

* fix GitHub scrape progress

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

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

* add resetApiBase export for test setup

* Studio: rename github-support-bot output columns to User / Assistant

Previously emitted user_request and coauthor_response, which did not
match the canonical User / Assistant chat-pair shape that downstream
SFT consumers expect. Renamed the columns in the recipe JSON (columns,
UI node ids, edges, notes, prompt Jinja refs) and the matching copy in
the learning-recipes index, data-recipes-page, and easy view.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-04-24 12:02:03 -07:00
Daniel Han
eb8b0dee2e
Studio: make stop button actually stop generation (#5069)
* Studio: make stop button actually stop generation

The UI stop button routes through assistant-ui's cancelRun, which aborts
the frontend fetch. Four issues combined to let llama-server keep decoding
long after the user clicked stop:

1. request.is_disconnected() does not fire reliably behind proxies
   (e.g. Colab) that don't propagate fetch aborts.
2. llama-server defaults n_predict to n_ctx when max_tokens is not sent,
   so a cancelled request keeps producing tokens up to 262144.
3. The httpx.Client pool keeps TCP keep-alive, so even a cleanly closed
   stream reuses the same connection and llama-server's liveness poll
   never sees a disconnect.
4. No explicit backend route to cancel - every cancel path relied on
   is_disconnected.

Changes:
- Add POST /api/inference/cancel keyed by session_id/completion_id, with
  a registry populated for the lifetime of each streaming response.
- Have the frontend (chat-adapter.ts) POST /inference/cancel on
  AbortController abort, alongside the existing fetch teardown.
- Send max_tokens=4096 + t_max_predict_ms=120000 as defaults on every
  outbound chat completion to llama-server; honoured by user overrides.
- Disable httpx keep-alive on the streaming client so connection close
  reaches llama-server and its 1s liveness check fires.

No behaviour changes for non-streaming paths or for existing callers
that already pass max_tokens/session_id.

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

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

* studio: harden stop-button cancel path and scope cancel route

- Require at least one identifier for /api/inference/cancel so a missing
  thread id cannot silently cancel every in-flight generation.
- Scope /cancel to a dedicated studio_router so it is not exposed under
  the /v1 OpenAI-compat prefix as a surprise endpoint.
- Store a set of cancel events per key in _CANCEL_REGISTRY so concurrent
  requests on the same session_id do not overwrite each other, and
  deduplicate in _cancel_by_keys so the cancelled count reflects unique
  requests.
- Always send session_id with chat completions (not only when tools are
  enabled) so non-tool GGUF streams register under it and are reachable
  from /cancel.
- Register the non-GGUF stream_chunks path in the cancel registry too,
  so transformers-based stop-button works behind proxies that swallow
  fetch aborts.
- Only apply the 2-minute t_max_predict_ms wall-clock cap when the
  caller did not pass max_tokens, so legitimate long generations on
  slow CPU/macOS/Windows supported installs are not silently truncated.
- Remove the abort listener on normal stream completion so reused
  AbortSignals cannot fire a spurious cancel POST after the fact.

* studio: close cancel-race and stale-cancel gaps in stop path

- Register the cancel tracker before returning StreamingResponse so a
  stop POST that arrives during prefill / warmup / proxy buffering
  finds an entry in _CANCEL_REGISTRY. Cleanup now runs via a Starlette
  BackgroundTask instead of a finally inside the async generator body.
- Add a per-run cancel_id on the frontend (crypto.randomUUID) and in
  ChatCompletionRequest so /api/inference/cancel matches one specific
  generation. Removes the stale-cancel bug where pressing stop then
  starting a new run in the same thread would cancel the retry.
- Apply t_max_predict_ms unconditionally in all three llama-server
  payload builders (previously gated on max_tokens=None, which made it
  dead code for UI callers that always send params.maxTokens). Raise
  the default to 10 minutes so slow CPU / macOS / Windows installs are
  not cut off mid-generation.
- Make _cancel_by_keys refuse empty input (return 0) so a future
  internal caller can not accidentally mass-cancel every in-flight
  request.
- Accept cancel_id (primary), session_id, and completion_id on the
  /api/inference/cancel route. Unify the three streaming sites on the
  same _cancel_keys / _tracker variable names.
- Annotate _CANCEL_REGISTRY as dict[str, set[threading.Event]].

* Add review tests for PR #5069

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

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

* studio: harden stop-button cancel semantics and wall-clock cap

- Make /inference/cancel match cancel_id EXCLUSIVELY when supplied.
  Previously the handler iterated ('cancel_id','session_id','completion_id')
  and unioned matches, so a stale cancel POST carrying {cancel_id:old,
  session_id:thr} would still cancel a later run on the same thread via
  the shared session_id. cancel_id is now a per-run exclusive key;
  session_id / completion_id are only used as fallbacks when cancel_id
  is absent.

- Close the early-cancel race. If /inference/cancel lands before the
  streaming handler reaches _TrackedCancel.__enter__() (stop clicked
  during prefill / warmup / proxy buffering), the cancel was silently
  dropped. Stash unmatched cancel_ids in _PENDING_CANCELS with a 30 s
  TTL; _TrackedCancel.__enter__() now replays any matching pending
  cancel by set()-ing the event immediately after registration.

- Make t_max_predict_ms = _DEFAULT_T_MAX_PREDICT_MS conditional on
  max_tokens is None at all three llama-server payload sites. The cap
  is a safety net for callers who leave max_tokens unset (otherwise
  llama-server defaults n_predict to n_ctx, up to 262144). Callers who
  set an explicit max_tokens are already self-limiting and must not be
  silently truncated at 10 minutes on slow CPU / macOS / Windows
  legitimate long generations.

- Guard each StreamingResponse return with try/except BaseException so
  _tracker.__exit__ runs even if StreamingResponse construction or any
  preceding statement raises between _tracker.__enter__() and the
  BackgroundTask attachment. Prevents a registry leak on that narrow
  window.

* studio: close TOCTOU race and restore wall-clock backstop on UI path

- Close TOCTOU race in the pending-cancel mechanism. The previous fix
  split cancel_inference's (cancel_by_keys + remember_pending_cancel)
  and _TrackedCancel.__enter__'s (register + consume_pending) into
  four separate lock acquisitions. Under contention a cancel POST
  could acquire-then-release the lock, find the registry empty, and
  stash ONLY AFTER __enter__ had already registered and consumed an
  empty pending map -- silently dropping the cancel. Both call sites
  now do their work inside a single _CANCEL_LOCK critical section, via
  the new atomic helper _cancel_by_cancel_id_or_stash() and an
  inlined consume-pending step in __enter__. Reproduced the race under
  forced interleaving pre-fix; 0/2000 drops post-fix under parallel
  stress.

- Apply t_max_predict_ms UNCONDITIONALLY at all three llama-server
  payload sites. The previous iteration gated the cap on
  `max_tokens is None`, which turned out to be dead code on the
  primary Studio UI path: chat-adapter.ts sets
  maxTokens=loadResp.context_length after every model load, so every
  chat request carries an explicit max_tokens and the wall-clock
  safety net never fired. The cap's original purpose is to bound
  stuck decodes regardless of the token budget; it must always apply.

- Raise _DEFAULT_T_MAX_PREDICT_MS from 10 minutes to 1 hour. 10
  minutes was too aggressive for legitimate slow-CPU chat responses
  (a 4096-token reply at 2 tok/s takes ~34 min); 1 hour accommodates
  that and still catches genuine zombie decodes.

- Prune _PENDING_CANCELS inside _cancel_by_keys as well, so stashed
  entries expire proportionally to overall cancel traffic rather than
  only to cancel_id-specific POSTs.

* studio: trim verbose comments and docstrings in cancel path

* studio/llama_cpp: drop upstream PR hashes from benchmark comment

* Add review tests for Studio stop button

* Consolidate review tests for Studio stop button

* Align cancel-route test with exclusive cancel_id semantics

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

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

* studio: move cancel cleanup to generator finally; drop dead helper

- Move _tracker.__exit__ from Starlette BackgroundTask into each
  streaming generator's finally block. Starlette skips the background
  callback when stream_response raises (OSError / ClientDisconnect),
  which leaked _CANCEL_REGISTRY entries on abrupt disconnect.
- Check cancel_event.is_set() at the top of each GGUF while loop so a
  pending-replay cancel falls through to final_chunk + [DONE] instead
  of propagating GeneratorExit out of _stream_with_retry.
- Remove unused _remember_pending_cancel; _cancel_by_cancel_id_or_stash
  superseded it.

* Add review tests for Studio stop-button

* studio: wire audio-input stream into cancel registry

- Register cancel_event with _TrackedCancel on the audio-input streaming
  path so POST /api/inference/cancel can stop whisper / audio-input GGUF
  runs. Previously the registry stayed empty on this branch, so the stop
  button returned {"cancelled":0} and the decode ran to completion.
- Apply the same finally-based cleanup and pre-iteration cancel-event
  check used on the other three streaming paths.
- Update the _CANCEL_REGISTRY block comment to list cancel_id as the
  primary key (was stale "session_id preferred").

* Consolidate review tests for Studio stop-button cancel flow

- Merge the 6 behavioral tests from test_stream_cleanup_on_disconnect.py
  (finally cleanup on normal/exception/aclose, pre-set cancel_event
  pattern, and its regressions) into test_stream_cancel_registration_timing.py,
  which is the PR's existing file covering the same area.
- Extend structural invariants to include audio_input_stream alongside the
  three GGUF / Unsloth streaming generators: no _tracker.__enter__ inside
  the async gen body, cleanup via try/finally, no background= on
  StreamingResponse.
- Delete test_stream_cleanup_on_disconnect.py (now empty).

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

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

* studio: make cancel-via-POST interrupt Unsloth and audio-input streams

Close two remaining gaps in the stop-button cancellation wiring:

- stream_chunks (Unsloth path): add a top-of-loop cancel_event check and
  call backend.reset_generation_state() so cancel POSTs flush GPU state
  and close the SSE cleanly instead of relying on request.is_disconnected
  (which does not fire through proxies like Colab's).
- audio_input_stream: run the synchronous audio_input_generate() via
  asyncio.to_thread so blocking whisper chunks do not freeze the event
  loop, matching the pattern already used by the GGUF streaming paths.

* Add review tests for Studio stop-button cancel flow

* Consolidate review tests for Studio stop-button cancel flow

- Delete standalone test_cancel_registry.py at repo root: tests duplicated
  test_cancel_atomicity.py / test_cancel_id_wiring.py and re-implemented
  registry primitives inline (scaffolding).
- Extend tests/studio/test_stream_cancel_registration_timing.py with
  regression guards for the iter-1 cancel-loop fixes:
    structural: each streaming generator checks cancel_event in its loop;
                audio_input_stream offloads next() via asyncio.to_thread;
                stream_chunks cancel branch calls reset_generation_state().
    runtime:    Unsloth loop breaks on external cancel and resets state;
                audio loop stays responsive under blocking next();
                both loops emit zero tokens on pre-set cancel (replay path).

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

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

* studio: extend stop-path to passthrough streams; tighten wall-clock cap

- Lower _DEFAULT_T_MAX_PREDICT_MS from 1 hour to 10 minutes so the
  wall-clock backstop actually bounds runaway decodes when cancel
  signaling fails.
- Wire _TrackedCancel and cancel_event.is_set() into
  _openai_passthrough_stream and _anthropic_passthrough_stream and
  disable httpx keepalive so stop requests from /v1 and /v1/messages
  tool-calling clients reach llama-server.
- Apply t_max_predict_ms to the tool-passthrough request body so the
  backstop covers passthrough paths as well.
- Symmetric pre-registration stash for session_id/completion_id
  cancels (_cancel_by_keys_or_stash) so early cancels by those keys
  replay on later registration like cancel_id.
- Drop dead except BaseException guards around StreamingResponse()
  at four streaming sites; cleanup lives in the generator's finally.

* studio: harden cancel registry against ghost-cancel and leak paths

- Revert the session_id/completion_id stash in the fallback cancel
  helper. session_id is thread-scoped and reused across runs, so
  stashing it on an unmatched POST would fire cancel_event for the
  user's next unrelated request via _TrackedCancel.__enter__.
  cancel_id remains the only per-run unique key that gets stashed.
- Default max_tokens to _DEFAULT_MAX_TOKENS in the tool-passthrough
  body. Mirror the direct GGUF path so OpenAI/Anthropic passthrough
  callers who omit max_tokens get the same zombie-decode cap instead
  of relying on the wall-clock backstop alone.
- Wrap _openai_passthrough_stream setup with an outer try/except
  BaseException. The inner except httpx.RequestError does not catch
  asyncio.CancelledError at await client.send, which would otherwise
  leave _tracker registered in _CANCEL_REGISTRY indefinitely.
- Frontend stop POST uses plain fetch + manual Authorization header
  instead of authFetch. A 401 on the cancel POST no longer refreshes
  tokens or redirects the user to the login page mid-stop.

* Add review tests for Studio stop-button cancel flow

* studio: trim comments on stop-button review changes

Collapse multi-paragraph rationale blocks on the cancel registry,
_openai_passthrough_stream, and the frontend onAbortCancel handler
into one-line explanations of why the non-obvious behaviour exists.
Drop authFetch import that became unused when the cancel POST
switched to plain fetch.

* Consolidate review tests for Studio stop-button cancel flow

Move review-added tests out of test_cancel_dispatch_edges.py into the
existing PR test files that already cover the same areas:
- backend registry fan-out / exclusivity / idempotency / falsy-keys
  edge cases moved into tests/studio/test_cancel_atomicity.py
- frontend plain-fetch (not authFetch) + manual Authorization header
  moved into tests/studio/test_cancel_id_wiring.py
Delete the now-empty test_cancel_dispatch_edges.py.

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

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

* Studio: stop default-capping responses at 4096 tokens (follow-up to #5069) (#5174)

* Studio: stop default-capping responses at 4096 tokens

Follow-up to #5069. The 4096 default introduced for runaway-decode
defense silently truncates any caller that omits max_tokens. The
Studio chat UI sets params.maxTokens = loadResp.context_length after
a GGUF load, so it's fine, but every other consumer is not:

- OpenAI-API direct callers (/v1/chat/completions, /v1/responses,
  /v1/messages, /v1/completions) where the OpenAI default is
  effectively unlimited per response. langchain, llama-index, raw
  curl, and the openai SDK all rely on that.
- Reasoning models. Qwen3 / gpt-oss reasoning traces routinely exceed
  4096 tokens before the model emits a single visible content token.
  The user sees the trace cut off mid-thought.
- Long-form generation ("write a chapter", "produce a full SVG").

Reproduced on this branch: gemma-4-E2B-it-GGUF Q8_0, prompt asking
for a 10000-word story, no max_tokens in the request:

    finish_reason: stop  (misleading -- should be 'length')
    content_chars: 19772
    content_tail: ...'a comforting, yet immense, pressure.\n\n*"'

Body ended mid-sentence on a stray opening quote, right at the 4096
token mark.

After this patch the same request returns 38357 chars ending with
'...held in a perfect, dynamic equilibrium.' -- a natural stop, not
a truncation.

Implementation: rename the constant to _DEFAULT_MAX_TOKENS_FLOOR and
set it to 32768. Each call site now uses the model's effective
context length when known, falling back to the floor:

    default_cap = self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR

The 10-minute t_max_predict_ms wall-clock backstop from #5069 is
preserved as the second line of defense.

Plumbed _build_passthrough_payload + _build_openai_passthrough_body
through the routes layer so the Anthropic and OpenAI passthrough
paths also respect the model's context length.

* [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>

* Studio: cancel passthrough streams during llama-server prefill + route through apiUrl for Tauri

Three reviewer-flagged correctness gaps in the stop-button mechanism.

1) `_openai_passthrough_stream` could not honor cancel during prefill.
   The cancel check ran inside the `async for raw_line in lines_iter`
   body, so a cancel POST that arrived before llama-server emitted the
   first SSE line was unobservable until prefill completed. With a long
   prompt under proxy/Colab conditions -- the exact target scenario for
   this PR -- that left the model decoding for a long time after the
   user clicked Stop. Add an asyncio watcher task that closes `resp` as
   soon as `cancel_event` is set, raising in `aiter_lines` so the
   generator can exit. The watcher polls a threading.Event because the
   cancel registry is keyed by threading.Event for the synchronous
   /cancel handler.

2) `_anthropic_passthrough_stream` had the same blocking-prefill pattern.
   Same fix.

3) The frontend's stop-button cancel POST used a bare relative
   `fetch("/api/inference/cancel", ...)`, which targets the webview
   origin in Tauri production builds (where the backend is at
   `http://127.0.0.1:8888`). Route through the existing `apiUrl()`
   helper from `lib/api-base.ts` to match every other Studio call.
   Browser/dev builds get the empty base, so behavior is unchanged
   there.

Verified via temp/pr_simulation/sim_5069_prefill_cancel.py: cancel
during prefill terminates within ~250ms on both passthrough paths
(was 145s+ on the Anthropic path before this change), and the standard
non-passthrough chat path still cancels with no regression.

* Studio: log cancel-body parse errors instead of silently swallowing

Reviewer-flagged defensive logging gap. The bare `except Exception: pass`
in `cancel_inference` would mask malformed payloads that hint at a buggy
client or a transport issue. Log at debug so future investigation isn't
left guessing whether `body={}` came from a missing body or a parse
failure. Behavior is unchanged: an unparseable body still falls through
to the empty-dict path and the cancel call returns `{"cancelled": 0}`.

* Studio: Anthropic passthrough cancel parity with OpenAI passthrough

Two reviewer-flagged consistency gaps in the cancel surface for
/v1/messages.

1) Anthropic passthrough did not register cancel_id, so a per-run cancel
   POST (the cleanest Studio-style cancel path) silently missed when
   the route hit `_anthropic_passthrough_stream`. The OpenAI passthrough
   has registered (cancel_id, session_id, completion_id) since this PR
   was first opened; mirror that here. Also add `cancel_id` to
   `AnthropicMessagesRequest` so the route handler can plumb it through.

2) The cancel handler's fallback key list checked only completion_id
   and session_id, never message_id. Anthropic clients that send their
   native `id` (returned in the SSE message_start event) for cancel had
   no way to hit the registry. Add message_id to the fallback list.

Verified via temp/pr_simulation/sim_5069_prefill_cancel.py: P2 now
cancels by cancel_id in 137ms (was hanging pre-fix), and the new P2b
case cancels by message_id in 77ms. P1 (OpenAI) and P3 (standard chat)
still pass with no regression.

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
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: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-04-24 10:09:25 -07:00
Daniel Han
8264e80dd9
Studio: probe AMD GPUs in llama-server VRAM detection (#5172)
* Studio: probe AMD GPUs in llama-server VRAM detection

_get_gpu_free_memory in studio/backend/core/inference/llama_cpp.py
only queried nvidia-smi. On AMD ROCm hosts that returns nothing, so
the GPU list is empty, the auto-fit logic falls into the no-gpus
branch, and llama-server gets --fit on with no -ngl to anchor it.
The model loads on CPU even though the GPU is detected elsewhere in
Studio. Addresses #5106.

Add a torch-based fallback that runs after nvidia-smi fails or returns
empty:

    import torch
    if torch.cuda.is_available() and hasattr(torch.cuda, "mem_get_info"):
        for ordinal in range(torch.cuda.device_count()):
            free, _total = torch.cuda.mem_get_info(ordinal)
            gpus.append((ordinal, free // (1024 * 1024)))

Works on AMD because the ROCm torch wheels Studio installs reuse the
entire torch.cuda.* namespace via HIP. Also rescues NVIDIA hosts
where nvidia-smi is missing from PATH (a secondary cause of the bug
on Windows). Matches the convention
studio/backend/utils/hardware/hardware.py:412 already uses for the
same fallback purpose.

Verified locally: nvidia-smi path returns the expected GPU and free
MiB; torch fallback returns valid VRAM when nvidia-smi is forced to
fail. Note: PR #4874 is a draft taking a different approach
(parsing vulkaninfo); the two are complementary.

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

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

* Address review feedback on PR #5172

torch.cuda.device_count() enumerates GPUs RELATIVE to the current
CUDA_VISIBLE_DEVICES (or HIP_VISIBLE_DEVICES on ROCm). Returning
those visible ordinals directly lets _select_gpus rewrite
CUDA_VISIBLE_DEVICES with the wrong physical IDs: a process started
with CUDA_VISIBLE_DEVICES=2,3 would get its child llama-server
relaunched with CUDA_VISIBLE_DEVICES=0,1, targeting the wrong GPUs
and violating any scheduler pinning.

Translate visible ordinals back through the active CVD/HIP/ROCR
mask before returning. Falls through to bare ordinal when no mask
is set. Also drop the redundant int() cast on // -- bytes // 2**20
already returns int.

Verified: with CUDA_VISIBLE_DEVICES=6 and nvidia-smi forced to fail,
the torch fallback now returns (6, free_mib) instead of (0, free_mib).

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

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

* Studio: fix ROCm visibility precedence + narrow ROCm child env

Two reviewer-flagged correctness bugs in the AMD GPU probe path.

1) ROCm visibility precedence was reversed. torch.cuda enumerates GPUs
   relative to HIP_VISIBLE_DEVICES / ROCR_VISIBLE_DEVICES on ROCm builds,
   but the probe's env-var lookup checked CUDA_VISIBLE_DEVICES first. With
   CUDA_VISIBLE_DEVICES=0,1 and HIP_VISIBLE_DEVICES=6,7 the probe returned
   [(0, ...), (1, ...)] when torch's view was actually [(6, ...), (7, ...)].
   The wrong physical IDs flowed downstream into CUDA_VISIBLE_DEVICES for
   the llama-server subprocess, pinning it to GPUs 0,1 instead of 6,7.

   Fix: branch on torch.version.hip. On ROCm, prefer HIP > ROCR > CUDA
   (matches torch's own ordering). On NVIDIA, use CUDA only -- ignoring
   any HIP/ROCR vars the parent happens to have set.

2) Child env narrowing only set CUDA_VISIBLE_DEVICES. On ROCm, llama-server
   honors HIP/ROCR; if the parent shell exported HIP_VISIBLE_DEVICES=4,5
   and the selector picked just GPU 4, the child still saw both because
   we never narrowed HIP/ROCR. Now we set all three on ROCm so the AMD
   subprocess actually sees the planned subset.

Both branches verified via temp/pr_simulation/sim_5172_rocm_precedence.py
(7/7 cases pass), including the reviewer's verbatim R5 case
(CVD=0,1 + HIP/ROCR=6,7).

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

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

* Studio: sort GPU probe result + honor explicitly empty ROCm masks

Two reviewer-flagged correctness nits on top of eff55fb8.

1) Gemini medium: the torch fallback returned an unsorted list when the
   visibility mask was non-sequential (e.g. CUDA_VISIBLE_DEVICES=5,2,9),
   diverging from the docstring guarantee and the nvidia-smi path. Now
   sorted by physical id.

2) Codex P2: an explicitly empty HIP_VISIBLE_DEVICES="" should mean
   "no GPUs" per the codebase convention in
   utils/hardware/hardware.py::_get_parent_visible_gpu_spec. The previous
   `or` chain treated empty string as falsy and silently fell through to
   ROCR / CUDA, producing wrong physical IDs. Switch to `is not None`
   checks to match.

Verified via sim_5172_rocm_precedence.py (9/9 cases pass) including the
two new R8 (sort) and R9 (empty-HIP honored) cases.

* Studio: align nvidia-smi probe with torch fallback (sort + robust CVD)

Two follow-up Gemini-medium nits on PR #5172.

1) Fragile CVD parsing on the nvidia-smi path: `cvd.split(",")` would
   raise ValueError on a trailing comma like "0,1," because the empty
   trailing token is not skipped. The torch fallback already filters
   empty tokens via `if x.strip()`; mirror that here.

2) Missing sort guarantee on the nvidia-smi path: the docstring promises
   sort-by-id, the torch fallback now sorts, but the nvidia-smi path
   relied on driver enumeration order. Add an explicit sort.

Both changes match what shipped in 6b1cccd6 for the torch fallback, so
the two probe paths now have identical CVD parsing + ordering semantics.

* Studio: drop cvd.strip() truthiness so empty CVD filters all GPUs

Reviewer-flagged correctness bug. The previous `if cvd is not None and
cvd.strip():` guard treated `CUDA_VISIBLE_DEVICES=""` as if the variable
were unset, leaving `allowed=None` (and `physical_ids=None` on the torch
path). On the nvidia-smi path that mattered: nvidia-smi ignores CVD
entirely, so the probe's `allowed` filter is the only thing that
respects the parent's "no GPUs" intent. Pre-fix the probe returned every
physical GPU when the parent had explicitly hidden them.

Drop the `.strip()` truthiness check on both paths. The downstream
`if x.strip()` token filter still keeps trailing-comma masks like
"0,1," safe, and an empty mask now produces an empty allowed/physical
set as expected (matching utils/hardware/hardware.py convention).

Verified via sim_5172_rocm_precedence.py R10 + R11 (now 11/11 cases
pass): nvidia-smi path with `CUDA_VISIBLE_DEVICES=""` now returns []
instead of leaking the hidden GPUs.

* Studio: log ROCm env-var failures instead of silently swallowing

Reviewer-flagged defensive logging gap. The bare `except Exception: pass`
around the HIP/ROCR env-var assignment would mask anything from a
missing torch import to an unexpected version object shape. Log at
debug so a failed AMD child-env narrowing is at least traceable.
Behavior is unchanged: torch missing or version probe failing still
leaves the child with only CUDA_VISIBLE_DEVICES set.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-04-24 10:00:42 -07:00
Daniel Han
ae9de7f2df
Studio: stop currency escape from breaking inline LaTeX (#5170)
* Studio: stop currency escape from breaking inline LaTeX

The currency-escape preprocessor in studio/frontend/src/lib/latex.ts
matched the opening dollar of any $<digits>...$ span and inserted a
backslash. The result was that text like "$30^\circ$" or
"**$90 - x$**" rendered as raw characters with stray dollar signs.
Fixes #5164.

Add two helpers in front of the escape:
- hasInlineMathCloser looks for an unescaped, non-doubled closing
  dollar within the same line. Bold-wrapped spans (**$X$**) are always
  treated as math since LLMs use that form for bold math.
- looksLikeMathBody filters multi-token bodies that look like prose
  between two currency tokens ($5 to $10, $5, $10).

Verified against 111 inputs: the issue body, common LaTeX patterns
(Greek vars, fractions, integrals, vectors, exponents), prose currency
in lists and sentences, code blocks, and headings. All pass.

* Address review feedback on PR #5170

- Drop ^ and _ from MATH_OP_RE since LATEX_CHAR_RE already short-
  circuits on those before MATH_OP_RE is consulted (Gemini comment).

- Treat compact currency ranges like $5-$10 and $5/$10 as currency
  rather than math. The body between the first two dollars in those
  forms is "5-" or "5/", a single non-whitespace token that previously
  hit the math shortcut. Extend TRAIL_PUNCT_RE to strip - and / so the
  trimmed body comes back as pure currency. (Codex comment.)

- Honour __underscore-bold__ around math the same way as **-bold**.
  Markdown allows both delimiters and LLMs do reach for the underscore
  form. (Gemini comment.)

Verified against the existing 18 cases plus 5 new ones for the range,
slash, and underscore-bold scenarios. All pass.

* Studio: fix numeric inline math + currency-as-closer in LaTeX preprocess

Two reviewer-flagged real-world misses in the inline-math heuristic.

1) Numeric-only operator forms like $2 + 2$, $100 < 200$, $1,000 - 500$
   were getting their leading $ escaped, so the renderer never saw them
   as math. The body has a math op but no lone-letter variable, so the
   old looksLikeMathBody required the lone-letter clause and rejected
   purely numeric expressions. Add SIMPLE_MATH_RE to recognise number-
   or-letter operands joined by math operators.

2) Prose like "Starts at $5 + a $10 add-on" was being treated as one
   math span "5 + a " with the second currency token mistaken for the
   closer. The body satisfied the math-op + lone-letter check, so the
   span got accepted and the renderer ate "10 add-on". In hasInlineMathCloser,
   reject any candidate $ whose next character is a digit -- that's almost
   always another currency token starting, not the closer of a real math
   span (math doesn't follow $ with a bare digit).

Verified via temp/pr_simulation/sim_5170_latex.mjs: 25/25 cases pass,
including the 6 reviewer numeric-math cases, 3 currency-as-closer cases,
and 16 regression checks against the originally shipped behavior.
2026-04-24 09:06:01 -07:00
Daniel Han
c2dc2eb1b1
Studio: kill in-flight llama-server before spawning a new one (#5171)
* Studio: kill in-flight llama-server before spawning a new one

Two rapid Apply clicks in the chat settings panel can race two
load_model calls. Both pass the Phase 1 _kill_process (because neither
has stored its Popen handle yet), both download / read metadata, and
both reach Phase 3 and spawn a server. Only the last reference is
tracked in self._process. The first server becomes an orphan that
holds the model in RAM until the kernel OOM kicks in. Addresses #5161.

Two complementary changes in studio/backend/core/inference/llama_cpp.py:

1. At load_model entry, set the existing _cancel_event so any in-flight
   load aborts at its next checkpoint, then bind a fresh Event for
   the new load. Subsequent _kill_process and download phases pick up
   the new event.
2. Inside the Phase 3 lock, immediately before subprocess.Popen, run a
   defensive _kill_process that removes any orphan handle a racing
   load might have stored after the first kill ran.

Speculative decoding cleanup (also touched while in this code path):

The chat UI used to send "ngram-mod" as the wire value when the
speculative dropdown was On, and the backend mapped that to the
4-flag combo --spec-type ngram-mod --spec-ngram-size-n 24 --draft-min
48 --draft-max 64. Switch the wire value to "default" and have the
backend pass the single llama-server flag --spec-default. That flag
expands to the exact same params (see common/arg.cpp:3905-3914 in
llama.cpp). Default-on for non-vision models is preserved.

Verified:
- Unit test: planted Popen orphan handle is terminated before
  self._process is overwritten.
- All ten spec-cmd mappings produce the expected llama-server args
  ("default" -> --spec-default, "off" / null -> no flag, vision ->
  always disabled, manual "ngram-mod" / "ngram-simple" still work).

* Address review feedback on PR #5171

The previous attempt at cancelling in-flight loads via
``self._cancel_event.set()`` followed by
``self._cancel_event = threading.Event()`` was broken in two ways
(flagged independently by Gemini and Codex):

1. Sub-methods like _download_gguf consult ``self._cancel_event``
   on every check. After the rebind, the in-flight thread reads the
   FRESH unset Event, not the one we just set, so cancellation never
   propagates.

2. Worse, if unload_model() lands between ``set()`` and the rebind,
   unload's signal hits the OLD event and is then immediately
   discarded when load_model swaps in a fresh Event. The user's
   stop-request silently no-ops.

Revert to the original ``self._cancel_event.clear()``. The Phase 3
defensive ``_kill_process()`` introduced in this branch still closes
the orphan-process race that #5161 reports: even if two concurrent
loads both pass Phase 1 with self._process == None, the loser's
Phase 3 kill terminates the winner's Popen handle before overwriting
it, so we end up with exactly one llama-server process.

* Studio: coerce legacy speculative-type values for the simplified dropdown

The Speculative Decoding control was simplified to On (default) / Off,
but the backend still accepts and reports the older manual modes
(ngram-mod, ngram-simple). When a load response or status refresh comes
back with one of those values -- whether from an external API caller, a
model loaded before this PR landed, or a not-yet-upgraded backend -- the
controlled Select renders with an empty trigger because the value is not
in the SelectItem list.

Add a tiny normaliser at both entry points (status refresh + post-load)
so legacy manual modes coerce to "default". The user sees "On" instead
of a blank dropdown, and reapplying lets llama.cpp pick its own preferred
strategy via --spec-default.

Reviewer-flagged finding on PR #5171.
2026-04-24 09:05:31 -07:00
Etherll
0326577b82
fix: guard resolve_model_class fallback against unresolvable transformers AutoModel entries (#5155)
* fix: avoid PerceptionEncoder ImportError blocking trust_remote_code model loads

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

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

* Update config class retrieval in _utils.py

Refactor config class retrieval logic to use model mapping.

* resolve_model_class: restore _extra_content fallback

The previous fallback iterated mapping.items(), which transformers'
_LazyAutoMapping defines as _model_mapping entries + _extra_content
entries. The PR's per-key loop covers only _model_mapping, so
subclasses of configs registered via AutoModel.register(cfg, model)
silently resolve to None. Add a safe isinstance pass over
_extra_content (no lazy loads, no crash risk) before giving up.

* Add tests for resolve_model_class fallback

* [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: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-04-24 05:59:17 -07:00
luo jiyin
06ed94da0d
chore: fix typo cleanup across tests and backend strings (#5152)
* chore: fix typos in studio/backend/routes/models.py

* chore: fix typos in tests/saving/non_peft/test_mistral_non_peft.py

* chore: fix typos in tests/saving/non_peft/test_whisper_non_peft.py

* chore: fix typos in tests/saving/vision_models/test_index_file_sharded_model.py

* chore: fix typos in tests/saving/vision_models/test_push_to_hub_merged.py

* chore: fix typos in tests/saving/vision_models/test_save_merge_qwen2.5vl32B_model_ocr_benchmark.py

* chore: fix typos in tests/saving/vision_models/test_save_merge_vision_model_ocr_benchmark.py

* chore: fix typos in unsloth/import_fixes.py

* Split: keep only 6 file(s)

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-04-24 12:51:27 +01:00
Konstantin Azizov
c875dc17e4
Studio: use (gguf) context length before max seq length (#5111)
* fix: use (gguf) context length before max seq length

For GGUF models context length is used instead of `maxSeqLength`

Fixes #4893

* Studio: split rollback max_seq_length from target load/validate

Restore params.maxSeqLength as the value passed to the target model's
validateModel and effectiveMaxSeqLength fallback, and introduce a
separate rollbackMaxSeqLength used only in the rollback loadModel call.
Without the split, switching from a GGUF model with a large native
context to a non-GGUF target polluted the new load via the
effectiveMaxSeqLength else-branch.

Also:
- Detect a previous GGUF via isGguf, activeGgufVariant, or a .gguf
  suffix on the checkpoint, so local/LM Studio paths not present in
  the models catalog still take the GGUF rollback path.
- Use 0 as the GGUF rollback fallback to match the sentinel used at
  the normal GGUF load site (ggufContextLength fallback to 0); 4096
  would reintroduce the original truncation bug when both
  customContextLength and ggufContextLength are null.
- Reuse the captured stateBeforeUnload for modelRequiresTrustRemoteCode
  and drop the now-unused DEFAULT_INFERENCE_PARAMS value import.

* Studio: drop pending customContextLength from GGUF rollback

rollbackMaxSeqLength previously preferred customContextLength over
ggufContextLength, but customContextLength is a pending, not-yet-applied
slider edit: chat-settings-sheet.tsx treats it as the "dirty" marker
(ctxDirty = customContextLength !== null) and use-chat-model-runtime.ts
clears it to null on every successful load. Rollback exists to restore
the previously-loaded model at its confirmed running context, so leaking
a pending slider value can load the rollback target at a context the
user never validated against VRAM, potentially causing the rollback
itself to fail.

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-04-24 11:53:10 +01:00
Daniel Han
5c473fab80 Bump versions v0.1.37-beta 2026-04-23 07:05:47 -07:00
Daniel Han
563fcf8952
Studio: detect reasoning_effort and preserve_thinking in chat templates (#5149)
* Studio: detect reasoning_effort and preserve_thinking in chat templates

Previously Studio's chat template sniffer only recognized Qwen's
enable_thinking and DeepSeek's thinking markers. For gpt-oss (Harmony
templates) and newer Qwen3.6 templates, the Think toggle was hidden or
could only be flipped on/off.

This change adds two new detections and corresponding UI controls:

1. reasoning_effort style (gpt-oss). When the chat template contains
   reasoning_effort, the Think button becomes a Low / Medium / High
   dropdown and the backend forwards {"reasoning_effort": <level>} in
   chat_template_kwargs. Load-time --chat-template-kwargs flag is also
   switched to the new style.

2. preserve_thinking kwarg (Qwen3.6). Independent of the reasoning
   toggle. When the template mentions preserve_thinking, a new
   Preserve Thinking on/off pill is shown next to Think. Off by
   default, persisted via localStorage. When on, the backend adds
   {"preserve_thinking": true} to chat_template_kwargs so past-turn
   <think> blocks are kept in the prompt instead of being stripped.

Backend helper _request_reasoning_kwargs now merges all applicable
kwargs into a single chat_template_kwargs dict based on the model's
detected style and template capabilities. Inputs are validated with
Literal types in the Pydantic request model.

Tested end to end against cached GGUFs for unsloth/gpt-oss-20b-GGUF and
unsloth/Qwen3.6-35B-A3B-GGUF. Confirmed the llama-server startup
--chat-template-kwargs flag and per-request JSON body carry the
expected keys for all combinations.

* Studio: review pass and CI format fixes for reasoning-styles PR

Addresses review feedback and pre-commit CI:

- Preserve Thinking pill in shared-composer now gates on modelLoaded
  only, matching the thread.tsx toggle. Previously the inline version
  disabled whenever supports_reasoning was false.
- The non-GGUF already_loaded LoadResponse now emits reasoning_style
  (and supports_preserve_thinking=False) so a reconnecting frontend
  sees the correct style for an already-running gpt-oss safetensors
  model.
- use-chat-model-runtime reconnect path now always clears
  reasoningEnabled for models without reasoning support instead of
  inheriting the previous model's state.
- _reasoning_default is now reset alongside the other reasoning flags
  in both backend reset blocks.
- supports_reasoning description updated to mention reasoning_effort
  alongside enable_thinking.
- Ran scripts/run_ruff_format.py on the touched Python files to
  satisfy pre-commit.ci.

* Studio: detect reasoning flags on safetensors load + share Qwen param helper

Addresses bot review feedback:

- Extract the chat-template substring sniffer out of _read_gguf_metadata
  into a module-level detect_reasoning_flags(template, model_id) helper.
  Also runs on the safetensors / transformers load paths:
    - POST /api/inference/load non-GGUF LoadResponse
    - already_loaded non-GGUF early return
    - GET /api/inference/status non-GGUF branch
  The gpt-oss fallback via backend._is_gpt_oss_model() is preserved so
  safetensors gpt-oss still surfaces reasoning controls even when no
  chat_template is stored on the model record.

- Deduplicate the Qwen3 / Qwen3.5 / Qwen3.6 Think-toggle parameter
  adjustment into a single features/chat/utils/qwen-params.ts. Both
  the assistant-ui Think toggle (thread.tsx) and the shared composer
  (shared-composer.tsx) now import the same helper. The superset that
  applies presence_penalty=1.5 for Qwen3.5 and Qwen3.6 is now used by
  both sites (thread.tsx previously did not apply it).

* Studio: fill missing reasoning flags on safetensors status + add always_on reset

Round 4 review fixes:

- routes/inference.py safetensors status response now populates
  reasoning_always_on and supports_tools from detect_reasoning_flags.
  Previously Pydantic defaulted both to False, so safetensors models
  with always-on <think> templates or tool-calling templates were
  silently losing those flags on /api/inference/status reconnect.
- routes/inference.py already_loaded safetensors branch now falls back
  to backend._is_gpt_oss_model() when the chat template is missing,
  matching the status-endpoint behaviour.
- Safetensors status endpoint log_source set to "Safetensors status"
  so the emitted template-detection log lines are attributable.
- chat-runtime-store clearCheckpoint now also resets reasoningAlwaysOn
  so switching from an always-on reasoning model to a non-always-on
  one does not leave the Think button permanently locked on.

* Studio: skip reasoning kwargs when always-on; narrow non-GGUF advertisement

Round 5 addresses reviewer feedback:

- _request_reasoning_kwargs and the load-time --chat-template-kwargs
  emission now skip when _reasoning_always_on is true. Templates with
  hardcoded <think> tags do not consume enable_thinking / reasoning_effort
  so sending them was noise.
- Non-GGUF (Unsloth / transformers) LoadResponse and InferenceStatusResponse
  paths no longer advertise template-derived supports_reasoning /
  reasoning_style / supports_preserve_thinking / supports_tools. The
  transformers generation path does not yet forward chat_template_kwargs
  to tokenizer.apply_chat_template, so exposing the UI controls on those
  models was misleading. Only the gpt-oss Harmony case is kept
  (reasoning_style = reasoning_effort) because it is handled via the
  HarmonyTextStreamer at the tokenizer level. A follow-up PR can thread
  chat_template_kwargs through the transformers path and re-enable the
  broader detection.
- GGUF / llama-server paths keep the full detect_reasoning_flags output.
2026-04-23 06:57:36 -07:00
Daniel Han
ad6bd780a9 Update _utils.py 2026-04-23 06:42:12 -07:00
Wasim Yousef Said
a5eb2e3d50
Add tauri (#5144)
* add unsloth studio desktop app

* Fix review findings

- studio/src-tauri/tauri.conf.json: retarget updater to staging repo
  (danielhanchen/unsloth-staging-2); switch to unslothai/unsloth on upstream merge.
- studio/src-tauri/linux/postremove.sh: drop the interactive read loop and the
  /home/* iteration. Package maintainer scripts must stay non-interactive and
  must not touch other users' data.
- studio/frontend/src/app/auth-guards.ts: honor tauriAutoAuth() boolean. Failed
  auto-auth now redirects to /login; requireGuest/requirePasswordChangeFlow
  only redirect to /chat when auth succeeds. The new early-return on failed
  auth is intentional so the login / change-password flows remain reachable
  when desktop auth is not yet established.
- studio/frontend/src/config/env.ts: keep fetched=false on health failure so
  later calls retry instead of caching the client-side platform guess.
- studio/src-tauri/src/install.rs: pick the available system package manager
  (apt-get, dnf, zypper, pacman); AppImage bundles run on non-Debian distros.
- studio/frontend/src/lib/open-link.ts + markdown-text/sources callers: return
  boolean from openLink so callers only preventDefault on handled URLs; relative
  hrefs now navigate natively.
- studio/frontend/src/features/settings/tabs/about-tab.tsx: fetch(apiUrl(...))
  so the version request targets the backend port in desktop mode. The bare
  /api/health predates the Tauri webview (blame: the earlier onboarding commit,
  which ran with same-origin frontend/backend); in desktop mode the webview
  origin is tauri://localhost so the bare path fails.
- install.ps1: gate the install_python_stack.py hotfix on a sentinel comment
  instead of a content regex; append the sentinel after applying so reruns
  are unambiguous.
- unsloth_cli/commands/studio.py _write_auth_secret: use the atomic mkstemp +
  os.replace path on Windows too; chmod calls are wrapped in try/except OSError.
- studio/src-tauri/src/preflight.rs probe_existing_backends: fan out the health
  probes concurrently; desktop-auth status still runs sequentially per candidate.
  reqwest::Client is internally Arc-wrapped so the in-loop .clone() is a
  refcount bump, not a deep clone; annotated inline.
- studio/src-tauri/src/preflight.rs run_cli_probe: wait() after kill() to reap
  the child, matching probe_cli_capability.
- studio/src-tauri/src/process.rs + main.rs: add stop_backend_detached and use
  it from the tray quit handler so the 5s graceful-wait does not block the
  Tauri main loop. RunEvent::Exit keeps the synchronous safety-net call.
- studio/backend/main.py: drop the permissive localhost CORS regex in
  api-only mode; the explicit allow_origins list is sufficient.
- .github/workflows/release-desktop.yml: drop max-parallel: 1 so platform
  builds run in parallel, and lift releaseBody to an env var so the three
  tauri-action invocations share one source of truth.

* Fix review findings (loop 2)

- studio/backend/auth/storage.py update_password: clear_desktop_secret()
  alongside clear_bootstrap_password() so rotating the admin password
  also revokes any previously provisioned .desktop_secret. Without this,
  an old local desktop credential keeps minting fresh admin tokens via
  /api/auth/desktop-login after a password rotation.
- studio/src-tauri/src/desktop_auth.rs provision_desktop_auth: wrap
  cmd.output().await in tokio::time::timeout(30s). DESKTOP_AUTH_LOCK is
  held across the whole desktop_auth flow, and previously a hanging
  `unsloth studio provision-desktop-auth` subprocess would pin the lock
  indefinitely and freeze every subsequent desktop_auth call.

* Add review tests

* Consolidate review tests

Merge review-added tests into the existing studio/backend/tests/test_desktop_auth.py
(the PR's authoritative desktop-auth test file). Drops three scaffolding files under
tests/python/ in favor of five focused tests next to the tests they extend:
- test_update_password_clears_desktop_secret (runtime)
- test_update_password_on_unknown_user_leaves_desktop_secret_intact (runtime)
- test_cli_provisioning_delegates_to_storage_create_desktop_secret (source-level)
- test_cli_connect_auth_db_reads_storage_db_path (source-level)
- test_desktop_auth_provision_has_bounded_timeout (Rust source-level)

* Revert auth-guards.ts Tauri branches to unconditional form

The review loop on PR 5144 introduced a regression: the isTauri branch of
requireAuth redirected to /login when tauriAutoAuth() returned false, and
requireGuest / requirePasswordChangeFlow silently fell through on the same
condition. The Tauri desktop app authenticates via a local auto-generated
secret; it must never surface /login or /change-password to the user. A
failed auto-auth should let the startup layer retry, not expose a password
form.

Restore the three Tauri branches to the author's original unconditional
form (requireAuth: return; requireGuest / requirePasswordChangeFlow: throw
redirect({to: '/chat'})). Keep the rest of the review fixes -- the
apiUrl() fetch wrapping, authRedirect helper, and fetchAuthStatus refactor
are all legitimate improvements and are preserved.

* Revert release-desktop.yml to author's version

The review loop's workflow-file tweaks (drop max-parallel: 1, lift releaseBody
to an env var) are cosmetic. OAuth tokens cannot push workflow-file changes,
and fine-grained PATs cannot honor maintainerCanModify on a third-party fork.
Reverting the workflow file to wasimysaid's version lets the push go through
without needing a classic PAT with both repo and workflow scopes.

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

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

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Daniel Han <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-04-23 04:50:10 -07:00
Daniel Han
114908cd9f
fix(install): clear STUDIO_LOCAL_* env on POSIX normal install (#5146)
install.sh's normal-install branch passed the inherited parent-shell
environment to setup.sh without resetting STUDIO_LOCAL_INSTALL or
STUDIO_LOCAL_REPO. Consumers treat either as truthy:

  studio/setup.sh:491          checks STUDIO_LOCAL_INSTALL != "1"
  studio/install_python_stack.py:868  reads STUDIO_LOCAL_REPO, falsy
                                      if empty

Net effect: if a user or developer shell has stale STUDIO_LOCAL_*
exports from a previous --local run, a subsequent 'normal' install or
desktop-managed install silently takes the local-dev path: version
checks are skipped and an editable overlay points at the stale repo.

Fix mirrors install.ps1:1082-1087 on Windows: set STUDIO_LOCAL_INSTALL=0
and STUDIO_LOCAL_REPO= explicitly in the env prefix for the non-local
branch so setup.sh sees a clean state regardless of parent exports.

Co-authored-by: Daniel Han <unslothai@gmail.com>
2026-04-23 04:46:03 -07:00
Octopus
72c1c3b254
fix: patch CONTROL type for special tokens in sentencepiece GGUF export (#5080)
* fix: patch CONTROL type for special tokens in sentencepiece GGUF export (fixes #5070)

When converting a Gemma 3 fine-tune to GGUF via save_pretrained_gguf,
tokens like <start_of_turn> (id=105) and <end_of_turn> (id=106) are
already present in the sentencepiece model but are typed as NORMAL (1)
instead of CONTROL (3). llama.cpp only recognises CONTROL tokens when
parse_special=True is active, so these tokens get BPE-split during
chat inference and the model produces garbage output.

fix_sentencepiece_gguf now reads tokenizer.json's added_tokens list and,
for any token with "special": true whose ID falls within the existing
sentencepiece vocabulary, updates its type from NORMAL to CONTROL before
writing the patched tokenizer.model to disk. The same CONTROL type is
also applied when new tokens are appended for the out-of-range case, so
both code paths are consistent.

* Wire fix_sentencepiece_gguf into tokenizer save path and guard np.diff

- save.py: call fix_sentencepiece_gguf inside unsloth_tokenizer_save_pretrained
  after _preserve_sentencepiece_tokenizer_assets. The helper was previously
  unreferenced in the repo, so the PR's CONTROL-type patch never actually ran
  during save_pretrained_gguf.
- tokenizer_utils.py: add an early-return guard for len(added_tokens_ids) < 2
  before the existing np.diff contiguity check. np.diff on a single-element
  array returns [] and .min() raises ValueError, which would discard the new
  in-vocab CONTROL patch; the guard flushes tokenizer.model first. Guard is
  inserted before the existing lines (diff = np.diff(...) and the min/max
  check) so their blame is unchanged.

Dropped the separate refactor to fold the four duplicated "if patched > 0:
write tokenizer.model" blocks into a helper because doing so re-indents
lines whose blame is "Formatting & bug fixes"; the duplication
remains the author's pattern.

* Fix review findings: negative token_id guard and np.diff single-element

- tokenizer_utils.py:481: add 0 <= lower bound to the special_token_ids
  bounds check. Previously a negative token_id from tokenizer.json passed
  'token_id < sentence_piece_size' and Python's negative indexing wrapped
  tokenizer_file.pieces[-1] to silently corrupt the last piece to CONTROL.
- tokenizer_utils.py:513: replace the loop-1 'if len < 2: return' guard
  (which was too broad: it silently skipped vocab extension for single-entry
  added_tokens.json) with a pre-pass that substitutes a trivially-contiguous
  2-element sentinel for the contiguity check, then restores the original
  array before the append loop. Lines 519 ('diff = np.diff(added_tokens_ids)')
  and 520-529 (min/max/boundary checks and early-return write blocks) are
  left literally unchanged so blame remains intact.

* Restore real added_tokens_ids before min boundary check

Move the '_real_added_tokens_ids' restore above the
'added_tokens_ids.min() != sentence_piece_size' check. With the previous
order the sentinel [sentence_piece_size, sentence_piece_size + 1] was
still in scope when the min check ran, so any single-entry added_tokens
.json with an out-of-range start id (e.g. 99 when sentence_piece_size=2)
bypassed the boundary check and fell through to the append loop.

* Scope fix_sentencepiece_gguf to GGUF export path only

Previously wired fix_sentencepiece_gguf into unsloth_tokenizer_save_pretrained,
which is the generic monkey-patch replacement for every tokenizer.save_pretrained
call. That caused the GGUF-specific mutation (and the unconditional protobuf
import in fix_sentencepiece_gguf) to run on every LoRA / merged 16-bit /
push_to_hub / torchao save, where it has no purpose and can abort the entire
save if the protobuf runtime is unavailable.

- save.py: remove fix_sentencepiece_gguf call from unsloth_tokenizer_save_pretrained.
- save.py: add the call inside unsloth_save_pretrained_gguf immediately before
  save_to_gguf, wrapped in try/except so a protobuf import failure logs a
  warning and lets GGUF conversion proceed rather than aborting the save.

* Broaden special-token retag to USER_DEFINED and narrow save.py except

- tokenizer_utils.py:483: the in-vocab retag previously only promoted NORMAL
  pieces to CONTROL, but the real Gemma tokenizer (e.g. unsloth/functiongemma
  -270m-it) stores <start_of_turn>/<end_of_turn> as USER_DEFINED (type 4).
  Extend the predicate to cover both NORMAL and USER_DEFINED so tokens marked
  "special": true in tokenizer.json are promoted regardless of their current
  sentencepiece type. Only tokens explicitly flagged special are touched, so
  non-special USER_DEFINED pieces are unchanged; already-CONTROL pieces stay
  unchanged. The warning message is generalised accordingly.
- save.py:2294: narrow the except clause from Exception to ImportError. The
  loop-3 try/except was added to tolerate a missing protobuf runtime; leaving
  it broad also swallows OSError/PermissionError mid-write, which would ship
  a corrupted tokenizer.model to save_to_gguf. ImportError still covers the
  protobuf case while letting I/O errors propagate to the outer save handler.

* Harden fix_sentencepiece_gguf: widen except, protobuf fallback, revert USER_DEFINED widen, guard entry id

- save.py:2294: widen except from ImportError back to Exception. The loop-4
  narrowing let JSONDecodeError / KeyError / OSError / PermissionError from
  fix_sentencepiece_gguf abort the entire GGUF export, a regression vs
  pre-PR behavior. The outer save_to_gguf try/except still covers GGUF-side
  failures; any fix-side failure now logs a typed warning and lets
  conversion proceed.
- tokenizer_utils.py:445: the direct 'from transformers.utils import
  sentencepiece_model_pb2' raises TypeError ("Descriptors cannot be created
  directly") on modern protobuf runtimes. Prepend a sys.modules.setdefault
  pre-population using transformers.convert_slow_tokenizer.import_protobuf()
  so the subsequent from-import finds a compatible module via the module
  cache. The original import line is left verbatim at its place as the
  final resolver.
- tokenizer_utils.py:483: revert loop-4 widening; retag only NORMAL pieces
  to CONTROL. Retagging USER_DEFINED pieces caused a concrete tokenization
  regression where an intentionally-USER_DEFINED in-vocab special token had
  its sentencepiece encoding broken ('<user> hello' changed from [11, 3, 8]
  to [11, 0, 12, 21, 0, 8]). The PR's stated scope is the NORMAL->CONTROL
  Gemma case; USER_DEFINED handling is deferred.
- tokenizer_utils.py:475: defensive guard around entry["id"]. A malformed
  added_tokens entry missing the "id" field or with a non-int id is now
  skipped rather than raising KeyError / inserting garbage.

* Add review tests for sentencepiece GGUF fix

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

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

---------

Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
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-04-23 03:11:35 -07:00
Daniel Han
41a6cc8692
Studio: split vision-cache exception test to match transient vs permanent (#5145)
`TestVisionCacheOnException::test_exception_result_cached` currently
patches `load_model_config` with `side_effect=OSError("network down")`
and asserts `assert_called_once()`. That assertion is impossible by
design: `_is_vision_model_uncached` in
`studio/backend/utils/models/model_config.py` intentionally returns
`None` for `OSError` so `is_vision_model` does not cache the fallback
and retries on the next call. The module docstring on
`_vision_detection_cache` itself spells this out:

    Only definitive results (True/False from successful detection) are
    cached; transient failures (network errors, timeouts) are NOT
    cached so they can be retried.

The test has been failing identically on every downstream review run
against `unslothai/unsloth` main (e.g. `unsloth#5115`, `unsloth#5080`),
but the failure is not introduced by any of those PRs and does not
gate correctness.

Fix the collision by splitting the class into the two contracts the
code actually implements:

1. `test_permanent_exception_result_cached` keeps the original
   intent ("exception falls back to False and that False is cached")
   but uses `ValueError`, which is one of the exception types
   `_is_vision_model_uncached` treats as permanent and caches. No
   `huggingface_hub` import needed.

2. `test_transient_exception_not_cached` pins the opposite contract
   with the original `OSError("network down")`: the call returns
   False but the second invocation re-runs detection
   (`call_count == 2`). This guards against a future regression
   where somebody caches transient failures and then users with a
   flaky network permanently see wrong detection for a model.

Both tests use `assert ... is False` on the public API and mock-count
assertions on `load_model_config`; no private helpers are touched.
2026-04-23 00:22:40 -07:00
DoubleMathew
a1fb7c1297
fix/llamacpp_prebuilt_install (#5135)
* fix/llamacpp_prebuilt_installinclude libllama-common.so in bundle as its needed by llama.cpp now

* include support for rocm linux
2026-04-23 00:15:42 +04:00
Daniel Han
2bd6d544ff
Bump installer floor to 2026.4.7 (#5134) 2026-04-22 09:28:47 -07:00
Daniel Han
71014f4f4e Update _utils.py 2026-04-22 09:17:50 -07:00
Datta Nimmaturi
f9682e656c
update gema4 chat templates (#5116)
* update gema4 chat templates

* udpate template

* update template for gemma4

* Add gemma4 chat template tests

* [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-04-22 09:04:08 -07:00