Two more from the follow-up list closed (569 tests passing):
1. ``ast.Subscript`` resolution. ``open(['/etc/shadow'][0])`` and
``open({'k': '/etc/shadow'}['k'])`` previously slipped because
``_extract_string_from_node`` had no Subscript handler. List /
tuple / dict subscripts are now resolved: when the index is a
static constant we return the indexed value; otherwise any
sensitive entry in the container surfaces so the gate fires.
Indexes outside the container's static range fall back to
sensitive-scan + first-resolvable so adversarial patterns like
``open(['safe.txt', '/etc/shadow'][i])`` are still blocked.
2. UDP / ``connect_ex`` metadata destination. The connect-only
``NetworkAndIoVisitor`` gate missed ``s.sendto(data, address)`` /
``s.sendmsg(buffers, ancdata, flags, address)`` (the destination
tuple is positional but not at index 0) and ``s.connect_ex(addr)``
(non-raising connect variant). The visitor now matches the full
``{connect, connect_ex, sendto, sendmsg}`` set and scans every
positional arg for a ``(host, port)`` tuple shape; the first
resolved host wins.
17 new regression tests cover the Subscript class (8 blocked, 3
allowed) and the UDP / connect_ex class (4 blocked, 2 allowed).
After this commit, ``bypass_hunt.py`` reports zero NEW bypasses;
the only remaining ALLOWs are the documented follow-up list
(``getattr(__builtins__, ...)``, ``vars(__builtins__)[...]``,
``base64.b64decode`` of paths, ``chr()`` / ``str.join`` concat,
trusted-host upload-shape evasion).
Two final bypass classes from the round-5 follow-up list are now
closed (552 tests passing):
1. Bash glob under a sensitive root. ``cat /etc/sha*ow``, ``cat
/etc/sh?dow``, ``cat /etc/*``, ``cat ~/.ssh/id_*`` -- the shell
expands ``*`` / ``?`` against the filesystem at runtime, so the
literal-path scan never sees ``/etc/shadow``. A new
``_SENSITIVE_ROOT_WITH_GLOB_RE`` mirrors the existing
``_SENSITIVE_ROOT_WITH_EXPANSION_RE`` (which gates ``$(...)`` /
backtick substitutions) for the ``*`` / ``?`` family. The
``[^\s'\";&|`$]*`` literal-text-only constraint keeps the match
attached to the sensitive root token, so ``find /etc/ -name
'*.conf'`` (whitespace between root and glob) and project-local
globs like ``./src/*.py`` stay allowed.
2. Ternary ``IfExp`` branches. ``open('/etc/shadow' if cond else
'data.txt')`` previously slipped because
``_extract_string_from_node`` had no ``ast.IfExp`` handler.
Either branch can execute at runtime; the gate now resolves both
branches and prefers the sensitive one (via the same
``_looks_sensitive`` check that backs the binding-bias) so the
downstream check fires. Falls back to whichever branch resolved
when neither is sensitive.
24 new regression tests cover the glob class (10 blocked, 6 allowed)
and ternary (6 blocked, 2 allowed).
_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.
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.
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).
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.
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).
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.
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.
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.
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.
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).
* 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.
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.
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.
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
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.
* 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>
* 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>
* 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>
* 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.
* 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>
* 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>
* 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>
* 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).
The spinner inside ThreadWelcome subscribed to the global
generatingStatus from chat-runtime-store, so any in-flight warmup
(or stale leak from a prior run) surfaced Generating on the empty
Chat with your model surface, even while the user was still typing.
On the normal path the welcome view is gone the moment a message
is submitted, and the assistant bubble already renders its own
per-message GeneratingIndicator. Remove the welcome-screen spinner,
its component, and the now-unused LoaderIcon import.
* studio: register /settings route that opens the settings dialog
Navigating to /settings used to render Not Found because the route
was never registered. The settings dialog only opened via the user
menu, so /settings was a broken deep link if shared. Add a route
that calls useSettingsDialogStore.openDialog() and redirects to the
post-auth landing page so the modal appears on top of the chat.
* studio: harden Connections dialog provider sync and allow manual model IDs
Two related fixes for the Connections panel.
1. Keep localStorage providers when the server returns an empty list.
The dialog used to sync from /api/providers/ on mount and unconditionally
overwrite the Zustand provider store with the server result. When the
server had no enabled configs but the local store had entries (legacy
users, fresh dev installs, or providers created via earlier paths),
opening the dialog silently wiped them. The model picker reads from the
same store, so the chat header reverted from 'gpt-4o . OpenAI' to the
raw 'external::openai-1::gpt-4o' key. Treat the server as authoritative
only when it actually has rows; otherwise keep the local view.
2. Accept manual model IDs alongside the live catalog for remote-mode
providers (DeepSeek, OpenAI, etc.). Previously the only way to save was
to load the available-models catalog via a live API call, which fails
in air-gapped setups, behind 502s, or when the user already knows the
exact model ID. Add a Textarea fallback in the same render block, and
relax the validation to accept manual IDs even when availableModels is
empty. The validation message now points users at the manual path.
* studio: restrict manual model ID entry to openrouter among remote providers
Address review feedback: major remote providers (openai, anthropic,
gemini, mistral, cohere, deepseek, ...) expose large per-model
parameter surfaces that differ across models, so accepting pasted
model IDs leads to mismatched parameter expectations and frustrating
runtime errors. Keep their catalog curated by hiding the manual
textarea and falling back to the prior 'Load available models first'
validation toast for them.
OpenRouter drops unsupported parameters server-side, so manual entry
remains useful there; keep the textarea and the union save path for
it. Custom and curated backends already gated via isCustomProvider /
isCuratedModelList and continue to require manual entry as before.
* studio: shorten code comments in chat-providers-dialog.tsx
Trim three multi-line comment blocks to single lines per review.
* Studio: gate image input on a usable mmproj for GGUF vision models
* Improve image gating and model capability sync
Tighten image-handling and model capability syncing across the chat flow. Key changes:
- chat-adapter: Replace per-message current-user image check with a simpler gate that blocks if ANY image is present in the outbound payload when the selected model cannot handle vision. Show the toast reason and flip the per-thread running flag on→off to avoid hanging wait promises before throwing.
- shared-composer: Simplify and correct image-attachment gating for single vs compare modes. Use an attach-time gate that defers to send/ensureModelLoaded in compare mode, introduce attachUnavailableReason, and only block immediately for single-mode. Remove an unused models selector.
- shared-composer: Sync the runtime models[] entry with the response from ensureModelLoaded so UI/send gates read fresh capabilities (isVision, isGguf, isAudio, audioType, hasAudioInput). This addresses catalog lag (e.g., GGUF mmproj arriving after the catalog snapshot).
- UX tweak: the file-picker button no longer outright blocks on image availability; addFiles still filters images per-file and toasts appropriately.
These changes prevent mid-stream server rejections, avoid deadlocks, and ensure model capability checks are accurate when attaching images or audio.
* studio: only pass --mmproj to llama-server when effective_is_vision
When a text-only GGUF (static is_vision=False) was paired with a
family-matching mmproj path, the launcher appended both --mmproj and
--spec-default, leaving llama-server in an inconsistent state while
Studio reported is_vision=False. Gate the --mmproj flag on
effective_is_vision so the launch command tracks the runtime
capability the rest of Studio sees.
* studio: reject image content in streaming /v1/responses for non-vision GGUF
_responses_stream forwards the OpenAI request body directly to
llama-server's /v1/chat/completions, bypassing the image-vs-vision
guard that openai_chat_completions enforces for the wrapped path.
Add the same check at the top of the streaming entry point so an
SDK client that posts an image to a non-vision GGUF receives a
typed 400 instead of an opaque downstream error.
* studio: gate external chat providers in the image input helper
External selections (cohere, deepseek, mistral, openrouter, ...) live
in externalProviders, not in runtime.models[], so activeModel is
undefined for them and the helper short-circuited to allow. Result:
images attached to a non-vision external chat model were dropped
silently downstream instead of rejected up front.
Add providerTypeSupportsVision to external-providers.ts (false for
known text-only providers, true for known vision-capable ones, null
for unknown / custom self-hosted) and thread externalSupportsVision
+ externalModelLabel through the helper. shared-composer.tsx,
runtime-provider.tsx (VisionImageAdapter.add), and chat-adapter.ts
pre-stream gate all resolve the provider type and pass it.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
* fix(studio/worker): inject --gcc-install-dir for HIP source builds on Ubuntu 24.04
On Ubuntu 24.04 + ROCm clang-20, the HIP source-build fallback in
`_install_package_wheel_first` (causal-conv1d, mamba-ssm source fallback,
flash-attn source fallback) dies at:
/opt/rocm-X.Y/lib/llvm/lib/clang/20/include/__clang_hip_runtime_wrapper.h:112:10:
fatal error: 'cstdlib' file not found
Root cause: clang-20 picks the highest-numbered /usr/lib/gcc/x86_64-linux-gnu/<N>
runtime dir by default. On 24.04 that's gcc-14, whose runtime objects ship in
the gcc-14 package but whose C++ headers (/usr/include/c++/14) come from
libstdc++-14-dev — NOT in the default apt set. libstdc++-13-dev IS in the
default set, so /usr/include/c++/13 exists. clang has no way to discover
that asymmetry and the build fails.
Fix: new `_hipcc_gcc_install_dir()` helper iterates gcc 14 → 11 and returns
the first /usr/lib/gcc/x86_64-linux-gnu/<N> dir where BOTH the runtime AND
/usr/include/c++/<N> exist. The HIP branch of `_install_package_wheel_first`
appends `--gcc-install-dir=<that path>` to HIPCC_COMPILE_FLAGS_APPEND before
invoking pip. Respects an existing `--gcc-install-dir` in the env var
(user-set takes precedence); preserves any other flags the user has set
(appends to the end rather than overwriting). No-op on non-HIP, non-Linux,
non-x86_64.
Mirrors the same fix bbf004c added to studio/setup.sh for the llama.cpp HIP
build branch (#5301), but via env var since pip-driven source builds can't
take CMake flags directly.
Verified on Ryzen AI MAX+ 395 / Radeon 8060S (gfx1151) / Ubuntu 24.04 /
ROCm 7.13 nightly: `_hipcc_gcc_install_dir()` returns
`/usr/lib/gcc/x86_64-linux-gnu/13`, which matches the manual workaround
that already lets `pip install causal-conv1d` succeed on this hardware.
Tests added (8 new in test_training_worker_flash_attn.py):
- test_hipcc_gcc_install_dir_picks_highest_with_headers
- test_hipcc_gcc_install_dir_picks_14_when_headers_exist
- test_hipcc_gcc_install_dir_returns_none_when_no_match
- test_hipcc_gcc_install_dir_returns_none_on_non_linux
- test_hipcc_gcc_install_dir_returns_none_on_non_x86_64
- test_install_injects_gcc_install_dir_on_hip_source_build
- test_install_appends_to_existing_hipcc_compile_flags
- test_install_respects_user_gcc_install_dir
- test_install_does_not_inject_env_on_cuda
Per @danielhanchen's suggestion in
https://github.com/unslothai/unsloth/pull/5434#issuecomment-4469980122
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* review: apply gemini-code-assist suggestion on _run_kwargs env handling
Use _run_kwargs.get("env", os.environ).copy() + key-mutation instead of
rebuilding env from os.environ directly. Today both forms are equivalent
(no earlier code in _install_package_wheel_first sets _run_kwargs["env"]),
but the .get().copy() pattern survives any future env modification added
upstream of this block without silently throwing it away.
No behavioural change; tests already assert the final HIPCC_COMPILE_FLAGS_APPEND
value, not the env-construction pattern.
Per https://github.com/unslothai/unsloth/pull/5517#discussion_r... (gemini-code-assist[bot])
---------
Co-authored-by: h34v3nzc0dex <h34v3nzc0dex@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
* studio: extend offline DNS auto-detect to inference parent + training
#5505 fixed the GGUF/llama-server load path. Studio still has two
adjacent code paths that burn ~30-60s of soft-failed timeouts before
the worker subprocess starts when DNS to huggingface.co is dead and
the model is already in the local HF cache.
Inference parent process (routes/inference.py:load_model):
* ModelConfig.from_identifier now runs inside _hf_offline_if_dns_dead
so the LoRA-detect hf_model_info call and the urllib config probes
in utils/transformers_version.py short-circuit when DNS is dead.
* utils/models/model_config.py: extracted the inline HF_HUB_OFFLINE/
TRANSFORMERS_OFFLINE check used by list_gguf_variants and
detect_gguf_model_remote into a shared _env_offline() helper, then
reused it to gate the LoRA-detect hf_model_info call.
* utils/transformers_version.py: _check_tokenizer_config_needs_v5 and
_check_config_needs_550 now early-return False when offline instead
of issuing a 10s urllib.urlopen against huggingface.co/raw/main.
Training worker (core/training/worker.py:run_training_process):
* Add the same 2s DNS probe used by core/inference/worker.py at the
top of the training subprocess. On failure, set HF_HUB_OFFLINE,
TRANSFORMERS_OFFLINE, and HF_DATASETS_OFFLINE before the rest of
the subprocess imports torch/transformers/unsloth, so every
from_pretrained, snapshot_download, and load_dataset call below
resolves from cache. Scope is per-subprocess; the orchestrator
always spawns a fresh worker per training run.
Training trainer (core/training/trainer.py:load_model):
* Skip the proactive hf_model_info gated-repo probe when _env_offline()
is true. The API is unreachable anyway, and a gated model that is
already cached is exactly the scenario the user is trying to train
against. from_pretrained surfaces the real error if access is
actually denied.
Tests (tests/test_offline_inference_parent.py, 7 new cases):
* _env_offline truthy/falsy parsing across HF_HUB_OFFLINE and
TRANSFORMERS_OFFLINE.
* transformers_version urllib short-circuit when offline.
* LoRA detect hf_model_info skip when offline.
Existing tests/test_offline_gguf_cache_fallback.py still passes
(26 cases) because the inline env check was extracted, not changed.
* tests: prefer real httpx over stub in offline-test files
The studio test stub convention only included the 6 httpx exception
names that existed callers needed. Newer huggingface_hub (1.15+)
imports HTTPError, Response, Request, HTTPStatusError, AsyncClient,
and more at module import time. When httpx is truly absent the stub
chase becomes a treadmill.
Use the real package when installed (the CI install list already
includes httpx, so this is the production environment). Fall back to
the stub only when httpx is genuinely missing.
No code under test changes.
* studio: detect cached LoRA adapters offline; tighten test
Two follow-ups from the review pass on #5512:
* ModelConfig.from_identifier no longer skips the remote LoRA-detect
hf_model_info call when _env_offline() is true. huggingface_hub
short-circuits the call via OfflineModeIsEnabled in ~0ms when
HF_HUB_OFFLINE is set, so the original 25s concern was moot once
routes/inference.py wrapped the call in _hf_offline_if_dns_dead.
Skipping the API meant users with a cached LoRA adapter
(adapter_config.json on disk) got is_lora=False and the load
failed. After the API call (which raises fast offline) a new
cache-fallback walks the HF cache snapshot for adapter_config.json
via the existing _iter_hf_cache_snapshots helper.
* test_hf_model_info_not_called_when_offline replaced. The old test
raised AssertionError inside production code that catches Exception,
so it passed even if the call happened. New tests use MagicMock and
assert call_count >= 1, plus a fixture that stages a fake HF cache
with adapter_config.json to verify the offline cache detection.
Test count goes from 7 to 8 in test_offline_inference_parent.py.
Combined with test_offline_gguf_cache_fallback.py: 34 pass in 9.75s.
* Fix/adjust offline training DNS probe per PR #5505 review
Same fix as #5505's _probe_dns_dead refactor: run gethostbyname on a
daemon thread with join timeout so concurrent sockets in the parent
interpreter never inherit a process-wide socket.setdefaulttimeout
mutation. Adds a static-pin regression test that the inference parent
file does not regress on this.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Trim verbose code comments per review feedback
Shorten the longer explanatory comments added by this PR while keeping
the WHY of each non-obvious branch:
- trainer.py: collapse the 5-line proactive gated-check comment.
- training/worker.py: trim the offline auto-detect preamble and the
"logger isn't configured" note.
- routes/inference.py: shorten the DNS-probe wrap rationale.
- transformers_version.py: collapse the two urllib short-circuit notes.
- model_config.py: shorten the LoRA detect + cache-fallback notes.
- tests/test_offline_inference_parent.py: tighter module docstring,
trim class docstrings, drop multi-line explainer comments inside the
tests; behaviour and coverage unchanged (9/9 tests still pass).
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
* Studio: warn when llama.cpp prebuilt is at least 3 days behind
Layered on #5528. Generalises the MTP-specific staleness warning to
every llama.cpp prebuilt update, not just the ones that add MTP. If
the installed prebuilt is at least 3 days old AND its tag differs
from the latest published tag on the helper release repo (default
unslothai/llama.cpp), Studio nudges the user to run
"unsloth studio update".
How it works
Reads the install marker UNSLOTH_PREBUILT_INFO.json that
install_llama_prebuilt.py already writes to install_dir. The marker
carries the installed tag, the helper repo, and an installed_at_utc
timestamp. Studio compares those against the latest published tag
from the GitHub releases API for the helper repo.
GitHub fetch is cached at two levels:
- Process-level memo for /status hot path.
- Disk-level cache (24h TTL) at ~/.unsloth/studio/cache/llama_cpp_freshness/
so cold-start Studio launches do not always hit the API.
On a transient fetch failure (offline, rate-limited) we keep the
last-good disk value alive rather than poisoning the cache with None.
The check fails open: if anything is missing (marker, timestamp,
GitHub response), stale stays False so users never see a misleading
banner.
Surfaced in two places
1. Startup banner (logs + stderr) in main.py:lifespan(), alongside the
MTP capability probe added in #5528. Single line, e.g.:
WARNING: llama.cpp prebuilt is 5 days behind: installed b9190,
latest b9300. Run "unsloth studio update" to refresh.
2. /api/inference/status now returns:
llama_cpp_prebuilt_stale: bool
llama_cpp_installed_tag: str | None
llama_cpp_latest_tag: str | None
so the frontend can render a banner / popup with the actual tag
delta the user is missing.
3-day threshold
Mirrors the typical Unsloth llama.cpp release cadence. Anything
shorter would nag users who restart Studio at the wrong moment;
longer leaves real bugs sitting on the user's machine. Configurable
via the threshold_days kwarg if a future call site wants a different
window.
Tests
17 new cases in tests/test_llama_cpp_freshness.py cover marker
discovery in both cmake and root install layouts, missing / invalid
marker, GitHub fetch caching across process restarts (disk cache hit
after the in-memory cache is reset), the stale / not-stale decision
matrix (tag mismatch + age threshold), fail-open behaviour when
GitHub is unreachable, custom threshold, singular/plural day in the
warning string, and unparseable installed_at_utc. The broader
205-test inference regression suite still passes.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
* Studio: warn when llama.cpp prebuilt is too old for MTP
Layered on #5527. Adds a one-shot llama-server --help capability probe
so users get a clear signal when their prebuilt is missing MTP support,
plus a graceful fallback if they load an MTP GGUF against an outdated
binary.
What's surfaced:
1. Startup log + stderr line in main.py:lifespan() if MTP isn't
advertised:
WARNING: llama.cpp prebuilt is missing MTP support
(--spec-type mtp / draft-mtp). Run `unsloth studio update` to
refresh it. MTP GGUFs will load without speculative decoding.
2. Load-time graceful fallback in load_model's spec block: skip the
auto-emit and log a clear warning instead of letting llama-server
fail with an unknown-flag error.
3. /api/inference/status now returns llama_cpp_supports_mtp: bool so
the frontend can show a banner / popup.
Probe internals:
- Class-level cache keyed on (binary_path, mtime). One subprocess call
the first time, instant thereafter. Touching the binary (e.g. via
`unsloth studio update`) invalidates the cache automatically because
the mtime changes, so the new build is picked up without restarting
the server.
- Recognises both upstream naming forms: the original draft-mtp from
llama.cpp PR #22673 and the renamed mtp variant in later commits.
- Spec block uses whichever token the binary accepts so we emit the
right value regardless of which release the user has.
Tests:
- 6 new cases in test_llama_cpp_mtp_detection.py covering each probe
variant (draft-mtp, renamed mtp, pre-MTP build, missing binary,
mtime-based cache invalidation).
- Existing 38 MTP detection cases still pass; broader 188-test
regression suite (server args, reload inheritance, gguf metadata,
load progress, context fit, model validation) still green.
* [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>