Commit graph

5,346 commits

Author SHA1 Message Date
danielhanchen
115810eae3 studio/sandbox: include /etc/passwd in pre-pass binding bias
_find_sensitive_paths does not match /etc/passwd (it lives in the
open-call gate's _SENSITIVE_FILE_PREFIXES list, not in _ABSOLUTE_SENSITIVE),
so a chained reassign p='/etc/hosts'; p='/etc/passwd'; open(p) kept
/etc/hosts as the representative and slipped through. Duplicate the
open-call prefix list in the pre-pass scope so _looks_sensitive catches
/etc/passwd and the analogous /proc/<pid> reads too.
2026-05-24 14:41:33 +00:00
danielhanchen
35d7b72832 studio/sandbox: use _find_sensitive_paths for binding-bias instead of substring
Refines the round-5 ``_looks_sensitive`` heuristic that biases
``_record_string_binding`` toward the dangerous value in a chained
reassignment. The substring hint set conflated ``/etc/shadow`` with
``/etc/hosts`` -- both contain ``/etc/`` -- so a payload like
``p = '/etc/hosts'; p = '/etc/shadow'; open(p)`` had ``cur`` already
flagged sensitive, the guard refused to update, and the resolved
value stayed at ``/etc/hosts`` (allow-listed). The chained shadow
binding then slipped through.

Now ``_looks_sensitive`` delegates to ``_find_sensitive_paths``, the
authoritative bash / file gate matcher, so the distinction is exact:
``/etc/hosts`` is allow-listed and ``/etc/shadow`` is sensitive.
``_record_string_binding`` also adopts a clean three-way rule mirroring
Python's last-wins semantics for sensitive values:

  * New sensitive value: always wins (covers the chained shadow case).
  * New benign value, current sensitive: keep current (static gate
    cannot prove the new value executes; err on blocking).
  * Both benign: latest seen wins.

Test suite still 528 passing.
2026-05-24 14:39:16 +00:00
pre-commit-ci[bot]
b038848482 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:35:56 +00:00
danielhanchen
17739721da studio/sandbox: close 7 bypass classes from cross-reviewer round-5 audit
Sonnet-panel review of round-4 surfaced seven concrete bypass classes
in the static gate. All seven are now closed (528 tests passing, 55
new R4 / R5 regression tests):

1. ``os.path.join`` alias bypasses. ``import os as o; o.path.join(...)``,
   ``from os.path import join`` (and ``as j``), ``from os import path``
   (and ``as op``), ``import posixpath as pp``, ``from posixpath import
   join`` -- previously the FQ match was literal-only (`os.path.join`,
   `posixpath.join`, `ntpath.join`). A pre-pass walk collects every
   alias of ``os`` / ``os.path`` / ``posixpath`` / ``ntpath`` and every
   from-import of ``join`` / ``expanduser``; the resolver checks
   ``<alias>.join`` and bare aliased names too.

2. ``shutil`` alias bypasses. ``import shutil as sh; sh.copy(...)``,
   ``from shutil import copyfile``, ``from shutil import move as mv``,
   etc. -- the file-copy gate matched only the literal ``shutil.X`` FQ.
   The pre-pass now tracks shutil module aliases and from-import
   aliases for ``copyfile`` / ``copy`` / ``copy2`` / ``copytree`` /
   ``move``; the gate canonicalises any matched alias to ``shutil.X``
   so the error message identifies the operation.

3. First-assignment-wins binding bypass. ``p = '/tmp/safe'; p =
   '/etc/shadow'; open(p)`` previously slipped because the pre-pass
   guard ``_target.id not in string_bindings`` ignored every
   reassignment, and the AST walk picked the safe value while Python
   uses last-wins at runtime. New ``string_bindings_all`` tracks every
   literal ever bound to a name; ``_record_string_binding`` biases the
   representative value toward sensitive-shaped paths via a substring
   hint set covering the credential / process-state root tokens. The
   reverse order (``shadow`` then ``safe``) is also caught.

4. Brace-expansion off-by-one. ``cat ~/.aws/{x0,...,x62,credentials}``
   exploited that ``_expand_brace_projections`` started with
   ``out = {original}`` (1 item) so a cap of 64 only left 63
   alternative slots. The inner loop also broke per-alternative on
   the cap, so the sensitive name at position 64+ was never reached.
   Raised the cap to 1024 and the inner loop now expands all
   alternatives of a brace in one pass before the outer cap can stop
   the queue.

5. ``thread-self`` in shell-expansion regex. ``cat /proc/thread-self/
   $(echo environ)`` was missed because ``_SENSITIVE_ROOT_WITH_EXPANSION_RE``
   only listed ``self|\d+`` in the ``/proc/...`` alternation, while
   ``_ABSOLUTE_SENSITIVE`` correctly included ``thread-self``. One
   alternation entry restores symmetry.

6. Eval / exec pre-pass not re-run. ``exec("p='/etc/shadow'\nopen(p)")``
   slipped because the inner AST visit ran without the string-binding
   pre-pass. Extracted the pre-pass into ``_run_string_binding_prepass``
   and call it on each inner literal payload before the visitor
   recurses, so payload-local variable assignments are visible.

7. Pathlib name binding pre-pass. ``p = Path('/etc/shadow');
   p.read_text()`` slipped because the pre-pass only resolved string
   literals -- pathlib constructor calls returned None and the bound
   name remained unresolved. Pre-pass now falls back to
   ``_extract_pathlib_target`` using per-tree alias sets so
   ``import pathlib as pl; p = pl.Path(...)`` and ``from pathlib import
   Path as P; p = P(...)`` both resolve. ``NamedExpr`` (walrus) is also
   surfaced by the pre-pass so walrus-inside-eval expressions are
   visible.

Pre-pass call order. The initial pre-pass invocation moves to AFTER
``_extract_pathlib_target`` is defined so the closure cell binds
correctly (Python looks up free variables in the enclosing scope at
CALL time, not at function-definition time).

Full sandbox suite: 528 passed (455 prior + 73 R4 / R5 regression tests).
2026-05-24 14:35:17 +00:00
pre-commit-ci[bot]
a6400ffc07 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:17:45 +00:00
danielhanchen
02e9e4867d studio/sandbox: close round-4 bypass classes (aliasing, FileIO, walrus, copytree)
Closes additional bypass classes surfaced while exercising the gate:

1. Module / function aliasing (`m = os; m.system(...)`,
   `p = os.popen; p(...)`): `visit_Assign` now propagates the source
   alias when one tracked-module name is bound to another, and tracks
   bound method references into `shell_exec_aliases`. Previously only
   `m = __import__('os')` was handled.

2. Importlib from-alias (`from importlib import import_module as IM;
   IM('os').system(...)`): a new visitor-scope `import_module_aliases`
   set plus a `_resolve_dynamic_module` wrapper recognises the
   bound name in both inline-call and bound-name forms.

3. Shutil directory exfil (`shutil.copytree('~/.ssh', dst)`):
   `_matches_sensitive_dir()` adds a directory-only matcher used by
   the file-copy gate only. The boundary `(?=/?$|/?[\s'\";&|)<>])`
   matches the path AS the directory but NOT a single file inside it,
   so per-file allow-listed reads (`~/.ssh/known_hosts`, `~/.ssh/id_rsa.pub`)
   still pass. Covers `.ssh`, `.aws`, `.config/gcloud`, `.gnupg`,
   `.docker`, `.kube`, `.password-store`, plus `/etc`, `/etc/ssh`,
   `/var/spool/cron`, `/proc/<pid>`.

4. Explicit-reader / aliased file readers (`io.FileIO('/etc/shadow')`,
   `codecs.open('/etc/shadow')`, `from io import FileIO; FileIO(...)`):
   the open-call detector now recognises these qualified forms and
   the visitor tracks `from io|codecs import FileIO|open` aliases.

5. Bytes-literal paths (`open(b'/etc/shadow')`) and walrus
   expressions (`open((p := '/etc/shadow'))`): `_extract_string_literal`
   and `_extract_string_from_node` resolve `bytes` Constants via strict
   UTF-8 decode and `NamedExpr` via RHS extraction (recording the
   binding so later uses of the walrus target resolve too).

6. Tuple / list unpacking destructuring (`(a, b) = ('/etc', 'shadow');
   open(a + '/' + b)` and `p, = ['/etc/shadow']; open(p)`): the
   string-binding pre-pass now folds matched-length Tuple/List
   destructurings element-wise.

7. Pandas / numpy file readers (`pd.read_csv('/etc/shadow')`,
   `np.fromfile('/etc/shadow')`, etc.): suffix-match the common
   reader method names so any alias of the source module flows
   through the same sensitive-path gate as `open()`.

91 new regression tests cover each class, both blocked and legitimate
allow-list cases. Full sandbox suite: 473 passed.
2026-05-24 14:17:20 +00:00
pre-commit-ci[bot]
60e0f5056e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-22 08:13:56 +00:00
Daniel Han
d64c2a10d4 studio/sandbox: close dynamic-import + /proc/self symlink bypasses
Closes two static-bypass classes flagged during round-3 review:

1. __import__('os').system(...) / importlib.import_module('os').popen(...)
   bypassed the bare os.system / subprocess.* gate because the receiver
   was an ast.Call rather than an ast.Name in os_aliases. Adds
   _resolve_dynamic_module_name() so:
     * inline __import__('os').system(...)
     * inline importlib.import_module('os').system(...)
     * import importlib; mod = importlib.import_module('os'); mod.popen(...)
     * m = __import__('subprocess'); m.run([...], shell=True)
   all flow through the same shell-escape detection as
   import os; os.system(...). Legit dynamic imports of safe modules
   (json, pathlib, ...) remain allowed.

2. /proc/<pid>/cwd and /proc/<pid>/root are symlinks to the process
   working directory and the filesystem root. The form
   open(/proc/self/cwd/../../etc/shadow) bypassed
   _normalize_path_separators because .. was collapsed against the
   literal path, not the symlinked target. The form
   open(/proc/self/root/etc/shadow) bypassed any chroot-style
   defence. Adds matching entries to _ABSOLUTE_SENSITIVE so the bash
   gate and the AST open() gate both block any access via these
   symlink prefixes. Legitimate /proc/self/status etc. introspection
   still flows.

Tests:
  TestFollowup_DynamicImportShellEscape (2 cases, 10 parametrised)
  TestFollowup_ProcSelfSymlinkTraversal (3 cases, 16 parametrised)

  pytest studio/backend/tests/test_sandbox_hardening.py -q
    -> 382 passed in 0.67s (was 356).
2026-05-22 08:13:37 +00:00
pre-commit-ci[bot]
f2bbe27cde [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 12:33:45 +00:00
Daniel Han
5b0735308a Merge branch 'studio-sandbox-hardening' of https://github.com/unslothai/unsloth into studio-sandbox-hardening
# Conflicts:
#	studio/backend/core/inference/tools.py
2026-05-19 12:33:29 +00:00
Daniel Han
8176694d94 studio: address round-3 sandbox review findings
Round-4 follow-up on the hardening PR after a third 20-reviewer pass.
Closes the high-impact items from that review while preserving the
"do not regress legitimate tool calling" floor; lower-vote items that
would have measurable regression on legit code paths (broad shell
glob ?/*, $VAR in dynamic paths, ANSI-C $'...') are intentionally
deferred.

Parent-directory traversal: _normalize_path_separators now follows
.. segments through posixpath.normpath and reattaches the tilde or
${HOME} prefix, so cat /etc/apt/../shadow and
Path('/proc/self/fd/../environ').read_text() both reach the
canonical regex.

Built-in open() accepts PathLike: open(Path('/etc/shadow')) and
open(file=Path('/etc/shadow')) now flow through the pathlib resolver
the same way receiver reads do.

Pathlib home and transforms: Path.home() resolves to ~ so
(Path.home() / '.aws/credentials') hits the home regex;
.expanduser() / .resolve() / .absolute() are pass-throughs.

Pathlib semantics: _join_path_parts() now matches pathlib's
absolute-segment reset so Path('/tmp') / '/etc/shadow' resolves to
/etc/shadow as it does at runtime.

from builtins import exec as e: tracked in both visitors via
eval_exec_aliases so the aliased call still routes through the
literal-payload recursion.

Process state extensions: /proc/self/cmdline,
/proc/thread-self/*, and /proc/<pid>/task/<tid>/* are added to
_ABSOLUTE_SENSITIVE.

Numeric f-strings: f'/proc/{1}/environ' folds to a literal because
numeric ast.Constant values inside ast.FormattedValue are now
stringified.

os.path.join / os.path.expanduser: resolved statically by
_extract_string_from_node so the stdlib-helper construction paths
do not hide sensitive targets.

Variable assignment tracking: a pre-pass collects ``name = literal``
and ``name = eval`` / ``name = exec`` bindings; the visitors and the
pathlib resolver consult those bindings. The trusted-host gate
intentionally uses a separate strict literal extractor so legit
patterns like ``url = some_input; requests.get(url)`` still pass.

shutil.copyfile / copy / copy2 / copytree / move: the source argument
is gated the same way open() is, blocking file-copy exfil.

Concrete pathlib classes: PosixPath / WindowsPath / PurePath / etc.
are registered in path_aliases by default.

requests.request positional+keyword: for URL-second APIs, args[0] is
the HTTP method (not the URL); when there is only one positional, the
URL extraction falls through to the url= keyword instead of grabbing
the method.

Tests grow from 281 to 357 hardening cases; combined sweep 487 / 487.
Every fix has positive and negative coverage; legit tool calls
(open(Path('data.csv')), os.path.join('logs', 'today.log'),
url = some_input; requests.get(url), shutil.copyfile('a.txt', 'b.txt'))
continue to pass.
2026-05-19 12:32:29 +00:00
pre-commit-ci[bot]
f2809221d4 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 12:02:09 +00:00
Daniel Han
7a0bacdba8 studio: address round-2 sandbox review findings
Round-3 follow-up on the same hardening PR after a second 20-reviewer
pass. Every change is detection-widening; legitimate tool calls remain
allowed.

Pathlib readers (Path.open, Path.read_text, Path.read_bytes) now share
one extraction path. The new _extract_pathlib_target helper resolves
Path(a), Path(a, b, ...), Path(...).joinpath(b), and Path(...) / b
through statically-resolvable string parts. NetworkAndIoVisitor tracks
Path aliases (from pathlib import Path as P) and pathlib module aliases
(import pathlib as pl) so the aliased forms hit the same gate. For
receiver-side reads the path is taken exclusively from the receiver --
Path('/etc/shadow').open('r') no longer mis-reads the mode flag as a
path.

The credential-path regex set now matches the POSIX ~user/ expansion
(cat ~ubuntu/.aws/credentials), and the SSH private-key end anchor
includes > so that redirect-attached forms (cat ~/.ssh/id_rsa>... )
are not split-tokenised through the gate. A new
_SENSITIVE_ROOT_WITH_EXPANSION_RE detects sensitive root prefixes
followed by $(...) or backtick substitution, and _find_sensitive_paths
now enumerates bash brace expansion {a,b} plus small glob char classes,
and runs every projection through path-separator normalisation that
collapses // and /./.

Network host validation reaches keyword arguments (url=, host=,
hostname=, address=), host-first APIs whose first positional arg is the
host (socket.create_connection, socket.getaddrinfo,
http.client.HTTPConnection, http.client.HTTPSConnection), and the
url-second APIs (requests.request, httpx.request).

builtins.exec, builtins.eval, and __builtins__.eval flow through the
same literal-payload recursion as the bare forms, including aliased
import builtins as b. open(file=...) and io.open(file=...) keyword
forms are gated alongside the positional form, and the open() path
candidates run through both backslash normalisation and the
//-collapse projection so equivalent spellings (/etc//shadow,
/etc/./shadow) cannot bypass.

Tests grow from 205 to 281 hardening cases (TestR2Finding1 through
TestR2Finding16) and from 131 + 205 = 336 to 131 + 281 = 412 in the
local sweep. Negative cases for every fix continue to ensure
legitimate tool use (Path('data.csv').open(), open(file='logs/today.log'),
requests.get(url='https://wikipedia.org/'), find src/, etc.) stays
allowed.
2026-05-19 12:00:34 +00:00
Daniel Han
e598ebf1d0 Merge remote-tracking branch 'origin/main' into studio-sandbox-hardening 2026-05-19 11:51:10 +00:00
alkinun
b01a1ba1c2
Fix GGUF multi-image chat handling (#5508)
Preserves per-turn OpenAI image_url content parts in the standard GGUF /v1/chat/completions path so multi-image chat history keeps each image attached to its original turn. Legacy top-level image_base64 is injected as a synthetic image_url part only when no message-level image exists. Tool use is disabled whenever any GGUF image is present. Fixes #5470.
2026-05-19 04:36:20 -07:00
Daniel Han
eda3be4101 Merge branch 'studio-sandbox-hardening' of https://github.com/unslothai/unsloth into studio-sandbox-hardening 2026-05-19 11:11:45 +00:00
Daniel Han
8fd57530d1 studio: always use POSIX shlex for sensitive-path dequote
On Windows runners shlex.split(posix=False) leaves splice quotes in place, so cat /etc/sha''dow tokenises to ['cat', "/etc/sha''dow"] and the dequoted scan projection still misses the credential. The threat model is POSIX-shell quote splicing in either bash invoked on Windows or POSIX shells on Linux/macOS; the dequote always wants POSIX semantics. Pre-normalise backslashes so Windows drive paths survive POSIX shlex's escape handling.
2026-05-19 11:11:20 +00:00
pre-commit-ci[bot]
40d60b05c5 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 11:09:53 +00:00
Daniel Han
e06126933e Merge branch 'studio-sandbox-hardening' of https://github.com/unslothai/unsloth into studio-sandbox-hardening
# Conflicts:
#	studio/backend/core/inference/tools.py
2026-05-19 11:08:34 +00:00
Daniel Han
2ae885ce72 studio: address sandbox hardening review findings
Round-2 fixes for ten issues surfaced by a 20-reviewer code review of
the initial hardening patches. Every change is detection-widening or a
false-positive narrowing; legitimate tool calls keep working.

Direct Python open now flows through _find_sensitive_paths so
open('/home/u/.aws/credentials').read() is gated the same as
os.system('cat ~/.aws/credentials'). The previous wiring covered only
the bash and shell-exec sides.

Both SignalEscapeVisitor and NetworkAndIoVisitor fail-closed once the
eval / exec literal recursion cap is reached. Wrapping a payload in
four or more nested literal exec layers no longer silently bypasses
inspection.

_find_sensitive_paths scans three projections of the command (raw,
backslash-normalised, shlex-dequoted) and recurses into nested
bash -c and cmd /c shells. Quote-spliced and Windows-backslash forms
of credential paths are all caught.

_HOME_PREFIX_RE adds Windows-style home prefixes (USERPROFILE,
HOMEDRIVE HOMEPATH, env:USERPROFILE, drive-letter Users) so cross-OS
hardening actually applies on Windows. Both sensitive-path regexes
now have a path-token start anchor so project-local lookalike paths
under workspace, fixtures, and tmp are not blocked.

Network host validation for sock.connect and the requests / urllib
FQ-prefix branch now use _extract_string_from_node instead of raw
ast.Constant checks, so concatenated and f-string literal hosts
resolve the same way the open gate already did.

pathlib.Path('/etc/shadow').open() is now inspected; the path is
extracted from the receiver constructor when node.args is empty.

The static-string resolver depth cap moves from 6 to 64, removing the
single-character literal-concat bypass while leaving the recursion
well inside CPython's default frame limit.

The SSH private-key regex gains a filename-end boundary so reading a
public ".pub" key stays allowed (legit developer action) while the
matching private key is still denied.

New regression tests cover one class per finding (TestFinding1 through
TestFinding10) plus updated nested-depth coverage; the previous
test_nested_depth_does_not_crash assertion was inverted by the
fail-closed change and has been replaced. Local sweep: 336 passed
(131 upstream + 205 hardening).
2026-05-19 11:07:16 +00:00
Daniel Han
6efb0f64ea Merge remote-tracking branch 'origin/main' into studio-sandbox-hardening 2026-05-19 10:58:55 +00:00
swappy
66cfbeac1d
Fix loss function not patched for Qwen3.5 models (#5442)
* fix: patch loss functions for Qwen3_5ForConditionalGeneration to prevent OOM errors

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

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

* Narrow except scope and simplify LOSS_MAPPING sweep

Replace bare except Exception with the only two compatibility errors we
actually care about so genuine bugs in the sweep surface. Drop the
redundant _key != "ForCausalLM" guard since the __name__ predicate
already excludes the patched entry (UnslothForCausalLMLoss != ForCausalLMLoss).

* [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-19 03:57:50 -07:00
Daniel Han
5ce4ab4d54
studio: emit one comma-chained --spec-type for CPU/Mac MTP path (#5575)
* studio: emit one comma-chained --spec-type for CPU/Mac MTP path

llama-server takes a single --spec-type whose value may be
comma-separated to chain implementations (e.g. ngram-mod,draft-mtp).
The CPU/Mac MTP branch in LlamaCppBackend.load_model was passing
--spec-type twice in the same invocation, which is not the documented
chaining mechanism and silently drops one of the two specs depending
on llama.cpp's argv handling.

Collapse the pair to --spec-type ngram-mod,{mtp_token} and update the
stale _extra_args_set_spec_type docstring that claimed llama-server
accumulates repeated --spec-type. Update the matching pass-through
fixture in test_llama_server_args.py.

* studio: align MTP ngram-mod knobs with llama.cpp upstream defaults

Two correctness fixes against the llama.cpp server README:

1. The CPU/Mac comma-chained branch was emitting
   --spec-ngram-mod-n-max 6 with --spec-ngram-mod-n-min 48, which is
   nonsensical (min > max). Per the upstream default the value is 64.

2. The standalone ngram-mod branch was emitting --spec-ngram-size-n,
   --draft-min, --draft-max. llama.cpp removed those arg aliases for
   ngram-mod (they live only on the ngram-simple / map families now);
   the correct knobs are --spec-ngram-mod-n-match / n-min / n-max.

Also refresh the inline comment block to point at the server README
rather than the older docs/speculative.md draft- aliases.
2026-05-19 03:16:05 -07:00
Junhyuk Lee
94026fc8dc
fix(loader): honour HF_HUB_OFFLINE / TRANSFORMERS_OFFLINE in from_pretrained (#5598)
Reads HF_HUB_OFFLINE / TRANSFORMERS_OFFLINE in FastLanguageModel.from_pretrained and FastModel.from_pretrained, forcing local_files_only=True so all delegation paths (load_in_4bit, load_in_8bit, full_finetuning, qat_scheme) and direct FastModel callers (FastVisionModel, FastTextModel) honour offline mode. Also gates HF_HUB_ENABLE_HF_TRANSFER in unsloth/dataprep/synthetic.py and adds an early return in get_statistics. Pairs with unslothai/unsloth-zoo#675. Fixes #5316.
2026-05-19 01:05:13 -07:00
Michael Han
c4908b7929
studio: fix toast close-button click and light-mode hover (#5597)
Two related issues on the chat toasts:

1. Close X did nothing. The lib/toast.ts wrapper defaulted every toast
   to `dismissible: false` (originally to keep swipe capture from
   stealing text selection). In sonner v2, `dismissible: false` makes
   the close-button onClick a no-op, so the X looked clickable but
   never dismissed the toast. The Toaster already sets
   `swipeDirections={[]}` in components/ui/sonner.tsx, so the
   per-toast swipe workaround is unnecessary and harmful. Replace the
   wrapper with a thin re-export of sonner.

2. Close X hover collapsed to a near-black circle in light mode.
   Sonner's default close-button styling uses fixed gray-scale tokens
   (--gray2 hover, --gray12 text) that ignore the theme attribute.
   Once the Toaster's inline style overrides --normal-bg with
   var(--popover), the base background follows the app theme but the
   hover state does not, so the hover bg lands on a color that has no
   contrast with the X glyph. Pin both base and hover to theme tokens
   (--popover, --muted, --popover-foreground, --border) so contrast
   stays visible in both light and dark modes.

Repro: open chat, load any cached model, hover the X on the
"<name> loaded" toast in light mode -- before this change the circle
turned dark and the click did nothing; after, the circle stays light
and the click dismisses the toast.
2026-05-19 00:55:55 -07:00
pre-commit-ci[bot]
829698280a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 07:52:08 +00:00
Daniel Han
8a5080f26b studio: regression tests for sandbox hardening patches A / B / D
124 tests across 7 classes:

  * TestPatchA_DynamicPaths — open() with concatenated literals + f-strings.
    Pins 7 attack patterns BLOCKED, 6 legitimate dynamic paths ALLOWED,
    12-level deep concat doesn't crash.

  * TestPatchB_FindSensitivePathsHomeAnchored — ~/.ssh/id_*, ~/.aws/,
    ~/.docker/, ~/.kube/, ~/.pypirc/.npmrc, ~/.netrc, ~/.password-store,
    ~/.gnupg/private-keys-v1.d across ~, $HOME, /home/<u>, /root,
    /Users/<u>. Pins 21 attack paths BLOCKED, 15 legitimate paths
    (~/.gitconfig, ~/.bashrc, ~/.ssh/{config,known_hosts}, ~/.npm,
    project-local rc files, /tmp/.npmrc) ALLOWED.

  * TestPatchB_FindSensitivePathsAbsolute — /etc/shadow, /etc/sudoers,
    /etc/ssh/ssh_host_*, /proc/{self,<pid>}/{environ,mem,maps,auxv},
    /proc/kcore, /proc/kallsyms, /var/spool/cron/. Pins 12 attacks
    BLOCKED, 11 legit paths (/etc/hosts, /etc/resolv.conf, /proc/cpuinfo,
    /proc/meminfo, …) ALLOWED.

  * TestPatchB_PythonShellExec — same surface flows through os.system /
    subprocess.run. 6 attacks BLOCKED, 11 legitimate tool-calls ALLOWED.

  * TestPatchD_EvalExecLiteralPayload — exec/eval with a literal payload
    parsed and re-checked. 5 attack payloads BLOCKED, 6 legit
    expressions (eval('1+2'), exec('print("hi")'), nested innocuous
    exec) ALLOWED.

  * TestPatchD_EvalExecDynamicPayload — non-literal eval/exec args
    flagged as dynamic shell escape. 4 patterns BLOCKED.

  * TestPatchD_NestedDepthCap — 10-level nested exec(exec(...)) caps
    at depth 3, doesn't crash, doesn't false-positive.

  * TestCrossCuttingNoRegression — 6 pre-existing BLOCK patterns still
    fire (sudo, signal tampering, /etc/passwd literal, untrusted host,
    metadata host); 7 pre-existing ALLOW patterns still pass (print,
    json.loads, trusted host, dataclass, legitimate open()).

Result on the rebuilt scaffold:
  131/131 pre-existing tests in test_sandbox_tools.py pass
  124/124 new hardening tests pass
  255/255 combined, zero regressions

The "must remain ALLOWED" cases form the non-regression floor that
prevents the patches from making LLM tool calling dumber.
2026-05-19 07:04:49 +00:00
Daniel Han
6984fa8d7c studio: recurse into eval / exec literal payloads (Patch D)
The AST gate previously had no special handling of eval() / exec(),
so a literal payload would slip past every detector:

  exec("import os; os.system('sudo whoami')")          # ALLOWED before
  exec("open('/etc/shadow').read()")                    # ALLOWED before
  eval("__import__('blocked_mod').dangerous()")         # still allowed (chained-call gap)
  payload = '...'; exec(payload)                        # ALLOWED before

Both visitors (SignalEscapeVisitor and NetworkAndIoVisitor) now share
the same gate at the top of visit_Call: when the call is bare-name
eval / exec, try to resolve the first argument via the shared
_extract_string_from_node helper (Patch A); if it resolves, parse it
and recursively visit so every existing detector runs on the inner
code — signal tampering, shell escape, sensitive-file open, network
policy, upload denylist, etc.

When the payload is not statically resolvable, SignalEscapeVisitor
appends a `shell_escape_dynamic` finding — eval/exec of runtime data
is the textbook code-injection vector and there is no legitimate LLM
tool-call reason to dynamically eval an external string. Static
literals (eval('1 + 2'), exec('x = 1\\ny = 2')) are unchanged because
the recursive visit only flags what the rest of the AST gate would
already flag at top level.

Each visitor caps recursion at depth 3 (own counter on the instance)
so adversarial nested eval('eval(...)') cannot blow the stack.

Closes gaps #7, #8 (partial), #11 (partial) from the 13-gap audit.
Out-of-scope chained-call cases (__import__('os').system(...),
getattr(os, 'sys'+'tem')()) stay documented gaps — the OS sandbox is
the intended backstop, see PR 5468.

Regression: 131/131 studio/backend/tests/test_sandbox_tools.py pass.
Legitimate eval/exec on literal expressions (eval('1+2'),
exec('print("hi")'), exec('exec("print(1)")')) verified ALLOWED.
2026-05-19 07:02:47 +00:00
Daniel Han
2965cd8310 studio: gate credential / process-state paths in bash and Python (Patch B)
Adds _find_sensitive_paths() and wires it into _bash_exec (alongside the
existing _find_blocked_commands check) and into _check_args_for_blocked
(so the Python AST gate catches os.system('cat ~/.ssh/id_rsa') the same
way bash $ cat ~/.ssh/id_rsa is caught).

The pattern set is intentionally narrow — only clear-cut credential and
process-state targets:

  Home-anchored (must be prefixed by ~, $HOME, ${HOME}, /home/<u>,
  /root, /Users/<u>):
    .ssh/id_rsa, .ssh/id_ed25519, .ssh/id_ecdsa, .ssh/id_dsa, .ssh/identity
    .aws/credentials, .docker/config.json, .kube/config
    .config/gcloud/{application_default_credentials,access_tokens,credentials}
    .pypirc, .npmrc, .cargo/credentials
    .netrc, .password-store, .gnupg/private-keys-v1.d

  Absolute system targets (match anywhere):
    /etc/shadow, /etc/sudoers, /etc/ssh/ssh_host_*
    /proc/{self,<pid>}/{environ,mem,maps,auxv}
    /proc/kcore, /proc/kallsyms
    /var/spool/cron/

The home-anchored category uses a regex that requires a HOME-equivalent
prefix, so project-local rc files like ./project/.npmrc remain readable
while ~/.npmrc is denied. Legitimate LLM-developer-tool paths
(~/.gitconfig, ~/.bashrc, ~/.ssh/config, ~/.ssh/known_hosts, /etc/hosts,
~/.cache/, ~/.bash_history, project rc files) are intentionally NOT in
the list and still flow through unchanged.

Closes gaps #1, #2, #3, #12, #13 from the documented 13-gap audit.

Regression sweep:
  * 131/131 studio/backend/tests/test_sandbox_tools.py pass
  * 24 legitimate-use cases verified ALLOWED
  * 17 attack patterns verified BLOCKED
2026-05-19 06:57:53 +00:00
Daniel Han
3e4704a856 studio: resolve concatenated + f-string paths in sensitive-file gate (Patch A)
Static-string resolution in _extract_string_from_node was limited to bare
ast.Constant. Concatenated string literals and f-strings with constant
parts evaluated to ast.BinOp / ast.JoinedStr and slipped past the
open() sensitive-file check, so:

  open('/etc/' + 'shadow')           # ALLOWED before
  open(f'/etc/{"shadow"}')           # ALLOWED before
  open('/etc/passwd')                # BLOCKED before (literal)

The helper now resolves ast.BinOp(Add) of two resolvable strings and
ast.JoinedStr whose parts are themselves resolvable. The open()
sensitive-file check uses the helper instead of an inline ast.Constant
isinstance check, so the same widening covers concatenated/f-string
paths without changing what was already blocked.

Resolution is depth-capped at 6 to keep adversarial deep nesting from
blowing the stack. All other call sites of the helper
(_check_args_for_blocked, dynamic-arg shell-escape detection, HF upload
path-shape inspection) automatically inherit the broader resolution.

Closes open() gaps #4 and #6 from the documented 13-gap audit. Does not
attempt to model variable flow (gap #5 stays open by design — the OS
sandbox is the right layer for runtime flow).

Regression: 131/131 studio/backend/tests/test_sandbox_tools.py pass.
2026-05-19 06:54:26 +00:00
pre-commit-ci[bot]
ba710a783a
[pre-commit.ci] pre-commit autoupdate (#5586)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.15.12 → v0.15.13](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.12...v0.15.13)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-18 22:57:43 -07:00
Daniel Han
27845b1fa2
studio: read Playwright default model from defaults.py without importing it (#5595)
* studio: read Playwright default model from defaults.py without importing it

The Playwright Chat UI job installs Studio with --no-torch and does not
have structlog. Importing core.inference.defaults pulls in
core/inference/__init__.py (eager orchestrator -> structlog) and
defaults.py's own `import utils.hardware.hardware as hw` (also
structlog), so the test died before the first page action.

Read DEFAULT_MODELS_GGUF as a literal via ast.literal_eval. Zero side
effects, no new test deps, the EXPECTED_DEFAULT_MODEL override still
wins.

* [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-18 20:03:55 -07:00
Lee Jackson
b7f63d3a9e
fix: derive Playwright default model expectation (#5589) 2026-05-18 18:22:26 -07:00
Daniel Han
f1fcf0054c
install scripts: bump unsloth pin to >=2026.5.4 (#5566)
PyPI unsloth 2026.5.4 is now live; update install.sh and install.ps1
to require at least that version so fresh installs pull the new release.
2026-05-18 08:52:09 -07:00
Daniel Han
4699c7e291
studio: engage draft-mtp on vision MTP GGUFs (drop incorrect vision gate) (#5560) v0.1.405-beta
* studio: engage draft-mtp on vision MTP GGUFs

The draft-mtp auto-promotion in LlamaCppBackend.load_model was gated on
not effective_is_vision, and the spec-emit branch repeated the same
guard. Every Unsloth -MTP GGUF repo ships an mmproj projector, so
effective_is_vision was always True for those repos and the MTP speedup
silently never engaged out of the box.

llama.cpp #22673 explicitly states MTP is compatible with vision input.
The bundled b9204 server happily loads both: a manual run with
--mmproj ... --spec-type draft-mtp --spec-draft-n-max 6 logs
"loaded multimodal model" followed by
"adding speculative implementation 'draft-mtp'".

Drop the vision gate from both sites and rewrite the matching short
circuit in _already_in_target_state so reload checks reach the auto
promotion path on vision MTP loads. Add three regression tests covering
vision MTP match (auto and default), and non MTP vision repo unaffected.

Verified on a B200 with unsloth/Qwen3.6-35B-A3B-MTP-GGUF:UD-Q4_K_XL:
base decode 179.7 t/s vs MTP decode 253.8 t/s, draft acceptance 0.57,
1.41x speedup on a 255 token completion. mmproj still loads and image
input remains available.

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

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

* studio: prefer Qwen3.5 -MTP GGUF variants in default model lists

With the vision gate dropped in the previous commit, draft-mtp now
auto-engages on -MTP GGUF repos out of the box. Swap the four Qwen3.5
recommended entries in DEFAULT_MODELS_GGUF and DEFAULT_MODELS_STANDARD
to their -MTP-GGUF counterparts so new users get the speedup by default:

  unsloth/Qwen3.5-4B-GGUF        -> unsloth/Qwen3.5-4B-MTP-GGUF
  unsloth/Qwen3.5-9B-GGUF        -> unsloth/Qwen3.5-9B-MTP-GGUF
  unsloth/Qwen3.5-35B-A3B-GGUF   -> unsloth/Qwen3.5-35B-A3B-MTP-GGUF
  unsloth/Qwen3.5-0.8B-GGUF      -> unsloth/Qwen3.5-0.8B-MTP-GGUF

All four HF repos exist (HEAD 200) and ship the same UD-Q4_K_XL quant
layout as the non-MTP variants. Non-Qwen3.5 entries are untouched.

* bump version to 2026.5.4

Picks up the studio MTP vision-gate fix and the Qwen3.5 -MTP default
swap in this PR.

* studio: prefer Qwen3.6-35B-A3B-MTP-GGUF in default model lists

Same rationale as the previous Qwen3.5 swap. The Qwen3.6 MTP variant
exists at unsloth/Qwen3.6-35B-A3B-MTP-GGUF (HF HEAD 200) and now
auto-engages draft-mtp out of the box with the gate fix.

* studio: drop --spec-draft-n-max from 6 to 3 for draft-mtp

n=6 is too greedy: on Qwen3.6 the draft has to guess 6 tokens ahead
and acceptance crashes to ~0.45, leaving only ~14% throughput gain.

PR ggml-org/llama.cpp#22673's author benched n=3 at ~0.72 acceptance
and 2 to 3x speedup on the same Qwen3.6 family, and the README sample
command uses n=2 or n=3. Match that.

CPU/Mac branch already uses n=3, so this aligns both paths.

* studio: set --spec-draft-n-max back to 6 for draft-mtp on GPU

Reverts the n=3 tuning. n=6 is the original default; user-side comparisons
hold the larger draft window steady so the toggle (next commit) is the
primary on/off lever.

* studio: add Speculative Decoding toggle under Max Tokens

Adds a top-level kill switch (panel-switch under Max Tokens, mirroring
Auto-Healing Tool Calls) that forces the /load request's
speculative_type to "off" when disabled. The backend "off" branch in
LlamaCppBackend.load_model skips both the draft-mtp auto-promotion and
the spec-emit branch, so neither --spec-type draft-mtp nor
--spec-default reaches llama-server.

Wiring:

- chat-runtime-store: new speculativeDecodingEnabled bool, default
  true, persisted to localStorage under unsloth_speculative_decoding,
  plus a setSpeculativeDecodingEnabled setter.
- chat-settings-sheet: SpeculativeDecodingToggle rendered immediately
  beneath the Max Tokens slider for non-external models.
- use-chat-model-runtime: when speculativeDecodingEnabled is false,
  override speculative_type to "off" in the loadModel call so the
  switch wins over any pre-existing speculativeType state (including
  the existing per-model toggle in Model Settings).

Verified end to end on unsloth/Qwen3.6-35B-A3B-MTP-GGUF:UD-Q4_K_XL:
toggle ON emits --spec-type draft-mtp --spec-draft-n-max 6; toggle
OFF emits zero --spec-* flags on the same MTP GGUF.

* studio: relocate Speculative Decoding toggle into Model Settings

Move the toggle out from under Max Tokens and back into the Model
Settings section, directly beneath KV Cache Dtype, where the existing
Apply/Reset workflow already drives a reload on dirty. This way flipping
the switch in the UI actually picks up: the section becomes dirty,
Apply re-runs /load with the new speculative_type.

Drop the !currentModelIsMultimodal gate so vision MTP GGUFs can also
disable speculative decoding from the UI.

Switch the toggle's off-value from null to "off" so the backend's "off"
short-circuit fires for MTP models too (null normalises to None which
re-triggers the draft-mtp auto-promotion).

Tooltip now reads "Faster generation with 0% accuracy hit".

Remove the now-redundant speculativeDecodingEnabled bool + setter from
the runtime store and the load-time override in use-chat-model-runtime;
the toggle binds directly to speculativeType.

* studio: restore OOM/TIGHT badge on recommended GGUF rows

The recommended-list row passed vramStatus=null for any GGUF repo
because the existing useRecommendedModelVram hook reads safetensors
totals from HF model info, which GGUF-only repos do not expose. As a
result, an OOM Q-quant repo would render with only a "GGUF" badge and
no visual signal that nothing in it fits.

Add useGgufRecommendedFit: per repo, fetch the variant list via the
existing /api/models/gguf-variants endpoint, take the smallest
variant's size_bytes, and classify with the same 0.7*GPU + 0.7*RAM
thresholds as GgufVariantExpander. Session-scoped cache + in-flight
dedup so a repo is requested at most once.

Wire the result into the three GGUF row sites in pickers.tsx so OOM
and TIGHT badges show on the collapsed cards.

* Revert "studio: restore OOM/TIGHT badge on recommended GGUF rows"

This reverts commit 07793b1240df72b13e51d6dc15f63c4ee8c6cba9.

The new useGgufRecommendedFit hook was treating the symptom. PR #5561
identified the real root cause: useGpuInfo was calling /api/system
with plain fetch instead of authFetch, so the session-auth check
failed silently and gpu.available stayed false everywhere. With no
GPU info, every fit check (variant expander, recommended carousel)
fell back to "no signal" and dropped the OOM/TIGHT badges.

Reverting the over-engineered hook and applying the authFetch fix
in the next commit, which restores the existing badges with one line.

* chore: replace qwen suggested with MTP variant

* fix: restore GPU info auth for GGUF fit badges

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
2026-05-18 08:42:55 -07:00
Daniel Han
a2f3793145
install scripts: bump unsloth pin to >=2026.5.3 (#5557)
unsloth 2026.5.3 was just published to PyPI. Update install.sh and
install.ps1 so fresh installs pull the new release (5 occurrences each).

Co-authored-by: Daniel Han <info@unsloth.ai>
2026-05-18 06:46:50 -07:00
Ashwin Upadhyay
361f9f9d02
studio/chat: release stuck IME flag when compositionend never fires (#5551) v0.1.40-beta
* studio/chat: release stuck IME flag when compositionend never fires

Chrome on Windows talking to a WSL-hosted Studio (issue #5546) fires
compositionstart + compositionupdate but no compositionend after the
IME commits. The earlier hardening in #5327 cleared the stale flag on
the next non-composing input event, which never arrives in this
sequence, so composingRef stays true forever and the Send button stays
disabled even though the committed CJK text is already in the textarea.

Add a watchdog in both useImeComposerInputHandlers (main + edit
composer) and SharedComposer (compare mode) that runs the same reset
the missing compositionend would have done. The timer is rearmed on
every compositionupdate and on every non-composing input so it only
fires when the IME pipeline has actually gone quiet — normal candidate
selection keeps it alive, the WSL stuck case lets it expire.

Extends the existing IME Playwright smoke with a stuck-compositionend
repro and adds a static guard so the watchdog can't be removed without
the regression tests catching it.

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

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

* studio/chat: re-pin composing flag on IME keydown to close #5546 watchdog gap

The stuck-compositionend watchdog (PR #5551) releases composingRef after
2500 ms of IME silence so Send unwedges in the WSL+Chrome case. The same
release also fires during a long candidate-window pause in healthy IMEs,
which lets a subsequent IME-confirm Enter slip preedit text through
handleSubmit (main composer) or click-Send through send() (compare composer).

Add a keydown gate to both composers: when the browser still reports
nativeEvent.isComposing or keyCode 229, re-pin composingRef and cancel
any pending watchdog so the next form-submit / send() guard refuses.
The Send button stays visually enabled (avoids re-introducing the
stuck-UI bug) but the submit path is blocked until a real compositionend
or non-composing input arrives. Mirrors the existing isComposing guard
shape in shared-composer.onKeyDown.

Tests:
- tests/studio/test_composer_rtl_bidi_attribute.py: two new static
  guards asserting the keydown gate wiring in both composer files.
- tests/studio/playwright_chat_ime_i18n.py: new section 6c repro that
  fires the IME-confirm keydown after the watchdog has cleared, then
  triggers form.requestSubmit() and asserts the preedit text is not
  cleared (would indicate a leaked submit).

Verified across Chromium / Firefox / WebKit via a side-by-side pre-PR
vs post-PR simulation (54 scenarios, zero pageerror or console.error).
The #5546 stuck-end repro still passes (Send re-enables 2.5-3 s after
the silent commit) and the new keydown-repin probe confirms the submit
gate refuses on all three engines.

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

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

* studio/chat: re-arm IME watchdog after keydown re-pin (Codex P1)

The keydown re-pin added in 2c3c9793 closed the watchdog-race for
healthy IMEs, but on the same WSL+Chrome no-compositionend path this
PR targets it would re-lock Send permanently: setting composingRef=true
and only *clearing* the watchdog leaves the flag pinned forever if no
follow-up compositionend or non-composing input ever arrives.

Swap clearStuckTimer/clearStuckImeTimer for refreshStuckTimer/
refreshStuckImeTimer in both composer keydown gates so the watchdog
fires once more after every IME keypress. Same visual contract — Send
stays enabled — the submit gate just keeps a 2.5s window before
re-releasing instead of staying locked.

Extends the playwright IME smoke with section 6d: clears composing via
the watchdog, fires an IME keydown, then waits past the re-armed
watchdog window and asserts the form submit actually flushes the
textarea. Two new static guards in test_composer_rtl_bidi_attribute
lock the refresh call into both keydown handlers.

* [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-18 06:30:38 -07:00
Roland Tannous
c0cc975c91
fix(studio): handle expired OpenAI shell-tool containers without surfacing error in chat (#5547)
* fix(studio): transparent retry on expired OpenAI shell container

* fix(studio): drop expired OpenAI containers before send
2026-05-18 05:47:57 -07:00
Daniel Han
aa374319d1 Versioning 2026-05-18 05:29:52 -07:00
Daniel Han
eacf448aae
images: use narrower Discord button and drop duplicate (#5552)
Two near-identical Discord button images existed under images/, with
the only effective difference being the rendered button width. Keep
the narrower variant (formerly the lowercase "discord button.png")
and remove the wider "Discord button.png", consolidating to a single
"Discord button.png" file.
2026-05-18 05:00:59 -07:00
Daniel Han
d774af2041
tests + CI: callback signature drift detector (#5498)
* tests: callback signature drift detector

Static AST check that fails fast when a producer in unsloth_zoo (or
unsloth) changes the arity of a callback but a consumer callback def
still declares the old arity. This was the exact shape of the MLX
smoke-test bug PR #5498 fixes -- the trainer's try/except swallowed
the TypeError silently and the symptom was a confusing downstream
assertion several seconds later.

What the detector does:
  * Producer side: walks every .py and finds classes that own a
    self._<name>_callbacks list, populated via .append() from an
    add_<name>_callback method, and invoked via
    `for cb in self._<name>_callbacks: cb(arg1, ..., argN)`. The
    arity at the call site is the canonical expected arity.
  * Consumer side: walks every <obj>.add_<name>_callback(fn) call,
    resolves fn to a def or lambda in the same file, and asserts
    arity matches. Consumers that use *args or **kwargs are
    tolerantly accepted as any arity.
  * Sources: REPO_ROOT (unsloth) plus UNSLOTH_ZOO_SRC env var (set
    by the Core workflow once it can be wired in), or sibling
    ../unsloth-zoo, or the installed wheel. Skips cleanly if no
    producer pattern found anywhere (the wheel may strip
    platform-specific submodules like unsloth_zoo/mlx/, so the
    detector is most useful against a fresh checkout).

Validated end-to-end:
  * Reverted run_real_mlx_smoke.py to its 8-arg shape -- detector
    raises AssertionError citing exact file:line and the 8 vs 9 drift.
  * Restored the 9-arg shape -- detector PASSes.
  * Total runtime ~7 s in pytest.

Suggested CI wiring (workflow file change held out of this commit
because the pushing PAT lacks `workflow` scope; safe to apply via
the GitHub web editor or a maintainer push):

```yaml
- name: callback signature drift detector (HARD GATE)
  env:
    UNSLOTH_ZOO_SRC: ${{ runner.temp }}/unsloth-zoo
  run: |
    python -m pytest -v --tb=short tests/test_callback_signature_drift.py
```

Drop the step into .github/workflows/consolidated-tests-ci.yml right
after the existing public-api drift detector step. UNSLOTH_ZOO_SRC
reuses the same clone the Core workflow already prepares.

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

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

* ci: wire callback-signature drift detector into Core matrix

Drops a 6-line pytest step right after the public-api drift detector,
with UNSLOTH_ZOO_SRC pointed at the freshly cloned $RUNNER_TEMP/unsloth-zoo
so the detector sees unsloth_zoo/mlx/ (the wheel strips it).

Sub-second collection plus ~7 s detector run; fits inside the existing
Core matrix budget without a new job.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-18 04:42:37 -07:00
Daniel Han
525b3b4a43
tests/studio: tighten MLX smoke gates (loss + round-trip, _on_step grad_norm) (#5537)
* tests/studio: accept new grad_norm arg in MLX smoke _on_step callback

The MLX trainer's step callback now passes a ninth positional argument
(grad_norm) per unsloth_zoo/mlx/trainer.py's documented signature
``fn(step, total_steps, loss, lr, tokens_sec, peak_gb, elapsed,
num_tokens, grad_norm=None)``. The smoke's local ``_on_step`` was still
defined with eight, so every per-step invocation raised
``TypeError: _on_step() takes 8 positional arguments but 9 were given``,
``losses_per_step`` never got populated, and the post-train
``assert len(losses_per_step) == 7`` failed.

Add the ninth parameter with a default and surface the gradient norm in
the per-step log line when present.

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

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

* tests/studio: pin max_grad_value=0 in MLX smoke so max_grad_norm=1.0 wins

unsloth_zoo PR #5340 added per-element gradient clipping to MLXTrainer
and defaulted ``MLXTrainingConfig.max_grad_value = 5.0``. When both
``max_grad_norm`` and ``max_grad_value`` are set, the trainer warns:

  Unsloth: max_grad_norm and max_grad_value are both enabled;
  ignoring max_grad_norm in favor of max_grad_value.

and silently drops the test's ``max_grad_norm=1.0``. +-5.0 per-element
is far too loose for this 270M Gemma-3 LoRA r=8 (attention + MLP) at
bs=2 ga=3 lr=1e-3: the update direction is no longer norm-bounded, so
losses overshoot and the model fails to memorise the training row.

Reproduced on a CUDA mirror (scripts/cuda_mlx_mirror_sim.py):

  norm_1       (max_grad_norm=1.0, no clip): losses 7.64 -> 0.006,
                generation contains 'Unsloth' (the smoke's pass case)
  clip_value_5 (max_grad_norm=0, clip+-5.0): losses 7.29 -> 8.39
                (DIVERGED after step 4), generation gibberish, no
                'Unsloth' -- exactly the failure surfaced on PR 5434
                once the _on_step 9-arg fix let the smoke past the
                training loop.

Pin ``max_grad_value=0.0`` so the smoke uses the same ``max_grad_norm=
1.0`` clipping it was designed against. Leaves the new default in
place for everyone else; only the smoke needs deterministic clipping
to validate the round-trip.

* tests/studio: clarify why MLX smoke pins max_grad_value=0

Refresh the rationale comment to reflect the new default landing in
unslothai/unsloth-zoo#652 (max_grad_value=1.0, not 5.0). The smoke
still needs the explicit pin because neither default value reliably
converges in 7 steps at seed=3407:

  max_grad_value=5.0 -- diverges after step 4 (loss 7.3 -> 8.4)
  max_grad_value=1.0 -- stalls (loss ~3.2 plateau across seeds)
  max_grad_value=0.5/0.25/0.1 -- noisier still
  max_grad_norm=1.0  -- cleanly drops loss to <0.01, emits "Unsloth!"

Mention both the historical 5.0 default and the new 1.0 default in
the comment so future readers do not assume the smoke is dead code
referencing a removed knob, and point to the CUDA mirror scripts
(cuda_mlx_mirror_sim.py + cuda_mlx_clip1_vs_norm1.py) for the
empirical evidence.

No behaviour change; comment-only refresh.

* tests/studio: replace fragile substring gate with loss + round-trip gates

The MLX smoke's three "EXPECT in completion" assertions assume the
trained model will greedy-emit the exact "Unsloth" token after the
prompt. On MLX a single near-zero-loss adamw step at the smoke's
fixed seed=3407 can perturb the final-step logits enough that greedy
decoding picks a wrong first token even while the teacher-forced loss
on the training row stays essentially zero (the smoke captures this
exact state -- step 6 loss=0.049, step 7 grad=36.7, step 7 loss=0.17;
completion goes from "Unsloth!" to "5 lbs!"). Reproduced extensively
on CUDA via scripts/cuda_mlx_step7_*.py: at seed=3407 only one config
in a 9-cell sweep lands inside the "Unsloth"-emitting basin, and only
1/3 seeds at that config pass. This is a property of the assertion,
not of save/reload correctness.

Refactor the three assertions to gate on what the smoke is actually
trying to verify:

  in_memory:
    - hard gate: post_train_loss < 1.0 (training memorised the row).
    - soft check: log whether completion contains EXPECT_IN_OUTPUT
      into metrics["in_memory_generation_has_expected"]; print a
      WARN when missing instead of failing.

  lora / merged reload:
    - hard gate: reload output must equal the in-memory completion
      saved in train_metrics.json. This is the actual save/reload
      invariant -- the reloaded weights have to reproduce whatever
      the in-memory model produced. Falls back to the original
      gibberish gate if train_metrics.json is unavailable.

  gguf reload:
    - hard gate: llama.cpp produced usable, non-empty output after
      the prompt (>=4 chars). llama.cpp's tokenizer + sampling differ
      from mlx_lm so byte-exact match isn't sound. Log
      gguf_has_expected for visibility.

Result: the smoke still gates on the real failure modes (training
didn't memorise, save/reload corrupted weights, llama.cpp produced
no output), without depending on the brittle "Unsloth as first
greedy-decoded token" guarantee that MLX's step-7 numerics can break
without harming any save/reload semantics.

Cross-version constraint: no transformers / trl API touched.

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

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

* tests/studio: gate MLX reload on training-row loss, not greedy text

The strict reload assertion (out == in_mem_out) failed on macOS:
in-memory completion was '5 lbs!' and the reloaded completion was
'_________________________'. Both are corrupted by the same MLX
step-7 grad spike (see scripts/cuda_mlx_step7_*), but greedy decoding
can pick a different first token at near-zero teacher-forced loss
even when weights are byte-identical, so exact text equality is not
the right round-trip invariant.

Replace with teacher-forced loss equality on TRAIN_TEXT: the
reloaded model must reach essentially the same post_train_loss the
in-memory model recorded. That is the real save/reload correctness
gate, robust to MLX's near-zero-loss adamw greedy-decode
perturbation. Falls back to a non-empty-body check when
train_metrics.json is missing.

CUDA mirror at this seed converges cleanly to ~0.006 loss; on MLX
post_train_loss < 1.0 still holds via the existing memorisation
gate. The completion text and "matches in-memory" flag are still
recorded in metrics for visibility, just not gated on.

* tests/studio: align MLX smoke with elementwise-clip + 30-step gates

Two corrections to the earlier f93e918b / e05d6c7d direction:

1. max_grad_value=0.0, max_grad_norm=1.0 picked the memory-heavy
   norm clip. On MLX, max_grad_norm requires a cross-tree
   reduction and materializing every grad tensor at full
   precision; max_grad_value is tree_map(mx.clip) per leaf with
   no reduction. MLXTrainingConfig defaults to max_grad_value=1.0
   for exactly this reason. Flip the smoke to
   max_grad_norm=0.0, max_grad_value=1.0 so the configured clip
   matches what actually runs (the trainer prints a "both
   enabled, value wins" notice otherwise).

   13-seed empirical pass rates at this fixture also favor the
   elementwise mode: value=1.0 62%, norm=1.0 46%, value=5.0 33%,
   value=0.5 77%. Cheaper default = higher pass rate, no
   tradeoff. (See PR #5498 / staging-2#119 rounds A-AT.)

2. max_steps=7 was below the convergence horizon at every clip
   tested. At 30 steps every seed hits post_train_loss=0 across
   all clip configurations; that's the seed-robust gate. Bump
   max_steps 7 -> 30, tighten the memorisation gate from
   post_loss < 1.0 to post_loss < 0.1.

3. Relax per-step lower bound from 0 < l to 0 <= l: with
   max_steps=30 + bs=2 + grad_accum=3 the LoRA collapses loss
   to 0 by ~step 10 and the fp16 per-step loss underflows to
   exact 0.0 from then on. That's the success signal, not a bug.

Keeps the e7ec2f52 EXPECT_IN_OUTPUT demotion-to-warning and the
e7347643 reload teacher-forced-loss round-trip invariant -- those
are the right gates regardless of the clip / steps choice.

* tests/studio: hard gate via teacher-forced completion loss

The prior "soft warn + metric" was a step back from the original
hard assert: regressions could land silently if greedy decode
happened to pass on seed=3407 but post_train_loss diverged.
A true hard gate is needed.

Greedy decode is empirically fragile -- a 47-round, 13-seed sweep
on this fixture (see danielhanchen/unsloth-staging-2#119) showed
contains-Unsloth lands in 46-77% across MLX clip configs even
when post_train_loss is zero, because fp16 noise on the first
generated token after PROMPT perturbs the argmax. Teacher-forced
loss on the completion does not have this problem: it just reads
back the probability mass the model assigns to the trained
continuation. In every config where post_train_loss < 0.1, the
completion loss is essentially zero.

Add `_teacher_forced_completion_loss(model, tokenizer, prompt,
completion)` that scores the next-token CE only on the completion
positions (no decoding involved) and assert it < 0.5. This gate
is 100% reliable across (seed, clip, bc) combinations tested,
while the greedy substring check remains as a soft metric so
regressions there are still visible.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-18 04:30:06 -07:00
Daniel Han
2bf39dee64
studio/frontend: hide Current password input on first boot (#5545)
* studio/frontend: hide Current password input on first boot

PR #5490 added a third Current password input to the change-password form
so the admin-forced must_change_password reset path could supply a current
password (the bootstrap is empty in that path). The side effect is that the
dominant first-boot UX, which has window.__UNSLOTH_BOOTSTRAP__ present and
silently fed into currentPassword, now shows three visible inputs instead
of the two it had before.

Render the Current password input only when window.__UNSLOTH_BOOTSTRAP__
is absent. The loadBootstrap effect already seeds the password state from
the bootstrap and currentPassword keeps the bootstrap fallback, so
handleSubmit sees the same value as before. On admin-forced resets where
the bootstrap is undefined, the Current password input still appears so
the user can type their actual current password.

Verified end-to-end against a local install via UNSLOTH_STUDIO_HOME +
install.sh --local with Playwright driving the page: bootstrap present
renders two inputs (New, Confirm) and completes change-password into
/chat; bootstrap suppressed via a non-configurable property descriptor
init script renders the three inputs (Current, New, Confirm) and keeps
the #5490 fix intact.

* studio/frontend: add deterministic input-count tests for auth-form

Pure-source pytest covering the change-password JSX contract. No
browser, no Studio boot, no JS toolchain -- runs on any CI runner.
Complements the Playwright probe in tests/studio/playwright_chat_ui.py
which exercises the same contract end to end.

Pins seven invariants with explicit failure reasons:

  1. hasBootstrapPassword is derived from window.__UNSLOTH_BOOTSTRAP__
     so a future swap to a localStorage flag or prop cannot silently
     drift from the backend's _inject_bootstrap contract in
     studio/backend/main.py.
  2. Exactly one !hasBootstrapPassword conditional exists; multiple
     would split rendering into branches these tests cannot reason
     about.
  3. The Current password input sits inside that conditional, so it
     never renders on first boot (the regression PR #5490 introduced
     and that this fix reverses).
  4. The New password input sits outside it, so it always renders in
     change-password mode (admin-forced reset still works).
  5. Confirm password: same as New.
  6. The change-password JSX subtree declares exactly current /
     new / confirm; a fourth password input would almost certainly
     break the 2-input first-boot contract.
  7. The login JSX subtree declares exactly one password input.

Verified the tests fail loudly on the pre-fix auth-form.tsx at
c4575ca0 (5/7 fail with descriptive reasons) and pass on the fixed
version (7/7).

* [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-18 04:27:21 -07:00
Daniel Han
3ebe17fe41
fast_generate: unify legacy/new logits kwarg + fix Mistral merge site (#5543)
* fast_generate: unify legacy/new logits kwarg + fix Mistral merge site

Two related issues caught by review on PR #5538:

1. unsloth_fast_generate (models/llama.py)

   The previous patch promoted num_logits_to_keep -> logits_to_keep
   unconditionally whenever the caller supplied num_logits_to_keep,
   and only popped num_logits_to_keep (not logits_to_keep). On
   transformers older than 4.50 (legacy spelling is the only one the
   model forward accepts), the promotion broke things; symmetrically,
   a caller supplying logits_to_keep on those older transformers also
   went unchecked.

   Switch to the unified normalize-then-inspect pattern from the
   review:

     _provided_num    = kwargs.pop("num_logits_to_keep", None)
     _provided_logits = kwargs.pop("logits_to_keep",     None)
     _provided = _provided_logits if _provided_logits is not None else _provided_num
     _fwd_params = inspect.signature(self.forward).parameters
     if "logits_to_keep" in _fwd_params:
         kwargs["logits_to_keep"] = _provided if _provided is not None else 1
     elif "num_logits_to_keep" in _fwd_params:
         kwargs["num_logits_to_keep"] = _provided if _provided is not None else 1

   Inspect the runtime forward signature first, then choose the
   spelling it actually accepts, then route either user-supplied value
   under that spelling. Backward-compatible in both directions.

2. MistralForCausalLM_fast_forward (models/mistral.py)

   The max(num_logits_to_keep, logits_to_keep) merge was inside the
   `if UNSLOTH_RETURN_HIDDEN_STATES:` block, so it only fired on the
   GRPO hidden-states path. On the normal generation path the elif at
   line 316 only checked num_logits_to_keep, so a caller (including
   unsloth_fast_generate itself) passing logits_to_keep=1 ended up
   computing full prompt logits instead of slicing to the last token.
   For long prompts that reintroduces the large prefill logits
   allocation the default keep=1 was avoiding.

   Move the max() merge above the env-var branching so the normal
   generation path slices correctly too. Llama already did this
   merge at the top (unsloth/models/llama.py:1501); Mistral now
   matches.

No behaviour change on the default GRPO / SFT paths. Targets only the
edge cases the review flagged.

* fast_generate: preserve caller logits kwarg when signature inspect fails

If `inspect.signature(self.forward)` raises TypeError/ValueError (opaque
C-extension or compiled wrappers), the previous fix set `_fwd_params = {}`
which silently dropped the caller-supplied `logits_to_keep` /
`num_logits_to_keep`. Fall back to the spelling the caller used (default
`logits_to_keep=1` when neither was supplied) so generation still honors
the requested logits slice.

* fast_forward: do not max() int against tensor logits_to_keep

HF accepts logits_to_keep as a 1-D LongTensor of positions for
selective decode. The merge in mistral.py (added by this PR) and
the pre-existing one in llama.py both run max(int, Tensor), which
casts the comparison to a bool and raises on multi-element tensors.
Branch on type and skip the merge when either argument is a tensor;
downstream int-slice path is unchanged, so tensor callers fall
through with num_logits_to_keep == 0, matching pre-merge behavior.

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

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

* fast_generate/forward: shorten kwarg-merge comments

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-18 04:19:48 -07:00
Michael Han
fe9932ace4
studio/frontend: soften toast shadow and tighten vertical padding (#5511)
* studio/frontend: soften toast shadow and tighten vertical padding

Sonner's defaults felt heavy in the chat header surface: a 16px
all-around padding made the box taller than the two-line content
warranted, and the 4/12/0.10 drop shadow read as a hard slab
against the light background. Trim padding to 10px vertical
(horizontal unchanged at 16px) and dial the shadow back to
0 2px 6px / 0.08 so the toast still lifts off the surface without
casting a heavy halo.

* studio/frontend: annotate why toast override needs !important

Sonner injects its base styles at runtime from inside its JS bundle,
so a plain cascade tie can lose depending on injection order. One
short comment above the override saves the next reader the dig.

* studio/frontend: boost toast shadow opacity in dark mode

Sonner's lighter 0.08 shadow disappears on the dark popover surface:
quantitative measurement of the shadow band (10px below the toast)
across Chromium / Firefox / WebKit showed only a ~3% luminance drop
vs background, well below perceptual threshold. Bump the dark-mode
opacity to 0.3, matching the existing .shadow-border light/dark ratio
(0.1 -> 0.3) and bringing the toast in line with .menu-soft-surface's
dark-mode shadow (0.28). Light mode keeps the original 0.08.
2026-05-18 03:51:57 -07:00
Daniel Han
80d5acafb4
studio: install flash-linear-attention and tilelang for Qwen3.5 family (#5434)
* studio: install flash-linear-attention and tilelang for Qwen3.5 family

Studio currently only installs causal-conv1d for qwen3.5 / qwen3.6 /
qwen3-next models. Without flash-linear-attention installed alongside
it, transformers' Qwen3.5 fast-path gate stays False and the model
falls back to a pure-PyTorch loop for the GatedDeltaNet layers. In a
60-step run on unsloth/Qwen3.5-2B on B200, this fallback costs ~2.35x
vs the full fast path.

On top of that, FLA dispatches its hottest GDN kernels through a
TileLang backend when tilelang is importable. Adding tilelang plus a
pinned apache-tvm-ffi gives another ~26% on the same workload (4.73
s/step to 3.50 s/step) and is what users have been getting indirectly
when they install mamba-ssm (mamba-ssm transitively pulls tilelang and
pins apache-tvm-ffi<=0.1.9, which is the last working version on
sm_100; 0.1.10 and 0.1.11 crash Triton with misaligned address).

Changes:
  * _ensure_flash_linear_attention: pure-Python PyPI install gated on
    the same model match set as _ensure_causal_conv1d_fast_path.
  * _ensure_tilelang_backend: installs apache-tvm-ffi==0.1.9 and
    tilelang==0.1.8 in one pip resolve so the tvm-ffi pin wins over
    tilelang's >=0.1.2 constraint. Gated on the Qwen3.5 family only;
    SSM models (Nemotron-H, Falcon-H1, Granite-H, LFM2) do not use
    FLA's GDN dispatch.
  * UNSLOTH_STUDIO_SKIP_TILELANG_INSTALL=1 escape hatch matching the
    flash-attn pattern.
  * Orchestration block reordered: causal-conv1d -> fla -> mamba-ssm
    -> tilelang -> flash-attn (long context).
  * 7 new tests covering the new helpers, including SSM-model skip,
    skip-env, full Qwen3 family name variants, and graceful pip
    install failure.

Combined Qwen3.5-2B-Vision step time on B200 in our bench goes from
5.0 s/step (current Studio: causal-conv1d only) to 3.5 s/step
(causal-conv1d + fla + tilelang), a 1.43x speedup with no notebook
or user code changes required.

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

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

* tests/studio: accept new grad_norm arg in MLX smoke _on_step callback

The MLX trainer's step callback now passes a ninth positional argument
(grad_norm) per unsloth_zoo/mlx/trainer.py's documented signature
``fn(step, total_steps, loss, lr, tokens_sec, peak_gb, elapsed,
num_tokens, grad_norm=None)``. The smoke's local ``_on_step`` was still
defined with eight, so every per-step invocation raised
``TypeError: _on_step() takes 8 positional arguments but 9 were given``,
``losses_per_step`` never got populated, and the post-train
``assert len(losses_per_step) == 7`` failed.

Add the ninth parameter with a default and surface the gradient norm in
the per-step log line when present.

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

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

* ci: retrigger after zoo drift + IPython fixes landed in main

* tests/studio: pin max_grad_value=0 in MLX smoke so max_grad_norm=1.0 wins

unsloth_zoo PR #5340 added per-element gradient clipping to MLXTrainer
and defaulted ``MLXTrainingConfig.max_grad_value = 5.0``. When both
``max_grad_norm`` and ``max_grad_value`` are set, the trainer warns:

  Unsloth: max_grad_norm and max_grad_value are both enabled;
  ignoring max_grad_norm in favor of max_grad_value.

and silently drops the test's ``max_grad_norm=1.0``. +-5.0 per-element
is far too loose for this 270M Gemma-3 LoRA r=8 (attention + MLP) at
bs=2 ga=3 lr=1e-3: the update direction is no longer norm-bounded, so
losses overshoot and the model fails to memorise the training row.

Reproduced on a CUDA mirror (scripts/cuda_mlx_mirror_sim.py):

  norm_1       (max_grad_norm=1.0, no clip): losses 7.64 -> 0.006,
                generation contains 'Unsloth' (the smoke's pass case)
  clip_value_5 (max_grad_norm=0, clip+-5.0): losses 7.29 -> 8.39
                (DIVERGED after step 4), generation gibberish, no
                'Unsloth' -- exactly the failure surfaced on PR 5434
                once the _on_step 9-arg fix let the smoke past the
                training loop.

Pin ``max_grad_value=0.0`` so the smoke uses the same ``max_grad_norm=
1.0`` clipping it was designed against. Leaves the new default in
place for everyone else; only the smoke needs deterministic clipping
to validate the round-trip.

* tests/studio: clarify why MLX smoke pins max_grad_value=0

Refresh the rationale comment to reflect the new default landing in
unslothai/unsloth-zoo#652 (max_grad_value=1.0, not 5.0). The smoke
still needs the explicit pin because neither default value reliably
converges in 7 steps at seed=3407:

  max_grad_value=5.0 -- diverges after step 4 (loss 7.3 -> 8.4)
  max_grad_value=1.0 -- stalls (loss ~3.2 plateau across seeds)
  max_grad_value=0.5/0.25/0.1 -- noisier still
  max_grad_norm=1.0  -- cleanly drops loss to <0.01, emits "Unsloth!"

Mention both the historical 5.0 default and the new 1.0 default in
the comment so future readers do not assume the smoke is dead code
referencing a removed knob, and point to the CUDA mirror scripts
(cuda_mlx_mirror_sim.py + cuda_mlx_clip1_vs_norm1.py) for the
empirical evidence.

No behaviour change; comment-only refresh.

* tests/studio: replace fragile substring gate with loss + round-trip gates

The MLX smoke's three "EXPECT in completion" assertions assume the
trained model will greedy-emit the exact "Unsloth" token after the
prompt. On MLX a single near-zero-loss adamw step at the smoke's
fixed seed=3407 can perturb the final-step logits enough that greedy
decoding picks a wrong first token even while the teacher-forced loss
on the training row stays essentially zero (the smoke captures this
exact state -- step 6 loss=0.049, step 7 grad=36.7, step 7 loss=0.17;
completion goes from "Unsloth!" to "5 lbs!"). Reproduced extensively
on CUDA via scripts/cuda_mlx_step7_*.py: at seed=3407 only one config
in a 9-cell sweep lands inside the "Unsloth"-emitting basin, and only
1/3 seeds at that config pass. This is a property of the assertion,
not of save/reload correctness.

Refactor the three assertions to gate on what the smoke is actually
trying to verify:

  in_memory:
    - hard gate: post_train_loss < 1.0 (training memorised the row).
    - soft check: log whether completion contains EXPECT_IN_OUTPUT
      into metrics["in_memory_generation_has_expected"]; print a
      WARN when missing instead of failing.

  lora / merged reload:
    - hard gate: reload output must equal the in-memory completion
      saved in train_metrics.json. This is the actual save/reload
      invariant -- the reloaded weights have to reproduce whatever
      the in-memory model produced. Falls back to the original
      gibberish gate if train_metrics.json is unavailable.

  gguf reload:
    - hard gate: llama.cpp produced usable, non-empty output after
      the prompt (>=4 chars). llama.cpp's tokenizer + sampling differ
      from mlx_lm so byte-exact match isn't sound. Log
      gguf_has_expected for visibility.

Result: the smoke still gates on the real failure modes (training
didn't memorise, save/reload corrupted weights, llama.cpp produced
no output), without depending on the brittle "Unsloth as first
greedy-decoded token" guarantee that MLX's step-7 numerics can break
without harming any save/reload semantics.

Cross-version constraint: no transformers / trl API touched.

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

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

* tests/studio: gate MLX reload on training-row loss, not greedy text

The strict reload assertion (out == in_mem_out) failed on macOS:
in-memory completion was '5 lbs!' and the reloaded completion was
'_________________________'. Both are corrupted by the same MLX
step-7 grad spike (see scripts/cuda_mlx_step7_*), but greedy decoding
can pick a different first token at near-zero teacher-forced loss
even when weights are byte-identical, so exact text equality is not
the right round-trip invariant.

Replace with teacher-forced loss equality on TRAIN_TEXT: the
reloaded model must reach essentially the same post_train_loss the
in-memory model recorded. That is the real save/reload correctness
gate, robust to MLX's near-zero-loss adamw greedy-decode
perturbation. Falls back to a non-empty-body check when
train_metrics.json is missing.

CUDA mirror at this seed converges cleanly to ~0.006 loss; on MLX
post_train_loss < 1.0 still holds via the existing memorisation
gate. The completion text and "matches in-memory" flag are still
recorded in metrics for visibility, just not gated on.

* ci: retrigger Backend CI after transient pwsh-startup timeout

* ci: retrigger MLX dispatch after pytorch CDN DNS flake

* studio: harden FLA + tilelang installers per reviewer feedback

Addresses bot review on #5434:

  * Narrow `_ensure_flash_linear_attention` from `_model_wants_causal_conv1d`
    (which also matches Nemotron-H / Falcon-H1 / Granite-H / LFM2) to
    `_model_wants_tilelang` (Qwen3.5 / Qwen3.6 / Qwen3-Next only). True
    SSM families take the mamba_ssm path and never call FLA's GDN
    kernels, so installing FLA there is wasted bandwidth.

  * Pin both `flash-linear-attention==0.5.0` and `fla-core==0.5.0` and
    install with `--no-deps`. Otherwise pip resolves fla-core's
    declared `torch>=2.7.0` requirement and may silently upgrade the
    Studio venv's torch on environments running torch 2.4/2.5/2.6.

  * Skip both installs on Python <3.10 (FLA, fla-core, and tilelang
    all declare `Requires-Python: >=3.10`). On older interpreters the
    pip install would fail every launch and leave the worker on the
    slow torch fallback while still claiming to have set up the fast
    path.

  * Skip tilelang install on non-Linux platforms. `tilelang==0.1.8`
    only publishes Linux x86_64 / aarch64 and macOS arm64 wheels.
    Falling back to its 93MB sdist on a Studio worker is undesirable.

  * Detect an existing `apache-tvm-ffi` 0.1.10 / 0.1.11 install and
    force a reinstall to 0.1.9 with `--force-reinstall --no-deps`.
    Previously the import-only probe returned early and left the
    broken version in place, which crashes Triton on sm_100.

  * Add a 600s timeout to the tilelang and FLA subprocess.run calls,
    matching the existing flash-attn install pattern, so a network
    hang cannot block the training subprocess indefinitely.

  * 13 new / updated tests covering all six guards plus the
    pinned-spec, timeout, and force-reinstall code paths.

Total: 21 passing tests (8 original + 13 new / updated).

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

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

* studio: address reviewer.py P1/P2 findings on FLA + tilelang installers

Twelve-reviewer aggregated review on this PR flagged several real
correctness bugs in the first hardening pass. Fixes:

P1:
  * Add UNSLOTH_STUDIO_SKIP_FLA_INSTALL escape hatch for symmetry
    with UNSLOTH_STUDIO_SKIP_TILELANG_INSTALL and the existing
    UNSLOTH_STUDIO_SKIP_FLASHATTN_INSTALL.
  * Install einops alongside fla-core. `--no-deps` was suppressing
    fla-core's only non-torch runtime dep, so on a clean venv
    `import fla.modules` raised ModuleNotFoundError even though pip
    exited 0.
  * Drop --no-deps from the tilelang force-reinstall path. tilelang
    needs z3-solver, ml-dtypes, cloudpickle, etc. at runtime;
    --force-reinstall --no-deps left libz3.so missing and
    `import tilelang` raised OSError on the next training subprocess.
  * Skip FLA install when installed torch is below 2.7.0
    (fla-core declares torch>=2.7.0). Otherwise users on Studio's
    supported torch 2.4/2.5/2.6 stacks get an incompatible FLA
    installed silently.

P2:
  * Replace bare `except ImportError` probes with helpers that catch
    `Exception` so a broken native package (OSError on missing
    .so, RuntimeError in __init__, ...) does not kill the worker
    before the fallback path can run.
  * Tighten the tilelang platform guard from "any linux" to
    "linux + machine in {x86_64, aarch64, ...}" so ppc64le / s390x /
    armv7 do not fall through and download the 93 MB tilelang sdist.
  * Add --only-binary=:all: to the tilelang install command. The
    comment already said we never want the sdist; now the pip
    invocation enforces it.
  * Verify both FLA and tilelang are importable after pip exits 0;
    if not, report and continue on the fallback path.

6 new tests bring the suite to 27 passing (was 21).

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

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

* studio: pin packaging + triton with FLA --no-deps install

An end-to-end install simulation in a fresh venv caught a real
regression: `fla/utils.py` does `from packaging import version` and
`import triton` at module load, but fla-core's METADATA only declares
einops + torch. With `--no-deps` the worker would land FLA in any
runtime that lacks packaging (e.g. minimal torch builds) and the
post-install import probe would fall back to the torch GDN loop
silently.

Add `packaging` and `triton` to `_FLA_RUNTIME_DEPS` so the install
spec list always carries them. Tests updated to assert both are now in
the install command.

* studio: hook transformers' fast-path gates for just-in-time FLA + causal-conv1d install

The substring-based detection in this PR (`_model_wants_tilelang` /
`_model_wants_causal_conv1d`) is brittle: it depends on what the user
typed for the model name, not on what the architecture actually needs.
Users typing custom model paths, future Qwen3.7 / non-Qwen GDN
architectures, and any model whose author renamed it would silently
fall back to the torch loop.

The correct signal is the one transformers itself uses to gate the
fast path. `transformers/models/qwen3_5_moe/modeling_qwen3_5_moe.py`
does at module import time:

    if is_causal_conv1d_available():
        from causal_conv1d import causal_conv1d_fn, causal_conv1d_update
    if is_flash_linear_attention_available():
        from fla.modules import FusedRMSNormGated
        from fla.ops.gated_delta_rule import (
            chunk_gated_delta_rule, fused_recurrent_gated_delta_rule,
        )

Wrap both gates so the first call (always at modeling import, before
any forward pass) installs the matching kernel synchronously and
delegates to the original function. Any model whose architecture
queries those gates auto-triggers the install; models that never
query them (Llama, Gemma, dense Qwen, ...) never pay the cost.

Mechanics:

  - Split `_ensure_flash_linear_attention` and `_ensure_tilelang_backend`
    into `_unconditional` variants (no substring gate, retains python
    / torch / platform / skip-env guards) plus thin substring wrappers
    used by the legacy fallback path.
  - New `_install_fast_path_hooks(event_queue)` patches both gates on
    `transformers.utils.import_utils` AND sweeps `sys.modules` so any
    modeling file that already did `from ... import is_X` sees the
    wrapper (the local binding survives a module-level reassignment).
  - Wrappers clear the original's `lru_cache` before delegating, install
    on False, re-check, and short-circuit on subsequent calls.
  - Set `UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS=1` to fall back to the
    substring path.

Verified end-to-end against `transformers.models.qwen3_5_moe`:

  PRE_STATE fla=False tilelang=False causal_conv1d=False
  HOOK_INSTALLED
  Hook fired for is_causal_conv1d_available; installing kernel...
  Installing prebuilt causal-conv1d wheel...
  Hook fired for is_flash_linear_attention_available; installing kernel...
  Installing flash-linear-attention==0.5.0 (with fla-core==0.5.0) for the fast path...
  Installed flash-linear-attention for the FLA fast path
  Installing TileLang backend (apache-tvm-ffi==0.1.9, tilelang==0.1.8)...
  Installed TileLang backend for FLA fast path
  MODELING_IMPORT_OK
  FAST_PATH_SYMBOLS {"chunk_gated_delta_rule": true,
                     "fused_recurrent_gated_delta_rule": true,
                     "FusedRMSNormGated": true,
                     "causal_conv1d_fn": true,
                     "causal_conv1d_update": true}
  POST_STATE fla=True tilelang=True causal_conv1d=True

Adds 9 new tests covering: install-on-False, skip-on-True, idempotency,
install-failure handling, env-disable, lru_cache clear, sys.modules
rebind, missing-transformers fallback, substring fallback. Total
test count is now 36 (was 27).

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

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

* studio: address reviewer.py n=12 findings on the FLA hook path

Eight issues reproduced by parallel reviewers against 6ce495a; all
fixed and covered by regression tests. 45 pytest cases pass (was 36);
end-to-end Qwen3.5_MoE modeling-import drill still loads all five
fast-path symbols.

P1 fixes:

1. TileLang loses the Qwen-family guard on the normal FLA hook path
   (10/12 reviewers, reproduced with allenai/OLMo-Hybrid-1B). The
   hook unconditionally installed tilelang for any FLA-using model.
   - Threaded `model_name` through `_install_fast_path_hooks(event_queue,
     model_name)`.
   - `_fla_install` now gates tilelang on
     `_model_wants_tilelang(model_name)` AND a successful FLA install.

2. TileLang repair `--force-reinstall` (without `--no-deps`) could
   replace `torch==2.12.0+cu130` with `torch==2.12.0`. Split repair
   into TWO steps:
     step 1: `--force-reinstall --no-deps apache-tvm-ffi==0.1.9`
     step 2: regular install of tilelang + apache-tvm-ffi
   Step 1 surgically downgrades the broken package; step 2 resolves
   missing transitive deps (z3-solver, ml-dtypes) without
   --force-reinstall, so it never replaces torch.

3. Hook could return True after the installer's deep import probe
   failed: when pip exits 0 but `import fla.modules` raises, the old
   wrapper re-called `original()` (transformers' metadata check) and
   trusted it. Refactored:
     - `_ensure_flash_linear_attention_unconditional(...) -> bool`
     - `_ensure_tilelang_backend_unconditional(...) -> bool`
   The wrapper now uses the installer's bool directly.

4. SSM models (Nemotron-H, Falcon-H1, Granite-H) use
   `lazy_load_kernel("causal-conv1d")` and never call
   `is_causal_conv1d_available()`, so the hook never fires for them.
   The orchestrator now always runs `_ensure_causal_conv1d_fast_path`
   outside the hook-mode if/else.

P2 fixes:

5. `_rebind_in_already_imported_modules` invoked transformers' lazy
   module `__getattr__` (hundreds of "Accessing X from .models..."
   warnings, ~3.4s overhead). Switched to `module.__dict__.get(...)`
   which only sees real module-level bindings.

6. TileLang installed even when FLA was skipped (Torch <2.7) or
   failed (timeout, post-install probe failed). Now gated on the
   installer's bool return.

7. TileLang repair was skipped when FLA was already True but tilelang
   missing or apache-tvm-ffi on the broken list. Added an optional
   `post_available_fn` to the wrapper; the FLA hook's
   `_fla_post_available` runs `_ensure_tilelang_backend_unconditional`
   when (model wants tilelang) AND (tilelang missing OR tvm-ffi broken).

8. `_flash_linear_attention_importable()` only checks deep import,
   not version. Added `_flash_linear_attention_current()` that
   compares against the pinned `flash-linear-attention==0.5.0` /
   `fla-core==0.5.0`; older versions trigger `--force-reinstall
   --no-deps` so torch stays untouched.

Helpers extracted to keep the surface tight:
  - `_pip_install_cmd(*args)` builds `uv pip install` or
    `python -m pip install` depending on uv availability.
  - `_run_pip(cmd, event_queue, label)` runs a pip command with
    timeout / failure handling and a status emission.

Regression tests added:

  - test_hook_does_not_install_tilelang_for_non_qwen_fla_model
  - test_hook_does_install_tilelang_for_qwen35
  - test_tilelang_repair_does_not_touch_torch_cuda_stack
  - test_hook_trusts_installer_bool_not_metadata
  - test_rebind_does_not_trigger_module_getattr
  - test_hook_skips_tilelang_when_fla_install_is_skipped
  - test_hook_runs_tilelang_repair_when_fla_already_true
  - test_fla_installer_force_reinstalls_when_older_version_present
  - test_run_training_process_eagerly_installs_causal_conv1d_in_normal_mode

Existing tests updated for the new `_install_fast_path_hooks` signature
and the two-step tilelang repair flow.

End-to-end re-verified against transformers.models.qwen3_5_moe:
PRE_STATE fla=False, hook fires for both gates, FLA + tilelang +
causal-conv1d install, all 5 fast-path symbols non-None.

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

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

* studio: fix double-install of tilelang on the FLA hook install path

Backend CI surfaced a test-isolation bug introduced by the
post_available_fn mechanism for finding #7. The wrapper ran
`post_available_fn` in BOTH paths (install ran AND gate already True),
but `_fla_install` already chains tilelang on the install path, so the
post-available step then called tilelang install AGAIN.

This was masked locally because tilelang was installed in the
workspace venv (post_available short-circuited on
`_tilelang_importable()` returning True). CI starts with no tilelang,
so the second call actually fired and the mock recorded two calls.

Fix: only run `post_available_fn` when the install path did NOT run.
That preserves the finding #7 semantics (tilelang repair when FLA
already True but tilelang missing or tvm-ffi broken) without
duplicating the chained install on the gate-was-False path.

Also tightened `test_hook_skips_install_when_gate_already_true` to
monkeypatch `_tilelang_importable=True` and
`_installed_tvm_ffi_version=0.1.9` so it stays a pure "no install at
all" test regardless of the venv's actual state.

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

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

* ci: retrigger Mac Studio GGUF after transient HF DNS resolve flake

* studio: skip tilelang on HIP / ROCm torch (Strix Halo crash report)

h34v3nzc0dex tested PR 5434 on Strix Halo (gfx1151, ROCm 7.13,
torch 2.11.0+rocm7.13.0) and hit a hard regression:

  File ".../fla/ops/common/backends/tilelang/__init__.py", line 92,
    in chunk_bwd_dqkwg
  File ".../tilelang/jit/kernel.py", line 137, in __init__
  File ".../tilelang/tileop/gemm/__init__.py", line 143,
    in _select_gemm_instruction
  tvm.error.InternalError: Check failed: (0) is false:
    Unsupported target for gemm:
    hip -keys=hip,gpu -mcpu=gfx1151 ...

`tilelang==0.1.8` ships no HIP GEMM instruction; `_select_gemm_instruction`
raises at lower-time, not import-time. So:
  - pip install succeeds
  - `import tilelang` succeeds
  - `TileLangBackend.is_available()` returns True
  - FLA's dispatcher picks TileLang for `chunk_bwd_dqkwg`
  - training subprocess dies at first GDN backward, no graceful fallback

The PR's existing platform gate (`_tilelang_platform_supported`)
checked only `sys.platform == "linux"` and `platform.machine()`, both
of which look identical on a ROCm box.

Fix has two layers:

1. INSTALL GATE: new `_torch_has_hip()` helper checks
   `torch.version.hip is not None`. `_tilelang_platform_supported`
   now returns False on HIP torch, so the install never fires.

2. RUNTIME GATE: even with the install skipped, a user could have
   tilelang already present (e.g. venv carried over from a CUDA box).
   `_install_fast_path_hooks` now calls
   `os.environ.setdefault("FLA_TILELANG", "0")` when HIP is detected,
   which is the env-var FLA's `TileLangBackend` already honors. Users
   who know they have a HIP-aware tilelang fork can override by
   setting `FLA_TILELANG=1` explicitly.

This costs nothing on CUDA (the gate is a no-op when
`torch.version.hip is None`), and removes the crash for AMD users.
The benchmark numbers in the PR description (1.43x on B200 sm_100)
are not affected.

The other halves of the PR are confirmed working on gfx1151 by the
same report:
  - `flash-linear-attention 0.5.0` runs at production scale
    (B=1 T=8192 H=16 K=128 V=128 and others) with no patches.
  - `causal-conv1d` runs at the shapes the fast-path gate cares
    about. (A separate Ubuntu 24.04 `--gcc-install-dir` build
    workaround is needed for the source-build path; that mirrors
    bbf004c's llama.cpp fix and is out of scope here.)

Tests added:
  - test_tilelang_platform_unsupported_on_hip_torch
  - test_tilelang_install_skipped_on_hip_torch
  - test_install_fast_path_hooks_sets_fla_tilelang_zero_on_hip
  - test_install_fast_path_hooks_respects_user_fla_tilelang_override
  - test_install_fast_path_hooks_does_not_set_fla_tilelang_on_cuda

Total 50 passing (was 45).

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

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

* ci: retrigger Windows Studio UI after transient Playwright tab-lookup flake

* studio: auto-discover FLA-using model types from installed transformers

Drop the hand-maintained `_TILELANG_MODEL_SUBSTRINGS` tuple
(qwen3.5 / qwen3_5 / qwen3.6 / qwen3_6 / qwen3-next / qwen3_next)
and derive the allowlist by scanning the installed
`transformers/models/*/modeling_*.py` for `from fla.` imports.

A model "wants tilelang" iff its modeling file imports an FLA op,
which is the same signal `is_flash_linear_attention_available()` is
the runtime test for. The scan happens once per worker subprocess
and is cached for the process lifetime; an empty result (eg
transformers not importable) means "no tilelang pre-install" --
the FLA runtime hook still drives the install via the gate when
the loaded model actually probes it.

Verified against the live installed transformers, the auto-derived
set is {qwen3_5, qwen3_5_moe, qwen3_next}, with `_model_wants_tilelang`
matching the HF Hub names `unsloth/Qwen3.5-2B`, `Qwen/Qwen3.5-MoE-A3B`,
`mlx-community/qwen3-next-80b`, and correctly rejecting Llama,
Mistral, Nemotron-H, Falcon-H1, etc. Future GDN models (Qwen3.7,
OLMo-Hybrid-FA, ...) are picked up automatically once they ship in
transformers; no further worker edits needed.

Also trim docstrings / comments through the FLA / tilelang / HIP /
hook block: constants get 1-line trailing comments, function
docstrings collapse to 1-3 lines, and the fast-path-hooks banner
shrinks from a 27-line block to 4 lines. The file drops from 2847
to 2630 lines without losing the load-bearing WHY notes
(--no-deps protects torch; `__dict__.get` avoids lazy-module
__getattr__; two-step tvm-ffi repair keeps torch off the dep
graph; HIP setdefault disables FLA's TileLang dispatch even with
tilelang already installed).

7 new tests (50 -> 57 total): discovery returns only FLA-using
model_types; discovery cache reuse; missing transformers handled;
OSError on a modeling file is non-fatal; `_model_wants_tilelang`
matches real HF repo names across separator variants; empty
discovery -> always False; normalization across `-`, `.`, `/`,
space.

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

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

* test: hermetize the non-allowlist hook test against transformers 5.4.0+

transformers 5.4.0 added `olmo_hybrid` as an FLA-using model_type, so
the auto-discovered allowlist now includes it -- and the test's prior
choice of `allenai/OLMo-Hybrid-1B` as a "non-Qwen FLA-only" example
became an allowlist member. CI on Python 3.11 / 3.13 caught this.

Swap to a guaranteed-not-in-allowlist fake model_name AND patch
_discover_fla_model_types to a known {qwen3_5, qwen3_5_moe, qwen3_next}
set so the test stays valid as upstream transformers adds new
FLA-using architectures.

Renames the test to reflect the actual semantic under test:
"outside-allowlist -> no tilelang".

* ci: retrigger Windows Studio API after llama.cpp prebuilt staging WinError 5 flake

* tests: move MLX smoke gate changes to dedicated PR #5537

The seven MLX smoke commits in this PR's history (_on_step grad_norm,
max_grad_value pin, loss + round-trip gates) are unrelated to the
FLA / tilelang work. They now live in #5537 so this PR's diff is
limited to the studio worker installer changes.

Net effect on tests/studio/run_real_mlx_smoke.py vs main: zero.

* studio: friendlier install banners (drop hook / gate-name jargon)

User-visible status text now reads:
  Installing flash-linear-attention==<ver> for faster training...
  Installing TileLang==<ver> for faster training...
  Installing causal-conv1d for faster training...
  Installing flash-attn for faster training...

Removed the transient "Hook fired for is_flash_linear_attention_available;
installing kernel..." banner — the install banner that immediately follows
already tells the user what is happening, in plain English.

The internal logger.info messages (server-side log) still carry the
gate names + "Hook fired ..." for debugging.

* [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-18 03:49:06 -07:00
Michael Han
eb6b0c6db6
studio: add dismissable toasts with corner close button (#5509)
* studio: add dismissable toasts with corner close button

- Enable Sonner's close button globally on the Toaster, so every toast
  (model load progress, model loaded, load failure, etc.) gets an X that
  users can click to dismiss without waiting for the auto-dismiss timer.
  This matches the Claude desktop notification behavior.
- Drop the per-toast 'closeButton: false' overrides in the model load
  runtime so they inherit the global default. The existing 'onDismiss'
  handler already flips state to show an inline header status, so the
  X on the loading toast hides the toast without canceling the load
  (Cancel still aborts).
- Pin the close button to the top-right corner inside the toast box.
  Overrides Sonner's left-side default placement, outside-corner
  translate, and hardcoded 'top: 0'. Top is set via a small rule in
  index.css because Sonner does not expose it as a CSS variable.
- Add a small offset on the Toaster so toasts sit at the chat header
  line, shifted left of the parameters and settings buttons on the
  right edge instead of stacking on top of them.
- Bump the post-load success and failure durations from 2s and 5s to
  8s so users actually have time to read and click the new close X
  before the toast auto-dismisses.

* studio: explicit boolean for closeButton prop to satisfy biome

* studio: keep close button X visible in dark mode

Two defensive fixes for the dark-mode close button visibility:

- Use resolvedTheme so sonner's data-sonner-theme always matches the
  class next-themes applies to <html>. Passing theme can be 'system',
  which makes sonner resolve via its own media query; that can disagree
  with next-themes (Tauri webview, hydration races, OS quirks), leaving
  CSS vars dark while sonner still applies its light close-button colors
  (dark X on dark background).
- Bump the close-icon stroke from sonner's default 1.5 to 2.25 so the X
  is readable on a 12x12 svg sitting on dark backgrounds.

---------

Co-authored-by: shimmyshimmer <datta_mike@hotmail.com>
2026-05-18 03:47:36 -07:00
Michael Han
84d9d56062
studio/frontend: make toast and inline error text selectable and copyable (#5506)
* studio/frontend: make toast and inline error text selectable and copyable

Sonner toasts and the inline model-load error in the chat header were
showing copyable content (backend tracebacks, model-load failures, log
lines) that users could not actually select with the mouse.

Two underlying issues:

1. Sonner's swipe-to-dismiss handler calls `setPointerCapture` in
   `onPointerDown`, which preempts the browser's text-selection
   gesture. The capture only happens when `dismissible` is true. CSS
   alone cannot work around this.
2. The inline model-load error truncated with `text-overflow: ellipsis`
   and parked the full string in a native `title=` tooltip, which
   browsers render as an OS tooltip that cannot be selected.

Fixes:

- New `@/lib/toast` wrapper that defaults `dismissible: false` on every
  toast (callable plus `.success` / `.error` / `.info` / `.warning` /
  `.loading` / `.message` / `.custom`). API is identical to sonner's
  `toast`, so the 18 call sites just swap their import path. Callers
  can opt back into swipe-to-dismiss with `dismissible: true`.
- `<Toaster>` sets `swipeDirections={[]}` to make the intent explicit.
- `index.css` forces `user-select: text` on toast text content and
  keeps `user-select: none` on toast buttons.
- New `<CopyableErrorChip>` component replaces the truncated inline
  error in the chat header. The chip shows the truncated message
  inline and opens a popover with the full, wrap-friendly, selectable
  message and a one-click Copy button.

Toasts still auto-dismiss after their `duration`, close buttons and
action buttons still work.

* studio/frontend: tighten code comments in selectable-toast change

* studio/frontend: address PR review on selectable-toast change

Three review-driven fixes:

1. CopyableErrorChip clears the copied->reset setTimeout on unmount via
   a useRef + useEffect cleanup so setState cannot fire on an unmounted
   component.

2. index.css restricts `cursor: text` to text-bearing toast nodes
   (`[data-title]`, `[data-description]`, `p`, `span`). The toast
   container keeps its default cursor and no longer pretends to be an
   editable surface. `user-select: text` still applies to the full toast
   tree so a drag-select starting on padding still works.

3. Toast wrapper now also injects `dismissible: false` into the second
   argument of `toast.promise(p, data?)`, covering the loading /
   success / error toasts created from a single promise call. Explicit
   `dismissible: true` in the data continues to win.

A fourth review point asked us to drop the wrapper and instead pass
`toastOptions={{ dismissible: false }}` to <Toaster>. Sonner v2.0.7's
Toaster only forwards `duration`, `className`, `descriptionClassName`,
`closeButton`, `style`, `unstyled`, `classNames`, `cancelButtonStyle`,
`actionButtonStyle`, and `closeButtonAriaLabel` from `toastOptions`
(see index.mjs lines 1144-1164). `dismissible` is not forwarded, so the
global-option approach is a runtime no-op (verified empirically across
Chromium / Firefox / WebKit). Wrapper is required.

* studio/frontend: drop chip aria-label override so message reads via SR

The CopyableErrorChip trigger set a fixed `aria-label`, which overrides
the visible message in the accessibility tree. Inside the chat header's
`role="status"` region this caused screen readers to announce the
generic label instead of the actual model-load error, a regression
versus the old plain-text status div.

Removed the `ariaLabel` prop and the default override. The button's
visible message text is now its accessible name, so the full
(untruncated) error is announced. Truncation stays purely visual via
CSS. Caller in chat-page.tsx dropped the prop too.

Added a Playwright assertion that the trigger's accessible name
contains the error message across Chromium, Firefox, and WebKit.

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
2026-05-18 03:47:21 -07:00
Michael Han
b78fe45256
studio/frontend: grow chat composer to 16 rows and inset scrollbar (#5540)
* studio/frontend: grow chat composer to 16 rows and inset scrollbar

Raise the composer textarea cap from 6 to 16 rows so the input keeps
expanding as you type longer prompts. Also nudge the textarea in with
mt-2 / mr-3 so the internal scrollbar no longer sits flush against
the rounded edges of the chat composer surface.

* studio/frontend: lower composer cap from 16 to 12 rows

Keeps the composer growing past the previous 6-row cap while staying
conservative enough that a fully expanded textarea does not cover the
scroll-to-bottom button or a large slice of recent messages.

* studio/frontend: use symmetric mx-3 inset on composer-input

Replaces mr-3 with mx-3 (and width calc(100%-1.5rem)) so the textarea
sits inset from both edges of the chat composer surface. Keeps the
scrollbar tucked in regardless of writing direction: LTR scrolls on
the right, RTL scrolls on the left, and both edges are now ~16px in
from the surface (4px surface px-1 + 12px mx-3).
2026-05-18 03:47:01 -07:00
Michael Han
309af17366
studio/frontend: swap Hugeicons spokes spinner for CSS ring (#5531) 2026-05-18 03:46:47 -07:00