Commit graph

63 commits

Author SHA1 Message Date
Daniel Han
1bf4be70e3
Studio: budget the JavaScript that runs before the first screen (#8964)
* Studio: budget the JavaScript that runs before the first screen

* Report the eager chunk count without budgeting it

* Close the ways this gate could pass without measuring anything

Three of them were reachable.

Run through a symlinked checkout, the main-module guard compared
process.argv[1] (the path as typed) against import.meta.url (the real
path), they disagreed, and the script exited 0 having done nothing. That
is anything under /tmp on macOS. Both sides now go through realpath.

With build.modulePreload: false Vite emits the entry script and no
preload links. The empty-set check did not fire, and a real build of that
shape measured 424 KB of a 5,207 KB startup path and reported 4.8 MB to
spare. The guard now needs two chunks from Vite, and it counts scripts
and links together so the layout Vite uses when the entry module is
nothing but imports (one script per chunk, no links) still passes.

A chunk named in index.html but missing from the build threw out of
readFileSync, exiting 1 with a stack, indistinguishable from a budget
failure. It now reports the file and exits 2.

Also: a parser-blocking classic script is charged to startup. index.html
loads public/theme-boot.js that way, before the module graph, and it was
outside the budget it belongs in. defer/async and cross-origin scripts
are not counted. Matching is now case-insensitive and treats rel as a
token list, so a partial match cannot quietly shrink the measured set.

Total on this build goes from 5,207.2 KB raw / 1,496.2 KB gzip over 44
chunks to 5,208.3 KB / 1,496.8 KB over 45, still inside budget.

* Judge a script tag on whether the browser runs it

type="application/javascript" and the other JavaScript MIME types are
classic scripts too. Matching only text/javascript would have let one
sit outside the budget, which is the same silent under-measurement the
rest of this is about. importmap and application/json still do not
count: they are not code that runs.

* Charge deferred scripts, and size non-asset files as they are served

A deferred classic script runs after parsing but before DOMContentLoaded,
in document order with the module entry, which is itself deferred. It is
on exactly the timeline this budgets, so excluding it left a way to move
startup JavaScript out of the budget without moving it off the startup
path. Only async is excluded now, and async is the attribute tested
because it wins when a tag carries both.

The transfer column was gzip for everything, but the backend gzips the
/assets mount only; anything else goes out through a plain FileResponse.
theme-boot.js is the one such file today, and charging it gzip understated
what actually crosses the wire. Non-asset files are now charged their raw
size, and the column is called transfer rather than gzip, which is what it
has always been measuring.

5,208.3 KB raw / 1,497.3 KB transfer over 45 chunks, still inside budget.

* Anchor attribute matching on whitespace, not a word boundary

A hyphen is a word boundary, so the \btype pattern matched inside
data-type and read the decoy in preference to the real attribute. The
same held for data-src, data-href and data-rel. Reproduced: a tag with
data-type alongside type="module" dropped the entry entirely while its
preload links kept the shape guard satisfied, which is a silent
under-measurement of exactly the kind the rest of this exists to prevent.

Every attribute in a tag is preceded by whitespace, the tag name
included, so whitespace is the boundary HTML actually gives us.

* Read index.html with a tag scanner instead of a regex

Searching the whole tag text for an attribute name found it in other
attributes' values, and treating the first > as the end of the tag ended it
inside a quoted value.

  <script data-mode="load async later" src="/theme-boot.js">
  <script data-note="a > b" type="module" src="/assets/entry.js">

The first has no async attribute and is parser-blocking, so it belongs in the
budget; the second's entry is /assets/entry.js. The old code dropped both, and
in each case the rest of the build still satisfied the shape guard, so the gate
reported a comfortable pass over a startup path it had not measured.

Attributes are now parsed off each start tag: > ends a tag only outside a
quoted value, and a name is only read where a name can begin. Comments and
inline script bodies are skipped, which a stateful scanner has to do to stay in
sync, and which also stops a commented-out script being charged.

* Count async scripts that are asked to block rendering

The exclusion of async assumed it has no ordering relationship to the first
screen. blocking="render" creates exactly that relationship, and it is the
documented way to keep a boot script off the parser without letting the
unthemed page paint, which is what theme-boot.js is for.

Per the spec an element is potentially render-blocking if its blocking tokens
set contains render, OR if it is implicitly potentially render-blocking; the
async carve-out lives only in the implicit half, so the explicit attribute
applies to an async script too.

Measured rather than assumed. Holding /slow.js for two seconds moved first
contentful paint from 28 ms to 2,020 ms in Chromium 151, which also reports the
request renderBlockingStatus as blocking, and from 11 ms to 2,009 ms in WebKit
26.5. Firefox has not shipped it and treats the script as plain async.

Left uncounted, such a script delays the first screen by its whole fetch and
evaluation while the entry and preloads keep the shape guard satisfied.

* Recognise every JavaScript MIME essence a browser still runs

The set held the four spellings anyone writes today, but the rule it cites is
the spec essence list, which has sixteen. The other twelve are not dead
letters: measured in Chromium 151, application/x-javascript, text/jscript,
text/javascript1.5, text/livescript, application/x-ecmascript and
text/x-javascript all execute.

So a startup script tagged with one of those was fetched and run by the browser
and left out of the budget, while the entry and preloads kept the shape guard
satisfied.

The same probe confirms the two exclusions already relied on here:
text/javascript; charset=utf-8 and application/json do not execute, because the
attribute is matched against the whole essence string and a parameter makes it
match nothing. The list is frozen upstream, so it does not grow.

* Require a module entry before trusting the preload links

The shape guard counted entry scripts and preload links together, so 48 links
carried it on their own and a build whose entry was misread still measured and
passed. The entry chunk is the largest single thing on the startup path, so
that is the worst place for the total to paper over a gap.

Preloads without an entry is not a shape Vite emits. A modulepreload link
exists to announce the entry a static import closure hangs off, so links
surviving while the entry does not means the entry was read wrong.

The total still decides whether this is a code-split build, which keeps the
inlined-entry layout passing: several module scripts and no links at all is a
complete measurement.

This is the residue of two mis-parses fixed earlier in this branch. Both did
their damage the same way, by dropping the entry while the links kept the guard
satisfied, so the invariant is worth stating outright rather than relying on
the parser never being wrong again.
2026-08-17 03:35:18 -07:00
oobabooga
735ef68437
Studio: stop list items flashing as horizontal rules (#8378)
* Studio: stop list items flashing as horizontal rules

* Studio: respect markdown containers while streaming

* Restore lockfile peer metadata

* Preserve lockfile field ordering

* Studio: handle remaining streaming list prefixes

* Studio: handle tab-separated streaming list prefixes

* Studio: cover streaming footnote rendering

* Studio: avoid duplicate streaming markdown parses

* Generalize the streaming thematic-break fix beyond asterisks for PR #8378

* Match Streamdown's own parse pipeline when deciding to buffer for PR #8378

* Keep the streaming buffer decision inside the trailing block for PR #8378

* Bound the streaming buffer check to a realistic line length for PR #8378

* Tighten the streaming markdown comments for PR #8378

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-11 00:22:08 -07:00
Daniel Han
1d116166e8
Reject an incompatible GGUF and base pair before the download, not after (#8158)
* Studio: check FLUX.2 GGUF/base compatibility before the download, and validate the model catalog against the Hub

Two things a user hit by hand.

A FLUX.2 GGUF only carries the transformer, so its inner_dim has to agree with
the companion diffusers base the loader assembles around it.
assert_flux2_gguf_matches_base already said so, legibly, but it opens the
DOWNLOADED checkpoint: it fires from inside load_pipeline, after the prefetch
pulled ~19 GB of base shards and after _unload_locked freed the pipeline the
user was working with. The pick was never valid; they paid for both to be told.

diffusion_compat answers the same question from metadata alone -- one HTTP range
request for the first 256 KiB of the GGUF, which is where its tensor table
lives. That is cheap enough to run at selection time, so the check now happens
in three places instead of one:

  * POST /images/download-plan, reported in the envelope as incompatible_reason
    rather than raised. The images page falls back to /images/load on ANY plan
    failure, so a 400 here would start the very download this prevents; carried
    in the response, the picker refuses before the user commits.
  * preflight_base_access, beside the gated-repo refusal, so the route rejects
    the pick before it takes the GPU from chat.
  * _run_load, before _prefetch_files and before load_pipeline, so a direct
    begin_load (a saved config, the deploy path) is covered too.

The loader's own guard stays exactly where it was, as the backstop.

Fail-open is the whole contract, so it is enforced at the parser: the
header-only reader REFUSES a read past the prefix rather than zero-filling it.
Reading the table field by field means a prefix cutting between a tensor's name
and its dims would otherwise hand back shape 0 -- a wrong answer, not a missing
one, and one that refuses a valid pick. It also skips the base class's per-tensor
data views, which for a prefix would allocate the whole declared checkpoint
(tens of GiB, charged against the pagefile on Windows). Anything unreadable, any
base outside the size table, an offline host, or a server that ignores Range all
yield no opinion and today's behaviour.

Same header closes the sibling hole: sd_cpp_text_encoders_for picked the
FLUX.2-klein 9B text encoder by string-matching the repo id and filename, which
a renamed checkpoint defeats silently (the wrong encoder fails deep inside
sd-cli). It now prefers the checkpoint's own inner_dim, keeping the string match
as the fallback, and the committed dim is carried on _SdState so the delete
guard reconstructs the same pick without re-probing under the lock.

Catalog: unsloth/LTX-2.3 was never published. It was only a grouping key, but an
`unsloth/*` id that is not an artifact clears both the picker's owner guard and
the backend's, so a pick reaching the fall-through loaded as a pipeline and died
at the Hub. Keyed on Lightricks/LTX-2.3, which is a real artifact in the same
group; the retired id still resolves through the GGUF's suffix-stripped key.

To stop the next one, catalog:check gains an opt-in --network mode that HEADs
every declared repoId and filename anonymously (what a fresh install sees) and
asserts each `gated` flag matches what the Hub reports. Opt-in and scheduled,
never on pull_request: it depends on huggingface.co being up, and an
indeterminate answer is a warning, never a failure.

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

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

* Arm the GGUF header read's deadline instead of only testing it

The whole-body deadline the previous commit added did not work. It was
only evaluated BETWEEN chunks, but iter_content(chunk_size=65536) blocks
inside urllib3 until the full 64 KiB chunk has arrived, and every dribbled
byte resets requests' per-socket timeout. Against a server trickling four
bytes a second the loop body is not reached for 4.6 hours, and the picker
sits there: measured on a loopback socket, the read was still blocked at
60s with the deadline claiming 15s.

This is on the pre-eviction path, so the symptom is an /images/load that
never answers. No lock is held, so it is a hang rather than a deadlock,
but the mitigation the comment described was inert.

Arm it on a timer that half-closes the socket. response.close() is not
enough: it drops the file object while the socket stays readable, so the
parked read stays parked. urllib3's HTTPResponse.shutdown() is the only
thing that wakes it, with a guarded fallback to close() so an older
urllib3 is no worse than today.

Whatever arrived is kept rather than discarded. A merely slow link still
leaves the tensor table in hand, and the parser is truncation-safe: a
short prefix is answered or ignored, never guessed at. The happy path is
unchanged at 1.1ms for 256 KiB; chunk_size stays at 64 KiB because
dropping it to 1 costs 912ms on every pick.

* Bound the header read without depending on urllib3, and stop a Hub blip disabling the local probe

Three fixes to the range-read preflight, found reviewing the branch against old installs.

The whole-body deadline works by half-closing the socket, which is the only thing
that wakes a read parked inside iter_content. HTTPResponse.shutdown landed in
urllib3 2.3.0; below that the watchdog degrades to Response.close(), which leaves
the socket readable and the read parked. Measured against a trickling loopback
server on urllib3 2.2.3, the reader was still alive after 40 s, and this read sits
on the /images/load route thread. The drain now runs on a worker the caller
abandons, so the bound holds whatever urllib3 is underneath, and requirements
floors urllib3 at 2.3.0 for new installs as defence in depth.

A memoised "no opinion" was consulted before the on-disk read, so a transient Hub
failure -- or simply a probe that ran before the download finished -- permanently
disabled the free local read for that pick and left the guard silent on a file
sitting in the cache. The negative now only suppresses the network half.

The native backend probed the header before the lock but still on the route
thread, and begin_load returns at once by contract. It is offline-only there now;
the worker re-probes with the network and publishes the real encoder repos before
fetching a byte, so the delete-cached guard still names them.

Five tests, each checked against a mutation of the fix it covers.

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

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

* Key the header memo on the file and the credential, and bound the catalog fetch

Four review findings, all reachable.

The memo keyed on (repo, filename, has-token). A checkpoint swapped in place under the same
name kept the same key, so the old dim answered for the new file: a valid 9B pairing refused,
or the 4B text encoders handed to the native backend, until the process restarted. And every
non-empty token was one key, so a first probe with an expired credential cached its miss and
the working token pasted afterwards inherited it for the session, with the preflight silently
off for that pick. The key now carries a non-reversible token fingerprint and the local file's
(path, size, mtime). That also subsumes the earlier download-completion case: the file
arriving is a new key, so a miss taken before it landed re-probes off disk for free.

The catalog network check had no per-attempt deadline, so a peer that accepted the request and
then stalled never reached the retry or the fail-open path, and the scheduled job died on its
own 10-minute timeout as a red run for a transient stall. Each attempt now carries an
AbortSignal.timeout, and the body is read under that same deadline rather than by the caller,
where an abort would have been reported as unreadable JSON, which is a catalog failure.

Rejecting an incompatible pick reverted the quant label but not the steps and guidance the
pick had already applied, so the still-resident previous pipeline would run its next
generation at the rejected model's settings. All three revert together now, on the poll path
as well as the start path.

Three tests for the memo, each confirmed against a mutation of the key.

* Bound the whole network catalog pass, not just each request

Per-attempt deadlines fixed the hang but not the budget. The batches are serial: about 53 repos
at four per batch is 14 of them, and a peer that stalls every request costs three 20-second
attempts plus 1.5 seconds of backoff each, so a fully stalled Hub takes roughly 14.3 minutes to
fail open while the workflow kills the job at 10. That is the red run the retry logic exists to
avoid, arriving by a different route.

An overall seven-minute deadline is armed when the network pass starts, and every request past
it short-circuits to "no opinion" without reaching the socket, so the check still prints its
warnings and exits 0. Measured against a server that accepts and then stalls every request: a
scaled 53-repo run takes 7.5 seconds with the deadline and 105 without, all 53 failing open
either way.

The test reads the budget out of the source and the timeout out of the workflow, so raising one
without the other fails.

* Bound the header probe from the first byte, and stop stale picks rolling back

- requests' timeout is an inactivity timeout, so a peer trickling response
  HEADERS never reached the bounded body reader; the request now runs on the
  same abandonable worker as the drain, under one wall-clock deadline.
- the offline probe bailed before consulting the memo, so begin_load fell back
  to the filename heuristic right after a plan-time probe had answered, and
  published the 4B encoder repos for a renamed 9B checkpoint.
- picking a second model while the first plan request was in flight let the
  older rejection restore its own steps and guidance over the newer, accepted
  pick; a rollback now applies only if its selection is still current.

* Advance the selection token on curated picks, and mirror urllib3 into the extra

The curated non-GGUF branch applied its defaults and returned without touching the
selection token or the pending revert, so it was the one accepted selection the token
mechanism did not cover: an earlier GGUF pick whose plan came back incompatible still
matched its own token and restored ITS quant, steps and guidance over the curated model
that was already loading. It now follows the same optimistic-then-revert contract as the
other three branches, and the test counts token bumps against branches that apply a
model's defaults, so a fifth branch cannot opt out either.

urllib3>=2.3.0 went into studio.txt only, which left pip install "unsloth[studio]"
without the floor the range-read preflight's socket shutdown needs, and failed
test_studio_extra_matches_requirements.py.

* Do not replay a pick's settings over an edit the user made while it was pending

Staging an undownloaded model deliberately leaves `busy` unset for as long as the
download takes, so Steps and Guidance stay live the whole time. The pending revert holds
the values captured before the pick, and every rollback path replayed them
unconditionally, so a staged load that later failed or was cancelled silently undid the
user's own edit. The selection token cannot cover this: it moves on another pick, not on
a slider.

The sliders go through handlers that mark the settings edited, every settings restore is
behind that flag, and arming a fresh pick clears it so one edit cannot disarm every later
pick. The quant label rollback is unchanged: that one is still about the pick.

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

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

* Let the merged plan test see the compatibility field in the envelope

origin/main's test asserts the download plan dict exactly, and this branch adds
incompatible_reason to it, so the two only collided once they were in the same tree.

* Confirm the Hub revision before refusing a pick from a cached header

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

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

---------

Co-authored-by: Daniel Han <moonshotaisubstack@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-09 04:37:02 -07:00
Long Yixing
4a4509c156
feat(studio): add settings-managed remote access (#7875)
* refactor(studio): make cloudflare tunnels runtime-managed

* feat(studio): add public access controls

* fix(studio): preserve public stop responses

* feat(studio): show public access settings

* fix(studio): report settings-managed public links

* fix(studio): respect launch tunnel ownership

* fix(studio): apply public runtime trust policy

* refactor(studio): rename public access to remote access

* feat(studio): serve web ui through desktop remote access

* feat(studio): set the remote password from desktop

The desktop app signs in with a local secret and never sees the seeded administrator password, which is only printed to a terminal. Remote access refuses to open a tunnel while that seeded password is still in place, so a desktop user had no way forward: the change-password flow needs a current password they do not have, and the General tab hides its password row on desktop.

Add a Remote password row to the Remote access section, desktop only. While the seeded password is pending it sets the first password through a new POST /api/auth/desktop-initial-password; once a password exists the same row changes it through the existing endpoint. The new route accepts only a desktop-issued JWT (web sessions and API keys are refused), applies only while the seeded credential is unchanged, and binds its write to the credential version it read so a concurrent web change or CLI reset is not overwritten.

Both password routes now keep the local desktop credential valid and mint desktop-flagged tokens when the caller is the desktop app, so auto-auth stays passwordless and the session survives the change it made. Browser callers keep their existing behavior: the credential is revoked and ordinary tokens are issued.

Remote access status reports password_pending on its own, independent of block precedence, so the row is correct even when another block hides the reason. Re-reading status after either operation clears the pending-password block and lets the tunnel start; remote browsers then sign in as unsloth with the new password.

The action reuses the account password dialog in an initial-password mode rather than adding a component, and adds no file.

* feat(studio): redesign remote access settings as a status card

The remote access controls rendered as flat settings rows, so the
feature read as hidden and the tunnel URL was squeezed into a
description cell. Rebuild remote-access-section.tsx as a bordered card:

- header with globe icon, title, live status dot plus state text and
  the start/stop action together
- online state gets a dedicated URL panel with a copy button, a QR
  button that opens a scannable code for phones (react-qr-code, svg,
  zero deps), and a note that the URL plus remote password grants
  sign in
- block reasons and tunnel errors render as their own strip,
  destructive styling on error
- shine sweep on the status text while starting or stopping
- password and auto start rows move below a divider inside the card

Polling and start/stop/auto-start logic are unchanged.

* fix(studio): stop tunnel dns wait from stalling on negative caches

Starting remote access took 45-48s while a manual tunnel came online in
about 8s. _wait_for_dns queried DoH immediately after the edge
connection registered, before the fresh trycloudflare hostname
propagated, and the resolver negative-cached the NXDOMAIN
(trycloudflare.com publishes a 1800s SOA negative TTL; 1.1.1.1 was
observed serving the stale negative for about 40s while the loop
re-asked it every 2s).

- delay the first lookup 3s so it cannot seed those negative caches
- ask two independent resolvers per round (cloudflare and google DoH)
- cap the dns wait at 20s so it can never starve the health probe,
  which is what actually gates advertising the URL

Start to online now measures about 12s end to end against a live
backend. The consecutive-error bailout for blocked DoH is unchanged.

* fix(studio): drop stale stopping status after a finished stop

A stop worker outlives its completed stop by up to about a second while
draining stop responses. During that window remote_access_status kept
reporting state stopping and forced can_start false, so a quick
stop-then-start flashed "Stopping" in the UI and the start request
could 409 as operation_in_progress.

Once the tunnel controller reports off with no pending stop handle the
stop is done, so a still-alive stop worker no longer masks the idle
state or a newly admitted start. Unconfirmed terminations
(stop_pending) keep reporting stopping so retry stays visible.

* fix(studio): resolve the tunnel hostname through one resolver

Asking a second DoH resolver in the same round cannot rescue the poll: both queries land milliseconds apart, so the round that seeds one resolver's negative cache seeds the other's too. It also disclosed the freshly issued hostname to a third party on nearly every start, where the provider that handed the name out already knows it.

The hold-off and the wait cap stay, which is where the measured speedup comes from. The cap trades one case away: a hostname that only propagates after 20s now meets the OS resolver while it is still missing, and because the tunnel has registered by then a failed probe ends the attempt instead of retrying over http2. In exchange every start whose record exists by 20s spends the rest of the deadline on probe attempts rather than a single one at 45s.

* fix(studio): report a stop only while its teardown is outstanding

A stop worker outlives the teardown it performed, so reporting stopping until that thread exits hid the tunnel already being off and refused a start that would have succeeded. The worker's recorded admission sits behind the control token once its teardown advanced the generation, but a start advances the generation the same way, so only that advance together with an off tunnel and no pending stop shows the teardown actually happened.

* fix(studio): keep the remote access card legible

The status text goes back to full opacity. The shimmer needs partly transparent glyphs, which put the transitional states below the contrast that same text had before, and a viewer who asked for reduced motion paid that cost with no sweep to show for it. The status dot already marks the transition.

The auto-start switch's accessible name matches its visible label again after the rename, and the block reason no longer borrows the error colour when a failed tunnel is not what it is reporting. Moving the URL panel and the block-reason strip out of the section body keeps the section at the complexity it had before the card, and SettingsRow drops labelAccessory along with the row that used it.

* perf(studio): verify a new tunnel at the edge instead of through DNS

Cloudflare routes quick tunnels by TLS SNI, so the edge serves a tunnel as soon as its connection registers, before the hostname resolves anywhere. Probing an edge address with the tunnel's name in SNI and Host takes that hostname's DNS off the startup path, along with the wait that existed only to keep an early lookup from caching the miss.

Measured over four fresh tunnels each: verification after registration falls from 10.97s to 6.61s on average, and a whole start from 22.9s to 16.8s. It also removes the case behind the slowest starts, where one DoH query sent before the record propagates is negative-cached, blinding the poll until its own cap expires and pushing a start past 30s.

The hostname path stays as the fallback for networks that block direct addresses or intercept TLS. It is entered as soon as the edge looks unreachable rather than merely unready: an error 1033 page is an answer, a failed connection is not.

* fix(studio): bound the edge probe and dial distinct frontends

Verifying a quick tunnel at Cloudflare's edge shipped with two defects. The wait checked the clock only after trying every address, so a full pass always ran: verify_public_url with a 0.05s timeout still took 12s, against 1.65s before the edge probe existed, and a pass of two real TLS handshakes overshot the 15s cap to about 19s, taken out of the hostname fallback's share of the same deadline. The clock is now read before every attempt, and no attempt is given more time than the deadline leaves.

The addresses were also not distinct. macOS reports the A records again as IPv4-mapped under AF_INET6, so taking one per family dialled a single frontend twice, as 104.16.230.132 and ::ffff:104.16.230.132. That doubled the cost of a pass, halved the poll rate, and let two dependent samples of one address exhaust the unreachable counter. Deduplicating by frontend yields 104.16.230.132 and 104.16.231.132 here.

An intercepting proxy answering with its own page is an answer rather than an unreachable edge, so it resets that counter exactly as error 1033 does and the wait ends at the cap instead. The comments claimed interception left through the early exit; they now describe what it does.

* fix(studio): clear the python floor, read encoding and locale parity gates

The tunnel test annotated a helper with `str | None`, which evaluates on the 3.9 floor that pyproject declares and pushed the studio union ratchet from 35 files to 36. Six new test reads took the platform default encoding, so they break on Windows the moment those files gain a non-ASCII byte. The remote password dialog's six new strings existed only in en, which strict locale parity rejects; they are now translated into every overlay.

* Fix settings-managed tunnels dying on Linux, and stop-during-retry orphans for PR #7875

PR_SET_PDEATHSIG is a parent-THREAD death signal, not parent-process: the kernel
fires it when the thread that forked the child exits. start_remote_access forks
cloudflared from a short-lived worker thread, so the connector was SIGTERMed
about a second after it came online, every time. Reproduced 3/3 on a real
install: the URL is logged, then state goes to error with "cloudflared exited".
Auto-start shares the same worker, so it was affected too.

Only Linux and WSL are hit. macOS arms no per-thread signal and Windows uses a
process-wide Job Object, so both were fine, as was the launch path, which forks
from the main thread.

Changes:

- process_lifetime: spawn_on_lifetime_thread() performs the fork on one
  process-lifetime daemon thread, so PDEATHSIG means "die with the parent
  process" again. Non-Linux spawns directly. Falls back to an inline spawn when
  no helper thread can be obtained, so it can never block.
- cloudflare_tunnel: spawn through that helper. The abort branch now tears the
  connector down instead of returning the URL of a process it just detached,
  which could leave a live public tunnel no later stop_studio_tunnel() could
  reach. Restore _tunnel_state to "starting" after attempt 1's teardown so the
  http2 retry no longer advertises "stopping", which made stop_studio_tunnel()
  early-return and stop nothing, and made the Settings Stop route a no-op.
- run.py: an explicit --cloudflare/--no-cloudflare/--secure on this invocation
  now beats an inherited _UNSLOTH_CLOUDFLARE_INTENT, so a stale export, Docker
  ENV or systemd Environment= cannot re-enable a tunnel the user opted out of.
  The marker still softens the compatibility --no-cloudflare into "unset".
- Add tests for remote-access-state.ts, which had no coverage. No bugs found
  there; the logic is correct, it simply was not exercised.

Verified: 701 passed in the focused backend slice (the one failure is a
pre-existing missing optional dep, and fails on main too), 347 frontend tests,
typecheck, biome and 80 simulation tests including a real-child-process tunnel
lifecycle and Chromium/Firefox/WebKit CORS runs.

* Surface the unstoppable-connector error, bound the Stop wait, unlatch polling for PR #7875

Follow-ups from reviewing the remote access flow on Linux. The tunnel lifetime
fix landed already; these are the smaller things around it.

- remote_access_status collapsed "cloudflared could not be stopped" into the
  generic "Cloudflare tunnel failed". That is the one error the user can act on:
  the connector's exit was never confirmed, so it still holds the runtime slot
  and Start stays disabled. Pass it through like the other known messages.

- The Stop worker waited on a live start worker with no deadline, polling at
  100 Hz. A start that never claims settings ownership (foreign owner, or one
  that bailed on admission) deferred the user's Stop for the full probe
  deadline, up to about 170s. Bound it at 5s and poll at 50 Hz.

- setPollEnabled(false) had no path back to true. It fires when you stop a
  tunnel from a browser connected through that tunnel, which is correct, but
  the section then stayed dark until it unmounted. Resume on any later action
  or on a password-change refresh, since both prove the origin is reachable.

- test_change_password_policy called change_password positionally, so the new
  is_desktop parameter defaulted to the Depends object, which is truthy. Harmless
  today because the assertions fire earlier, but it silently exercises the
  preserve-desktop-secret branch. Pass False explicitly.

Verified: 230 passed in the focused backend slice, 347 frontend tests, typecheck,
build, locale parity, and no new biome warnings. Re-checked on a real install:
auto-start brought a tunnel up at boot and it stayed online, and Stop through
that tunnel returned 200 in 0.07s. The one backend failure
(test_health_response_reports_desktop_capability_fields) is a pre-existing
environment gap and fails on main too.

* Correct streaming_supported, shorten the CORS preflight window, guard the spawner across fork for PR #7875

Findings from simulating the remote access paths across platforms, browsers and
upgrade scenarios. Three small fixes, each measured rather than reasoned.

- streaming_supported was a hardcoded True. Every tunnel Studio opens is a
  Cloudflare Quick Tunnel, and Cloudflare documents that Quick Tunnels do not
  support Server-Sent Events. Measured against a real tunnel: a local SSE
  endpoint delivers 6 events over a 5.0s spread, and the same endpoint through
  the tunnel answers 200 with text/event-stream and then delivers nothing at all
  before the read times out. Chunked non-SSE streaming was blackholed the same
  way, so no transport change works around it. The field now reports whether a
  tunnel is currently carrying the traffic, so it is true locally and false while
  a tunnel is live. Nothing branches on it yet, which is exactly why it should be
  right before something does.

- The CORS middleware inherited Starlette's 600s Access-Control-Max-Age.
  is_allowed_origin closes the instant the tunnel URL clears, but a preflight the
  browser already cached does not. Measured across four engines: after remote
  access was stopped, WebKit reused its cached preflight and the state-changing
  POST still reached the server, while Chromium, Firefox and Edge re-preflighted
  and were refused. Pinning max_age to 60 keeps preflight caching useful and
  makes revocation nearly as immediate as every other trust signal here.

- spawn_on_lifetime_thread keeps a module-level lock and a helper thread. A fork
  child inherits the lock in whatever state it was in, and a spawner whose thread
  does not exist in the child. Reproduced: forking while the lock is held
  deadlocks the child permanently. Not reachable today, since the backend only
  uses the spawn start method and the one fork start method lives in the training
  worker, which never imports the tunnel. Registering an at-fork reset is three
  lines and closes the class of bug rather than the instance.

Also verified and unchanged: no schema change in any store, so an old studio.db
with no remote_access_auto_start row reads as false and fail-closed on corrupt or
non-boolean values; the argument parser is byte-identical to main, so no launch
flag default moved; the PR touches no hardware, GPU or desktop files, and all
twelve OS x accelerator combinations produce an identical remote-access status.
Host and Origin are forbidden request headers in all four engines, so no page
script can forge the Cloudflare provenance gate.

Verified: 95 simulation cases, 41 adversarial cases, 109 in the directly affected
backend slice, 347 frontend tests, typecheck, build and locale parity. On a real
install: auto-start brought a tunnel up at boot and it stayed online, Stop through
that tunnel returned 200 in 0.09s, and the live status now reports max-age 60 and
streaming_supported false.

* Keep the desktop backend up without a dist, and settle self-origin Stop on off for PR #7875

The desktop spawns "studio --api-only" with no --frontend, and install.sh
--tauri skips the frontend build outright. The new desktop-owned branch made
_serve_frontend true for that launch, so an unresolvable studio/frontend/dist
raised SystemExit before TAURI_PORT was emitted and the app never got a
backend. The SPA only backs the optional remote web UI there, so warn and
carry on API-only; a web UI launch still aborts loudly.

A Stop sent from the tunnel's own origin is answered with a terminal off, then
polling restarts in perform()'s finally and the first poll can still land
inside the ~50ms teardown drain, where the backend reports "stopping". That
overwrote the terminal off, cloudflared then exited, and the card latched on a
permanent "Stopping" for a tunnel that was already down. Teardown frames no
longer overwrite it, while a poll that can still stop the connector proves
teardown was abandoned and takes the card back over.

---------

Co-authored-by: Maheswar Kumar <110882203+mahiatlinux@users.noreply.github.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-08-06 07:53:44 -07:00
Daniel Han
e886bcf06c
Studio: restore locale parity and gate it in CI (#7904)
* Studio: restore locale parity and gate it in CI

The eleven overlays were each 18 keys behind en: the five shell.navigation
entries and five settings.appearance.sidebarNav keys from the sidebar
customizer, and the eight settings.voice.dictation.stt* keys from the
speech model download flow. zh-CN had the last eight. All 12 locales are
now at 1210 leaves.

check-parity.ts gains --strict, which names the missing keys and exits 1;
studio-frontend-ci.yml runs it via the new i18n:check:strict script. The
default mode is unchanged and the runtime English fallback is untouched.
Without a gate this drifted 22 keys behind, was repaired, and drifted 18
behind again within a day.

* Tighten the locale-gate comments; keep the rationale in one place
2026-08-05 01:18:33 -07:00
oobabooga
df5f139bac
Studio: add image generation, editing workflows and LoRA training with Unsloth GGUFs (#6763)
* Tighten comments in the image stack tests and scripts

* Close video single-file, training reservation, and image mount-resume gaps

Route on-device single-checkpoint video folders through the single_file loader:
a bare local .safetensors directory (no model_index.json) is advertised as a
pipeline with no filename, so validation rejected it before it could load.
Reinterpret the pick as a single_file load of the sole checkpoint, mirroring the
image load route.

Treat a reserved-but-not-yet-spawned LLM training start as active in
is_training_active() so /images/load, /video/load, and /diffusion/start cannot
race the reserved run for VRAM during the pre-spawn free window. Mirrors the
diffusion training service reservation.

Resume an in-flight image generation on the Images page mount: probe
generate-progress, re-enter the poll loop, and refresh the gallery on completion
so a run started elsewhere is reflected and its saved image appears without a
manual refresh. Seed resident image defaults from the resolved base_repo rather
than a possibly path-shaped repo_id so the first resident generation uses the
right recipe.

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

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

* Publish image generation active state before pre-denoise setup

generate() assigned self._gen only at the pipe() call, after deferred
compile, LoRA resolution/application, and ControlNet download/build had
run. Across that setup window generate_progress() reported inactive even
though _generate_lock was held, so a reloaded page's mount probe showed
idle and let a second generate queue behind the first.

Publish an active step-0 _GenState the moment the generation lock is
acquired, before the setup work, and clear it in the outer finally so a
setup-time error cannot leave the UI stuck active. Mirrors the video
backend's queued phase and the training start guard.

* Studio: fix diffusion install ownership, dataset upload atomicity, gallery pagination, and teardown races

install_sd_cpp_prebuilt: only write the .unsloth-studio-owned marker when the
install created the target directory or it was empty. Adopting a pre-existing,
unowned, non-empty directory (a user's own stable-diffusion.cpp checkout) made
it eligible for the uninstaller's recursive delete.

routes/training upload: make the multi-file promotion transactional. Back up
each displaced original and roll every destination back on any failure, so a
mid-loop rename error can no longer partially overwrite the live dataset.

routes/training _resolve_dataset_folder: reject a symlinked dataset directory
and prove the resolved folder stays under the datasets root, so image
read/caption/delete cannot escape the root through a link.

routes/training delete: escape glob metacharacters in the thumbnail filename so
deleting an image named like [ab].png removes only its own thumbnails.

image_gallery / video_gallery listing: filter records against the response
schema inside the pager via a valid callback, so offset/limit/has_more all count
over accepted records. A leading schema-invalid record no longer returns an
empty page with has_more=true and stalls infinite scroll at offset 0.

image_gallery / video_gallery save: publish via a temp file plus atomic rename
(the sidecar is the video pair's commit marker) and clean up on failure, so a
partial write never surfaces a truncated PNG or strands an orphan MP4.

diffusion_train_common discovery: treat an empty caption sidecar as a metadata
tombstone that still falls through to the dreambooth instance prompt, so
clearing every metadata caption no longer fails with no captioned images found.

diffusion backend unload: wait for an in-flight denoise to exit before tearing
down process-wide patches and state, mirroring the load path.

diffusion_engine_router: serialize the whole check/unload/publish transition so
a concurrent selection cannot return the engine being unloaded.

uninstall.ps1: gate the default sd.cpp process stop on the owner marker so a
user's own sd-server is not terminated for a directory we then keep.

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

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

* Reject native batch seeds outside the JSON-safe range

* Refuse sd.cpp install into unowned non-empty target dir

When the install target already exists, is non-empty and lacks the
.unsloth-studio-owned marker (a user's own stable-diffusion.cpp checkout,
or unrelated files beside a custom Studio root), install() previously still
extracted the release into it. Skipping the ownership marker only stopped the
uninstaller from deleting the directory; extraction still merged binaries into
the user's working tree and could overwrite same-named files.

Fail up front with a clear message pointing the user at a fresh/empty location
before any download or extraction, leaving their directory untouched. Update
the ownership test suite to assert the refusal.

* Studio: gate dataset uploads on the symlink check and surface local video single-file checkpoints

* Tighten comments and docstrings added by the image-generation fixes

* Studio: close arbiter load-registration race and surface native progress + local pipeline folders

Publish native sd.cpp generate progress (_gen) before LoRA resolution so a reload probe reads active during setup, matching the diffusers path.

Register the diffusion/video GPU load under the arbiter lock (acquire_for now takes a register callback) so a competing acquire cannot evict an owner before its load is marked in-flight and let two loaders allocate VRAM at once.

Admit local diffusers pipeline folders (root model_index.json, weights in component subdirs) in the local model scan so they reach task tagging and the On Device picker.

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

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

* Studio: include marker-owned custom sd.cpp roots in the uninstall stop scan; harden Pester install against the nuget.exe PSGallery bootstrap

* Studio: surface local pipeline scan roots, tag single-file checkpoints by filename, mark companion-only pipelines partial

- _scan_models_dir: admit a scan folder that is itself a diffusers pipeline
  (root model_index.json, weights in transformer/ vae/ subdirs). _is_model_directory
  rejects such a root, so the child scan would list the component subdirs as bogus
  models and hide the real pipeline; treat the root as one model via _local_pipeline_index.

- _local_is_diffusers / _local_model_task: include the sole checkpoint filename in the
  family-detection needles (_local_family_needles, resolved via resolve_local_single_file).
  A generically named folder holding one loadable qwen-image-*.safetensors / ltx-*.safetensors
  identifies its family only from the filename; the load route already resolves that file, so
  tag it or the task-scoped picker (which rejects task=null) hides the on-device model.

- list_cached_models: mark a companion-only base snapshot partial. A GGUF image load prefetches
  the base repo's VAE / text-encoder / model_index.json but skips the transformer (the GGUF
  supplies it); the snapshot has a pipeline manifest yet is not a loadable BF16 pipeline, and
  _cached_repo_partial misses it. _repo_pipeline_missing_denoiser flags a pipeline snapshot whose
  transformer/ or unet/ component carries no weight, so the picker drops it instead of advertising
  it as fully on-device.

* Studio: preserve foreign gallery files, force safetensors on remote ControlNets, and close dataset/seed/GPU gaps

Gallery clear/delete now scope to Studio-owned files: image_gallery and
video_gallery skip PNGs / MP4s without a readable recipe (a hand-dropped or
orphan file the listing already hides), so clear() and a guessed-id delete no
longer destroy files the gallery never surfaced.

Remote ControlNets now force use_safetensors: a bare owner/name reaches
from_pretrained without the base trust gate, and the Hub scan fails open when
unavailable, so requiring safetensors closes the pickle deserialization vector.

POSIX uninstall now stops resident sd-server / sd-cli under an owned sd.cpp root
before removing the tree (marker-gated), mirroring the Windows stop-before-delete
scan; a live native server no longer survives unlinking its binary.

Diffusion dataset containment: the training-start read path and the discovery
picker route bare names through the protected resolver, so a symlinked dataset
is rejected / not advertised like the caption/delete routes already do. Uploads
gain the inference decode guard (oversized real images 400 before OOMing the
trainer) and dataset upload/caption/delete/import are blocked with 409 while a
diffusion run is active.

JSONL readers (trainer + routes) tolerate non-object JSON and invalid UTF-8
instead of raising AttributeError / 500.

LoRA family compatibility is enforced in the shared resolver, not only the
picker, so a direct API client cannot apply a mismatched-family adapter.

GPU arbiter gains release_if so the image/video unload idle-check and release
are atomic against a concurrent same-owner load's registration. Native batch
recipes persist the base batch_seed and restore replays from it, so a native
batch_index>0 image no longer advances its seed twice.

FLUX.2-klein selects its sd.cpp text encoder by variant (4B -> Qwen3-4B,
9B -> Qwen3-8B) instead of the single family default.

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

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

* Studio: track the loaded GGUF filename so native companion resolution reproduces the load identity

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

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

* Studio: gate local-pipeline image tagging on a real family; validate video sidecars before delete/clear

* Studio: tighten image-generation fix comments and docstrings

* Studio: gate gallery serve/export on ownership; keep image progress active until persisted; reserve diffusion training before the dataset scan

* Studio: restore Reapply target on async image/video load errors; recheck training state before dataset commit; reclaim partial sd.cpp installs on retry

Images/Video: a background model load that fails AFTER starting (error/eviction during download) leaves the previous pipeline resident, but handleLoad had already overwritten lastLoad.current with the failed pick, so "Reapply to loaded model" reloaded the failed model. Carry the prior Reapply target into the poller and restore it on the async error/null paths, mirroring the quant rollback.

Training: an in-flight diffusion dataset upload passed _require_diffusion_dataset_mutable() at entry but could still commit files after a concurrent /diffusion/start reserved the training slot, mutating the dataset underneath the trainer. Re-check the interlock immediately before the commit phase; a 409 there leaves the staged temps for the finally to clean.

sd.cpp install: an interrupted extraction (disk full, killed process, a raising post-extract cudart fetch) left the target non-empty with no owner marker, so the next lazy install tripped the "not a Studio-managed directory" refusal and wedged native install. Write the ownership marker before the partial writes when the target is reclaimable, so a retry recognises the debris as ours and re-extracts.

* fp8 DiT quant: floor the dynamic activation scale with activation_value_lb

An all-zero activation token row makes the dynamic per-row fp8 scale 0,
which turns the quantized data to NaN and the render to black frames on
torchao's plain-torch kernel path. The fused fbgemm/mslk quantize kernels
clamp zero rows internally, so the bug only reproduces on machines without
them, which is most user environments. Zero rows are real inputs, not a
corner case: Wan 2.2 zero-pads its text conditioning, and Hunyuan-1.5 and
Qwen-Image regenerate zero rows inside their transformer blocks every step.

Pass activation_value_lb=1e-12 to Float8DynamicActivationFloat8WeightConfig
whenever the installed torchao supports the kwarg (Float8Tensor rework,
0.13+), checked via inspect.signature so older torchao keeps exactly the
current behaviour; the existing Float8MMConfig fallback chain is unchanged.
Verified on GPU: with the forced plain-torch kernel path a zero-row input
NaNs without the floor and stays finite with it, and end to end on
HunyuanVideo-1.5 fp8 goes from a solid black frame (LPIPS 1.00) to a normal
render (LPIPS 0.225); on Wan the floor matches the condition_embedder
exclusion (LPIPS 0.211 vs 0.206). Same-seed renders with fused kernels
present are unaffected, and pre-quantized fp8 checkpoints stay valid since
weight scales are untouched.

* Wire hosted pre-quantized DiT checkpoints into the image families

Point prequant_repos for flux.1, flux.2-klein, flux.2-dev, qwen-image
(int8 only there; fp8 is family-denied), z-image and krea-2 at the
unsloth/<Model>-FP8 Hub repos carrying gate-validated int8 and fp8
transformer checkpoints, so the fast quant path loads the small
pre-quantized file instead of materialising the dense bf16 transformer
and quantising on device. Measured on FLUX.2-dev int8: build peak drops
from 60.7 GB (dense + quantize) to 30.7 GB (hosted prequant), identical
30.7 GB resident after either path since loading a checkpoint is
bit-identical to on-the-fly quantisation.

The hosted repos name files <Model>-<SCHEME>.pt, so resolve_prequant_source
now derives that model-name filename from the repo id (scheme suffix
stripped case-insensitively) and carries the legacy transformer_<scheme>.pt
as a fallback the resolver tries when the primary 404s, keeping older
repos loadable.

Wiring a repo also exposed a fallback hazard: with a prequant source
present, the dense-fit preflight used to be skipped entirely, so a failed
prequant download would fall through to the dense bf16 load the memory
plan never budgeted, OOMing after eviction. The preflight now always runs
and gates an allow_dense_fallback flag through _load_dense_quant_pipeline:
a dense misfit still skips the fast path when no prequant exists, but with
one it proceeds and a prequant failure raises to the GGUF build instead of
loading dense. The same flag is set when the auto-policy replans an
offloaded GGUF against a prequant-sized transient.

Tests updated to the new filename convention plus new coverage for the
derivation and the legacy-name fallback; the prequant-skips-refit test now
asserts the re-check runs and forbids the dense fallback. Verified end to
end on GPU: z-image int8 resolves the hosted repo, downloads the
model-name file and renders (6.8s load, 5.9 GB peak).

* Route krea-2 through its per-component loader on the transformer-quant fast path

_assemble_pipe used Pipeline.from_pretrained for every family, but the krea repo
ships transformers-5.x configs and no top-level tokenizer files, so the tokenizer
dies with vocab_file=None. The pre-quantized checkpoint loaded fine and then the
assembly crashed, dropping the load to the GGUF build, which krea-2 cannot take
(Krea2Transformer2DModel has no from_single_file). Assemble per-component via
load_krea2_pipeline like the pipeline-kind and single-file paths already do.

Verified live: Krea-2-Turbo int8 and fp8 hosted prequant loads now assemble and
render through the Studio images tab.

* Keep Qwen-Image's text-stream linears bf16 on int8 (short prompts break torch._int_mm)

Qwen-Image's MMDiT runs every text-stream Linear at M = actual prompt tokens: the
Qwen2.5-VL embeds are not padded to a fixed length like FLUX's 512-token T5. A short
prompt (13 tokens) or the near-empty negative prompt drives torch._int_mm below its
M > 16 floor and the first denoise step raises 'self.size(0) needs to be greater than
16, but got 13' (measured on B200 through the Studio images tab).

Add per-family int8 exclusions (txt_in, add_q/k/v_proj, to_add_out, txt_mlp) for
qwen-image and qwen-image-edit, threaded through exclude_tokens_for_scheme(scheme,
family) and the prequant checkpoint validation, so a checkpoint baked under the old
token list is rejected and re-quantised instead of loaded crashing. The text stream
runs at M = tens vs the image stream's M ~ 4k, so the exclusion costs nothing; the
rebuilt hosted checkpoint gates 28/28 PASS with LPIPS mean 0.057 (was 0.069).

* Harden the diffusion memory plan against transient free-VRAM undercounts

A cold FLUX.2-dev int8 load on an idle 183 GB B200 planned offload=model
(companions exceed budget) and silently served the GGUF as-is; the identical
retry went resident and engaged the hosted prequant. The plan arithmetic was
byte-identical across both loads (required 90,228 MiB, resident needs free of
about 124 GB); the only divergent input was torch.cuda.mem_get_info, which is
device-wide and instantaneous: a transient foreign CUDA context briefly held
about 100 GB at the first snapshot, and the planner trusted that single read.

Three changes:
- settled_snapshot_device_memory: on cuda, synchronize + empty_cache
  (best-effort) and take the MAX free over up to 3 spaced reads. A transient
  can only shrink free, so the max rejects transient undercounts while a
  persistent tenant still caps every read. _plan_memory now uses it.
- plan_fits_total_capacity + one replan retry: when the dense/prequant
  candidate fits TOTAL device capacity under the standard reserve and the 0.85
  resident margin, an offload verdict can only stem from the free reading, so
  the loader re-snapshots and replans once before declining the fast path.
  Explicit balanced/low_vram modes skip the retry (they offload by mode).
- diffusion.transformer_quant_declined log line with required/budget/free and
  the plan reasons, so the next decline is diagnosable from the server log
  (previously silent).

Verified: cold FLUX.2-dev int8 first load in a fresh server now engages the
hosted prequant resident (offload=none).

* Add FLUX.2 Klein and FLUX.2-dev DiT LoRA training

Register flux.2-klein and flux.2-dev in the DiT trainer following the
upstream DreamBooth references: latents train patchified and batch-norm
normalized from the VAE posterior mode, the packed forward reuses
step-invariant position ids, and the guidance vector (3.5) is gated on
the variant's guidance_embeds config. Conditioning stacks load per
variant (Mistral via Flux2Pipeline for dev, Qwen3 via Flux2KleinPipeline
for Klein) and are encoded and freed before the transformer lands on the
device. The fused single-stream to_qkv_mlp_proj joins the attention
projections in the LoRA targets; the single-stream out projection stays
dense because its to_out suffix would also match the double-stream
ModuleList container.

Wire both families through the training registry (family set, labels,
VRAM notes, rank 16 / lr 1e-4 defaults, bf16-only preflight), mark them
trainable with train base repos in the family registry, add FLUX.2-dev
to the gated-repo token check, and trust both official bases for
training downloads.

Verified on B200: 30-step klein int8 (19.6s) and nf4 (20.9s) and dev
int8 (52.0s) runs train with finite decreasing loss and the saved
adapters apply on the bf16 base pipeline (weight 0 reproduces the base
image exactly, weight 1 visibly restyles it).

* Support LoRA adapters on torchao int8/fp8 quantized image pipelines

Adapters are baked at load time: they attach to the dense transformer,
then quantize_ converts only the frozen base linears (the lora_ side
path is excluded by name), then the loader compiles. Post-quant PEFT
injection is not possible on a manually quantized module, so the
prequant shortcut is skipped for a baked load and the memory plan is
sized for the dense build (force_dense on the quant candidate).

At generation time the baked topology is frozen: weight tweaks and
disabling (scale 0 reproduces the quantized base exactly) go through
set_adapters, while adding or removing adapters returns a clean 400
telling the client to reload with the new selection.

supports_lora now returns True for int8/fp8 diffusers loads (checked
before the gguf-kind early return, since the quant fast path keeps the
picker kind); nvfp4/mxfp8 and GGUF-via-diffusers stay blocked. The
load request model takes an optional loras list, threaded through
begin_load on both engines (native ignores it and keeps applying LoRA
at generation).

Verified end to end on GPU: Z-Image GGUF picker + int8 + trained
adapter loads through the API, bake marker logged, weight 1.0 vs 0
renders differ visibly, weight 0.5 accepted live, unknown adapter
rejected as 400. Affected suites: 296 passed.

* Add FLUX.1 Krea dev to the image model catalog

Krea's guidance-distilled FLUX.1-dev finetune keeps the exact dev layout, so it
runs under the existing flux.1 family unchanged. Wire it up end to end:

- Catalog group with the gated official bf16 pipeline and the open QuantStack
  GGUF quants; the gated artifact is skipped on auto-routing when undownloaded.
- Trust the official repo for non-GGUF from_pretrained loads, next to the other
  black-forest-labs bases.
- Generation defaults: 28 steps at guidance 4.5 per the model card. The generic
  "krea" defaults key (Krea-2-Turbo's 8-step no-CFG recipe) used to swallow the
  id, which would have produced garbage output; the new flux.1-krea key precedes
  it on both the backend table and the images page table.
- The flux.1 prequant checkpoints are schnell-based; the loader's baked
  base_model_id validation refuses them for the Krea-dev base, so int8/fp8
  requests dense-quantize instead (covered by existing prequant tests).

* Resolve pre-quantized checkpoints per base variant

One family entry covers several published variants whose weights differ
(flux.1: schnell, dev, Krea-dev), but prequant resolution was keyed on
(family, scheme) alone, so only the default base could ever be served: the
loader's baked base_model_id validation correctly refused the schnell
checkpoint for dev and Krea-dev bases and every such load paid the dense
download plus on-the-fly quantise.

Add an optional prequant_variant_repos table on DiffusionFamily as
(base_repo, scheme, repo_id) triples and thread the resolved base repo
through resolve_prequant_source / usable_prequant_source and their three
call sites (load fast path, memory-plan probe, auto-policy candidate). A
base without its own entry keeps returning the family default, preserving
the existing refuse-then-dense behavior exactly.

Wire the flux.1 variants: the gate-validated unsloth/FLUX.1-dev-FP8
checkpoints (built in the earlier campaign but never reachable) and the
new unsloth/FLUX.1-Krea-dev-FP8.

* Add the Lumina Image 2.0 family to the image catalog

Alpha-VLLM/Lumina-Image-2.0 is a 2.6B single-stream DiT with a Gemma2-2B
encoder and a standard 16-channel VAE, all transformers-4.x-compatible, so the
generic from_pretrained pipeline path loads it as a new lumina-2 family:

- Family entry (Lumina2Pipeline / Lumina2Transformer2DModel), aliased to
  lumina-image-2.0 / lumina-image-2 / lumina2. No bare lumina alias: Lumina-Next
  checkpoints are a different arch and must stay unknown rather than crash
  mid-load. bf16-only upstream, so the fp16 fallback stays off like z-image.
- Trust the official repo for non-GGUF loads; bf16 component table entry
  (ships fp32, ~5.2 GB transformer + 5.2 GB encoder bf16-resident).
- Generation defaults 50 steps / guidance 4.0 per the model card, and the
  generate call passes the card's cfg_trunc_ratio=0.25 itself (family-gated,
  signature-gated): the pipeline default (1.0) runs the CFG double-forward on
  every step and oversaturates output.
- Catalog group with the single ungated bf16 pipeline artifact (11 GB resident)
  plus routing assertions; images page defaults row.
- No GGUF artifact: none exists upstream (only finetune/LLM quants), so the
  dense transformer_quant fast path (GGUF-kind-only) stays unreachable for now.
  Offline probes of the future prequant campaign: int8 and fp8 both engage and
  render cleanly (fp8 LPIPS 0.11 vs bf16, int8 0.33 from 50-step trajectory
  drift with intact quality), so neither scheme is family-denied.

* Wire the hosted Lumina Image 2.0 int8/fp8 checkpoints

Gate-validated against same-seed bf16 renders (28/28 pairs per scheme, zero
failures): int8 LPIPS mean 0.146 / SSIM 0.937, fp8 LPIPS mean 0.116 /
SSIM 0.946. Uploaded to unsloth/Lumina-Image-2.0-FP8 following the existing
checkpoint repo conventions.

* Add the HunyuanImage 2.1 family to the image backend

The hunyuanvideo-community diffusers mirror carries the full stack in
standard layout: a 17B dual-stream DiT (32.5 GB bf16), a Qwen2.5-VL text
encoder, a ByT5 glyph encoder, the 32x HunyuanImage VAE, and
guider/ocr_guider components (AdaptiveProjectedMixGuidance) that diffusers
0.39 loads natively, so the generic from_pretrained pipeline path covers
everything with no per-component assembly.

Family notes:
- The call's guidance knob is distilled_guidance_scale (there is no
  guidance_scale kwarg), so cfg_kwarg routes the UI value there; real CFG
  runs inside the repo's guider at its baked scale. Defaults follow the
  card recipe: 50 steps, 3.25.
- 2K-native: verified live at both 1024 and 2048.
- Coexists with the HunyuanImage-3.0 structured exclusion (3.0 has no
  diffusers pipeline and stays excluded with its stated reason).
- int8/fp8 dense quantization verified live (LPIPS 0.186 both vs same-seed
  bf16); a short prompt does not trip the int8 torch._int_mm minimum on
  this arch, so no family exclude entry is needed.
- bf16 component table for the memory planner: (32.5, 16.3, 0.8) GB.

* Surface HunyuanImage 2.1 in the image model catalog

Catalog group with the open bf16 mirror pipeline (~50 GB resident, so a
bare click on a consumer card routes to the QuantStack GGUF quants, which
load and render through the generic GGUF path, verified live) plus the
images page defaults (50 steps, guidance 3.25 feeding
distilled_guidance_scale).

* Add the HiDream-I1 family to the image backend

A 17B MoE DiT (16 double + 32 single layers, 4 routed experts) with four text
encoders, on HiDreamImagePipeline (diffusers 0.39). One family covers the open
Full / Dev / Fast repos (same arch); per-variant generation defaults follow the
upstream inference recipes (Full 50 steps at guidance 5, the distilled Dev 28
and Fast 16 guidance-free).

The repos name a Llama-3.1-8B text_encoder_4 in their model_index but do not
ship its weights; the official example passes the gated meta-llama repo in by
hand. The loader instead assembles the component from the open unsloth mirror
(byte-identical weights, already inside the non-GGUF trust gate), injected at
the three pipeline from_pretrained sites, with output_hidden_states matching
the official example. Memory planning counts the assembled TE4: 34.2 GB DiT +
28.8 GB encoders, ~63 GB bf16-resident.

* Surface HiDream I1 in the image model catalog

One catalog group with the three official bf16 pipelines (Full, plus the Dev
and Fast distillations as labeled artifacts) at their ~63 GB resident size, so
auto-routing keeps this a datacenter-GPU pick. city96's GGUF is deliberately
not wired: the GGUF path would need the same Llama TE4 assembly for very small
demand. Images-page defaults mirror the backend table with the variant keys
ahead of the generic hidream key.

* Pin the measured HiDream quant verdict in tests

int8 and fp8 both engage and render cleanly on this family, including short
prompts on int8: the routed MoE expert Linears only ever see the concatenated
image+text stream (M far above the torch._int_mm minimum), so no deny entry
and no family exclude tokens are warranted. Assert that so a future table edit
cannot silently regress the measured behavior.

* Wire the hosted HunyuanImage 2.1 int8/fp8 checkpoints

Verified bit-identical to on-the-fly quantize: all 1264 state dict tensors
(456 quantized) dequantize equal between the loaded checkpoint and a fresh
quantize_ pass, so quality matches the runtime Dtype path exactly. Same-seed
LPIPS suite means (0.35 int8 / 0.28 fp8) blend trajectory divergence with this
family's own run-to-run nondeterminism (identical weights and seed reproduce a
17/255 mean pixel delta through the 50-step guider pipeline); per-case hard
checks pass and the drift is compositional, reviewed visually. Uploaded to
unsloth/HunyuanImage-2.1-FP8.

* Fix silent LoRA drop and wasted transformer prefetch on GGUF quant loads

Two live-test findings on the images load path:

- transformer_quant with baked LoRAs, when the dense quantized build is
  declined for memory or fails: the load completed as a plain GGUF with the
  adapters silently dropped (HTTP success, supports_lora=false after the
  fact) -- wrong output with no signal. The load now fails with the recovery
  options (drop the adapters, free VRAM, or pick a smaller model). Weight-0
  adapters still count as no bake request, and the plain no-LoRA decline
  keeps its silent GGUF fallback.
- A fresh GGUF load on a small GPU prefetched the base repo's full bf16
  transformer shards (~47 GB on Qwen-Image) because the dense-quant prefetch
  widening only checked scheme viability, not whether the device could ever
  hold the candidate resident. Gate the widening on total device capacity
  (reserve + 0.85 margin, the plan_fits_total_capacity bar) so a card that is
  certain to decline the dense build never pays the download; capable devices
  keep the prefetch.

* Fix video progress under-reporting during load and generate

Two live-test findings on the video progress endpoints:

- load-progress downloaded_bytes froze mid-download: the counter used
  scan_cache_dir, which skips in-flight *.incomplete blobs, so it sat at the
  last completed blob for the whole multi-GB shard pull while the disk kept
  filling. Count the repo's cache directory directly (completed plus incomplete
  blobs, snapshot symlinks skipped so nothing is double-counted).
- generate-progress reported total_steps=null / fraction=0 while step advanced:
  the video API only carried the native total field while the image API exposes
  total_steps and fraction, so one poller could not work against both. Derive
  the image-compatible aliases in generate_progress and declare them on the
  response model; the native total stays for back-compat.

* Wire the hosted HiDream I1 int8/fp8 checkpoints

Gate-validated: all 28 per-case pairs pass per scheme (LPIPS suite means 0.291
int8 / 0.278 fp8, in the 50-step trajectory-divergence band; CLIP delta means
0.007-0.008), and the int8 checkpoint is verified bit-identical to on-the-fly
quantize across all 1615 state dict tensors (1073 quantized, max abs diff 0.0).
Uploaded to unsloth/HiDream-I1-Full-FP8.

* Add a pre-cast text-encoder loader for the layerwise fp8 scheme

The runtime text_encoder_quant=fp8 path downloads the full bf16 text
encoder and layerwise-casts it in place on every fresh load. For the
heavyweight encoders (LTX's Gemma3-27B ~50 GB, FLUX.2-dev's Mistral-24B
~48 GB, Qwen-Image's Qwen2.5-VL ~16.6 GB) that download dominates load
time on a fresh machine.

diffusion_te_prequant.py loads a pre-cast fp8-storage state dict
instead: meta-init the encoder skeleton from the checkpoint's te_class,
load_state_dict(assign=True), rebuild on CPU if non-persistent buffers
stay on meta, then re-apply the same layerwise cast to install the
upcast hooks. The cast is a deterministic storage transform, so the
loaded encoder is bit-identical to dense-load-then-cast by construction.

v1 hosts the layerwise fp8 storage scheme only: its state dict is plain
tensors (torch.load(weights_only=True), no pickle execution). The
dynamic-compute schemes (fp8_dynamic, int8, nvfp4) build torchao
subclass wrappers at runtime and are deliberately not hosted.

Checkpoints validate format, scheme, component and base_model_id before
use and any problem falls back to the dense download and cast. Local
path overrides reuse the DiT prequant allowlist env var. Families opt in
via a new te_prequant_repos (scheme, component, repo_id) field on both
DiffusionFamily and VideoFamily; the field defaults empty so nothing
changes until a gate-validated artifact is wired.

* Inject hosted pre-cast text encoders during pipeline assembly

Wire te_prequant_pipe_kwargs into the three pipeline assembly sites:
the diffusion full-pipeline branch, the diffusion transformer-only and
GGUF branch (where the companion TE is the big remaining download), and
the shared video assembly path before the pipeline/component split.

Injection is gated exactly like the runtime cast (mode normalized to
fp8, device supported, family not denied), so it can never engage where
quantize_text_encoders would not; the later quantize_text_encoders call
re-applies the cast idempotently and keeps status reporting truthful.
With no hosted checkpoint configured the call returns {} and assembly
loads the dense encoder as before.

* Add the pre-cast text-encoder checkpoint builder

Applies the runtime layerwise fp8 storage cast to a model's dense text
encoder once and saves the cast state dict with baked metadata (format
tag, base_model_id, family, scheme, component, te_class, versions) in
the layout diffusion_te_prequant.py validates. Resolves the encoder
class from the checkpoint's config.architectures so the recorded
te_class matches what the pipeline instantiates. CPU-runnable: the cast
touches storage dtypes only.

* Test the pre-cast text-encoder load path

Hermetic CPU coverage for diffusion_te_prequant: the checkpoint
filename convention, family-table resolution by scheme and component
with malformed entries skipped, resolution priority (path override,
hosted repo, none) and the fp8-only scheme gate, the checkpoint
validation matrix (wrong format, missing state_dict, wrong scheme,
wrong component, wrong or missing base_model_id) with base case
folding, the local-path allowlist refusal and missing-file fallback,
and the assembly injection gating (mode, hosted entry, device support,
family deny, load failure, successful injection). Also pins the
te_prequant_repos field on both family dataclasses and that no family
ships a hosted TE checkpoint until the campaign wires one.

* Fix pre-cast TE checkpoint loading and engagement reporting

Two bugs found while building the hosted checkpoints:

- The builder recorded torch.__version__ (a TorchVersion object) in the
  checkpoint metadata, so torch.load(weights_only=True) rejected every
  artifact and the loader silently fell back to the dense download.
  Record plain strings.
- Re-applying the layerwise fp8 cast to an injected pre-cast encoder
  raised on the duplicate hook registration, making quantize_text_encoders
  report the engaged cast as failed (status showed no TE quant while the
  encoder ran fp8). _cast_fp8 now returns early when the hooks are
  already installed.

Also corrects the LTX TE size note: Gemma3-12B stored fp32 (~49 GB), not 27B.

* Wire the hosted pre-cast fp8 text encoders

qwen-image and flux.2-dev (diffusion) and ltx-2 (video) now resolve a
hosted pre-cast fp8 text encoder from their unsloth -FP8 repos:

- unsloth/Qwen-Image-FP8: Qwen2.5-VL-7B, 16.6 GB dense -> 8.8 GB
- unsloth/FLUX.2-dev-FP8: Mistral-Small-24B, 48.0 GB dense -> 24.7 GB
- unsloth/LTX-2-FP8: Gemma3-12B, 48.7 GB fp32 store -> 13.2 GB

Every checkpoint verified bit-identical to dense-load-then-cast
(729 / 585 / 1066 tensors, zero mismatches) and smoke-tested through the
real backends with the repo engagement marker. Tests cover the wired
entries, the resolver filenames, builder metadata weights_only survival,
and the idempotent re-cast.

* Report the compute dtype on fp8-cast encoders and inject the pre-cast TE on the dense fast path

Two more findings from the hosted-TE GPU smokes:

- Module.dtype reports the first floating parameter, which after the
  layerwise fp8 cast is the fp8 STORAGE dtype. Flux2 derives its prompt
  embed and latent dtypes from encoder.dtype and feeds them to
  randn_tensor, which has no fp8 kernel, so ANY flux.2 load with
  text_encoder_quant=fp8 crashed at generation (pre-existing, runtime
  cast included). The cast now swaps in a subclass whose dtype property
  reports the compute dtype; forward behaviour is unchanged.
- The dense transformer_quant fast path assembles companions through
  _assemble_pipe, which never received the pre-cast TE injection, so the
  hosted encoder engaged on full-pipeline and GGUF builds but not on the
  fast path. Threaded through like the other two branches.

Verified live on B200: qwen-image (full pipeline), flux.2-dev (GGUF picker
with int8 DiT prequant), ltx-2 (video backend) all engage the hosted TE,
render non-black, and report text_encoder_quant=fp8 truthfully.

* Key the fp8 cast idempotency on an explicit completion marker

Hook presence alone cannot distinguish a legitimately pre-cast text
encoder from leftover hooks after a cast that failed mid-pass, so the
early return now requires the completion marker _cast_fp8 sets once the
hooks are fully installed. Leftover partial state keeps failing closed.
Also tolerates non-Module encoder doubles in the hook probe and the
dtype override.

* Extend the fp8 TE quant to HiDream's Llama text_encoder_4

The generic quantize_text_encoders pass only covers text_encoder.._3, so
HiDream's HEAVIEST encoder (Llama-3.1-8B TE4, 16.1 GB bf16) always stayed
dense. TE4 is assembled separately (hidream_te4_kwargs), so the fp8 path
now lives there: when the requested TE quant is layerwise fp8 and the
device/family qualify, TE4 prefers the hosted pre-cast checkpoint
(unsloth/HiDream-I1-Full-FP8, 8.6 GB) and falls back to dense-load-then-
cast; a mid-pass cast failure reloads a fresh dense encoder instead of
shipping partial state. The pre-cast loader and builder gain
config_subfolder/config_overrides for standalone encoder repos whose
config sits at the root and whose pipeline needs forward flags
(output_hidden_states/attentions).

Verified on B200: bit-identity 291 tensors (225 fp8, 0 mismatches),
hosted checkpoint engages through the real backend (marker + status fp8),
load 24.3 s vs 48.0 s dense, LPIPS 0.133 mean over 3 same-seed pairs vs
the dense-TE render (gate 0.25), non-black frames.

* Correct the ltx-2 resident TE estimate to the bf16 cast size

The memory plan's bf16_components_gb held 50.4 GB for the LTX text
encoder, which is the fp32 hub store of Gemma3-12B (~49 GB download),
not what sits on device: the pipeline loads it torch_dtype=bf16, ~24.4
GB resident. The 26 GB over-estimate pushed the auto plan toward offload
on cards that fit the real footprint. Comments and the size-table test
now pin the resident semantics.

* Host pre-cast fp8 text encoders for four more families

Round 2 of the hosted TE set, each bit-identical to dense-load-then-cast
and gated through the real backend (marker + status fp8 + same-seed LPIPS
vs dense TEs):

- FLUX.1 T5-XXL (text_encoder_2): 9.52 -> 5.90 GB, one artifact for
  schnell/dev/Krea-dev (T5 shards byte-identical across all three,
  verified sha256). 220 tensors, 144 fp8, LPIPS 0.109.
- Lumina Gemma2-2B: fp32 hub store 10.46 -> 3.20 GB (3.3x download cut).
  288 tensors, 182 fp8, LPIPS 0.041.
- Z-Image Qwen3-4B: 8.04 -> 4.41 GB. 399 tensors, 252 fp8, LPIPS 0.112.
  NOT shared with flux.2-klein-4B: klein retrained layer 35's MLP
  (verified tensor diff, maxdiff 0.86), so klein hosts no entry.
- Krea-2 Qwen3-VL-4B: 8.88 -> 4.83 GB. 713 tensors, 460 fp8, LPIPS 0.082.
  The constructor-assembled krea pipeline takes the encoder directly
  (load_krea2_pipeline text_encoder kwarg); the loader remaps 5.x
  rope_parameters and re-ties weights after assign so the rebuilt encoder
  matches the builder's structure.

HunyuanImage 2.1 reuses the Qwen-Image artifact outright: its Qwen2.5-VL
text encoder is byte-identical (every shard sha256, 16,584,414,544 bytes),
recorded in the new component-level base-equivalence table the checkpoint
validator consults. The injection loop now covers text_encoder.._3 so a
family can host several components. Live check: LPIPS 0.123 vs dense.

* Report the fp8-cast compute dtype without swapping the encoder class

The dtype override swapped encoder.__class__ to a dynamic subclass, which
breaks transformers' kwargs-based output recording: a fp8-cast
Qwen3VLModel stopped returning hidden_states and every krea-2 generation
with text_encoder_quant=fp8 crashed at encode_prompt (regression from the
HiDream TE4 change; caught by the krea hosted-TE live smoke). The
override is now a property shadowed on the ORIGINAL class that prefers a
per-instance compute-dtype attribute, so class identity is preserved and
uncast instances keep the stock behaviour. The idempotency test now pins
exact class identity and the uncast-sibling fallback.

* Pass the calibrated distilled sigma curve to LTX-2.3 8-step runs

The 22B distilled DiT was trained against ltx_core's fixed
DISTILLED_SIGMA_VALUES, but the diffusers scheduler derives 8-step
spacing from resolution-shifted flow matching and lands far off at
every reachable mu (second sigma 0.945-0.981 vs 0.99375, tail
0.37-0.61 -> 0.1 vs 0.725 -> 0.42 -> 0). At the distilled default step
count the backend now passes the list verbatim, neutralising the
scheduler's dynamic shift and terminal stretch for the call (they
distort even explicit sigmas) and restoring them afterwards. Other
step counts and the dev/base DiT keep the scheduler's own spacing.

Live-verified on B200 through the video branch backend: the scheduler
holds the exact curve after an 8-step distilled GGUF generation, config
restored, healthy clip. Also reword the transformer_quant resolved
reason to the measured reality: quant halves resident weights and
hosted checkpoints cut load time, while per-step speed is roughly bf16
parity.

* Pin the fp8 weight-quantize kernel against silent MSLK switching

torchao's Float8Tensor KernelPreference defaults to AUTO, which switches
the weight-quantize kernel to MSLK whenever an mslk package is importable
on sm90+. Measured on B200: that changes fp8 scale rounding bitwise (8/8
FLUX matrices differ, scales ~55 percent of bytes), so a box that merely
gains mslk would break the hosted-prequant bit-identity invariant; the
mslk path is also slower under torch.compile (opaque extern call blocks
inductor's quantize fusion, FLUX.1 fp8 e2e 1.149 to 1.624 s). Pin
KernelPreference.TORCH explicitly, matching current no-mslk behaviour
bit for bit; signature-gated for older torchao. GPU-smoked (finite,
rel err 0.037) and pinned by test.

* Shift Qwen-Image training sigmas to the inference distribution

Qwen-Image's scheduler skips its static shift under use_dynamic_shifting,
so the DiT trainer was drawing UNSHIFTED uniform-schedule sigmas for it
(mean sigma 0.50) while inference always runs the exponential mu = log 3
shift plus the shift_terminal 0.02 stretch. Add a flow_shift config lever:
"auto" (the new qwen-image default) rebuilds the training sigma table
through the scheduler's own time_shift and stretch_shift_to_terminal so
the draw matches the inference distribution exactly (mean sigma 0.72);
a numeric value applies the standard linear shift s*u/(1+(s-1)*u); 1.0
keeps the historical identity behavior and stays the default for FLUX,
Z-Image and Krea 2. The model timestep conditioning follows the shifted
sigma, gathered in fp32 so bf16 rounding never skews it.

Also wire two opt-in levers with off defaults: cfg_dropout (per-sample
empty-prompt conditioning dropout, encoded alongside the captions before
the text encoders are freed) and weighting_scheme="bell" (bsmntw-style
mid-schedule Gaussian loss weighting normalized to mean 1).

Verified with two 80-step rank-8 bf16 LoRA runs on Qwen/Qwen-Image
(identity vs auto, same seed): both converge with finite decreasing loss
and produce coherent same-seed previews. Unit tests cover the exact
transform, the shifted sampling distribution, per-family defaults and
config plumbing.

* Add LoRA EMA, a persistent conditioning cache, and aspect bucketing helpers

diffusion_train_extras hosts the opt-in training extras: LoRAEMA shadows only
the trainable adapter params (warmup-ramped decay, default 0.99, exported as a
second adapter under output_dir/ema), PersistentConditioningCache stores latent
posterior stats and caption embeddings as safetensors keyed by content hash +
family + resolution, and the aspect-ratio bucketing helpers group mixed-aspect
datasets into same-area divisor-snapped shapes.

The DiT trainer wires the first two behind config flags that default to the
current behavior: ema_decay (0 disables) and cond_cache_dir (None disables).
A fully warm cache skips loading the VAE and text encoders entirely; a cache
hit is bit-identical to a fresh encode, including the per-channel qwen latent
normalization. Also fixes the stale _gather_sigmas call in the perf test that
still passed the scheduler instead of the sigma table.

* Tighten torchao configs and note the FSDP2 design for the DiT trainer

nf4 loads now enable double quantization (~0.4 bits/param off the frozen base
scales at no fidelity cost), fp8 training uses the rowwise recipe when the
torchao build ships it (per-row scaling confines the DiT activation outliers
that a tensor-wide scale collapses), and the inference quant filter gains a
per-scheme GEMM-tiling divisibility floor (16 for scaled_mm, 32 for MX blocks)
so one ragged Linear cannot crash the first denoise after a clean quantize
pass. plans/fsdp2_diffusion_design.md records the multi-GPU design: bf16/fp8
over FSDP2 with per-block units, LoRA attached before sharding, int8 out of
scope (DTensor over the quantized subclass is undefined), per-family notes.

* Batch diffusion inference with per-image seeds, an inference conditioning cache, and GGUF loader fixes

Batched generation: /images/generate takes a prompts list (one image per
prompt, txt2img only) or a seeds list (one prompt, one image per seed);
the legacy batch_size path derives per-image seeds base..base+n-1 like
the native engine. Every image gets its own torch.Generator so any batch
member replays alone from its gallery recipe; the whole list runs as one
forward by default with OOM backoff that halves a failed chunk, and an
explicit batch_size caps images per forward. Validated 10-22x over
serial engines on 32-image suites with LPIPS deltas within 0.002.

Conditioning cache on the inference path: UNSLOTH_DIFFUSION_COND_CACHE_DIR
(the inference sibling of the trainers' cond_cache_dir, same persistent
store) wraps encode_prompt so repeated prompts skip the text-encoder
forward entirely; verified bit-identical outputs. Bypassed while LoRA
adapters are attached; tensor-argument calls pass through uncached.

Compile cache: GGUF loads fingerprint their own bundles (quant=gguf, a
different compiled graph than the dense family) and batched calls
register every distinct (w, h, batch) chunk shape they ran, so the heavy
GGUF batched warmups (~159 s at batch 32 on 12B-class, ~655 s on 20B
CFG-batched) are paid once ever.

GGUF loader: strip the sd.cpp model.diffusion_model. container prefix in
the single-file converter; diffusers' FLUX.2 converter KeyErrors on it
and the Qwen-Image identity mapping strands the model on meta.

* Correct batched seed-replay docs to match measured behavior

Same-seed images at the same batch shape are bit-identical; a solo
regeneration with the recorded seed matches its batched rendition up to
batch-size-dependent kernel numerics (mean abs pixel delta about 2.5/255,
LPIPS delta under 0.002), not bit-exactly. The previous wording overclaimed
bit-identity across batch shapes.

* Note that batched bit-identity assumes a settled compiled graph

The first generation issued while the deferred compile is still in flight
can deviate transiently (observed once on a cold fp8 build: mean abs pixel
delta 0.063/255); once the graph is settled, same-seed same-batch-shape
images are bit-identical across runs.

* Studio sidebar: Image03/FlimSlate icons, More flyout, Train row, New pills

- Images uses Image03Icon and Video uses FlimSlateIcon.
- New "More" row (MoreHorizontalIcon) opens a right-side flyout on click or
  hover holding Video, Recipes and Export; the close is delayed 180ms so the
  pointer can cross the gap. Its SidebarMenuButton deliberately takes `title`
  rather than `tooltip`: with `tooltip` the button returns a Tooltip root and
  DropdownMenuTrigger asChild would hand its ref to a non-DOM node.
- Dropped the "Train" section heading; Train is now a top-level row between
  Images and More. data-tour="navbar" moves to the surviving nav group so the
  product tour keeps its anchor.
- "New" pill beside Images and (inside the flyout) Video, via NavBadge.

* Studio sidebar: match flyout rows and New pills to the existing scales

- More flyout rows dropped their sidebar-row typography and size-icon override,
  which fought DropdownMenuItem's own scale (text-sm, gap-2.5, px-3 py-2 and
  size-4 icons) and rendered oversized glyphs and text next to the nav.
- New pill reuses the brand "beta" badge recipe (nav-badge font, --ui-font-scale
  sizing, nav token colours) rather than hardcoded 9px values.
- The More row's native title tooltip (an OS box on hover) is replaced by the
  app's Tooltip, wrapped around DropdownMenuTrigger so both triggers compose onto
  the same button, and shown only on the collapsed rail like other nav rows.

* Settings: pin and reorder the sidebar navigation

Adds a "Sidebar navigation" section to Settings -> Appearance, above the
existing profile-menu customizer, with the same drag-to-reorder + switch UI.

- New sidebarNav preference: one { id, pinned } entry per navigable row
  (projects, hub, images, train, video, recipes, export), array order = render
  order. Defaults match the shipped layout, so an untouched install is unchanged.
- Unpinning moves a row into the More flyout rather than hiding it, so no page
  becomes unreachable. New chat and Search stay fixed as actions.
- app-sidebar now renders from one navRows descriptor map, so a pinned row and
  its flyout counterpart cannot drift; the More row appears only when something
  is unpinned and highlights off whatever it actually holds.
- Mirrored in the backend PersonalizationCustomization: without it the model's
  extra="ignore" would drop the field, and because sync replaces local state
  with the server's copy once customization is saved, the user's pin order would
  reset on the next sync. The validator dedupes and back-fills like sidebarMenu
  but preserves the client's order, since here order is meaningful.

Frontend typecheck, i18n parity and catalog checks pass; 32 personalization
tests pass, including a round-trip asserting a reordered list survives a save.

* Sidebar customizer: drop the Search row, skip More for a lone item

- Search is reached from the top bar, so it is no longer previewed as a fixed
  sidebar nav row; New chat stays.
- More now appears only when it would hold two or more rows. A single unpinned
  row renders inline in its saved order position instead: a flyout wrapping one
  item costs a click and earns nothing. The customizer's More preview follows the
  same threshold.

* Sidebar settings: hide a lone unpinned tab, match New chat icon, rename Profile menu

- With exactly one tab unpinned, both More and that tab are dropped, so nothing
  is drawn for it (previously it rendered inline). The page stays reachable by URL.
- The customizer's New chat preview uses PencilEdit02Icon, the icon the real row
  renders; Edit03Icon was a different glyph.
- "Sidebar menu" is now "Profile menu", described as the shortcuts behind your
  name at the bottom of the sidebar, so it no longer reads as a second name for
  the navigation section above it.

* Tighten comments in the new sidebar and delete-guard code

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

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

* Studio sidebar: keep the More row highlighted while its panel is open

Moving the pointer into the flyout left the row unhighlighted while the panel
stayed open. The row now carries data-menu-open, added to the nav hover selector
list. Not data-state: the tooltip and menu triggers both write that attribute, so
whichever lands last wins.

* Images: use the shared pill toggle for Create/Train and pad the panels

- Create/Train was the only segmented control on its own Tabs styling. It now
  uses PillTabs, the same control as the model picker and Hub toggles, pinned to
  the header row's 34px. PillTabs takes an icon per tab, so the inline-span
  workaround for TabsTrigger goes away.
- pt-3 on both the Create and Train panels, which sat flush against the model
  selector row.

* Images: make the workflow picker a dropdown instead of a 7-up strip

Seven workflows in a 340px rail left ~48px each, so the labels crowded and the
hints were only reachable as title tooltips. The strip is now a dropdown: the
trigger shows the current workflow and its hint, and each row carries its own
description. A row the loaded model can't run is disabled and shows the reason in
place of the hint, so the gating explains itself. Adding a workflow no longer
shrinks the others.

* Images: workflow icons, hint under the trigger, more top room, unclipped Train cards

- Each workflow carries an icon, shown on the closed trigger and on every row.
- The trigger is one line (icon plus name). The selected workflow's description
  moved below it, where it reads like the Field hints further down the rail.
- pt-6 instead of pt-3 on both Create and Train, so the cards clear the model
  selector row.
- The Train right column scrolls while its cards use ring-1, which draws outside
  the box and was clipped at the scroll edges. p-px gives the ring room.

* Images: one-line workflow rows, stronger trigger fill, roomier mode tabs

- Dropdown rows are icon plus name only. The selected row's description already
  shows under the trigger, and a disabled row keeps its reason as a title.
- Trigger fill moves to the bg-foreground/[0.07] dark:bg-foreground/[0.12] pair
  the hub cards use, so it reads against the card in both modes.
- Description under the trigger goes from text-ui-10 to text-ui-11p5.
- More horizontal padding on the Create / Train tabs.

* Images: drop card borders for the composer shadow, keep scrollbars inside, use app controls in Train

- Cards lose ring-1 for .panel-soft-surface: the composer's shadow in light, flat
  in dark, matching .chat-composer-surface and the menus.
- Both rails now clip (overflow-hidden) with the scroller inside, so the scrollbar
  can't ride over the rounded corner. Same shape video-page already uses.
- Train's 9 native selects become the app Select, so they no longer open an
  OS-native menu, and the native file input is hidden behind a Choose images
  button that reports the count.
- Image previews use explicit 8-10px radii: this theme sets --radius to 1.1rem,
  so rounded-md was 15.6px and the thumbnails read as circles.

* Images: one card for controls and preview, chat sliders, wider softer shadow

- Controls and preview were two floating cards; they now share one card split by
  a divider. The Advanced dock stays separate since it toggles.
- SliderField wraps Chat's ParamSlider, so the sliders match Chat (label row with
  the value, full-width neutral track) instead of a green track with a spin box.
  All 14 call sites keep their props.
- panel-soft-surface goes from 0 2px 8px -2px /0.16 to 0 4px 22px -6px /0.10:
  lighter, spread wider.

* Images: flat Create and Train panes, hover-only scrollbars, tidier Train dataset step

Both Images tabs now sit on the page background like the Hub: no card, no
shadow, no bounding box. A single rule divides the controls rail from the
preview canvas (Create) and from the run area (Train), and the settings and
previous-runs sections read as panes rather than nested cards.

Also:
- Scrollbars in these panes use the existing hover-scrollbar recipe, so the
  thumb only shows while the pane is hovered.
- Workflow rows explain themselves with a tooltip after a short hover, which
  also works on disabled rows, and the descriptions are much shorter.
- Training images rows are name plus image count; the license stays on the
  example card.
- The upload step loses its dashed box, the buttons match the sizes around
  them, and Upload only appears once files are picked.
- The empty preview uses the same icon as the Images nav item.

* Images: full-height panes, wider settings rail, Create/Train offset from the selector

The rule between the panes now runs the whole page height (the row drops its
bottom padding and each pane pads its own content), the settings rail is wider
on both Create and Train, and the Create/Train switch sits further right of the
model selector.

* Images: put both tabs on the Hub's centered measure

Top bar and content now share mx-auto max-w-1100 with px-5 / sm:px-8, so Create
and Train sit at the same width and position as the Hub instead of running edge
to edge.

* Images: restore the top bar position, drop the panes lower under it

* Images: center the mode switch, flip the arrow with the orientation, app tooltips everywhere

The Create/Train switch is centered on the page instead of trailing the model
selector, with wider buttons. The flip control's arrows now rotate with the
orientation and its label says which way the flip goes. Every native title
tooltip on the page is now the app's tooltip, so they all get the rounded
surface instead of the OS box.

* Images Train: plainer field text, no green buttons, columns that stop colliding

- The dataset name, trigger prompt, adapter name and custom base fields now say
  what they are in plain words instead of leaning on example values.
- Import, Upload, Back, Back to settings and Train another are outline buttons,
  not green ones.
- Example thumbnails are landscape tiles, so photos are not cropped to chunky
  squares.
- Settings cells get min-w-0 and the select value truncates, so a long option
  like the nf4 label no longer widens its column into the next one.
- The number stepper sits a little further in from the field edge.
- Create and Train are wider.

* Images Train: roomier example cards with Import on the thumbnail row

* Video: same treatment as the Images tabs

- No cards: the rail and the canvas sit on the page background, divided by a
  rule that runs the full page height, on the Hub's centered measure.
- Wider rail, chat's sliders, hover-only scrollbars.
- Every native title tooltip is now the app's tooltip, including the clip cards.
- Reapply and Cancel are outline buttons, the empty state uses the Video nav
  icon, and the clip tiles are less rounded.

* Images and Video: narrower generation rail, matching Train headings

Create and Video rails go from 392px to 368px. Train a LoRA and Training
settings are now the same size and both in the heading font: the h2 already
picks it up from the base rule, so the settings header opts in with
font-heading and the weight that rule pins.

* Images Train: shorter copy throughout

Family notes, example descriptions, precision labels and every helper line are
trimmed so they stop wrapping to three lines and colliding with the next
column. The nf4 label now fits its select without truncating.

* Images Train: a little more spacing between field groups

* Images and Video: tighten code comments

* Fix training start NameError, the load-order guard test and CPU-only diffusion tests

- start_training forwards resume_source_run_id to _start_training_impl, which
  reads it. Without it every start raised NameError.
- Restore main's anchor in the load-marker order test: the file now has an
  earlier `if config.is_gguf:`, so indexing the first one compared the wrong
  branch.
- The two diffusion tests that reach diffusers now skip when it is absent,
  matching the CPU repo-test env.
- The UI smoke finds nav rows that live in the sidebar's More flyout.

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

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

* Treat a null metadata caption as no caption

str(None) stored the literal "None" as the caption, so a null row counted as
captioned and would have trained on that text. Also drops an unused import.

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

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

* Fix invalid-UTF-8 500s, the flat Canny map and the dropped DiT knobs

read_text raises UnicodeDecodeError, which is not an OSError, so one bad caption
sidecar or video sidecar 500d the info, upload and gallery routes. A flat image
now yields the all-black edge map instead of its own luminance, and the four DiT
loss knobs the trainer implements are declared so model_dump keeps them.

* Use the ui font-size tokens instead of raw px text utilities

text-[11px] and friends ignore the UI font size preference, which the repo's
font-scale contract test enforces. Same rendered size at the default scale.

* Fix diffusion dataset 500s, the dropout-1.0 no-op run and the reset base pick

Four correctness fixes on the training side:

- The labeling grid read caption sidecars under except OSError, but a
  non-UTF-8 sidecar raises UnicodeDecodeError (a ValueError), so one bad
  file 500d /diffusion/dataset/{name}/images and the grid could not be
  opened to repair it. Read it as no caption, matching the info summary.
- An image past Pillow's own hard limit raises DecompressionBombError,
  which derives straight from Exception and so escaped the upload guard's
  (OSError, UnidentifiedImageError, ValueError) and returned 500 instead
  of the intended 400.
- lora_dropout accepted 1.0, which makes PEFT build nn.Dropout(p=1.0):
  lora_A and lora_B receive no gradient and the run saves an untrained
  adapter while reporting normal progress. Bound it below 1.0, matching
  the LLM request schema.
- The train panel re-seeded the base repo on every dataset refresh
  because the family object identity changes on each info fetch, so an
  upload or caption save silently replaced the user's chosen base and the
  run started on a different model. Track the pick and only re-seed on a
  real family change.

* Show the retained failure when a video page mounts after a failed job

Mount-time recovery handled only phase=completed, so reloading the page
after a multi-minute generation failed left an idle view with no
diagnosis: the backend keeps the terminal failed record only until the
next job, and nothing else survives the reload. Surface it the same way
the poll does, filtering the cancelled sentinel.

* Fix batched generation crashes, cache keying and unreplayable recipes

Four bugs in the batched inference path, all found by review:

- A mixed-prompt batch sent a scalar negative prompt against a prompt
  list. Z-Image asserts on the length, and Qwen-Image, Krea 2 and FLUX
  true-CFG encode a batch-1 negative against batch-N latents and fail in
  the transformer's text/image concat. Broadcast it to match the batch.
- The FBCache step-cache reset sat above the chunk loop. diffusers only
  resets that state at the end of a successful call, so a forward that
  raised (the OOM the backoff is meant to recover) left its own residual
  behind and the halved retry died on a shape mismatch. Reset before
  every forward instead.
- The conditioning cache keyed on the checkpoint alone, but a GGUF or
  single-file load takes its text encoders from the companion base, so
  the same checkpoint against a different base reused the previous
  base's embeddings. Key the base too.
- Gallery records stored the base seed and the requested batch size even
  when a prompts/seeds list drove the run, so restoring the second image
  of seeds=[5, 99] replayed seed 5. List-driven outputs now record as
  single-image recipes on their own seed.

Also bound strength above 0: every img2img pipeline derives its step
count from it, so 0 leaves zero denoising steps and either raises or, on
SDXL, crashes on empty latents.

* Fix quantized-load LoRA bake, prequant family exclusions and outpaint canvas

Six review findings across the Images page and model scanning:

- The quantized (int8/fp8) load path can only attach LoRA adapters
  before quantization, but the frontend load request had no loras field,
  so every generation after such a load was rejected and each reload
  repeated it. Send the selection with the load.
- build_prequant_checkpoint passed no family to the scheme exclusions
  while recording the family in metadata, so a Qwen int8 artifact baked
  the short-M text-stream linears and was then rejected wholesale by the
  loader's family-keyed check.
- Registering a bare single-file checkpoint directory produced no On
  Device row even though the images loader can load it; only its parent
  worked. Admit that shape when nothing else matched.
- Unload left the Reapply target set, so the repair path was skipped and
  Reapply reloaded the ejected model. Clear it, as the video page does.
- Both FLUX.2 bases were trusted for training but not inference, so
  Deploy to Create rejected every FLUX.2 adapter.
- Outpaint allocated the grown canvas before downscaling, exceeding the
  browser canvas area cap on a large photo; an over-cap canvas is
  unusable, so Extend silently posted a fully transparent image and
  mask. Scale the source first.

* Send the picked GGUF filename with the quant so diffusion loads fire

The variant expander emitted only the quant label, and nothing else in
the frontend set ggufFilename, so the Images and Video pages could never
take their GGUF branch: both gate it on meta.ggufVariant and
meta.ggufFilename, then fall through to the single-file path, which
returns because the id is a repo id and not a .gguf name. Every quant
pick was a silent dead click, with no load request reaching the backend.

The filename was already on the variant row (the picker keys its list on
it, and the variant validator requires a non-empty string), so thread it
through the click handler. The chat path is unaffected: it reads
ggufVariant and never needed the filename.

* Version the conditioning cache key and reject non-finite flow_shift

Two correctness fixes:

- The cache keyed the checkpoint and its companion base by name only, so
  a Hub repo advancing to a new commit, or a local directory updated in
  place, kept returning embeddings from the previous text encoder. Pair
  both with a revision marker: the locally resolved commit sha for a Hub
  repo, config plus text-encoder file stats for a directory. Neither
  loads the encoders, so a warm run still keeps them off the GPU.
- flow_shift only checked positivity, but JSON accepts 1e309, which
  floats to inf, and inf <= 0 is False while NaN fails every comparison.
  The sigma table then evaluates s * u / (1 + (s - 1) * u) as NaN, which
  poisons every sampled sigma and saves a corrupted adapter while
  progress looks normal. Require a finite value.

* Keep curated models listed, guard the video companion repo, pin diffusers

Three review findings:

- The picker filtered every catalog member out of Recommended and Hub
  search on the way to canonical group rows, but nothing renders those
  rows yet (catalogGroupFitsDevice and groupMatchesQuery are imported and
  unused). A task-scoped picker's models list is catalogToModelOptions(),
  i.e. group members exclusively, so both lists came back empty and no
  curated model could be discovered or downloaded. Keep the artifacts
  listed until the grouped UI exists.
- The video delete guard compared only repo_id, so deleting the
  companion base of a loaded GGUF video model was allowed even though it
  supplies the VAE and text encoders. Compare base_repo too, matching
  what the images guard already does for its companions.
- diffusers was declared unversioned while the diffusion stack requires
  0.39 (Krea2Pipeline, the cache_context child registries, the Flux2 and
  Z-Image pipelines), so an upgrade could keep an older release and
  selecting an advertised model failed until the user upgraded by hand.

* Namespace the trainer conditioning cache per checkpoint, bound the learning rate

- The trainer keyed its persistent conditioning cache on family and
  resolution only, while the keys themselves carry just the caption or
  image content and crop variant. One cache directory reused for two
  checkpoints, or for the same repo at a new revision, let a warm run
  skip loading its encoders and train on the other model's embeddings
  and latent statistics. Namespace on the base checkpoint and its
  resolved revision as well. The revision helper now lives beside the
  cache in diffusion_train_extras and the inference wrapper delegates to
  it, so the two cannot disagree about what counts as the same source.
- The diffusion learning rate only checked positivity, but 1e309 floats
  to inf and satisfies gt, so the route evicted the resident models and
  started AdamW with an infinite rate: the first step destroys the
  adapter while progress looks normal and the result is saved. Bound it
  below 1.0, matching the LLM schema, which rejects inf for the same
  reason.

* Fix GGUF image model picks doing nothing, and pick the train base in the top bar

The quant rows never forwarded the .gguf filename, so every hub GGUF pick on
Images/Video fell through to a silent return. On Train the top bar now picks the
training base instead of a generation model, which is GGUF-only and untrainable.

* Pin diffusion and video loads to the live HF cache root

Both read huggingface_hub's import-time HF_HUB_CACHE, which changing the cache
folder does not update: progress counted the old root while the download wrote to
the new one, and from_pretrained could split one model across both.

* Add the diffusion download plan endpoint

Reports the repos and exact files a pick needs so the download manager can stage
them with the loader's own file scope. A plain snapshot would add the packaged
root single, transformer shards and fp16 twins the loader never opens.

* Add a file-scoped flavour to the Hub download job

Lets a consumer that reads a deliberate subset of a repo stage it through the
normal download manager. Keyed as "@scope" so it never collides with a quant or
with the repo's full snapshot, and the file list rides the registry so an
XET to HTTP retry respawns the same scoped job.

* Stage image and video downloads through the Hub download manager

They downloaded inline inside the load, so they had none of the manager's disk
preflight, manifest verification, resume or panel progress. Picks now stage as
scoped jobs carrying the loader's own file list, then load from a warm cache.

* Fetch staged GGUF checkpoints as scoped jobs, and stop calling diffusion models unsupported

A GGUF entry went out as a full snapshot, whose ignore list drops *.gguf: the job
finished at once having fetched only docs, and the repo landed on device unloadable.
Every entry is scoped now. The Hub also no longer tags image/video models as
unsupported (they run on their own pages), and those pickers name what they select.

* Apply the picker task filter to local model sections

LM Studio, ./models and custom-folder rows ignored it, so the Images picker listed
chat GGUFs that 400 on a diffusion load. The backend already tags every local model
with a task for this purpose.

* Route a chat pick of a diffusion model to the Images or Video page

Chat cannot load one, so it was either hidden or failed on load. The unfiltered
picker now lists on-device diffusion models and navigates to the page that runs
them, passing the repo and quant so that page loads it.

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

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

* Route the real GGUF filename, keep non-GGUF curated models, key scoped downloads by file set

Five review findings, four of them ways a click did nothing or fetched
the wrong thing:

- A chat pick of a diffusion model routed ggufVariant (a label like
  Q4_K_M) in the search param the target page uses verbatim as the GGUF
  filename, so the load asked for a file that does not exist. Route
  ggufFilename; no filename means a curated non-GGUF pick, loaded as a
  pipeline.
- The task-scoped pickers kept only GGUF repos, so the catalog's bf16,
  bnb-4bit and single-file fp8 artifacts could not be discovered or
  downloaded on the Images and Video pages even though loadSpecFor
  knows how to load them. Keep curated artifacts whatever their format,
  in Recommended and in Hub search.
- Both pages deduplicated routed selections on the model alone, and
  they now stay mounted, so picking the same repo again -- another quant,
  or the same one after chat evicted it -- returned early without
  loading or clearing the query string. Key on model and quant.
- Every scoped image download shared one @diffusion job key regardless
  of the requested files, so switching quant mid-download adopted the
  running job: the UI waited on the first file set, then loaded a file
  that was never fetched. Include a digest of the file set in the key.
- A scoped plan silently dropped requested files missing from Hub
  metadata, and snapshot_download succeeds when an allow pattern matches
  nothing, so the job reported completion and triggered a load with
  required files absent. Fail the job instead.

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

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

* Keep the scoped download key derivable, and stop the hidden page hijacking a route

Four review findings, the first a regression from my own last commit:

- Keying scoped download jobs by a digest of the file set broke the
  download manager: it builds that key client-side (it polls and cancels
  before any response tells it a key), so it watched and cancelled a key
  no worker owned and never fired its ready callback. Keep the derivable
  "@scope" key and refuse the second request instead when a live job on
  the slot is fetching a different file set -- decided inside the
  registry claim, under the lock, so a concurrent claim cannot slip past
  it. The manager records the file set on the job as well, so a sibling
  quant's transfer is not adopted locally either.
- Both diffusion pages read the route query through a loose useSearch and
  both stay mounted once visited, so the hidden one consumed the other's
  ?model=: it navigated back to its own route and tried to load, say, an
  image checkpoint as a video model. Only the visible page consumes it.
- The staged download plan was built without the configured HF token or
  the Advanced values the load itself sends. The token matters most: the
  backend's Hub metadata lookup is best-effort, so a gated base silently
  planned no companion entry and the load pulled those multi-GB files
  inline, outside the manager. The memory/quant controls decide whether
  the base transformer/ shards are needed at all, and the route dropped
  memory_mode, cpu_offload, the prequant path and the LoRA selection
  before asking for the plan.
- The video preview kept playing after leaving the page: the keep-alive
  layout only hides it, and display:none does not pause a media element,
  so a clip the user unmuted kept its audio going over the next page.
  Pause on the active transition and do not auto-replay while hidden.

Also completes the hand-built request bodies in the hub download tests:
the scoped-files field this branch added to the route read as an
AttributeError against them, failing five tests.

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

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

* Serialize the GPU handoffs, gate DiT training on a GPU, and keep 3.9 installable

Six review findings, three of them evict-then-fail orderings:

- The chat load reclaimed the GPU without telling the arbiter it existed. A
  chat load holds no llama-server process until its GGUF has downloaded,
  which is minutes, so a competing Images/Video acquire in that window
  found nothing to cancel, took the GPU, and the chat load then spawned
  onto the same device. It now registers an in-flight marker through
  acquire_for's register hook (under the arbiter lock, as the image and
  video loads do), the evictor cancels a marked load, and the route undoes
  itself if ownership moved while it loaded.
- The Hub-download conflict check ran after that handoff, so a GGUF the
  download manager already owns destroyed the resident Images/Video
  pipeline and then 409'd, having loaded nothing. It moves above the
  handoff, together with the marker it handshakes with.
- The image load released the engine router's transition lock before
  registering the load, so a second load choosing the other engine could
  unload the still-idle engine this one captured; the load then landed on a
  deactivated engine, where generate, status, unload and the arbiter's
  evictor can no longer reach it. Registration now happens under that lock
  and refuses if the engine changed.
- Training a DiT family on a host with no GPU was accepted: nf4 is not a
  CPU fallback, its 4-bit load goes through bitsandbytes, which requires
  CUDA, XPU or MPS. The start unloaded the working Images pipeline, pulled
  the text encoders, and only then died in the child. Rejected before the
  teardown now, and /info stops advertising a precision that always 400s.
  SDXL keeps its documented fp32-on-CPU path.
- Both diffusion pages kept the routed-pick marker forever, so re-picking
  the same checkpoint (after chat evicted it) neither loaded nor cleared
  the query string. The marker is released once the query is gone. The
  Images key also carried a stray NUL byte, which made the file read as
  binary to grep and other tooling.
- diffusers dropped Python 3.9 in 0.38, so the unconditional >=0.39.0 pin
  left pip no candidate at all on 3.9 and made every install that composes
  the huggingface extras unresolvable there. The floor is conditional now.

Also fixes tests that were already red on the branch: two hand-built
request fakes had gone stale against fields this branch added, and the
handoff-ordering test only failed on a host with fewer than two GPUs.

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

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

* Fix the GGUF variant contract test against the merged handler signature

The assertion pinned the exact single-line call handleVariantClick(v.quant,
v.downloaded, expectedBytes, v.filename), but the handler takes (quant, filename,
downloaded, sizeBytes) and prettier wraps the call across lines, so the mandatory
repository test job failed on every push. Match the call structurally and assert
the filename really is forwarded in the handler's argument order.

* Stop a background page and a stale record taking the GPU or a download with them

Five fixes from a review pass over the diffusion work.

delete-finetuned rmtree'd a model the Images or Video engine was holding: every
guard on that route is chat-only, and Images loads any local path, so deleting a
local diffusion model under the storage root pulled the weights (and the
companion VAE / text encoders sd.cpp re-reads each generation) out from under a
live pipeline. The cached-model route already refuses this; the trained/exported
one now does too, matching by path rather than repo id, and failing open on a
chat-only install so it cannot block ordinary deletes.

A staged download finishing while its page was hidden loaded the model and
evicted whatever the user was actually using: both diffusion pages stay mounted
behind the router and a load takes the GPU unconditionally. The pick is now held
until its page is on screen again, which is also what chat does.

A scoped download could report success having fetched nothing. With Hugging Face
metadata unavailable no manifest is written, so verification is a no-op, and
snapshot_download returns an existing snapshot folder without downloading when
its own repo_info call fails. A repo already on disk from a full snapshot job
(which ignores *.gguf) therefore completed with no weights and auto-loaded
against them. The requested file list needs no network, so it is checked against
the disk directly.

The XET to HTTP retry reclaimed the job slot without the scoped file list, and
that claim overwrites the stored record, so a later identical scoped start
compared an empty list against the real one and 409'd instead of adopting the
running download.

The DiT accelerator gate probed torch.mps.is_available(), which only exists from
torch 2.5 while the supported floor is 2.4. All three probes shared one
try/except, so on torch 2.4 the AttributeError read as 'no block' and a CPU-only
host still evicted the resident pipeline, downloaded the encoders and died in
the child. Each accelerator is probed on its own now, through
torch.backends.mps.

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

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

* Stage what an LTX-2.3 load reads, keep a routed file's load kind, drop an unbakeable LoRA

Three from the latest review.

The video download plan always asked for the wide base file list, so an LTX-2.3
pick staged the 2.0 base's VAEs, vocoder and connectors that the checkpoint
supplies itself, while the companion files the 2.3 assembly does read were left
out of the plan and pulled inline at load, outside the panel's progress, cancel
and disk preflight. The plan now recognises a 2.3 pick by name (the load keeps
the authoritative header probe, and under-guessing only falls back to the
load-time pull), narrows the base list, and stages the extras in the same entry
as the checkpoint so one repo stays one scoped job.

A pick routed from the chat picker arrives as ?model= and ?quant= with no picker
metadata, so a bare local .gguf or .safetensors was loaded as a pipeline: an
explicit model_kind wins over the backend's filename sniffing, so it evicted the
resident model and then failed on the missing model_index.json. Both pages now
derive the load kind from the path, the same way their own picker handlers do.

A torchao int8/fp8 build takes adapters only at load time. Switching artifact
inside one family keeps the LoRA selection, since the family did not change,
but the load did not bake it, so the next generation was rejected with 'reload
the model with the adapter selection' while the picker still showed the adapter
as active. The selection is now dropped once per resident build, with a message
saying to pick and load again.

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

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

* Cancel an evicted safetensors load, spare the arbiter for CPU-only chat, fetch clips lazily

Four fixes from the latest review round:

- The GPU arbiter's chat evictor only cancelled the llama.cpp side. The
  orchestrator publishes active_model_name once its worker reports success, so
  an in-flight safetensors load was visible only as an entry in loading_models
  and finished onto the GPU after ownership had transferred. Cancel every
  pending load, and give the safetensors branch the post-load ownership recheck
  the GGUF branch already had.
- A manual gpu_layers=0 GGUF load runs on the CPU with the GPUs hidden from the
  child, yet it took the arbiter unconditionally: it cancelled a running image
  or video generation for a model needing no VRAM, then held CHAT ownership so
  the next GPU workload unloaded it for nothing. Gate the acquire on the same
  predicate the launch-time CPU-only mask uses, as the image and video loaders
  gate on their resolved device.
- The staged-download hook subscribes per repo, not per job, so another job on
  the same repo advanced the staged queue (starting a load whose scoped files
  were still downloading) or wiped a queue that was still running. Compare the
  variant each callback carries, like the chat page's auto-load does.
- The video gallery fetched every record of a page into an object URL that
  lives until the page closes: 50 clips at tens to hundreds of MB each, for
  cards the user may never scroll to. Fetch a clip as its card nears the strip's
  edge, plus the selected one the player needs.

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

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

* Trim the comments across the diffusion backend

Comment-only pass over the Python this PR touches: drop what the code already
says, collapse multi-line explanations that still read on one line, and keep
the reasoning that is not recoverable from the code. No code, docstring
semantics or behaviour changes; verified with an AST comparison against the
previous revision, and the backend suite is unchanged (same 37 environment
failures as before: the API integration tests that need a live keyed server,
the flash-attn install hooks, and the GPU memory fields).

* Invalidate latents on a VAE swap, keep a cut-off generation, surface the EMA adapter

- source_revision() scanned the checkpoint root plus text_encoder/tokenizer but
  not vae, so swapping or fine-tuning the VAE in place left the conditioning
  cache namespace unchanged and a warm run trained against latents from the old
  checkpoint. Include the vae directory, like any other component the cached
  tensors come from.
- /images/generate answers only when the images are saved, and secure mode's
  tunnel caps an origin response near 100 seconds, which a native CPU or a
  high-step run passes routinely. The page reported failure while the work kept
  running, and a retry would duplicate it. A lost response (fetch rejection or
  a gateway status the origin never answered) is now told apart from a refusal:
  the page waits out generate-progress and reloads the gallery, so the run it
  started still lands.
- The trainer emits the EMA adapter's path with the terminal event, but the
  state update dropped it, so neither the run history nor either response
  schema carried it and an enabled EMA left nothing discoverable. Keep it, and
  show it next to the primary adapter.
- weighting_scheme advertised a choice of timestep sampling; sampling is always
  logit-normal and the flag only selects the bell loss weights. Describe what
  it does.

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

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

* Hide unloadable cached rows, hold the dataset interlock, bound a GIF export

- The cached-model listing tagged any repo with a model_index.json as
  text-to-image, so a community pipeline the image loader's trust rule refuses
  still got a row in the Images picker, and a detected-but-untrusted video repo
  fell through to that same tag. Gate the image tag on the load path's rule and
  hide an untrusted video repo outright.
- A routed diffusion pick only carries a GGUF filename, which is all the chat
  picker has, so a curated single-file artifact arrived with no quant and was
  loaded as a pipeline: from_pretrained on a repo with no model_index.json. Pass
  the page's own catalog spec into the route pick, so a routed pick resolves to
  exactly what a direct pick on that page resolves to.
- The dataset mutation endpoints checked is_active() and only then handed their
  filesystem work to a thread, so a start reserving in that gap changed captions
  or removed images underneath the preflight or the running trainer. The
  interlock is now registered for the whole request under the lock reserve()
  uses, and a start refuses while a mutation is open rather than waiting on it.
- GIF export held every kept frame as a paletted image before encoding; a clip
  may be 2048x2048 for 1024 frames, and at the 12 fps target the step is 1, so
  one export click could allocate over 4 GB and take the backend down. Downscale
  past 720 px and widen the step to keep at most 300 frames.
- seed accepted any Python int, so an out-of-range one passed every preflight,
  evicted the resident models, spawned the trainer and only then died in
  torch.manual_seed. Bound it to torch's 64-bit range in the request and config.

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

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

* Pin the accelerator probes in the DiT family-metadata tests

Six tests read family_train_infos() (or a start preflight) without pinning the
host probes, so they only held on a machine with a bf16 accelerator: on a
GPU-less runner the DiT gate empties precision_modes, turns supports_compile
off, and replaces any other preflight message with the no-accelerator note, and
all six failed there. A conftest fixture pins both probes for exactly those
tests, so they assert the family metadata they are about on every host. The
gate's own CPU-only behaviour keeps its dedicated tests.

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

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

* Carry the pipeline task into the hub inventory the pickers read

- The task-scoped pickers filter On Device rows on a task, and the chat picker
  routes a diffusion pick by the same field, but those rows come from the
  /api/hub inventory, which never carried one: the Images and Video pickers
  listed nothing on device and the routing never fired. Both cached scans and
  the local listing now tag rows with the classifiers the models API already
  uses, the schemas and the frontend adapter carry it through, and a row the
  backend classified as a generation task is exempt from the chat-only guard
  that was also dropping it.
- The local routing map was keyed by model_id while the row click passes id (a
  filesystem load id for a models_dir or LM Studio entry), so the lookup missed
  and the pick fell through to the chat loader. Key both.
- A staged download whose start answered "error" left its head in place, where
  the effect never re-runs and onReady never fires, so the pick was stranded
  until the user reselected. Clear the queue and say so.
- Every scoped pick in a repo shares the @diffusion variant, so the variant
  alone cannot tell two file sets apart: restaging while the first job finished
  let its completion pass for the new pick and load a checkpoint that had not
  downloaded. Bind the callbacks to the repo + file set they started, and to the
  staging generation.
- A rejected generate POST does not say whether it reached the backend, so an
  immediately idle progress read was ambiguous and a submission that never
  landed looked like a finished image. Require evidence: progress seen active,
  or a gallery record that was not there before the POST.

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

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

* Make the OpenAI image URL fetchable, keep WebM audio, stream example imports

Four review items on the diffusion Studio work:

- response_format=url returned the bearer-gated gallery route, which a standard image
  client downloads with no Authorization header, so the default response format was
  unusable. Mint a short-lived HMAC link instead (the shape RAG already uses for pdf.js)
  served by a signed route, and leave the gallery route itself bearer-only.
- A manual gpu_layers=0 load carrying speculative_type="off" -- a value the UI persists
  and sends -- read as GPU-bearing, so it took the GPU arbiter and evicted a resident
  image/video pipeline even though the launcher hides the GPUs for it. Canonicalize the
  mode and exempt "off".
- The curated example import prepared the whole split before the loop stopped at the
  10-100 image cap; m1guelpf/nouns is 49,859 rows / 328 MB. Stream instead, with the
  prepared load kept as a fallback for a repo that cannot stream.
- WebM export dropped the audio track an LTX-2 clip carries, silently, on the format
  offered for web embeds. Mux it as Opus through a resampler + FIFO, and keep exporting
  the video alone on a build without libopus.

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

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

* Do not stub out triton on a GPU host when the Xet backend fails to import

The lazy loader retries `import unsloth_zoo.hf_xet_fallback` under
UNSLOTH_ZOO_DISABLE_GPU_INIT=1 whenever the first attempt raises. That flag makes
unsloth_zoo take its MLX/CPU path, which injects triton and bitsandbytes STUBS into
sys.modules for the rest of the process.

On a working GPU box whose first import failed for an unrelated reason (a
bitsandbytes/CUDA mismatch, say) the retry succeeds, so Studio boots looking healthy
and then dies at the first CUDA-only kernel: a GGUF or compiled diffusion generation
hits the stub and returns

  NotImplementedError: Unsloth: 'triton.tools.experimental_descriptor.enable_in_pytorch'
  was called on Apple Silicon / MLX, where triton is stubbed out.

so every image generation 500s with an Apple-Silicon message on a Linux CUDA host,
while the load reports success. Found by loading Z-Image-Turbo GGUF through the API on
a box where bitsandbytes could not initialise.

Gate the retry on the host genuinely having no accelerator. The Xet stall watchdog is
optional and already degrades with a warning; a process whose triton is stubbed out is
not recoverable. The warning now says why it did not retry.

* Fix the lost-generation proof set, the settle timeout and the hub inventory's diffusion gates

Seven fixes from the latest review round on the Images page and the hub cache inventory.

Images page:
- The lost-POST settle path built its "already seen" gallery id set inside the catch, after
  the request failed. By then the earlier runs of the same batch had already prepended their
  records, so run 2 could accept run 1's image as proof that its own request reached the
  backend. The set is now captured once before the first POST and grows with every record the
  batch produces.
- settleLostGeneration fell out of its SETTLE_MAX_MS loop and returned normally, so a wedged
  generation was counted as done and the next run started against a busy backend. It now
  throws on timeout.
- Restoring a recipe cleared the ControlNet selection but left the workflow tab and the
  init / mask / reference images pointing at whatever was loaded, so the next Generate
  conditioned on an unrelated image. It now clears all of them and returns to Create.
- The download plan omitted the adapter selection the load itself bakes in. A baked LoRA
  forces the dense build path, so the plan described a different file set than the load that
  followed and the rest was pulled inline, outside the download manager. Both now derive the
  list from one helper.

Hub cache inventory:
- A download for a repo an Images or Video load is staging was allowed to start: only the
  llama.cpp loader was consulted. Both diffusion backends already expose loading_repo_ids for
  the delete guard, and the download guard now reads them too.
- A companion-only prefetch (pipeline manifest plus VAE and text encoder, no transformer)
  passed the snapshot-partial check, since every file its manifest expected did arrive, and
  was advertised as on-device although from_pretrained cannot load it.
- The single-file flag never reached the picker through the hub inventory path, so a
  checkpoint-only diffusion repo read as a full pipeline and failed after the handoff.

The two pipeline-shape helpers now live in hub/utils/inventory_scan.py so /api/models/cached
and the hub inventory classify the same repos the same way.

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

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

* Stop adopting an unknown scoped download, leaking raced blobs and resurrecting deleted clips

Three items from the latest review round.

A scoped download job carries a deliberate file subset, and every file set of one repo rides
the same "@scope" slot. A client that adopts a live job from the backend had no file list to
compare against: the active-downloads response never carried one, so an adopted job's set was
unknown and any later scoped request for the same repo read as "already started". Selecting a
different checkpoint then waited on the wrong transfer and tried to load a file nobody
fetched. The response now publishes the scoped file list, adoption records it, and an unknown
set no longer satisfies a scoped request.

A gallery record can be deleted while its blob is still downloading. The delete revokes the
URL present at that moment, so the fetch that lands afterwards inserted a fresh object URL for
a record no card renders and nothing can revoke: a full MP4, tens to hundreds of MB, pinned
for the rest of the session, and once per raced fetch. Both galleries now discard a blob whose
record went away, with an epoch covering the video page's Clear all.

The video backend keeps the last completed job until the next one starts, and the Video page
merges that record on mount to cover a job that finished after the gallery fetch. Deleting the
clip left the record in place, so every reload prepended a ghost card whose file request 404s
until another generation replaced it. Deleting the clip, or clearing the gallery, now clears
the matching terminal record, and the page skips a record it deleted itself.

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

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

* Resolve revisions from the live cache, serialize dataset imports, and stop pinning every gallery blob

Five more items from the review round.

The conditioning-cache revision marker read huggingface_hub's import-time HF_HUB_CACHE
constant. Studio can move its cache during a session and loading follows the live setting, so
after a move the marker went unresolved (or pointed into the previous root) and pulling a new
revision of the same checkpoint no longer invalidated the cache: a warm run could reuse the
old encoder's embeddings and the old VAE's latents. It now looks in the active Studio cache
first and keeps the environment and the library constant as fallbacks, which the trainer
subprocess still needs.

The dataset interlock counts mutations rather than excluding them, so two imports of different
examples into the same empty name both got past the emptiness check. The winner promoted its
staging directory atomically; the loser found the folder non-empty, fell back to a per-file
move, and merged its images and captions into the winner's dataset. Imports now take a
per-folder lock, a second one is refused with 409, and the emptiness check is repeated under
the lock.

On Windows the sd.cpp asset resolver filtered only by accelerator token, so a Windows arm64
host matched an x64 zip, downloaded and installed it, and failed later when the binary would
not run. It now filters by architecture the way the Darwin and Linux branches do.

Every gallery page fetched every PNG up front and kept the object URL for the session, so
scrolling a large gallery grew memory without bound for tiles the user may never look at. The
Images strip now fetches a tile as it nears view, like the Video strip, and keeps the eager
path only where IntersectionObserver is unavailable.

A 503 carrying a JSON body comes from the application, not a proxy, so it is surfaced as the
error it is instead of entering lost-response settlement and being reported as a request that
never reached the server.

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

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

* Name the class of a failed generation instead of a bare "Image generation failed."

Found on a macOS runner: the native renderer aborts inside its own text encoder there, and the
page showed only "Image generation failed." with the sd-server backtrace left in the server log,
so nothing about the failure reached the user. The failure is now classified into fixed text, out
of memory and native-process death, so the message says what happened and what to try. None of the
engine's own output is echoed, since a native tail carries local paths and argv; that stays in the
log, and an unrecognised failure keeps the original literal.

* Treat an undecodable caption sidecar as the tombstone the trainer sees

Uploads store .txt and .caption sidecars as raw bytes, so one can hold invalid UTF-8. The
trainer treats any existing sidecar, decodable or not, as an empty tombstone and never falls
back to the metadata row for that image. The labeling grid and the dataset summary read an
undecodable sidecar as absent instead, so both showed a metadata caption that the run would
silently replace with the instance prompt, and counted the image as captioned. Both now track
sidecar presence separately, so what the user reviews is what the run trains on.

* Keep the reason a native server died, not just its backtrace

A ggml abort prints its cause first and then a stack trace, so reporting the last twenty captured
lines gave twenty addresses and nothing about the failure: on the macOS runner the native server
died on an unimplemented Metal op and the message carried only frame pointers. The captured tail
now leads with the lines that name a cause and keeps recent context after them, for both the
startup failure and the mid-request death.

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

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

* Stop staging the dense text encoder for an fp8 video load

Two halves of the same gap, found while measuring the LTX-2.3 download plan:

- The video download plan and the scoped pre-download never saw
  text_encoder_quant. An fp8 request loads a hosted pre-cast encoder, so
  asking for one still staged and downloaded the base repo's dense Gemma3
  (48.79 GB of Lightricks/LTX-2 on the 2.3 distilled pick) that the pipeline
  then never opened. The plan now drops those shards and stages the pre-cast
  checkpoint instead; their configs stay, since the pre-cast loader still
  meta-inits the encoder from the base repo's component config.

- The LTX-2.3 assembly builds every component itself, so pipe_kwargs (which
  carries the pre-cast encoder for from_pretrained) never reached it and an
  fp8 request silently loaded the dense encoder anyway. It is passed across
  explicitly now.

The dense skip is earned, not assumed: only a pre-cast checkpoint that
resolves on the Hub lets the plan drop the dense shards, and only one already
fetched to disk lets the pull drop them, so an unpublished or gated artifact
leaves both exactly as they were. If injection still fails after that, the
load tops the dense weights back up rather than handing from_pretrained a
snapshot with no encoder in it.

Measured against the real Hub on the 2.3 distilled Q4_K_M pick: 67.24 GB
before, 18.92 GB with a 0.43 GB stand-in for the pre-cast artifact (the base
entry drops from 24 files / 48.79 GB to 13 files / 0.04 GB).

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

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

* Match the file's typing idiom for flow_shift

models/training.py annotates with typing constructs throughout (105 Optional[...],
no Union), and flow_shift was the one place using PEP 604. Union[] reads like the
rest of the file, and it also drops the runtime evaluation that would raise on
Python 3.9.

* Bound the gallery blob cache, and three interlock fixes

Four review findings, all reproduced first:

- The gallery object-URL caches were unbounded. A clip runs from a few MB to a
  few hundred, both pages stay mounted after their first visit, and entries were
  only dropped on delete, so scrolling pinned everything for the session. Both
  pages now share a byte-budgeted LRU (512 MB video / 192 MB images) keyed off
  the visibility signal the near-viewport fetching already provides. On-screen
  media, the selected clip or image, and the item just fetched are never
  evicted, so eviction is invisible and a single item larger than the whole
  budget cannot evict itself into a refetch loop.

- The image, video and chat load guards ran two independent training probes but
  returned early when the FIRST one raised, so an unreadable LLM backend
  disabled the diffusion interlock and a load could proceed straight into an
  active diffusion trainer on the same GPU. The probes are independent now.

- An engine switch swallowed a failed teardown and published the new engine
  anyway, which is exactly the leak the unload exists to prevent: the arbiter's
  evictor, /images/unload and the next load all resolve through
  get_active_diffusion_engine(), so the still-resident pipeline (or a live
  sd-server) became unreachable and the next load allocated on top of it. The
  switch now fails and leaves the old engine published, so it stays reclaimable.

- The native generation timeout was 30 minutes while the Images page waits up to
  6 hours (SETTLE_MAX_MS), so slow-but-progressing CPU jobs died deterministically
  at the deadline. Measured on GPU-less runners, a 512x512 4-step Q2_K generation
  took 900 s on Linux and 1465 s on Windows, so larger images or step counts clear
  half an hour easily. The ceiling now matches the page's window and applies to
  the whole request: chunks of a split batch share one deadline instead of each
  getting a full budget. Cancellation is unchanged.

Declined: gating the huggingfacenotorch extra off Python 3.9 over the
conditional diffusers marker. The marker is deliberate and its comment says why:
diffusers dropped 3.9 in 0.38, so pinning >=0.39 outright leaves pip no candidate
and the whole extra unresolvable there. The pipelines it names live in
studio/backend, which cannot install on 3.9 anyway (studio.txt pins
matplotlib==3.10.9 and fastmcp>=3.0.2, both requires_python >=3.10), and the
extra is the general core one, so the alternative drops 3.9 for library users who
never touch Studio.

* Close the load-versus-training-start race, and two picker fixes

- The image and video load guards read is_active() and only then selected an
  engine, acquired the arbiter and registered the load. A /train/diffusion/start
  reserving inside that window freed residents the load had not registered yet,
  so the trainer came up beside a brand-new pipeline. The service already had
  exactly the right pattern for this in dataset_mutation, so gpu_load_admission
  mirrors it: reserve() refuses while an admission is open, an admission refuses
  once a start is reserved, both decided under the one lock. The span is only the
  registration, since begin_load returns as soon as the load is registered and
  _free_gpu_for_diffusion_training preempts an in-flight load from that point.
  Chat is deliberately not covered: its load spans an eviction plus a multi-minute
  GGUF load, and it admits models that fit beside training by design, which is a
  different contract from the diffusion pipeline's all-or-nothing one.

- Hugging Face gives the LTX-2 family the image-to-video pipeline_tag (both
  Lightricks/LTX-2 and unsloth/LTX-2.3-GGUF report it), so a text-to-video-only
  filter dropped the flagship audio family out of Video Hub search while the rest
  of the app routed it to Video.

- Task-scoped quant fit sized picks against the LARGEST visible device while
  resolve_diffusion_device_target returns a bare "cuda" and torch places on the
  current one. On a heterogeneous host that recommended a checkpoint sized for the
  bigger card and then loaded it onto the smaller one. Fit now uses the device the
  load actually lands on; identical on a homogeneous host.

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

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

* Expose the persistent conditioning cache in the start schema

DiffusionLoraConfig has carried cond_cache_dir for a while and the DiT trainer
acts on it, but DiffusionTrainingStartRequest omitted the field, so Pydantic
dropped it silently and every API-driven run fell back to the in-memory cache
that is rebuilt from scratch each time. The warm path skips loading the VAE and
the multi-GB text encoders on a rerun whose images, captions and resolution are
unchanged, so this was a real capability that could not be reached.

Contained like output_dir rather than left to the trainer subprocess's cwd,
since it is another directory the trainer writes to. Blank or omitted still
means the in-memory cache, so it must not resolve to the outputs root.

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

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

* Fix diffusion policy and classification issues from review

fp8 auto precision defaulted to precise accumulate on any non-consumer GPU,
which made fp8 2.05x slower than int8 on RTX 6000 Ada and slower than not
quantising at all. NVIDIA's professional whitepapers do publish equal FP8 rates
for both accumulate modes there, so the hardware premise held, but the cost is
in the cuBLAS path rather than the published rate. Default to fast accumulate:
measured on B200 the flag is a no-op (4096^3 _scaled_mm at 3023.8 vs 3041.8
TFLOP/s, bitwise-identical output, 1.213 s vs 1.230 s end to end), so it is a
large win where it bites and free where it does not. Precise accumulate stays
available via transformer_quant_fast_accum.

Z-Image's DiT is a Lumina2 derivative, so unsloth/Z-Image-GGUF and
unsloth/Z-Image-Turbo-GGUF both declare general.architecture = "lumina2" and the
whole line was tagged image-diffusion-unsupported and hidden from the Images "On
Device" list, though validate_load_request loads them. Resolve shared archs from
the repo/file name like bare "wan" already does, with a test asserting the picker
and the loader agree for every family.

The sage attention on-demand install ran an unpinned `pip install sageattention`,
but PyPI's newest wheel is 1.0.6 and diffusers refuses anything below 2.1.1: the
install always "succeeded", wrote an unusable version into the running venv, and
was rejected on the next line. Carry the dispatcher's floor so pip resolves
nothing instead.

The dense-quant disk gate sized the download from the bf16-RESIDENT table. The
fp32 families download twice that (Z-Image: 23,479 MiB against a 21,970 MiB
gate), leaving a window where the check passed and the download filled the disk;
Ideogram 4 ships fp8 and was overcharged the other way. Size the gate by
published bytes, verified against HF sibling metadata for all 12 families.

Patch installs went through unsloth_zoo, which refuses to import unless
UNSLOTH_IS_PRESENT is set, and that is set by unsloth itself. The server imports
unsloth at boot so it never showed there, but any other process ran silently
unpatched with every install returning False, which is 13 test failures on a
clean environment. Import unsloth and retry once, memoised per process.

Also: the GGUF+LoRA refusal pointed at the native engine without saying a GPU
host only selects it under UNSLOTH_DIFFUSION_ENGINE=sd_cpp, so the suggestion was
unreachable; the gallery recipe recorded loras from the generate request alone,
losing a load-time bake; load-progress claimed "40.07 GB downloaded" for a fully
cached load; and pickers.tsx imported three catalog-group helpers it never used.

Reported by oobabooga.

* Keep the sd.cpp text encoder on CPU under Metal

macos-14 loads FLUX.2-klein-4B Q2_K natively on mps and then dies on the first
generation with exit code -6:

    ggml_metal_op_encode_impl: error: unsupported op 'RMS_NORM' -> ggml_abort
    LLMEmbedder::encode_prompt -> LLMRunner::compute -> GGMLRunner::compute

ggml's Metal backend gates RMS_NORM on contiguous rows and aborts the process
when that does not hold, with no per-op CPU fallback, so any LLM text encoder
(Qwen3 for FLUX.2 and Z-Image, T5 for FLUX.1) takes sd-server down. The encoder
runs once per prompt while the DiT runs every step, so pinning only the encoder
keeps Metal for the part that matters. UNSLOTH_DIFFUSION_SD_CPP_METAL_TE_GPU=1
opts back in once ggml grows the kernel.

* Gate the unsloth retry in the diffusion patch backend

The retry added for the clean-environment patch failures is not free: importing
unsloth pulls torch in behind it, which costs ~940 MB of RSS measured in a
process that had neither, and on a host with no accelerator it fails anyway. A
cross-platform CI job that had generated fine at ~900 s later died 19 s in with
SIGTERM and every 'if: always()' step skipped, which is the runner being torn
down rather than a step failing.

Retry only when torch is already imported (true of the server and of anything
patching a real module, and the condition that stops the retry from being what
loads torch), unsloth is installed but not yet imported, and the first failure
was the ImportError the sentinel guard raises. The clean-environment case it was
added for still passes 29/29.

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

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

* Only retry the unsloth import where it can succeed

The gate still let the retry run on hosts unsloth does not support, which is
where it is most harmful: a 7 GB macOS runner lost the Studio server 26 s into a
load, and the Linux runner was torn down mid-generation. Neither MPS nor plain
CPU can complete the import, so the retry there pays the cost and fails anyway.

Require an accelerator unsloth actually supports (CUDA/ROCm via torch.cuda, or
XPU), with UNSLOTH_ALLOW_CPU as the documented override, and hoist the predicate
to module level so it is tested directly rather than through the import system.
On a CPU-only host the retry no longer fires at all; on CUDA the clean-environment
case it was added for still passes 29/29.

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

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

* Guard old diffusers, stream video exports, record conditioned recipes

Three fixes from review.

The 0.39-only pipeline classes (Flux2Klein, Z-Image, Krea 2, LTX-2,
HunyuanImage) were resolved by getattr deep in the load, so on the older
diffusers that packaging still allows on Python 3.9 -- diffusers dropped 3.9 in
0.38 and this project still supports it, so the 0.39 floor has to be conditional
or the extra becomes unresolvable -- an advertised model failed with a bare
AttributeError after its checkpoint had already been downloaded. Krea 2 already
guarded itself this way; assert_pipeline_class_available now runs the same check
for every image and video family from validation, before any fetch, and names
the version and the fix.

WebM export accumulated the whole VP9 output in a BytesIO and returned it as one
bytes object that the response held again. The request caps allow 2048x2048 for
1024 frames, so an export runs to hundreds of MB and concurrent clicks could
exhaust the process, while the MP4 route beside it already streamed from disk.
transcode_to_file encodes to a temp file and the route returns a FileResponse
with a background unlink, so nothing large is resident.

A conditioned generation's recipe carried only the txt2img fields, so the
gallery presented an inpaint or upscale result as a complete Create recipe and
restoring it replayed an unrelated text-to-image request. The images themselves
are still not persisted (user uploads with their own lifetime), but the workflow
and its scalars are, restore reapplies them, and the toast now names the inputs
that have to be supplied again instead of silently landing on Create.

Reported by Codex.

* Per-load video cancel event, family-gated image picker, cond cache refusal

A cancelled video load could resume: begin_load cleared the shared cancel
event, and unload() drops _loading without waiting for the worker, so the
next load cleared the very object the cancelled worker was watching and its
multi-gigabyte pull ran on alongside the replacement until the token check
at the end. Each load now gets its own threading.Event, passed down through
_fetch_te_prequant and _predownload_base, so a cancelled worker stays
cancelled.

A cached repo with a model_index.json was advertised as text-to-image on the
trust rule alone, but validate_load_request also requires a detected image
family, so a trusted pipeline of an unsupported class produced a picker row
that deterministically 400s. The picker now applies both gates, mirroring the
video branch.

cond_cache_dir was accepted for sdxl and then ignored: only the DiT trainer
reads it, while the SDXL trainer builds a per-run in-memory latent cache, so
the promised cross-run reuse never happened. The route now refuses it with a
400 that names the families which do support it, checked against the resolved
family so an omitted model_family with an SDXL base is caught too.

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

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

* Fix the frontend build broken by the gallery blob cache

tsc -b failed on the branch head, so npm run build produced no dist and every
platform job fell back to --api-only:

  blob-url-cache.ts(29,15): TS1294: This syntax is not allowed when
    'erasableSyntaxOnly' is enabled
  dataset-labeling-grid.tsx / dataset-showcase.tsx: Argument of type
    '{ url: string; bytes: number; }' is not assignable to parameter of type 'string'

The cache took its budget as a constructor parameter property, which the
project's tsconfig forbids, and fetchGalleryObjectUrl now returns the blob size
alongside the URL for that budget, which the two dataset thumbnail components
still consumed as a bare string. Declare the field explicitly and destructure
the URL at both call sites. tsc -b is clean and vite build emits dist again.

* Recover from a ggml unsupported-op abort by restarting on the CPU backend

ggml checks every node against the device's supports_op and calls GGML_ABORT
when one is not implemented, because a single-backend graph has nowhere else to
put it: there is no per-op CPU fallback. The whole sd-server dies with SIGABRT
mid-generation and the user gets "the native image renderer stopped
unexpectedly" with no way forward.

Seen on macos-14 arm64 with FLUX.2-klein-4B Q2_K through the cross-platform CI:
the text encoder is already pinned to CPU, and the abort moved into the denoise
loop instead.

    ggml_metal_op_encode_impl: error: unsupported op 'MUL_MAT' -> ggml_abort
    StableDiffusionGGML::sample -> sample_k_diffusion

A retry on the same backend would abort identically, so the load is restarted
once with --backend cpu (the only flag that changes which backend executes the
graph; --offload-to-cpu moves parameters, not compute) and the generation is
re-submitted. The same checkpoint then renders slower rather than not at all.
Strictly bounded: the signature must carry both the unsupported-op line and
ggml_abort, the device must not already be CPU, and it happens once per load,
so an OOM kill or a genuine crash still surfaces as itself.

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

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

* Fix two tests that only fail in a full-suite run

The 3.10 CI leg resolves PyAV 17, where av.container.OutputContainer is an
immutable C type, so the no-libopus export test died on "cannot set
'add_stream' attribute of immutable type" before it asserted anything. Inject
the refusal by wrapping the container av.open() returns instead; modules stay
patchable on every build. Removing the injection makes the test fail again, so
it still covers the branch it is named for.

The Xet shim's degraded-path tests drop utils.hf_xet_fallback from sys.modules
and import a throwaway copy. Restoring only the sys.modules entry left the
utils package attribute bound to the throwaway, and the two disagreed for the
rest of the process: a later monkeypatch of the dotted target patched one copy
while the code under test imported the other, so the patch did nothing and
test_fetch_te_prequant_only_reports_what_it_downloaded reached the real Hub and
got a 401. Restore both bindings.

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

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

* Stop an ignored-cancel sd-server, guard deletes during diffusion training, repair unusable managed binaries

sd-server does not interrupt an in-flight job, so when it ignores a cancel the
grace branch abandoned the poll and reported cancellation while the native job
kept a core (or the GPU) busy to completion and held the server's job slot. The
comment said the caller stops the server, but only unload does that
immediately: a superseding load stops it after its multi-gigabyte download, and
a load that then fails never gets there. Stop it here, as the deadline branch
already does.

DELETE /api/models/delete-finetuned checked only the LLM trainer, so it could
rmtree the output directory a live diffusion LoRA run was about to write its
adapter into. Consult the diffusion training service too, like the dataset
mutation and model-load routes.

find_sd_*_binary only checks is_file(), so an interrupted extraction (or a
prebuilt for the wrong CPU) left a present-but-unrunnable binary the installer
never retried: every load probed it, fell back to diffusers, and native
inference stayed off until the directory was deleted by hand. Probe it and
reinstall, but only for a copy under the installer-owned root -- SD_CLI_PATH,
UNSLOTH_SD_CPP_PATH, an in-tree build and anything on PATH are the user's.

* Plan the pre-cast text encoder, and make the cross-trainer GPU admission atomic

An fp8 text-encoder request loads a hosted PRE-CAST checkpoint, but the image
download plan never received text_encoder_quant, so the manager staged the base
repo's dense encoder (FLUX.2-dev's Mistral-24B is ~48 GB, Qwen-Image's
Qwen2.5-VL ~16.6 GB) and the load then pulled the pre-cast file inline, outside
the manager's progress and disk preflight. The plan now takes the field,
resolves the hosted artifact with the same resolver the injection uses, stages
that file, and drops only those components' dense weight shards. The load's own
prefetch takes the same treatment, since it paid the same cost. Only a
checkpoint that really resolves on the Hub earns the drop, so a gated or renamed
artifact still stages the dense encoder the load will fall back to.

The two trainers admitted each other with independent check-then-act guards:
the diffusion route checks the LLM backend several network-bound preflights
before it reserves, and the LLM route checks the diffusion service well before
it spawns, so two near-simultaneous starts could both pass and train on one GPU.
reserve() now re-tests the LLM backend under its own lock, and the LLM route
holds the diffusion service's gpu_load_admission across its spawn, so exactly
one of the two wins. Both halves fail open, so a chat-only install still
trains.

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

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

* Pin the Advanced options a staged download planned against

Staging does not set busy, so while a multi-gigabyte download runs the user can
still change precision, memory mode, speed or the baked LoRA selection. The
pending record held only the repo and artifact, and the completed download fired
a load that read the CURRENT state: the staged file set could then be missing
files that load needs (fetched inline, with no progress and no disk preflight)
or hold gigabytes it no longer uses.

One snapshot of every Advanced control is now taken when the plan is built, and
it travels with the pending record into the load, so the load that runs is the
one the download was planned for.

* Do not advertise a family the installed diffusers cannot build

The newer families (Z-Image, Krea 2, FLUX.2, LTX-2, HunyuanImage) exist only
from diffusers 0.39, and 0.39 cannot be installed on Python 3.9 at all --
diffusers dropped 3.9 in 0.38, so the requirement is conditional or the whole
extra becomes unresolvable. On such an environment the picker still offered
those rows, every pick failed deterministically, and the error's advice to run
pip install -U diffusers could not fix it without also upgrading Python.

The cached-repo picker now applies the same availability check
validate_load_request does, which is keyed on the pipeline class actually
present rather than on the Python version, so it is also right for an
intentionally pinned older diffusers on 3.10+. Fails open when diffusers cannot
be imported at all: that is a different problem and the load path reports it.

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

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

* Restore the diffusion engine selection after each router test

The active engine is module state, and several tests set it by plain assignment
because what _activate does to it is the thing under test, so monkeypatch could
not undo it. A leaked ENGINE_SD_CPP left get_active_diffusion_engine() handing
back the sd.cpp backend for the rest of the process, and every later route that
reads the active engine then saw an unloaded model: eight tests in
test_openai_images_generations_route.py returned 503 in a full-suite run while
passing on their own. The autouse fixture now snapshots and restores it.

* Stream gallery clips, and close three races around them

Four fixes from the latest review pass.

The video gallery downloaded each clip into a blob before it could play, so
playback waited on the whole file (tens to hundreds of MB), seeking was
limited to what had arrived, and every viewed clip stayed pinned in the
webview. The file route already streams and serves ranges; it just could not
be a <video src> because it is bearer-gated. Mint a short-lived signed link
instead (its own HMAC secret, 12 hour TTL, separate from the image links) and
hand it to the element, which then fetches only the ranges it plays. That
removes the blob budget, its LRU and every revoke on this page.

The sd.cpp readiness probe accepted any process answering on the port, so a
foreign server that grabbed the port between the bind check and the spawn was
adopted as ours. Confirm the listener is our child before reporting ready,
and stay best-effort (psutil missing, an unknown owner, or any probe error
still passes) so the check can only reject a definitely foreign process.

Dataset import held its lock for the extract but not for the upload path, so
two concurrent uploads into the same folder interleaved; take the same lock
and return 409. And reject Windows device names (CON, NUL, COM1..9, LPT1..9,
with or without an extension) plus trailing periods in dataset names, which
are unopenable on Windows.

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

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

* Build the image download plan for the engine that will load

/images/download-plan always asked the diffusers backend, while /images/load
picks the engine per host: a GGUF pick on a machine with no usable GPU routes
to native sd.cpp, which reads a single-file VAE plus text encoders and never
opens the base repo's sharded components.

Measured on unsloth/FLUX.2-klein-4B-GGUF (Q2_K): the plan staged 7.66 GB of
FLUX.2-klein-4B components the native load discards, and the 7.80 GB sd-cli
actually needs was then fetched inline by the loader, outside the download
manager's progress and its disk preflight. Z-Image-Turbo is the same shape.

The plan now asks whichever engine the load will select. predict_engine()
applies the selection policy without any side effect: it activates nothing
(staging a download must not unload the resident model) and only locates the
binary rather than installing it, but still counts an installable binary as
available, since that is what the load does on a fresh host. The native
backend gains a download_plan built from the same _asset_specs the loader
fetches, returning the same envelope, so the manager stages exactly the files
sd-cli opens.

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

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

* Do not let a queued generation outlive the model, and three scan fixes

Five items from the latest review; four were real.

An unload or arbiter eviction only cancels the generation holding
_generate_lock. A second request queued behind it holds no cancel event yet,
and Python locks are not FIFO, so it could take the lock the instant the
active denoise released it, still see a loaded pipeline, and run a whole new
denoise after the model was told to go away: the eviction then waits minutes
for it and an image lands after the eject. Unload and a superseding load now
raise a fence under _lock before they queue, and a generation that wins the
lock while one is pending refuses instead.

The cached-model scan judged pipeline completeness across every revision, so
a repo holding an older complete snapshot plus a newer companion-only one
read as complete while the snapshot from_pretrained actually opens has no
transformer. Both scans now look at the revision the loader will open.

Deleting a dataset image deleted its caption sidecar unconditionally, which
for cat.jpg alongside cat.png removed the caption the survivor still resolves
to. The sidecar now goes only with the last image of that stem, matching what
the thumbnail cleanup beside it already did.

Importing an example into a folder that holds no images but does hold files
fell back to promoting the staging dir one file at a time, so an interruption
left a partial dataset that the image_count check accepts as complete on
retry. Those files are folded into the staging dir instead and the promotion
stays a single atomic rename.

The MPS generator report does not apply: torch.Generator(device="mps") has
worked since PyTorch 2.0 (pytorch/pytorch#91348) and the studio installer
pins torch>=2.4.

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

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

* Tighten diffusion comments

Collapse the multi-line comment blocks across the image, video, sd.cpp and diffusion-training code to one or two lines each, and drop comments that only restate the statement below them. Comments only, no code or behaviour changes.

* Tighten diffusion comments (second pass)

Collapse the remaining multi-line comment blocks in the video page, training routes and service, sd.cpp server and installer, memory and speed planners, and the shared request models. Comments only, no code or behaviour changes.

* Tighten diffusion comments (third pass)

Collapse the remaining multi-line comment blocks in the attention, cache, LoRA, prequant, precision and compile-cache modules, the sd.cpp arg builder and engine, the video routes, the Ideogram 4 assembly, the model picker, and the diffusion test suites. Comments only, no code or behaviour changes.

* Restore the dataset when an example import cannot be promoted

Promotion folds the folder's pre-existing entries into the staging dir so
the swap is one atomic rename. Every failure after that fold left the user
with nothing: the 409 path and an os.replace error both fell through to
'finally: shutil.rmtree(staging)', which deleted the entries that had just
been moved in there, while the response said 'Nothing was written'. A
same-named entry was also unlinked outright before the promotion was known
to succeed.

Park superseded same-name entries in a rescue dir instead of deleting them,
record every move, and restore all of them if any step of the promotion
fails. The fold loop itself is covered too: renaming a non-writable
directory raises EACCES on POSIX, which previously escaped as a 500 after
the earlier entries had already been moved out. A failed rename now maps to
the same retryable 409 as the rmdir conflict.

Verified with the reported trigger (a non-empty mode-500 directory whose
name collides with an imported file): the folder listing is now identical
before and after the failed import.

* Drain the teardown fence on a failing unload, give each load its own cancel event

Two independent leaks on the image path, both already solved elsewhere in
the same file.

unload() incremented _teardown_waiters, ran _unload_locked() and
decremented, with no try/finally, while the superseding-load path used a
finally for the same pair. _unload_locked ends in clear_gpu_cache(), whose
CUDA branch calls synchronize/empty_cache/ipc_collect unguarded, and a
sticky CUDA fault makes those raise. The count then never drained, so every
later generation was refused as cancelled for the life of the process, a
fresh load included, since begin_load's own increment and decrement are
symmetric. unload() is reached from the chat/video GPU handoff, the engine
router and two training routes, so one fault during an ordinary handoff
wedged image generation until restart. Release it in a finally.

The image and native backends each cleared one shared cancel Event on a new
load. unload() sets that event to cancel an in-flight multi-GB download and
drops _loading in the same breath, so a replacement load is admitted while
the cancelled worker is still inside the fetch, and its clear() re-enabled
the very object that worker was watching: the cancelled download resumed
and ran alongside the replacement. Take a fresh Event per load and thread it
to the worker, as the video backend already does, and set it under the lock
since begin_load now rebinds the attribute.

* Name utf-8 on the diffusion text I/O and the sd.cpp subprocess pipes

tests/test_text_io_encoding.py failed on five files this branch adds. Text
I/O without an explicit encoding falls back to the Windows ANSI codepage, so
a non-ASCII path or manifest value round-trips corrupted, and the three
sd.cpp pipes decode the child's UTF-8 output as ANSI on Windows despite
already passing errors = 'replace'.

Eleven read_text() / write_text() sites across diffusion_compile_cache,
diffusion_ideogram4 and diffusion_krea2, plus text = True on the sd-cli
version probe, the sd-cli run and the sd-server pipe.

* Record the load-time build on a gallery image's recipe

A gallery record documents itself as the image's full generation recipe and
is embedded in the PNG, but the only load-related field it carried was the
repo id. A GGUF repo holds many quants, so that does not say which one made
the pixels, and it says nothing about an adapter baked in at load time.

The fallback meant to cover the baked case could never fire: with no loras
on the request _adjust_baked_loras zeroes every baked adapter and
_active_lora_pairs drops zero-weight entries, so active_loras was always
empty. A baked-and-disabled build is not the same pipeline as a never-baked
one, so the recipe could not reconstruct the image once the model was
rebuilt.

Persist model_kind, gguf_filename, transformer_quant and the baked adapter
names, read off the load state rather than the request, and show them in the
recipe popover. The new fields are optional with defaults, which matters
because list_gallery_images drops any record that fails validation, so a
required field would have emptied every existing gallery; a regression test
pins that.

* Drop eleven duplicated comment tails, restore the mxfp8 denial note

The comment passes collapsed several wrapped blocks onto one line without
deleting the last physical line of the original wrap, leaving the tail of
each sentence repeated as its own comment underneath. Two of the eleven were
re-worded rather than byte-identical, so a strict suffix match missed them.

6e16ad16f also dropped the line justifying the qwen-image mxfp8 denial while
that rule stayed live in _FAMILY_SCHEME_DENY, under a header that then
documented only fp8 and nvfp4. Restored.

Comments only; verified with the AST gate.

* Only let the dense-quant fallback use shards the prefetch actually staged

The prefetch skips the base repo's transformer/ shards whenever a prequant
checkpoint is expected, since that checkpoint replaces them. But a prequant
fetch can fail for reasons the planner cannot see: an unpublished, gated or
renamed artifact, a hub 5xx, a proxy, a checkpoint the validator rejects.
The loader then fell through to from_pretrained(subfolder = 'transformer')
and pulled those shards inside the load lock during 'finalizing', after the
previous pipeline was already evicted, where the cancel event has no reach,
load_progress has already reported bytes_downloaded == bytes_total, and the
cache-disk gate had only reserved the small prequant checkpoint. That is
verbatim the situation _dense_quant_prefetch_needed's own docstring exists
to prevent.

Gate the in-loader dense fallback on the shards being staged, read off the
returned file list rather than the request so a failed size estimate closes
it too, and let the GGUF build take over otherwise, which is what the
prequant-sized replan already does one branch over. It is also the invariant
the text-encoder path already enforces: only a component that really
resolves may have its dense weights dropped from a plan or a prefetch.

Adds the missing coverage for the gate's prequant arm, which the existing
disk-gate tests never reached.

* Refuse a training output_dir that resolves to the outputs root

resolve_output_dir strips a leading 'outputs' and drops '' / '.' segments,
so '.', './', './.', 'outputs', 'outputs/outputs' and ' . ' all clean away
to nothing and land on the outputs root itself rather than a run directory
under it. The DiT trainer then writes pytorch_lora_weights.safetensors flat
into the root, where scan_trained_models and scan_checkpoints cannot see it
(both filter is_dir()), and a second such run overwrites the first. The UI
only checks the field is non-empty, so 'outputs' is one plausible run name
away.

Refuse it with a 400 that says what to do instead. cond_cache_dir collapses
the same way but has an honest 'off' to fall back to, so a root-resolving
value now means the in-memory cache, which is what the comment above it
already promised: otherwise a run drops one flat safetensors per cached
latent and caption into the directory trained models live in.

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

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

* Repair three defects the main merge left behind

Git merged all three files without a conflict, but the result was wrong in
each case. The tests only started failing once the merged tree was run.

routes/inference.py duplicated the GGUF load block: this branch had moved
the gguf_load_in_flight marker and the _hub_download_blocks_gguf_load guard
under "if config.is_gguf and config.gguf_hf_repo", and the conflict
resolution re-added main's copy at the old position, so both ran. Dropped
main's copy; the earlier placement is the deliberate one, so a 409 from the
hub guard cannot tear down a resident Images or Video pipeline.

test_gpu_selection.py still called _hf_offline_if_dns_dead, which main
renamed to _hf_offline_if_unreachable_for (#7591). Disjoint edits, so no
conflict, but four route-error tests referenced a function that no longer
exists.

test_gguf_load_cache_reuse.py anchored its ordering assertion with rindex
over "if config.is_gguf:", taking the last one before the load marker. That
only held while _resolve_inherited_extra_args sat above every such line;
main has since hoisted it above the gpu_ids preflight, so the anchor landed
between the call and the marker and the assertion compared against an
unrelated later call site. The ordering it checks is a property of
_load_model_impl as a whole, so it now anchors on the function.

The ordering itself is intact: _resolve_inherited_extra_args, then the
gguf_load_in_flight marker, then the hub guard, then the chat handoff, then
unload_model.

* Stop a real sd.cpp install from breaking its own discovery tests

The five "nothing is installed" assertions in test_sd_cpp_engine.py cleared
SD_CLI_PATH and UNSLOTH_SD_CPP_PATH and patched Path.home, which covers hops
1, 2 and the fallback half of hop 3. It leaves two hops live.

Hop 3 goes through managed_install_root(), which honors UNSLOTH_STUDIO_HOME
and STUDIO_HOME and resolves to the stable-diffusion.cpp directory beside the
studio home. That is the documented way to run side-by-side Studios, so anyone
who has one set gets a real sd-cli back from a finder the test expects to
return None. Hop 4 is the in-tree developer build, which does the same for
anyone who built sd.cpp inside the checkout.

Isolated with an autouse fixture rather than another helper call, because
reaching the failure needs no fixture: SdCppEngine(binary = None) runs the
finder from its constructor, which is why test_generate_raises_when_binary_missing
failed too. The fixture points the studio home and the in-tree root at an
empty tmp tree, so every hop is answered by the test rather than by the box.

The in-tree root moves into a named in_tree_install_root() so it can be
pointed somewhere empty; behaviour is unchanged, including the OSError and
IndexError guard on an unexpected layout.

* Make the sd.cpp uninstall test actually extract the code it tests

The two sed ranges anchored the production fragments at column 0, but both
blocks sit inside the main removal function and are indented, so each range
matched nothing and LOOP_FILE / DEFAULT_FILE came out empty. Sourcing an
empty file is a no-op, so the suite reported 4 passed / 7 failed: the seven
removal assertions failed because nothing ran, and the four that passed were
'kept' assertions that pass trivially when nothing runs.

The shell job auto-discovers tests/sh/test_*.sh and runs each under set -e,
and this file is not in its skip list, so it was a deterministic red.

Anchor on optional leading whitespace and fail loudly on an empty or
_remove_path-less fragment, so a future reshuffle of uninstall.sh cannot
make the suite vacuous again. Now 11 passed, 0 failed, against the real
removal loop and the real default-mode block.

* Agree on what an engine can build, and on what the installer owns

Two gates, each half-applied.

The unbuildable-family gate had one caller, the image branch of the cached
repo picker. The GGUF classifier, the local-model classifier and the video
branch had none, so on a diffusers too old for a family the picker still
offered its GGUF and the load then failed. Meanwhile the loader asserted the
diffusers pipeline class before engine selection, so a GGUF this host routes
to native sd.cpp, which instantiates no pipeline class at all, was refused
with an upgrade instruction that could not help. Both are wrong one-sidedly:
hide a family only when NEITHER engine can build it, and demand the diffusers
class only when diffusers is what will load it. One predicate,
family_buildable_here, now answers both, so the picker and the loader cannot
disagree. The population is real on Python 3.9, whose diffusers ceiling is
0.36: Flux2KleinPipeline, Krea2Pipeline and LTX2Pipeline are all absent
there, and FLUX.2-klein GGUF is a repo in this PR's title.

That assertion also raised RuntimeError, which /images/load maps to 409, the
status that otherwise means a load is already in progress, and which escaped
/images/download-plan (it catches ValueError and FileNotFoundError) as a bare
500 with the message lost. It is an unloadable pick like every other, so it
raises ValueError and both routes answer 400 with the text intact.

The managed-binary repair used a path test for ownership while the installer
uses a marker. On a managed root without the marker, which is any install
predating it, the repair deleted sd-server and the reinstall was then
refused, permanently, because the surviving sd-cli keeps the directory
non-empty so the marker can never be claimed: the user went from an
unrunnable binary to no binary and no way back. Require the marker before
discarding, which is the same definition of ours that uninstall.sh already
uses to keep a user's own stable-diffusion.cpp checkout. A genuinely
interrupted extraction still self-heals, since install() writes the marker
before it extracts.

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

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

* Add gguf and av to the studio extra so the wheel install matches studio.txt

This branch added both to studio/backend/requirements/studio.txt, which is
what install.sh uses, but never to the studio extra in pyproject.toml, which
is what 'pip install unsloth[studio]' uses. Nothing else keeps the two in
sync, so tests/studio/install/test_studio_extra_matches_requirements.py
failed deterministically, and the CI job runs the whole tests/ tree.

The drift is not only a red test: gguf backs diffusers' GGUFQuantizationConfig
and av does the MP4 encode and audio mux, so a wheel install got a Studio
that cannot read a GGUF or export a video, which is most of what this PR
adds.

* Fix two test-suite defects: the CPU-only patch gate and a popped module

The patch backend reaches unsloth_zoo's helpers only through an ~940 MB
'import unsloth', which a CPU-only host cannot complete, so every patch
install returned False and nine arch/eager/compile tests failed there --
exactly the runners the retry was narrowed to protect. unsloth_zoo only
wants UNSLOTH_IS_PRESENT in the environment, which costs nothing and needs
no accelerator, and the conftest already sets its sibling UNSLOTH_ALLOW_CPU.
Set it there too, as run.py and main.py already do at module scope, so no
real server ever took the expensive route. CPU-only goes from 9 failed to 24
passed, 1 skipped; the GPU run is unchanged at 29 passed.

test_setup_cache_env_hf_home popped utils.hf_cache_settings to model a fresh
process and never restored it, so a later import built a second module object
and rebound it on the utils package. test_hf_cache_settings then wrote its
setting into one object while core.inference.diffusion read the other, the
same split-module failure the xet shim already had. Restore both bindings in
teardown: 21 pass together, and each file still passes alone.

* Restore the setup_fail assertions the main merge reverted

A fourth instance of the class 9541cc535 fixed: the merge took main's
studio/setup.sh, which #7644 changed to abort through the setup_fail helper
so desktop mode still emits [TAURI:ERROR], but kept this branch's older copy
of the test, which still asserted the literal 'exit 1'. setup.sh is
byte-identical to main here and the only diff in this file was the reverted
assertions, so take main's version. 60 passed.

* Fence the video teardown, reject non-finite clipping knobs, stop a test installing sd.cpp

The video backend never had the teardown fence the image backend documents.
Its unload and its superseding-load path both signalled the active
generation, then did 'with self._generate_lock: pass' and tore the state
down with the lock free. A generation queued behind that barrier holds no
cancel event yet, so the signal cannot reach it, and Python locks are not
FIFO: it won the lock the instant the barrier released it, read a
still-loaded state and denoised a whole clip against the pipeline being
freed. Reproduced on both paths, where the queued generation returned a
finished MP4. Mirror the image backend: count waiters, refuse a generation
while one is pending, and tear down inside the barrier with the counter
released in a finally. _teardown_state becomes _teardown_state_locked since
the caller now holds both locks.

max_grad_norm and snr_gamma were bounded on one side only, so 1e309 floated
to inf and passed. clip_grad_norm_ then computes an infinite clip
coefficient, clamps it to 1.0 and scales nothing, and min(snr, inf) / snr
makes every min-SNR weight 1.0. The run starts, reports normal progress and
trains with the requested knob silently disabled. Measured both. NaN already
failed the bounds; allow_inf_nan makes that explicit. learning_rate and
flow_shift already guard this exact vector.

test_load_routes_to_sd_cpp_on_cpu stubbed ensure_sd_cpp_binary but not
ensure_sd_server_binary, which select_and_activate_engine probes first with
installs enabled, so the unit test downloaded and unpacked 108 MB into the
developer's real ~/.unsloth when UNSLOTH_STUDIO_HOME was unset. Now 0 bytes.

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

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

* Key the video schedule reset on the resolved defaults, not the repo id

A GGUF repo holds several variants, so another client swapping a distilled
build for the base one from the same repo changes the steps and guidance the
backend reports while repo_id stays put. modelChanged stayed false, the page
kept the previous schedule, and later generations ran an 8-step distilled
setting on a model that expects roughly 40 steps and CFG 4.

Include the reported defaults in the key. They only move when the resident
artifact does, so a manual steps change still survives a status poll.

* Retire the release-lag pin now that 2026.7.6 carries the relaxed gates

The virgin Windows container row that installs from PyPI on purpose was
tolerated with continue-on-error, because a Server Core container has no
Microsoft Store and so no winget, and the released studio/setup.ps1
hard-stopped on a winget-only git gate and reached for winget again for the
VC++ runtime. #7549 relaxed both, but it landed on 2026-07-28 and the newest
wheel was 2026.7.5 from the 23rd, so the row could not get past it.

The pin carried its own tripwire for exactly this moment, and it has fired:
unsloth 2026.7.6 shipped 2026-07-29 and the released wheel now installs end
to end, so the step reported success and the assertion errored with "delete
this pin and drop continue-on-error from the Install step so this row gates".

Doing that. Both rows now gate unconditionally, and the assertion that
enumerated the accepted failure signatures goes with the pin it protected,
since there is no longer an accepted failure for it to describe.

The other three release-lag mentions in this file are descriptive rather
than tolerated-failure pins: they explain why the fedora and ubuntu-nonroot
legs run with overlay: true, and both already refuse a triton failure as a
regression rather than accepting it as lag. Left alone.

* Pin the hosted pre-quant onto the plain-torch fp8 kernel

A Z-Image GGUF pick at the DEFAULT speed mode, once the hosted fp8 pre-quant
is actually reachable, dies at generate with an HTTP 500:

  torch._dynamo.exc.Unsupported: Operator does not support running with fake
  tensors. Developer debug context: unsupported operator:
  mslk.f8f8bf16_rowwise.default

_fp8_config already pins KernelPreference.TORCH when it BUILDS a config,
precisely because the default AUTO switches to the MSLK kernel wherever an
mslk package is importable (sm90+). A hosted checkpoint escapes that pin
completely: the preference is serialized per Float8Tensor, and all 239
weights in the published Z-Image-Turbo-FP8 checkpoint carry AUTO. Loading it
re-arms the very kernel the pin exists to avoid, mslk.f8f8bf16_rowwise has no
fake impl, and the first compiled generate therefore cannot be traced.

Isolated away from the product to be sure of the mechanism: quantise one
Linear three ways on this box and compile each.

  KernelPreference.TORCH   eager ok, compiled ok
  KernelPreference.AUTO    eager ok, compiled FAILS on mslk.f8f8bf16_rowwise
  library default          eager ok, compiled FAILS the same way

So the pin is correct and necessary, and the only gap is that the hosted path
never got it. _validate_checkpoint checks scheme, granularity, base model,
min_features, exclude tokens and fast_accum, but not this.

Rewriting the preference on load is safe: it selects a matmul kernel, it is
not weight data, so the tensors stay bit-identical and the checkpoint's own
state_dict_sha256 still describes them. It is also the faster path compiled,
since the opaque extern call blocks inductor quantize fusion.

Why this went unnoticed: the pre-quant repos are private, so nothing that
could not read them ever took this path. It becomes the default the moment
they are readable.

Verified end to end at 1024x1024, 8 steps, speed_mode default, on the
published checkpoint:

  before  HTTP 500 at generate, diffusion.generate_failed
  after   pinned 239 weights to the plain-torch fp8 kernel, load 10.0 s,
          transformer_quant fp8 with compiled engaged, cold 7.9 s, warm
          0.87 0.87 0.87 0.86 0.87 s

That warm number also beats the 1.4 s recorded for this shape on 2026-07-26.
159 prequant and transformer-quant tests pass.

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

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

* Cover the fp8 kernel pin so the 500 cannot come back quietly

The hosted pre-quant re-arming MSLK was only visible as an HTTP 500 on a
compiled generate, which needs a GPU, a readable private repo and an
importable mslk to reproduce. None of that is available in CI, so the pin
would rot unnoticed.

Three hermetic cases on _pin_kernel_preference instead: AUTO weights are
rewritten and already-TORCH ones are left alone (counting only what changed),
a weight that refuses the assignment does not sink the whole load, and with no
torchao enum available the checkpoint is left exactly as saved.

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

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

* Studio: tighten comments across the image generation, video and training code

Rewrite the comments added by this branch to be shorter and clearer:
collapse multi-line explanations into one or two lines, drop comments that
only restate the code, and keep the rationale that explains why a choice was
made. No code changes.

* Studio: let the pipeline-class guard survive a host with no diffusers

Backend CI installs the CPU-only dependency set, which has no diffusers, and runs
without a GPU. 13 tests failed there for three separate reasons, one of which is a
real product bug rather than a test-environment gap.

assert_pipeline_class_available did a bare "import diffusers", so on any host
without it the call raised ModuleNotFoundError instead of the ValueError its own
contract promises. /images/download-plan catches only ValueError and
FileNotFoundError, so that escaped as a bare 500 with the message lost, which is
the exact failure the guard exists to prevent. It is reachable in production: the
native sd.cpp engine serves GGUF picks on a CPU or Apple host where diffusers is
never installed. Absent diffusers now returns, since the check answers "is the
installed diffusers new enough for this family" and there is no version to judge;
a pick that genuinely needs diffusers still fails in the loader with its own
message.

The rest were tests asserting through gates unrelated to what they cover:

- Three cond_cache_dir route tests drive a DiT family, which the start route
  refuses without an accelerator. They now take the existing dit_train_host
  fixture, written for this case, so they keep testing the schema on every host.
- test_download_plan_forwards_the_load_time_controls stubs the diffusers planner
  but sends a GGUF pick, which routes to the native planner on a GPU-less host.
  It now pins the engine, so it tests kwarg forwarding rather than the hardware.
  Engine selection keeps its own tests.
- The pipeline-class guard test needed a real diffusers only for its sweep over
  every shipped family. That half is split out and skips on its own; the
  stub-driven refusal needs no diffusers and still runs. Added a test for the
  absent-diffusers contract above.

409 pass on a GPU host with diffusers; 301 pass and 1 skips with diffusers
blocked and no GPU.

* Studio: cut the image generation comments down further

Second pass over the comments this branch adds. Collapse the multi-line
blocks that still read as paragraphs, rewrite the longest one-liners so
they say the same thing in fewer words, and drop a stale comment that had
drifted away from the constant it described.

No code changes: only comments and whitespace.

* Studio: workflow rail, sidebar Video row, and Train settings polish

Sidebar
- Pin Video under Images by default; persist store bumped to v3 so an
  untouched install adopts it while a custom arrangement is left alone.
- Add "Customize sidebar" to the end of the More flyout, opening
  Settings > Appearance scrolled to the sidebar nav section.

Images
- Replace the workflow dropdown with an icon rail down the left edge:
  all seven workflows visible, keyboard nav, tooltips carry the labels.
- Workflows stay selectable with no model loaded, so one can be set up
  before picking a model. Generate is already gated on a loaded model.
- Video moves out of the Create/Train strip into a link at the far right,
  with a matching Images link on the Video page.
- Wider gutters around the settings column, headings matched to Train.

Train
- Field guidance moves into "i" tooltips; only state that limits a
  control stays on the page. Steps, LoRA rank and the rest gain hints.
- /info reports params, qlora_vram_gb, gated and note as fields, so the
  family note renders as chips. vram_note is rebuilt from them unchanged
  for older clients.

Shared
- Number steppers appear on hover or focus.
- Lighter control border (#e9e9e9) on Images and Video, light mode only.

* Studio: move Advanced inline on Images and Video

- Replace the top-bar toggle and right-docked Advanced panel with a
  disclosure under Seed, so load-time tuning sits with the settings it
  affects and opening it no longer shrinks the preview canvas.
- Video uses the bordered variant, since offload and memory decide
  whether a model fits at all.
- Open state persists per page in localStorage.
- Tighten the Steps unit trigger: 14px chevron, less right padding.

* Studio: full width media pages and layout polish

- Drop the 1100px cap on Images Create, Images Train and Video, so the
  preview canvas grows with the window instead of sitting in a band.
- Align the workflow rail to the model selector label, widen the gap
  before its divider, and restore the divider itself.
- Advanced: more room above and below, icon and label sized to the
  slider rows, and the same quiet row on Video as on Images.
- Give Video a pane heading and description, matching Images.
- More space above Seed on both pages.
- More no longer takes the active style when the current page is one of
  its own rows.

* Studio: fix the Extend side toggles

The resting outline used a ring, and index.css blanks the ring on the
button holding mouse focus, so the side you just clicked lost its border
until focus moved. Use a border for the outline and leave the ring to
focus-visible.

Also separates the two states properly (the off state had no surface of
its own), drops the border in dark mode as the inputs do, and adds the
aria-pressed and focus-visible styling the buttons were missing.

* Studio: drop the border on the Extend side toggles

Light mode should carry no border either, so the fill alone marks the
state: a muted surface when off, a primary tint when on. Matches the
borderless treatment dark mode already had.

* Studio: move the Images workflows into the sidebar

The workflow switcher was a vertical icon rail on the page. It now lives
in the sidebar under Images, so the page keeps that width for the canvas.

- Workflows list under the Images row, with a chevron to fold them away.
  Hovering the row peeks them in a flyout on the standard menu surface.
- Clicking Images while already there toggles the list instead of
  navigating.
- The listed workflow carries the highlight, so the Images row drops it.
- Create takes its own icon: it was sharing the New chat pencil.
- Model hub moves above Projects. Persisted layouts bump to v4 and only
  adopt the new order where the stored one is still a shipped default.
- Images and Video content both start at 32px, clear of the sidebar.

* Studio: simplify the Images workflow submenu

Dropping the hover flyout: two ways to reach the same seven workflows,
one of them overlapping the list right below it, read as clutter.

- The flyout is gone. The workflows are rows under Images and nothing
  else.
- On the Images page they are always open, since they are that page's
  switcher. Elsewhere they stay folded, and the row's chevron opens
  them.
- Create takes the sparkles icon.
- Create and Train both pad their settings column to 40px a side, so
  the two tabs line up with each other and with the model selector.

* Studio: dock the generate action and tidy the Create controls

The primary action sat at the foot of a long scroll, so it was off screen
until you scrolled for it.

- Generate, Video's Generate and Train's Start training float at the
  bottom of their settings column. No bar behind them: hover lightens the
  fill rather than thinning it, and the disabled state is opaque, so the
  controls underneath never show through.
- Aspect ratios read as names: Square (1:1), Widescreen (16:9) and so on.
- Width and height replace their sliders with two compact boxes. Type a
  size or pick one from the menu; the value still snaps into range and
  still drives the locked ratio.
- Negative prompt gets a quiet disclosure under the prompt. It was there
  before but only above guidance 0, which is not the default, so it never
  showed.
- The info "i" is smaller across the settings UI.

* Studio: reveal Images and Video field hints on hover

The "i" next to every field label sat there permanently, which made a
column of settings read as busier than it is.

Scoped to the diffusion pages by CSS rather than per component: the hints
come from the page's own Field and SelectRow, the train panel's
FieldLabel, and the chat ParamSlider, which is shared and should not
change for chat. Reveals when the pointer is inside the field, on
focus-within for keyboard, and stays inert while hidden.

InfoHint grows a data-slot so the rule has a stable hook, rather than
keying off its aria-label.

* Studio: one-line sliders, edge fades, shared negative prompt

- Steps, Guidance and the rest put label, track and value on one row.
  ParamSlider is shared with chat, so this is an opt-in prop and chat
  keeps the stacked layout.
- The settings columns fade at whichever edge they run past, as the
  sidebar and model picker do, instead of cutting off. New
  useScrollFades hook drives all three.
- Negative prompt moves to a shared component and Video picks it up, so
  both pages collapse it the same way.
- Right gutter sits closer to the rule, Steps takes a bigger break above
  it, and the Advanced rule sits between its neighbours rather than up
  against the field above.

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

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

* Studio: add the API monitor to the sidebar nav

Sits under More, below Export. Points at the monitor page, not the API
keys dialog the profile menu opens.

No store version bump: an id missing from a stored layout is appended
with its default pinned state, so existing sidebars gain the row at the
end and keep their own order.

* Studio: take main's copy of the sidebar spinner test

The merge kept this branch's older version, which pins the Recents row
to h-[33px]. Main already relaxed that to any height, since row density
moves independently of the trailing column the test is about.

* Studio: fix FLUX.2 klein size resolution, HunyuanVideo tier routing, and the personalization save

Five fixes found while auditing the model registries against the live Hub.

FLUX.2 klein 9B loaded against the 4B config. One family covers both klein sizes and defaults
to 4B, relying on the base_model card tag for the real base, but the trust allowlist only had
klein-4B, so the correct tag was discarded and a 9B GGUF (inner_dim 4096) was loaded against a
4B config (3072). That surfaced as a bare shape mismatch from inside the GGUF quantizer naming
neither the file nor the repo. Adds the missing allowlist entries and a header-level size check
that fails early with a legible message; the check is fail-open, so an unreadable file, a
non-FLUX.2 family, or an unmapped base leaves the load exactly as it was. The sd.cpp text
encoder rule matched the literal "klein-9b", so klein-base-9B was handed the 4B encoder.

HunyuanVideo-1.5 720p checkpoints routed to the 480p family. Only the literal 720p_t2v path was
aliased, so 720p_i2v and every GGUF repack fell through to the generic token and inherited the
480p base repo, which also supplies the VAE and text encoder. The tier is baked into the
weights (transformer target_size 640 vs 960, scheduler shift 5.0 vs 9.0) and the two bucket
lists are disjoint, so this ran the whole pipeline off-tier.

Resolution presets that the checkpoints were never trained for. Wan2.2 TI2V-5B is 720P-only
upstream (SUPPORTED_SIZES is exactly 704x1280 and 1280x704, asserted in generate.py), and the
HunyuanVideo 480p square preset 624x624 is not a bucket of its tier; 640x640 is.

Personalization could never be saved. The frontend ships eight sidebar nav ids and refills every
missing one on each save, but the backend Literal had seven and no "api", so every PUT to
/api/settings/personalization returned 422. Aligns the server defaults and the Literal with the
shipped layout.

A catalog row pointing at a file that does not exist. unsloth/Qwen-Image-2512-FP8 holds torch
prequant .pt checkpoints, not qwen-image-2512-fp8.safetensors, and fp8 is denied for this family
anyway because it renders black, yet the row was the auto-route target on any GPU above 40 GB.
Also marks FLUX.1-schnell, Krea-2-Turbo and the two Ideogram repos gated: all four are gated on
the Hub today, and schnell being Apache-2.0 does not make it ungated.

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

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

* Studio: stop a case-only duplicate upload destroying an image, and size the load against the right GPU

Two fixes from the review round.

A dataset upload containing two names that differ only by case silently loses one on Windows and
macOS. The in-batch duplicate check is an exact string compare, so "Cat.png" and "cat.png" both
pass, but on a case-folding filesystem they are one destination: the commit step moves the first
staged part aside, writes the second over it, then deletes the backup, while the response still
reports both as uploaded. Captions collapse the same way and never even reach the image branch.
No single folder can hold such a pair, but the Windows and macOS open dialogs flatten search and
Recents results across folders into one multi-selectable list, which is a normal way a LoRA
dataset gets assembled.

Rejecting the pair everywhere would regress Linux, where the two really are different files with
their own sidecars, so the check probes the filesystem once per process instead of keying off
sys.platform: macOS also ships case-sensitive APFS volumes, and a Linux host can keep its Studio
home on an exFAT or NTFS mount. A failed probe answers "case-sensitive", which leaves today's
behaviour untouched.

The quantization fit budget was sized against the wrong card under a reordering
CUDA_VISIBLE_DEVICES. A bare "cuda" load lands on visible ordinal 0, but the reducer took the
lowest PHYSICAL index, which the nvidia-smi path reports as index_kind "physical". Under "3,1"
that sizes against GPU 1 while the pipeline loads onto GPU 3. The hook's own interface doc
already promised the lowest visible ordinal. Ranks by visible_ordinal, falling back to index
only for an older backend that omits it.

* Studio: rework the diffusion dataset panel and align the pane gutters

Upload:
- one Upload button beside the dataset name, with an upload icon
- picking files uploads them, no second confirm click
- Add button beside the Training images dropdown, so images can go into a
  set that already exists
- fall back to the upload form when a selected dataset no longer resolves

Labeling grid:
- two columns at any width; sm:grid-cols-3 crushed the tiles in a fixed
  width column
- pin the caption size so the Textarea's md:text-sm does not outsize a tile
- name the tile hover group; a bare one revealed every tile's Remove at once
- Remove is an icon button on the image, not a word over the artwork
- drop the amber tile fill, keep the No caption label
- match the header, toggle and status text to the section's type scale

Gutters:
- the run area and the preview canvas now sit 40px off the rule, the gutter
  the settings column has off the page edge

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

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

* Studio: give the dataset preview per-image hover and remove, and wrap it onto rows

- each thumbnail is its own button, so it can carry an X that removes just
  that image; the strip was one button and could not nest a second control
- hover lightens the image instead of drawing a border
- wraps onto more rows rather than scrolling sideways, which also keeps the
  bottom corners rounded
- samples 12 thumbnails, up from 8, now that a second row is available
- a delete refreshes the panel's dataset counts

* Studio: show the workflow icon beside the Images and Video headings

Images reads it off WORKFLOW_TABS, so it is the same icon the sidebar
submenu shows and it follows the active workflow. Video's heading is also
Create, so it takes the Create icon rather than the nav row's film slate.

* Studio: reject a duplicate LoRA id on the diffusion load path too

DiffusionGenerateRequest already refuses a repeated adapter id, with a comment naming the
hazard: _resolve_lora_set suffixes colliding adapter names, so the same id resolves the SAME
adapter twice and set_adapters stacks both copies past the per-adapter weight bound.
DiffusionLoadRequest bounds only the list length, and it matters more there. Generation-time
stacking spoils one image; on the load path the adapters are baked into the quantized build
before compilation, so the unintended combination rides every image until a reload.

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

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

* Studio: teach the GGUF reuse test double about holds_no_vram

The resident-GGUF fast path consults llama_backend.holds_no_vram before asserting CHAT ownership,
but two tests hand it a SimpleNamespace built before that attribute existed, so they raised
AttributeError and returned a 500. A real LlamaCppBackend exposes it as a property; the doubles
now carry it too. Product code is unchanged: these pass on main and were the branch breaking its
own test, not a defect in the guard.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: michaelhan <michaelhan2050@gmail.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: Michael Han <107991372+shimmyshimmer@users.noreply.github.com>
2026-08-04 08:11:01 -07:00
Daniel Han
2af7c06159
Studio frontend: override brace-expansion 5.0.8 and fast-uri 3.1.4 (#7781)
Clears four high-severity advisories in the build and lint tooling:

  brace-expansion  GHSA-3jxr-9vmj-r5cp / CVE-2026-13149
                   GHSA-mh99-v99m-4gvg / CVE-2026-14257
  fast-uri         GHSA-4c8g-83qw-93j6 / CVE-2026-13676
                   GHSA-v2hh-gcrm-f6hx / CVE-2026-16221

Both packages are transitive only, so this goes through the existing
overrides block rather than adding phantom direct dependencies. The
stale brace-expansion@5.0.5 pin is replaced: it was holding the two
5.x copies at 5.0.6 and blocking npm from resolving them forward.

Nothing here reaches the browser bundle. brace-expansion arrives via
minimatch under eslint, typescript-eslint and shadcn/ts-morph;
fast-uri via ajv under shadcn. Patching them keeps the npm audit and
osv-scanner jobs green, which run without --omit=dev by design.

5.0.8 and 3.1.4 are the newest releases older than the 7 day
min-release-age floor in studio/frontend/.npmrc.

Still outstanding: the dev-only 1.x line, node_modules/brace-expansion
1.1.14 via eslint minimatch@3, needs 1.1.17+ for CVE-2026-14257.
1.1.17 is 4 days old, so it lands in a follow-up once it clears the
cooldown.

npm audit goes from 11 findings / 5 high to 9 / 3. typecheck, tests
and build all pass.

Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-03 06:45:11 -07:00
Wasim Yousef Said
65b4d9d9e7
Add Unsloth desktop deep links (#7560)
* Add Unsloth desktop deep links

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

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

* Address deep-link review feedback

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-28 16:52:53 +02:00
oobabooga
fc861cc870
Studio: preserve durations across reasoning blocks (#7520)
* Studio: preserve durations across reasoning blocks

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

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

* Studio: keep a reasoning group's timer running when it reopens

A rendered reasoning group can be closed and then reopened: parseAssistantContent
coalesces adjacent reasoning parts, so a provider that emits each block as a
complete <think>...</think> chunk lands several blocks in one group. The tracker
wrote a group's duration once and never revisited it, so such a group froze at
its first close and displayed 0 seconds.

Measure from the first time an index becomes visible rather than from the last
startGroup, and reopen a closed group while its reasoning text is still growing.
Gating on growth is what stops the timer running on into the answer. A duration
supplied by the server is now recorded as authoritative so local timing cannot
overwrite it.

Also fill indices that a single delta skips. startGroup(n) could jump past
earlier indices and leave array holes, which JSON.stringify persists as null; a
skipped group became visible and closed inside the same chunk, so it gets a
measured zero instead.

Test discovery now globs tests/, so a second test file cannot be silently
skipped by CI, and tsconfig.test.json puts tests/ under typecheck for the first
time.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-07-28 04:53:26 -07:00
Michael Han
bc23135996
Unsloth: appearance palettes, customization options, and control restyle (#7077)
* Unsloth: appearance palettes, customization options, and control restyle

Adds Standard, Classic, and Minimal color palettes to Appearance settings,
each adapting to light and dark mode. Classic is a neutral enterprise look
that reserves its blue accent for toggles, badges, and focus rings; Minimal
is strictly black, grey, and white.

Adds customization options scoped to the active mode: accent, background,
and foreground colors with an in-app color picker, UI and code fonts with a
searchable dropdown covering bundled, device, and imported fonts, font file
import, UI and code font sizes, contrast, pointer cursors, reduce motion,
font smoothing, and translucent sidebar. Settings persist through the
personalization API with backend validation and sync across devices.

Restyles core controls for a cleaner, flatter look in both modes: bordered
white input fields, fully rounded pills for single-row controls, no drop
shadows, simple straight-line chevrons replacing all rounded arrow icons,
and consistent hover tones in dropdown menus. Popovers now portal into the
open dialog so their lists scroll correctly inside modal dialogs.

Moves Language into General settings and Chat defaults into the Chat tab
above the Canvas section.

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

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

* Unsloth: appearance follow-ups, font options, and settings search

Neutralizes focus and selection rings across all palettes so highlighted
elements, including typing boxes and the selected palette card, never take
the accent color. The custom accent no longer recolors rings.

Restyles the color controls as filled pills showing the hex value inside,
with text and border contrast picked from the color's luminance. Menus in
popovers now match the app's dropdown menus: rounded-lg corners, tighter
padding, accent hover rows, and a bordered search field. Popovers inside
modal dialogs are modal so their lists scroll with the wheel. Outline
buttons share the same dark fills as dropdown triggers.

Adds heading and chat font options next to the UI and code fonts, each
using the searchable font dropdown and persisting through the
personalization API. Removes the translucent sidebar option end to end.

Adds settings search: a search field at the top of the settings sidebar
that filters setting names across every tab, grouped by tab with icons,
and jumps to the tab on click.

* Unsloth: use the shared accent token for dark hover fills

The settings dialog nav, its close button, the model selector, and the
project switcher hovered with hardcoded blue tinted greys (#3a3d43,
#2d2e32) in dark mode while every menu and sidebar uses --accent. All
hover and active pill fills now use the accent token so dark hovers are
the same everywhere and adapt to the active palette.

* Unsloth: settings search polish and jump to matched setting

Widens the settings dialog to 880px and the sidebar column to 248px so
the search field has more room. The search pill aligns with the left
start of the Settings title, gets more spacing above and below, and its
icon and placeholder sit slightly further left.

Search results now jump to the exact setting: rows and sections expose
their label as a data attribute, and picking a result opens the tab,
scrolls the matched row into view, and flashes it briefly.

* Unsloth: settings search bar spans the full nav pill width

The search field now starts and ends at the same edges as the nav hover
pills instead of being inset to the title text.

* Unsloth: address review findings on motion, sync, and font limits

Reduce motion Off now opts back out of the OS reduced-motion preference
for CSS animations via a force-motion class that the media rules skip,
and forcing reduce motion On keeps the loader exceptions (spinners,
loading dots, progress bars) animating.

When the color scheme follows the system, the resolved mode is now part
of the theme store snapshot, so an OS scheme flip re-renders consumers
and reapplies per-mode custom colors instead of leaving stale inline
variables from the previous mode.

Imported fonts get an aggregate size cap (4.4M characters) on both the
frontend sanitizer and the backend model so the persisted store always
fits browser localStorage quotas, with a clear error toast when an
import would exceed it. Backend validation also tightens imported font
names (rejects CSS delimiter characters) and requires strict base64
font data URLs, matching the frontend patterns.

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

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

* Unsloth: profile toggle to hide the sloth in the chat greeting

Adds a Show greeting sloth switch to Settings > Profile. The chat welcome
hides the mascot when it is off. The preference persists locally and
through the personalization API, with backend validation and tests, and
the row is reachable from settings search in all four locales.

* Unsloth: control restyle, dropdown scrolling, and palette consistency

Settings sidebar puts search on top with the tab list under a small
Settings label. Combobox popups scroll with the wheel inside dialogs by
falling back to manual list scrolling while a dialog scroll lock is
active, and the local model selector popover became modal for the same
reason. Number inputs swap native spinners for a shared grey stepper
that clamps to min, max, and step. Run settings fields in light mode use
the same white fill and border as the settings dialog. Selection and
focus rings derive from each palette's border color instead of near
black, hover borders soften the same way, the Classic sidebar stays
white like Standard, decorative greens follow the palette accent, and
meaning-carrying marks like the hub verified badge keep the brand green
in every palette.

* Unsloth: palette card selection keyed off the palette attribute

Switching palettes restyles the whole page the moment data-palette lands
on the html element, but the React re-render that moves the selection
classes arrives later, so the ring and check briefly stayed on the
previous card with the new palette's colors. The active ring and check
now key off html[data-palette] in CSS, so they swap in the same style
pass that swaps the tokens. Also adds breathing room around the settings
search bar and under the Settings label, shortens the greeting sloth
description, and renames the avatar section to Or pick a sloth profile
picture in all locales.

* Unsloth: restore neutral rings, drop the palette check, sidebar spacing

Puts the ring tokens back to their fixed per palette values and removes
the hover border darkening, undoing the derived border experiment. The
selected palette card no longer shows a check since the ring already
marks it. The settings sidebar search bar, nav pills, and search results
get a little side padding, and the Settings label lines up with the pill
text.

* Unsloth: indicator restyle, sidebar menu customization, edge fade toggle

- Derive focus and selection rings from the border color so indicators
  stay 1px and adapt to every theme and palette
- Suppress mouse focus rings except on pressed controls to remove the
  selection flash on the avatar and palette pickers
- Defer settings panel rendering so the active nav pill updates instantly
- Customizable sidebar user menu with drag to reorder and shortcuts to
  the settings tabs
- Grey hover for the standard light palette instead of green
- Borderless controls in dark mode with fill based focus states
- Profile picture: no picture option, pencil edit icon, atomic selection
- Font dropdowns: narrower triggers and the resolved default shown as
  Inter Variable (Default)
- System prompt border darkens on focus
- New appearance setting to swap edge fades for thin divider lines
- Move the theme bootstrap to an external script to satisfy CSP

* Unsloth: harden theme boot and Firefox scroll container focus

- Guard the theme and palette storage reads separately so a blocked
  localStorage (private browsing) still resolves a mode from the OS
  preference instead of skipping the boot entirely
- Firefox makes scrollable containers keyboard focusable and drew its
  3px UA outline on them; swap it for the app's soft 1px indicator

* Unsloth: make the UI and code font settings reach the font utilities

The theme block declared the sans and mono stacks as literals, so
Tailwind inlined them into every font-sans and font-mono utility at
build time and the runtime overrides from Settings > Appearance never
applied. Reference the :root tokens instead, matching how the color
tokens already work.

* Unsloth: in-dropdown font upload, accent meters and avatar, naming cleanup

- Move font importing into each font dropdown: Upload and Select folder
  sit side by side under the list, imported fonts get an inline remove,
  and the standalone Import font row is gone
- Uploads reuse fonts the user already has (bundled, imported, or
  installed, matched by file name with style suffixes stripped) instead
  of embedding a duplicate copy; only new fonts are embedded
- Folder scan lists font files from a picked folder in every dropdown
  for the session; picking one imports it through the same path
- Fallback avatar uses the control accent with a readable foreground
  instead of the neutral primary that rendered black outside standard
- Monitor bars, progress defaults, sliders, and usage meters use the
  control accent; warning and danger tiers stay amber and red
- User facing strings that called the app just Studio now say Unsloth
  in all four locales, keeping Unsloth Studio and LM Studio intact

* Unsloth: left align the font upload actions and divide them

Upload and Select folder now read from the left like the list items,
with a short vertical rule between the two.

* Unsloth: keep sliders neutral and the chat greeting on Hellix

- Sliders are controls, not meters, so their fill goes back to the
  neutral primary instead of the palette accent
- The base h1 rule reads --font-heading with !important and the chat
  thread root resets that variable to the sans stack, which pulled the
  greeting off Hellix; restore the stack on the greeting element

* Unsloth: move the None avatar cell last and keep footer actions on one line

- None sits after the sloth pictures instead of leading the grid
- Upload shrinks to its label so Select folder no longer wraps

* Unsloth: size the folder action to its label

Both footer actions now hug their content so the hover pill does not
stretch across the leftover row width.

* Unsloth: separators only between unrelated settings clusters

Rows inside a titled section are related, so the per row divide-y is
gone from SettingsSection. A SettingsGroupDivider marks the two real
boundaries in the theme section (colors to fonts, fonts to contrast)
and the Clear all chats row gets its destructive border back now that
divide-y no longer draws one for it.

* Unsloth: balance the two font upload actions

Both actions share the footer row evenly again; nowrap keeps Select
folder on one line at the narrower width.

* Unsloth: drop the theme section dividers and split the chat menu groups

The colors, fonts, and contrast rows read fine without rules, and the
chat menu gains its one real boundary between the pin toggles and the
disclaimer rows.

* Unsloth: normalize oversized sidebar menus and reject newline font data URLs

Two backend validation fixes in PersonalizationCustomization:

- sidebarMenu refused any list longer than the number of distinct ids
  because Field(max_length) is enforced before the dedupe validator runs.
  A stale or duplicated payload that would normalize to one entry per id
  was rejected outright, defeating the normalizer that exists for exactly
  that case. Cap the incoming list at a generous multiple so it reaches
  the validator; a pathologically long list is still refused.

- The imported font dataUrl validator used re.match on a pattern ending
  in $, which also matches just before a trailing newline, so
  "data:font/woff2;base64,AAAA\n" passed even though the frontend JS
  pattern rejects it. Use re.fullmatch for parity.

Adds covering tests for both.

* Unsloth: preview fonts in their own typeface and slim the color pills

- Every font dropdown entry, the default item, and the closed trigger
  render in the font they name, falling back to the UI stack for
  families the browser cannot resolve
- Color swatch pills drop from 36px to 28px so they sit closer to the
  row label height

* Unsloth: drop the font row and theme section descriptions

The labels carry the meaning on their own; the mode switching note in
particular read long and confusing.

* Unsloth: let the chat greeting follow the heading font setting

The greeting stays on Hellix by default but adopts a chosen heading
font through a --custom-heading-font variable the applier sets only
while an override exists, so the thread root's sans reset for chat
prose no longer hides the user's pick from the greeting.

* Unsloth: divide the theme section clusters and align the color pill height

Separators return between colors and fonts and between fonts and
contrast, and the color pills share the 32px height of the font
dropdown triggers.

* Unsloth: color pills at half the dropdown width

Fixed w-24 against the w-48 font triggers, with tighter padding so the
hex value still fits.

* Studio: update dep-removal test after next-themes was replaced

The frontend no longer declares next-themes or imports it in src (it was
replaced by the custom theme store and boot script), so the checker now
reports its removal as a safe no-op. The C1 and C8 fixtures in
test_frontend_dep_removal.py still asserted next-themes was a used
dependency, which fails the studio frontend CI dependency-removal safety
check. Update C1 to expect a no-op PASS and drop next-themes from the C8
expected failures so the suite matches the checker's correct output.

* Studio: remove unused ageLabel and exportCollectionJsonl helpers

* Studio: fix blocked-storage theme desync, search jump race, font validation

- theme-store.ts: keep an in-memory currentTheme/currentPalette so a selected
  value survives when localStorage is blocked (private browsing). The snapshots
  previously re-read empty storage and reverted React state to the default while
  the DOM already changed. The matchMedia handler no longer re-reads storage, so
  it cannot clobber the in-memory choice; cross-tab storage events still adopt.
- settings-dialog.tsx: the search jump waited a single fixed 60ms for the
  deferred tab panel to render, then silently missed under render lag. Retry
  across animation frames until the target row exists, then scroll and flash.
- settings.py: apply the font-name character check to the four selected-font
  fields (uiFont/headingFont/chatFont/codeFont), and forbid backslash, comma,
  slash and control characters so a name cannot escape the quoted CSS
  font-family or smuggle extra fallbacks. Adds covering tests.

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

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

* Fix appearance customization edge cases for PR #7077

- Reset all local preferences now also clears palette and appearance customization
- Number input wrapper keeps full width so fields fill their flex/grid cell, and the stepper stays pinned to the field edge
- Number stepper snaps to the min anchored step grid like the native spinner instead of leaving a step-invalid value
- Code font now applies to chat code fences and inline code via a dedicated token
- Reduce motion (on/off) is honored by onboarding/tour confetti and the theme toggle view transition
- Re-importing a font under the same name with new bytes now swaps the FontFace
- Keep local customization when a synced record predates the customization field, and re-push it

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

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

* Align client font name sanitization with server validation for PR #7077

sanitizeFont now strips the same characters the backend _FONT_NAME_FORBIDDEN
rejects (backslash, slash, comma, backtick) plus control chars, so a locally
chosen font name can no longer pass the client but fail the personalization PUT
and silently stall appearance sync.

* Address follow-up review items for PR #7077

- Number input wrapper carries React Flow interaction classes (nodrag/nopan/nowheel) so clicking the stepper arrows increments instead of dragging the node
- Preserve local palette and greeting-sloth toggle when the synced record predates those fields, and re-push them, mirroring the customization handling (new paletteSaved and greetingSlothSaved response flags)
- Add settings-search scroll targets (data-settings-label) for the Profile title, description, display name, nickname, and avatar shape rows

* Preserve absent personalization fields on PUT for PR #7077

A stale client that omits palette or customization previously had those
defaults materialized by model_dump() and persisted, which flipped
paletteSaved/customizationSaved to true and defeated the legacy detection.
The PUT now dumps only the request's set fields and merges them onto the
stored record, so omitted fields keep whatever was already stored.

* Persist theme and palette via a fixed allow-list for PR #7077

The theme/palette values reach setTheme/setPalette from the authenticated
personalization sync, which made the CodeQL clear-text-storage query treat
writing them to localStorage as storing sensitive data. Store a re-derived
literal from a constant map instead, so a plain UI preference is not tracked
as sensitive; behavior is unchanged.

* Harden imported-font handling for PR #7077

- syncImportedFonts: a rejected FontFace.load() only clears the registry entry
  if it still points at that face, so a same-name re-import while the old load
  was pending is no longer untracked/leaked.
- Cap imported-font names to the backend length (100) so an over-long name can
  no longer pass the client but fail the personalization PUT and stall sync.
- Add a backend test that a stale PUT preserves an existing stored palette and
  customization (not just that absent fields stay absent).

* Return the merged personalization record from PUT

The PUT /personalization handler returned the request payload, which
Pydantic had already filled with defaults for any field the client
omitted. A partial or stale write (for example a client sending only
theme) therefore got back a response that contradicted both storage and
the next GET: preserved fields like palette and the custom font showed
their defaults instead of the stored values.

Return model_validate(merged) so the response mirrors what was stored.
The stored record is still the full merged dict, so legacy fields the
model does not know about are preserved as before.

* Fix small UI and keyboard-focus defects in appearance settings

- Settings search now scrolls to the result within its destination tab
  instead of a same-named row in the previously rendered deferred tab
  (for example "Storage" and "Models folder" appear in both General and
  Resources).
- The reduce-motion segmented control honors its own Off/On/System choice
  by reading useReducedMotionConfig instead of the OS-only useReducedMotion.
- The color picker saturation/value area is operable by keyboard, so the
  role="slider" surface responds to the arrow keys it advertises.
- Profile avatars and palette cards show a visible keyboard focus ring
  again.
- Guard the persisted appearance-customization write so a blocked or full
  localStorage does not throw out of a store action, matching the theme
  store.
- Import the appearance store symbols from the settings feature barrel.

* Tighten appearance fix comments

---------

Co-authored-by: shimmyshimmer <shimmyshimmer@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>
2026-07-14 05:12:08 -07:00
OrbisAI Security
220ff5aaba
fix: CVE-2026-54290 security vulnerability (#6736)
Automated dependency upgrade by OrbisAI Security

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-06-29 15:28:06 +01:00
dependabot[bot]
86ec407f9b
Bump vite (#6354)
Bumps the npm-frontend-security group with 1 update in the /studio/frontend directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 8.0.10 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
  dependency-group: npm-frontend-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-23 16:58:59 +02:00
Burak Emir
7467064c66
Bump hono to 4.12.21, fixes CVE-2026-47676 (#6014) 2026-06-11 16:45:10 +02:00
Daniel Han
3307561f85
Studio: npm v12 readiness for install-script gating (#6128)
npm 12 (July 2026) stops running dependency install scripts unless they
are approved via allowScripts, and npm 11.16 already warns. Studio has
no git or remote URL deps anywhere, so script gating is the only
exposure:

- commit the allowScripts policy that npm approve-scripts writes for
  @biomejs/biome and msw, plus a manual fsevents entry: the tooling
  cannot match a darwin-only optional dep from Linux, but the strict
  check walks the platform independent ideal tree and flags it anyway
- drop the minimum-release-age npmrc alias; npm >=11.16 flags it as an
  unknown project config that stops working in npm 12
- approve bun's postinstall in the setup.sh / setup.ps1 bun bootstrap;
  under npm 12 defaults npm install -g bun otherwise leaves a broken
  stub and setup falls back to the slower npm install path
- fix the stale esbuild comment in studio-frontend-ci.yml: the vite 8
  chain ships napi binaries with no install scripts
2026-06-10 02:20:27 -07:00
Daniel Han
8848a310df
Studio: clean-room compact RAG (knowledge bases, hybrid search, fast indexing) (#5910)
Adds a self-contained RAG stack to Studio: knowledge bases with chunked indexing, hybrid (dense + lexical) retrieval, and an automatic first-pass context inject into chat. Embeddings run through a local llama-server GGUF backend (default unsloth/bge-small-en-v1.5-GGUF) with a sentence-transformers fallback. The chat tool loop gains a search_knowledge_base tool, a per-turn re-search cap, and source citation, layered on top of the shared ToolLoopController.
2026-06-09 21:17:04 -07:00
Eyera
aec41d17ed
feat(studio): Hub + Download Manager (#5916)
Adds the Studio Hub and download manager: browse Hugging Face models and datasets, download GGUF and safetensors with live progress and cancellation, and manage on-device inventory. The Hub does not require a GPU, so it is available on chat-only hosts.

CI: all substantive checks pass, including the three Core jobs after unsloth-zoo#736. The two red checks are non-code flakes, a transient npm-registry DNS resolution failure in the package scan and one quantized vision-model output assertion whose sibling shards passed.
2026-06-09 04:11:24 -07:00
danielhanchen
4f501e53e9 Update vulnerable dependencies to patched versions
Clears the safe set of Dependabot advisories.

Frontend (overrides, all transitive; npm audit now 0):
  mermaid 11.14.0->11.15.0, hono 4.12.17->4.12.18, qs 6.15.1->6.15.2,
  ip-address 10.1.0->10.1.1 (also clears express-rate-limit), and
  brace-expansion 5.0.5->5.0.6 (scoped, the 1.1.14 line is untouched).

Desktop: tauri 2.10.3->2.11.1, pulling the runtime crates it requires
(tao, wry, tauri-runtime, tauri-build, tray-icon).

Backend (test-only): pytest <9.0 -> >=9.0.3,<10, and the pinned
pytest-rerunfailures==15.1 -> >=16.2,<17 (16.2 is the first release with
pytest 9 support). Verified pytest 9.0.3 + rerunfailures 16.3 +
pytest-json-report + pytest-xdist run reruns, fixtures, json reports and
xdist together.

Left out intentionally: transformers (stays 4.x for unsloth compat; patch
is 5.0.0rc3), sqlfluff (major 3->4), glib/rand (stack-coupled transitive).
2026-06-03 05:08:00 -07:00
oobabooga
85692f1c1c
Studio: persist Tauri window size and maximized state across launches (#5799)
Some checks are pending
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Studio GGUF CI / Tool calling Tests (push) Waiting to run
Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio API CI / Studio API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Mac Studio Update CI / Studio Updating Tests (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
* Studio: persist Tauri window size and maximized state across launches

* Studio: keep window state under the app home dir, not ~/.config

* Undo an unnecessary change

* Address Gemini's feedback

* Revert to tauri-plugin-window-state implementation

* fix(Studio): restore saved window size before default layout

* Fix cross-platform window-state restore

* fix(Studio): avoid clobbering saved window size

---------

Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-06-02 09:26:15 -07:00
Lee Jackson
e0ff6a1404
Studio: manage chat history with projects (#5725)
* feat: align project sidebar UX with ChatGPT

* feat: align project sidebar UX with ChatGPT

* feat(chat): load stored project list

* feat(chat): add project sidebar workflows

* fix: stabilize project page navigation

* fix: projects chat loading

* fix: show project chat thread

* style: sidebar project spacing and hover clipping

* style: add expandable project chat history and move-to-project submenu

* feat: polish project sidebar

* feat: persist project sandbox paths

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

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

* fix: only create sandbox project workspace dir

* feat: add optional project workspace deletion from delete dialog

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

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

* fix: stabilize chat projects CI failures

* fix: polish project chat navigation

* Studio: manage chat history with projects

Group chats into projects with a dedicated projects page and route.
Sidebar shows recents with per-row actions and a vertical more-vertical
menu, and the sidebar scrollbar stays hidden so rows never shift on
hover. Includes chat settings and composer refinements.

* Studio: projects sidebar and breadcrumb polish

Sidebar:
- Remove the Compare nav item.
- Widen the sidebar to match the projects layout.
- Replace the scroll-gated bottom fade with a static fade pinned above
  the profile box, so it no longer attaches to Recents or lags the
  collapse and expand animation.

Topbar breadcrumb (chat-page):
- On a project landing show "Projects" linking to the projects list.
- Inside a project chat show the project name and chat title, with the
  project name linking back to that specific project page.
- Drop the divider between the model selector and the breadcrumb.

* Studio: make project workspace delete test cross-platform

test_chat_project_delete_files_removes_workspace rooted the project under
pytest tmp_path, which resolves to /private/tmp on macOS. The workspace
delete guard refuses paths under the system denylist by design, so the
test passed on Linux CI but failed on macOS.

Add a workspace_projects_home fixture that keeps tmp_path on Linux and
Windows (CI unchanged) and falls back to a home subdir only when the temp
root is on the platform denylist. Derive the workspace path from the
created project so it tracks the projects home.

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

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

* Studio: satisfy import-hoist check for new path re-exports

documents_root and project_workspaces_root are re-exported from
utils.paths but only referenced as __all__ string literals, which the
import-hoist safety net does not count as a use. It flagged the two newly
added re-exports as unused imports and failed Source lint.

Name-load both via a module-level _REEXPORTED tuple so the check sees
them used. No behaviour change; consumers still import them from
utils.paths.

* fix: avoid projects empty-state flash

* fix: batch chat search indexing

* Studio: polish chat sidebar, run settings, and search

- Use the native OS scrollbar for the chat sidebar, Run settings panel, and chat search list instead of a custom scrollbar
- Highlight the active run in the sidebar and keep chat search available during training
- Stop the training log view from replaying when navigating back to a run
- Rename the chat settings panel to Run settings and align its toggle icon and position
- Tighten heading and sidebar letter spacing and lighten the Train and Recents labels
- Match the search dialog corner style across light and dark and drop the stray border
- Make the MCP Servers section header plain text instead of a link
- Remove a stray .orig backup file

* studio/frontend: restore Compare entry point in the sidebar

The chat-projects sidebar redesign dropped the Compare nav item and moved
it to thread-sidebar.tsx, which is not imported or rendered anywhere. That
left no way for a user to start a new model comparison (enterCompare only
fired from the guided tour and the training handoff), and broke the
Compare/Recipes/Export UI smoke test that clicks [data-tour="chat-compare"].

Re-add the Compare NavItem to the New Chat / Search group, carrying
data-tour="chat-compare" and the same new-comparison navigation as before.

* studio/frontend: use Unsloth green for the fallback profile avatar

Switch the initials-avatar background from blue to #14b789 so the sidebar
and edit-profile avatar match the Unsloth brand colour.

* studio/frontend: turn project breadcrumb into a project switcher dropdown

* studio/frontend: stop project card kebab clicks from opening the project

* studio/frontend: hide project switcher outside projects

* studio/frontend: stabilize project switcher loading

* style: project switcher alignment

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-06-01 22:09:16 +04:00
wuwuwu
4891118b5e
Studio: add frontend i18n support (#5765)
* Studio: add frontend i18n support

* Studio i18n: guard storage events, restore plurals, fill zh-CN, add parity check

- locale-store.ts: wrap window.localStorage access in handleStorageEvent
  with try/catch. readStoredLocale and writeStoredLocale already guard the
  same API; the storage-event path can throw the same way in privacy/
  restricted contexts and was the only unguarded localStorage call. Refactor
  the storageArea + key match into isLocaleStorageEvent for clarity.

- chat-tab.tsx + en.ts/zh-CN.ts: restore singular handling for chat-clear
  copy that the i18n migration dropped. Pre-PR code rendered "1 chat" but
  the new template strings always said "chats", so a user with exactly one
  chat saw "Cleared 1 chats", "Clear 1 chats?", and "1 chats cleared;
  1 chats remain". Add clearOneChat*, clearedOneChat, oneChatClearedRemain*,
  chatsClearedRemainOne, and storageClearFailedOne keys and pick them in
  chat-tab.tsx when count === 1.

- zh-CN.ts: fill ~50 previously English-fallback keys across studio.configure,
  studio.model VRAM helpers, studio.dataset (source, browsing, tooltips,
  preview/split/subset), studio.params tooltips and learningRateDescription,
  studio.training (audio/vision incompatible), studio.trainingStart.terminalStart,
  studio.tour.guidedTour, settings.chat.clear*, settings.connections,
  settings.apiKeys.newBadge. shell.{beta,brand,product} kept as brand strings.

- src/i18n/check-parity.ts + npm i18n:check: small script that verifies every
  locale overlay against the English baseline. Catches placeholder mismatches,
  shape mismatches, and unintended extra keys; runs via node --experimental-
  strip-types with no new devDependencies.

Verified locally:
  npm run typecheck, lint, build, biome:check, i18n:check all pass.
  24 vitest unit tests cover locale resolution, persistence failures,
  storage-event sync (including window.localStorage throwing), interpolation,
  and fallback.
  33 Playwright e2e tests pass across Chromium, Firefox, and WebKit covering
  default load, switch + reload persistence, unsupported/garbage locale
  fallback, storage-event cross-tab sync, and storage clear.

* Studio i18n: use translated API-key error copy instead of raw err.message

The API helpers in src/features/settings/api/api-keys.ts throw generic
English Error objects ("Failed to load API access", "Failed to create
access token", "Failed to revoke access token"). ApiKeysTab and
CreateKeyForm caught those and preferred err.message over the translated
"settings.apiKeys.loadError" / .createError / .revokeError keys, so in
zh-CN mode failed load/create/revoke requests still surfaced the English
strings instead of the translated copy.

Switched the four call-sites to always render the translated message and
left the helper throws unchanged (they are still useful for diagnostics
but should not be treated as user-facing localized copy).

* Studio i18n: polish two zh-CN embedding LR tooltips

Translation-pass review surfaced two awkward phrasings I introduced earlier:

  "常用区间是主学习率的 2 至 10 倍小"
    -> "常用区间是比主学习率小 2 至 10 倍"

Both versions are grammatical, but the new "比 X 小 N 倍" phrasing is the
standard idiomatic comparative for "N times smaller than X" in technical
Chinese writing. The earlier "X 的 N 倍小" reads as a non-native construction.

Applies to:
  studio.params.embeddingLearningRateTooltip
  studio.params.embeddingLearningRateDescription

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-27 04:52:16 -07:00
Wasim Yousef Said
31ac558a73
Recipe Studio local model selector (#5769)
* feat(recipes): round-trip local model variants

* feat(recipes): add local model selector

* feat(recipes): wire selector into model editors

* fix(recipes): clear stale model state on relink

* feat(recipes): load selected local models for jobs

* chore(frontend): simplify biome scripts

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

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

* fix(recipes): handle local selector edge cases

* fix(recipes): polish local model selector behavior

* fix(recipes): delay local model restore until terminal runs

* fix(recipes): accept resolved default gguf variants

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-26 02:37:24 -07:00
alkinun
08d5f941e8
Add OpenDocument chat attachments (#5510)
* Add OpenDocument chat attachments

* Preserve typed ODS cell values

* Exclude hidden OpenDocument review text

* fix(chat): harden OpenDocument attachment extraction

* fix(chat): close opendocument attachment leaks

* fix(chat): unblock failed attachments

* fix(chat): preserve covered cell columns

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
2026-05-18 03:41:24 -07:00
Daniel Han
d2e25ee131
studio/frontend: drop unused dependencies, move type pkg to devDeps (#5477)
* studio/frontend: drop unused dependencies, move type pkg to devDeps

Removes 11 declared deps that are not imported anywhere in src/, the
Tauri config, src-tauri Rust, backend, scripts, CI workflows, or
sibling workspaces. Moves @types/canvas-confetti to devDependencies
since it ships TypeScript types only.

Removed from dependencies:
  @assistant-ui/react-markdown   (no imports; not a peer of any used pkg)
  @assistant-ui/react-streamdown (no imports; not a peer of any used pkg)
  @langchain/core                (no imports anywhere)
  @streamdown/cjk                (no imports; not a peer of streamdown)
  @radix-ui/react-checkbox       (re-exported by the radix-ui umbrella;
                                  no direct imports)
  @radix-ui/react-label          (same)
  @radix-ui/react-select         (same)
  @radix-ui/react-separator      (same)
  date-fns                       (already a direct dep of react-day-picker)
  remark-gfm                     (already a direct dep of streamdown)

Removed from devDependencies:
  playwright                     (CI installs the pip playwright; the
                                  npm one is unused)

Moved to devDependencies:
  @types/canvas-confetti         (TypeScript types only; not a runtime dep)

Verified with npm install + npm run build (tsc -b && vite build),
clean exit, dist/ produced. Live unsloth studio launch returns 200
on /, on the main JS / CSS bundles, and on /api/health.

* studio/frontend: keep @radix-ui packages (per maintainer)

Maintainer asked to keep the four @radix-ui packages this PR was
originally dropping:

  @radix-ui/react-checkbox  ^1.3.3
  @radix-ui/react-label     ^2.1.8
  @radix-ui/react-select    ^2.2.6
  @radix-ui/react-separator ^1.1.8

Restored to dependencies and refreshed the lockfile. Build still
green (1044 packages, vite build 2.1s, same dist contents).
2026-05-16 05:49:23 -07:00
Daniel Han
30f6280835
studio/frontend: drop unused next dependency (#5438)
The frontend is a Vite SPA wrapped by Tauri and served by FastAPI's
StaticFiles in web mode. Nothing in src imports from next/, no
next.config exists, and no script invokes the Next.js server. The
package was dead weight in node_modules and was being flagged by
SCA scanners under CVE-2026-44578 (Next.js SSRF via WebSocket
upgrade) despite the vulnerable code path never being reachable.

next-themes is unrelated and stays; its only peers are react and
react-dom.

Verified with npm install + npm run build (tsc -b && vite build),
clean exit, dist/ produced as before.
2026-05-15 03:53:48 -07:00
Roland Tannous
9a0d6f80cb
studio: API external provider support for chat (OpenAI, Mistral, Gemini, Cohere, Anthropic, OpenRouter, DeepSeek, custom providers) (#4706)
* studio: add external provider support for chat inference

Adds the ability to connect to OpenAI, Mistral, Google, Cohere, Together,
Fireworks, and Perplexity from the Studio chat interface.

- Provider configs stored in SQLite (no API keys persisted)
- RSA-2048 key pair generated at startup for client-side key encryption
- httpx proxy client streams SSE responses in OpenAI-compatible format
- New /api/providers routes: registry, CRUD, test, models
- /v1/chat/completions routes to external provider when provider fields present
- Integration test suite covering CRUD, connection, model listing, and inference
- Frontend spec doc with full API contract

* remove frontend spec doc from branch

* fix auth fixture: handle forced password change on fresh install

* fix tests: default port 8000, allow 400 for no-model-loaded

* fix: update Cohere models to current (command-r retired Sept 2025)

* feat: add OpenRouter as 8th provider

* feat: add native Anthropic provider with Messages API translation

* fix: correct Anthropic base URL and drop top_p (conflicts with temperature)

* feat: add DeepSeek provider (deepseek-chat, deepseek-reasoner)

* feat: rename google -> gemini, refresh model list to 2.5 series

* feat: remove together, fireworks, perplexity providers

* feat: multimodal image support for external providers

- Add _build_external_messages() that preserves image_url parts for
  vision-capable providers instead of stripping them
- Update _proxy_to_external_provider() to use new helper
- Translate image_url content parts to Anthropic native image format
  in _stream_anthropic()
- Add TestVisionInference pytest class (1x1 PNG smoke test)

* test: use sloth photo URL for vision test, add Anthropic remote URL support

* fix: update Mistral model to mistral-small-2506

* update mistral default model to mistral-large-2512

* fix gemini vision test: download image as base64 data URI instead of remote URL

* add gemini-3-flash-preview as default gemini model

* fix gemini truncated reply (max_tokens 16->64) and suppress GeneratorExit on client disconnect

* increase vision test max_tokens to 215

* fix GeneratorExit: aclose stream generator before closing httpx client

* fix httpcore GeneratorExit: explicitly aclose aiter_lines before response closes

* fix duplicate [DONE] and suppress httpcore RuntimeError on Python 3.13 asyncgen cleanup

* fix: call response.aclose() before lines_gen.aclose() to prevent httpcore RuntimeError on Python 3.13

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

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

* Potential fix for code scanning alert no. 36: Clear-text logging of sensitive information

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

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

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

* review: add comments for manual iteration rationale, mask password in test print, clarify Anthropic URL/models support

* perf: use shared module-level httpx client for connection pooling across requests

* studio: add API provider UI and integrate wiring (#4737)

* feat: expose external models in selector and chat settings

* feat(chat): wire external providers to backend + RSA key flow

- Fetch registry/configs; create/update/delete saved providers
- Encrypt API keys (Web Crypto RSA-OAEP) for test/models/chat
- External model selection + chat payload (provider_id/type, external_model, encrypted key, optional base URL)
- Local storage for keys + provider list; small UX/copy and guardrails

* add missing providers-api.ts file by Imagineer99

* fix: address PR review comments — system prompt visibility, retry loop, test logging

* feat(studio): encrypt external provider API keys at rest in localStorage

API keys for external providers (OpenAI, Mistral, etc.) were stored as
plaintext in localStorage, vulnerable to browser extensions and XSS.

Add password-derived AES-256-GCM encryption: on login the user's password
is used via PBKDF2 (100k iterations, SHA-256) to derive an in-memory
encryption key. API keys are encrypted before writing to localStorage and
decrypted on read. The derived key is never persisted — cleared on logout,
re-derived on next login.

Legacy plaintext keys are transparently migrated on first access. Password
changes re-encrypt all stored keys. No backend changes required — the
existing RSA-OAEP transit encryption is unaffected.

* fix: cast PBKDF2 salt to BufferSource for strict TypeScript lib types

* fix: persist session password in sessionStorage to survive page refreshes

* feat(studio): preserve image parts in external provider chat requests

toOpenAIMessage() now returns multimodal content arrays (OpenAI vision
format) when messages contain images, instead of always flattening to
plain text. This enables vision-capable external providers (OpenAI,
Gemini, Anthropic, etc.) to receive user images. The backend already
handles image_url content parts in _build_external_messages().

* studio: fix external models selectable in chat-only mode (#4779)

* fix: external models selectable in chat-only mode

* fix: model selector tabs default to active model kind

* Studio: API external provider registry + curated catalogs (HF/OpenRouter) and chat UX (#4787)

* fix: external models selectable in chat-only mode

* fix: model selector tabs default to active model kind

* feat(studio): expand provider registry, curated catalogs, and chat UX

- Add Hugging Face, Kimi, Qwen; remove Cohere; reorder registry
- model_list_mode curated for HF/OpenRouter; lightweight /models check
- API returns default models for curated providers; expose model_list_mode
- Frontend: provider logos in model picker, providerType on external models
- Chat providers dialog: curated vs remote flows, motion polish
- Thread: LayoutGroup + composer motion alignment with app easing

* fix(studio): disable Anthropic tool-calling flag and preselect curated defaults

* feat(studio): add external provider logos and ApiProviderLogo helper

* Studio: Polish API Providers dialog  (#4899)

* fix: lower verbage in API providers page

* fix: fix(studio): tune API Providers dialog width with rem-based responsive caps

* feat: add custom provider support (#4902)

* fix: replace crypto.subtle with node-forge for HTTP compatibility

crypto.subtle is only available in secure contexts (HTTPS/localhost),
which breaks provider API key encryption when Studio is accessed over
plain HTTP on remote GPU VMs. Switch to node-forge for RSA-OAEP and
AES-256-GCM operations — same algorithms, works on any origin.

* fix: store provider API keys as plaintext in localStorage

Drop AES-256-GCM at-rest encryption for provider API keys. The
session-password-derived encryption broke on auto-login via refresh
token (password never captured), causing keys to silently vanish.
API keys are still RSA-encrypted in transit via node-forge. At-rest
encryption in localStorage added no real security since the
decryption key also had to live client-side.

Removes crypto-storage.ts, session password plumbing, and
reEncryptAllKeys.

* fix: use max_completion_tokens for OpenAI provider

Newer OpenAI models (gpt-4o, gpt-5.x) reject the max_tokens param
and require max_completion_tokens instead. Other providers still use
max_tokens.

* fix: skip empty assistant messages in external provider requests

Some providers (Mistral) reject assistant messages with empty content.
Filter them out when building the message list for external providers.

* Update model-selector.tsx

* Update model-selector.tsx

* Update model-selector.tsx

* Update chat-adapter.ts

* Update chat-adapter.ts

* Update chat-page.tsx

* Update chat-settings-sheet.tsx

* Update chat-settings-sheet.tsx

* Update chat-settings-sheet.tsx

* Update chat-providers-dialog.tsx

* feat: polish providers settings form UI

* style: polish provider row icon sizing and alignment

* style: stabilize provider layout

* style: add provider API key visibility toggle

* fix: add provider render on empty list

* studio/frontend: sync package-lock.json with package.json

npm ci was failing because node-forge and @types/node-forge were
declared in package.json but missing from the lockfile. Ran
npm install to regenerate.

* studio/backend: fix backend CI failures for providers router

- test_desktop_auth: include providers_router in the routes stub so
  studio.backend.main imports cleanly under the monkeypatched module
- test_providers_api: skip the whole module when STUDIO_TEST_PASSWORD
  is unset (it is an integration test against a live Studio server,
  same shape as the already-ignored test_studio_api.py)

* studio/chat: drive ChatSettingsPanel from a per-provider capability map

Replace the binary isExternalModel toggle in the sampling section with a
provider-aware capability map. Each external provider type advertises
which of top_k / min_p / repetition_penalty / presence_penalty its
chat-completions API actually accepts, so the panel only renders the
knobs that map onto the active provider's request body.

Anthropic now exposes top_k; DeepSeek hides presence_penalty (deprecated
in their docs); OpenRouter and custom providers continue to show every
knob (OpenRouter drops unsupported server-side, custom assumes
OpenAI-compat or a permissive vLLM/Ollama backend). Local models are
unaffected — null capabilities means 'show everything'.

chat-adapter.ts now forwards top_k / presence_penalty to the external
proxy only when the active provider's capabilities permit it, so the
request body matches what the UI shows.

* studio/backend: forward top_k to Anthropic; filter OpenAI model list

Two paired changes so the frontend capability map has matching backend
behaviour:

1. ExternalProviderClient.stream_chat_completion now accepts top_k and
   forwards it to the Anthropic Messages body. OpenAI-compat providers
   (which all reject unknown sampling params) still receive only the
   fields they document. The proxy route in routes/inference.py passes
   payload.top_k through, so a UI request with top_k actually reaches
   Anthropic instead of being silently dropped at the boundary.

2. PROVIDER_REGISTRY['openai'] gains a model_id_allowlist regex that
   scopes the /models picker to current-gen ids (gpt-5.5 / gpt-5.4 /
   gpt-5.3 / gpt-4.5 / o3 families). The remote /v1/models listing
   otherwise returns dozens of historical snapshots, fine-tunes and
   non-chat models (embeddings, TTS, image, moderation) that we never
   want in the chat UI. default_models is refreshed to match.

* studio/chat: relax presence_penalty to optional on OpenAIChatCompletionsRequest

Followup to 1fbf445a — chat-adapter now omits presence_penalty for
providers that do not accept it (Anthropic / DeepSeek), but the
request type still required it as a non-optional number, breaking
tsc. The backend pydantic model already defaults presence_penalty
to 0, so making it optional client-side matches reality.

* studio/backend: route OpenAI traffic through /v1/responses

OpenAI's new flagship models (gpt-5.x) return 404 'This is not a chat
model' on /v1/chat/completions and are only reachable via /v1/responses.
Add a dedicated _stream_openai_responses path in ExternalProviderClient
that:

- Translates outbound messages into the Responses shape: system messages
  are folded into the top-level 'instructions' field, user/assistant
  messages become {role, content} items with input_text / input_image
  content parts (data URLs and https URLs both pass through).
- Drops presence_penalty / top_k / frequency_penalty, none of which the
  Responses contract accepts.
- Translates inbound SSE events back into OpenAI Chat Completions
  chunks so the frontend keeps a single SSE shape:
    response.output_text.delta  -> delta chunk with content
    response.completed          -> chunk with finish_reason='stop'
    response.incomplete         -> chunk with finish_reason='length'
    response.failed / error     -> propagated error SSE line
  Stream terminates with data: [DONE] (Responses emits this verbatim).

stream_chat_completion dispatches all provider_type='openai' calls to
this path; other OpenAI-compatible providers (mistral, gemini, etc.)
continue to use /v1/chat/completions.

Frontend provider-capabilities map updated to hide presence_penalty for
OpenAI in the chat settings panel, matching the new request contract.

Includes unit coverage in tests/test_openai_responses_translation.py
exercising the request body translation, image-part rewriting, and
SSE-to-chat-completions translation via httpx.MockTransport.

* studio/chat: clamp external max_tokens to 32k to stay within provider caps

The chat settings slider already capped maxTokens at 32768 for external
models, but a value persisted from a prior local-model session (where
the cap can be 128k+) was sent verbatim to the provider — Claude Opus
returns 'max_tokens: 131072 > 128000' on requests like that, and other
providers have stricter limits still.

Expose EXTERNAL_MAX_OUTPUT_TOKENS from provider-capabilities (32k) and
use it both for the slider max and as the clamp inside chat-adapter's
external-request body. 32k sits below the tightest declared output
limit across the providers we ship and well above what a typical chat
reply needs; the local-model path is unaffected.

* studio: drop temperature/top_p for OpenAI reasoning models

gpt-5.x / o3 / gpt-4.5 are reasoning-class models served via
/v1/responses, and reject temperature and top_p with
'Unsupported parameter' 400s. The OpenAI registry allowlist already
scopes the picker to those families, so neither knob ever applies on
this branch.

- external_provider._stream_openai_responses no longer puts
  temperature or top_p in the request body (kept on the method
  signature for API symmetry with the other stream methods).
- ProviderCapabilities gains temperature/topP flags; OpenAI sets both
  to false. ChatSettingsPanel hides the sliders for OpenAI so the user
  does not see inert controls.
- chat-adapter omits temperature/top_p from the external request body
  when the active provider does not advertise them.
- OpenAIChatCompletionsRequest type marks both as optional, matching
  the new chat-adapter shape.
- test_responses_request_body_uses_input_and_instructions: assertions
  flipped to confirm temperature / top_p are absent from the body.

* studio: stop forwarding top_k to Anthropic

Claude 4.x (Opus / Sonnet / Haiku 4.x) returns 400 'top_k is
deprecated for this model' on any request that includes top_k. It
was always optional on the older 3.x line, so dropping it
unconditionally for every Anthropic call is the simplest path —
no per-model gate to maintain.

- external_provider._stream_anthropic no longer adds top_k to the
  Messages body (kept on the method signature for API symmetry).
- provider-capabilities sets anthropic.topK = false so the chat
  settings panel hides the Top K slider for Anthropic providers
  and chat-adapter does not send top_k in the external request.

* studio: gate Anthropic top_k drop to Claude 4.7 only

Previous commit (b5aa6ffd) dropped top_k for every Anthropic call,
but only Claude 4.7 (Opus/Sonnet/Haiku) actually rejects it. 4.6, 4.5,
and the 3.x line still accept top_k and use it as documented.

Backend: _stream_anthropic matches the model id against
^claude-(opus|sonnet|haiku)-4-7(-|.|$) and only strips top_k when it
hits. Every other Claude generation continues to receive the value
from the chat settings panel.

Frontend: anthropic.topK is restored to true so the Top K slider is
visible again — the backend handles the per-model drop, and the
4.7 case is silent (request still succeeds without top_k).

* chore: hide dated openai models in provider select

* studio/providers: apply model_id_denylist when listing remote models

The OpenAI registry entry gained a model_id_denylist regex matching
dated snapshot ids (-YYYY-MM-DD) in 048d73bf, but the list-models
route was never consulting it, so the snapshots still showed up
alongside their canonical ids (gpt-5.5 and gpt-5.5-2026-04-23 both
listed). Apply the denylist with .search() right after the allowlist
filter so dated entries are dropped before the response is built.

* studio/chat: seed registry default_models for remote providers in picker

The Anthropic provider runs in remote model-list mode, so the picker
started with an empty availableModels until the user clicked
'Load Models'. If that /api/providers/models call fails (e.g. the
known transient decryption error during key rotation), the user sees
no models at all — claude-haiku-4-5 in particular was missing from
the dialog even though it is seeded in the registry.

Always pre-populate availableModels with the registry's default_models
when a provider type is selected (curated and remote alike), and have
loadModels() return the union of defaults + the live /models response
so registry-seeded ids are reachable regardless of what the provider's
endpoint returns or whether the call succeeds at all.

* studio/backend: diagnostic logging on provider key decryption

Decryption failures currently log just 'Failed to decrypt API key:
Decryption failed', which leaves no way to tell whether the cause is
a stale public key in the browser, a corrupted ciphertext, an
unexpected exception class, or a server-side keypair rotation. That's
the gap the next reproduction needs to close.

- key_exchange now publishes a short SHA256 fingerprint of the public
  key PEM. init_key_pair logs the fingerprint on generation and warns
  if it is ever called a second time (re-init silently invalidates
  every browser that cached the previous public key).
- decrypt_api_key wraps both the base64 decode and the RSA decrypt
  in dedicated try/excepts that log exception type, ciphertext byte
  length (RSA-2048 should be exactly 256), input string length, and
  the current public-key fingerprint.
- GET /api/providers/public-key returns the fingerprint alongside the
  PEM so the frontend can correlate a future encrypt-time fingerprint
  against the decrypt-time fingerprint and prove or rule out a
  keypair rotation as the cause.
- The /test and /models route-level decrypt warnings now include the
  exception class name (alongside the existing message).

* studio/providers: hide dated Anthropic snapshots from the model picker

Anthropic's /v1/models returns dated snapshot ids (e.g.
claude-3-5-sonnet-20241022, claude-3-5-haiku-20241022) alongside
the canonical names users actually want to pick. Same intent as
the OpenAI denylist added in 048d73bf, just a different date
format — Anthropic uses -YYYYMMDD (no dashes) while OpenAI uses
-YYYY-MM-DD.

- Add model_id_denylist = re.compile(r'-\d{8}$') to the anthropic
  registry entry. The /api/providers/models route already applies
  any denylist after fetching, so dated ids drop out automatically.
- Strip the dated 3.5 ids from default_models so the seeded picker
  no longer surfaces them; keep claude-opus-4-7 and the 4.5 family
  as the curated set.

Net effect: the picker shows opus-4-7 / opus-4-5 / sonnet-4-5 /
haiku-4-5 only, regardless of whether the remote /models call
succeeds or fails.

* fix: provider dialog and mistral short list

* style: fix provider dialog curated list styling

* fix: provider dialog curated model ids placeholder reference

* style: rename Providers to Cloud and tighten dialog header spacing

* UX: rename Providers to Cloud, remove header shortcut

* studio/chat: normalize structured delta.content from reasoning providers

Mistral's magistral (and similarly-shaped reasoning models) stream
chat-completion deltas where choices[0].delta.content is an array of
structured parts rather than a plain string, e.g.
  [{ type: 'text', text: '...' }, { type: 'thinking', thinking: '...' }]
The accumulator did 'cumulativeText += delta', which coerced each
part to '[object Object]' and produced output like
  '[object Object][object Object]...Hey there!'.

Add extractDeltaText() to normalize delta.content before append:
- string → returned as-is
- array of parts → text/output_text parts contribute their .text or
  .content; thinking/reasoning parts are re-wrapped inline as
  <think>...</think> so the downstream parseAssistantContent lifts
  them into a reasoning part the same way it does for providers that
  emit thinking inline. magistral keeps its thinking panel; no other
  provider's output shape changes.
- unknown shapes → dropped rather than stringified, so a stray field
  cannot pollute the rendered chat with '[object Object]'.

* Studio: restore Cloud icon shortcut in chat header

Brings back the header chip that opens Settings -> Cloud (external
providers) directly from the chat view. Same button as before the
bf24e604 removal: single-mode only, opens useSettingsDialogStore on
the 'connections' tab, tooltip 'API providers'.

* studio/chat: strip trailing template literal from external provider streams

Mistral's magistral occasionally appends a literal '${response}' token
after its actual answer — likely a training-format artifact, since it
keeps happening with an empty system prompt and only on that model.

Apply a tight strip in the chat-adapter SSE accumulator: when the
active provider is external, drop a trailing '${...}' template literal
(with optional whitespace) from cumulativeText after each chunk. The
regex anchors to end-of-string, so mid-stream fragments ('${re')
remain untouched and only collapse once the closing brace arrives.
Local-model output is unaffected.

* studio/providers: scope Kimi picker to kimi-k2.6 / kimi-k2.5

Mirror what the live Kimi docs surface as the current models
(https://platform.kimi.ai/docs/models). Everything else the
remote /v1/models call returns — moonshot-v1-* legacy ids and
dated k2 previews like kimi-k2-0711-preview — is filtered out.

- default_models: ['kimi-k2.6', 'kimi-k2.5'] (was four
  legacy moonshot-v1 ids plus the dated k2 preview)
- model_id_allowlist: ^kimi-k2\.[56]$ applied in the
  /api/providers/models route after the live fetch
- doc-link comments point at platform.kimi.ai overview /
  models / list-models for the next refresh

* studio: drop temperature/top_p for Kimi reasoning models

Kimi k2.5/k2.6 are reasoning-class. The API locks temperature and
top_p to fixed defaults and 400s on any other value with
'invalid temperature: only 1 is allowed for this model'.

The frontend capability map already gated these knobs out of the
external request body, but the OpenAI-compat path on the backend
unconditionally re-adds them from the pydantic ChatCompletionRequest
defaults (temperature=0.7 etc), so the gate was bypassed end-to-end.

Add a generic body_omit hook on the provider registry that
stream_chat_completion consults after building the body, and use it
to strip temperature/top_p for Kimi. Frontend provider-capabilities
flips kimi.temperature and kimi.topP to false so the sliders are
hidden in the chat settings panel as well.

* studio/providers: scope Gemini picker to current 3.x + *-latest aliases

Google's /v1beta/openai/models returns dozens of historical,
experimental, and non-chat ids that we never want in the chat UI.
Cap the picker to the current curated set:

- gemini-3.1-pro-preview
- gemini-3.1-flash-lite
- gemini-3-flash-preview
- gemini-pro-latest
- gemini-flash-latest
- gemini-flash-lite-latest

Default_models seeded with these, model_id_allowlist applied in
the /api/providers/models route to drop anything else the live
fetch returns.

* studio/providers: switch Hugging Face to remote model listing

Per the Inference Providers docs
(https://huggingface.co/docs/inference-providers/index),
GET https://router.huggingface.co/v1/models returns the full
chat-model catalog across all providers, including per-provider
metadata. The OpenAI-compatible endpoint we already use for
chat completions accepts the same Bearer token, so flipping
model_list_mode from 'curated' to 'remote' lets users discover
models via the existing list_models() path without any new
wiring.

- model_list_mode: 'remote' (was 'curated')
- default_models refreshed with current popular ids
  (gpt-oss-120b, DeepSeek-V3, Llama-3.3-70B, Qwen2.5-72B) so the
  picker still has a sensible seed if /v1/models fails
- notes updated to reference the docs page and clarify the
  endpoint is chat-only

* UX: chat cloud icon changed to model select signifier

* studio/providers: org allowlist + count cap for HF Inference picker

The HF /v1/models response is the full cross-provider catalog (hundreds
of ids — community fine-tunes, mirrors, fp8 variants, dated snapshots).
Scope the picker to the first-party org repos worth surfacing and cap
the post-filter list.

- model_id_allowlist matches the org prefixes openai/, deepseek-ai/,
  google/, meta-llama/, Qwen/, moonshotai/, mistralai/, zai-org/.
  Anything outside those orgs is dropped.
- model_id_limit (new registry field) caps the post-filter list. The
  list-models route now slices [:limit] after allowlist/denylist; set
  to 15 for HF Inference. Other providers leave it unset and behave
  exactly as before.
- default_models stays as the seed so the flagship ids users care
  about (gpt-oss-120b, DeepSeek-V3, Llama-3.3-70B, Qwen2.5-72B) are
  always reachable regardless of the API's response order.

Dedup is already handled in loadModels() via Set, so no additional
work needed there.

* style: adjust cloud icon right margin with rem spacing

* Studio: cloud openai reasoning level toggle (#5402)

* feat: cloud openai reasoning level toggle

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

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

* fix: honor enable_thinking=false

* fix: prevent local reasoning toggle regressions and align OpenAI effort levels

* fix: isolate external OpenAI reasoning toggle state

---------

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

* fix: clamp reasoning effort

* fix: align OpenAI reasoning effort

* fix: clear stale GGUF badge state

* ui: new badge on cloud setting

* fix: separate selected models from cached provider model list

* Studio: anthropic effort by model family (#5412)

* feat: external thinking control and Anthropic effort mapping

* fix: anthropic thinking constraints and 4.6 max effort mapping

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

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

* fix: harden Anthropic thinking params and effort mapping

---------

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

* studio/backend: drop top_p from Anthropic body when thinking is enabled

PR 5412 added body['top_p'] = max(0.95, min(top_p, 1.0)) inside the
thinking branch of _stream_anthropic, but Anthropic returns 400 on
extended/adaptive thinking when both temperature and top_p are set:

  invalid_request_error: temperature and top_p cannot both be
  specified for this model. Please use only one.

(Observed on Claude Opus 4.6.) The contract for thinking-enabled
requests is temperature=1 with neither top_p nor top_k allowed.

Replace the body['top_p'] = ... line with body.pop('top_p', None).
Defensive pop rather than a bare delete: the base body construction
above does not currently set top_p, but a future edit that adds it
would silently reintroduce the regression.

* studio/chat: force reasoningEnabled=true on local reasoning-effort models

Followup to PR 5402 / 5412. The model-status refresh path in
use-chat-model-runtime carried reasoningEnabled forward verbatim for
every reasoning-capable model. That left one observable edge case:

  1. user picks an external model that supports Off (gpt-5.x, Claude
     4.x), clicks Off — store sets reasoningEnabled=false
  2. user switches back to a local reasoning-effort model
     (gpt-oss / Harmony-style) which does NOT support Off
  3. composer's effectiveReasoningEnabled override paints the UI as
     'Think: <level>' (on)
  4. chat-adapter sees reasoningEnabled=false on the local branch
     and sends '{}', so the backend's _request_reasoning_kwargs
     returns None and the Harmony template falls back to its own
     default effort instead of the displayed level

Mirror the composer's override in the store on load: for local
reasoning-effort models (where supportsReasoningOff is false), force
reasoningEnabled=true so the store and the UI agree on every send.
Other reasoning styles still inherit prior state — only the
reasoning-effort family changes.

* studio/backend: align Anthropic thinking with the extended-thinking docs

Two compliance fixes against
https://platform.claude.com/docs/en/build-with-claude/extended-thinking

1. Adaptive-mode effort field shape
   The docs spell adaptive thinking as:
     {'thinking': {'type': 'adaptive'}, 'effort': {'type': '<level>'}}
   We had been sending the legacy 'output_config: {effort: <level>}'
   shape, which Anthropic appears to silently ignore — adaptive ran
   at the server default effort regardless of the user's selection.
   Rename to 'effort: {type: <level>}'.

2. thinking_delta event translation
   The Messages-API streams reasoning content as
   content_block_delta events with delta.type == 'thinking_delta',
   which our SSE loop was dropping entirely. On Claude 4.5/4.6 with
   display=summarized (the default), the user would see the answer
   text but never the reasoning panel. Wrap thinking_delta.thinking
   as inline <think>...</think> chunks (same pattern as the OpenAI
   Responses path) so the frontend's parseAssistantContent lifts it
   into the reasoning channel. The </think> closer fires on the
   first text_delta transition, on content_block_stop for the
   thinking block, on message_delta, and on message_stop —
   whichever arrives first — so no model path can leak an
   unclosed <think> into chat output.
   signature_delta events are left as no-ops; they carry
   verification metadata, not user-visible content.

Adds test_anthropic_thinking_translation.py with httpx.MockTransport
coverage of: effort shape on adaptive (Claude 4.6), budget_tokens
shape on manual (Claude 4.5), thinking_delta wrapping with signature
suppression, and thinking-only turns (display=omitted on Opus 4.7).

* studio/backend: revert Anthropic adaptive effort to output_config nesting

The previous commit (0a664df4) moved the adaptive-thinking effort
field to a top-level 'effort: {type: <level>}' based on a misread of
the docs page. The actual Messages API schema nests it under
output_config:

  thinking:       optional ThinkingConfigParam   ({type: 'adaptive'})
  output_config:  optional OutputConfig
    effort:       optional 'low' | 'medium' | 'high' | 'xhigh' | 'max'

Sending the top-level field produced:
  400 invalid_request_error: effort: Extra inputs are not permitted

Restore the body to:
  body['thinking'] = {'type': 'adaptive'}
  body['output_config'] = {'effort': effort}

This was the shape PR 5412 originally shipped (and the author
validated against live APIs). My 'compliance fix' was a regression.

The companion thinking_delta SSE translation added in 0a664df4 stays
— that part WAS missing from the previous shape and is unchanged
by this revert. Test pinning the body shape flipped to assert
output_config.effort, top-level effort is asserted absent.

* studio/backend: opt in to summarized thinking display on adaptive

Per the adaptive-thinking docs, the 'display' field on the thinking
config defaults to 'omitted' on Claude Opus 4.7 (and Mythos Preview).
With 'omitted' the API still emits a thinking content block, but its
'thinking' field is empty — only the signature_delta arrives.

Our SSE handler would then surface a stray '<think></think>' for the
empty block and the reasoning panel would stay blank for the entire
response. Set 'display': 'summarized' explicitly on the adaptive
thinking config so Opus 4.7 emits thinking_delta events the same way
Opus 4.6 / Sonnet 4.6 do (where 'summarized' is the default, making
the explicit setting a no-op there).

The manual-thinking branch (Claude 4.5) is unaffected — its default
is also 'summarized', and we have no reason to override it.

* studio/backend: log Anthropic SSE event counts for thinking diagnostics

Reports of 'no reasoning panel content on Anthropic' have two
distinct causes that produce the same symptom:

  1. Anthropic streamed thinking_delta events but our frontend
     dropped them somewhere on the rendering side.
  2. Anthropic did not emit thinking_delta at all (adaptive mode
     can skip thinking for simple prompts even with effort=high,
     and display=summarized only re-enables the *content* — it
     does not force thinking to happen).

Tally each event type for the duration of one stream and log the
counts in the finally branch, so the next 'no reasoning content'
report shows immediately whether thinking_delta was even on the
wire. Zero counts → upstream (model/effort/prompt choice).
Non-zero counts → triage moves to chat-adapter / parse-assistant
-content / the reasoning component.

* studio/backend: route external_provider logs through structlog

The studio backend wires structlog as the active logger (via
LogConfig.setup_logging at main.py:262), but external_provider.py
was using stdlib logging.getLogger(__name__) for every diagnostic.
The stdlib root logger defaults to WARNING with no handlers
attached, so plain logger.info('...') and logger.debug('...') from
this module were being silently dropped — including the
'Proxying chat completion to <url>' and the new
'Anthropic stream event counts' lines. Only WARNING/ERROR survived
(via the implicit fallthrough that the user actually observed
when an Anthropic call 400'd).

Switch the module-level logger to structlog.get_logger(__name__),
matching the routes/providers.py and routes/inference.py pattern.
All existing call sites use printf-style positional args, which
structlog accepts unchanged — no other edits needed.

* studio/backend: disable read timeout on SSE streams to external providers

Anthropic Opus 4.7 (adaptive thinking) and OpenAI gpt-5.x (/v1/responses)
can pause for tens of seconds between bytes while the model is
internally reasoning. httpx's read timeout is the *gap* between
successive reads, not a wall clock on the whole request — so the
shared 120s default was cutting streams mid-response:

  log: Anthropic stream event counts (... text_delta: 11)
       Read timeout from anthropic

(eleven text deltas in, no content_block_stop, no message_stop)

Add a separate _stream_timeout on ExternalProviderClient with
read = None (no gap timeout) and the same 10s / 120s connect/write/
pool bounds, then use it at the three SSE streaming call sites:
default OpenAI-compat chat completions, _stream_anthropic, and
_stream_openai_responses. Non-streaming call sites (chat_completion,
list_models, verify_models_endpoint_lightweight) keep self._timeout
because a stuck non-streaming response should still fail fast.

* studio/backend: log outbound Anthropic request shape for thinking debug

After bumping to Xhigh effort the user still saw zero thinking_delta
events and only one content_block_start, meaning Anthropic Opus 4.7
opened no thinking block at all. Per the effort docs that should be
impossible — Xhigh always thinks. Two open hypotheses:

  1. Our adaptive branch is not wiring output_config.effort onto the
     outbound body for this code path (regex miss, frontend never
     propagated reasoning_effort, etc).
  2. Anthropic is silently accepting output_config as an unknown
     field and falling back to high default effort regardless.

Add a single-line structlog INFO right before the stream POST that
echoes the keys actually present on the body (thinking, output_config,
temperature, presence of top_p / top_k, max_tokens). Messages are
deliberately excluded to keep PII out of the log. With this in place
the next 'no thinking on 4.7 at Xhigh' report shows immediately
whether we sent the effort knob — separating client bug from
provider behaviour.

* studio/chat: surface delta.reasoning_content from Kimi / DeepSeek thinking

Kimi (kimi-k2.6, kimi-k2-thinking) and DeepSeek's reasoner stream
their thinking content via a separate top-level field on the
chat-completion delta — choices[0].delta.reasoning_content — rather
than as a structured part inside delta.content. Per Kimi docs:

    In streaming output (stream=True), the reasoning_content field
    will always appear before the content field.

Our chat-adapter SSE loop only read delta.content (via
extractDeltaText), so the entire reasoning channel from these
providers was being silently dropped — kimi-k2.6 thinks by default
yet the chat UI showed no reasoning panel.

In the adapter:
- Read both delta.content and delta.reasoning_content per chunk
- When reasoning_content arrives, open a <think> block in
  cumulativeText (mirrors how the backend wraps Anthropic
  thinking_delta and OpenAI Responses reasoning summaries)
- When content arrives after reasoning, close </think> first
- On stream end, force-close any still-open <think> so
  parseAssistantContent can lift it into a reasoning part cleanly

Anthropic and OpenAI Responses paths are unaffected — they already
wrap as <think> on the backend and never set reasoning_content.

* studio: Kimi thinking toggle + 16k max_tokens floor

Two coordinated changes so Kimi's thinking is user-controllable and
the response budget meets the docs' floor.

Toggle (frontend + backend):
- getExternalReasoningCapabilities now handles provider=='kimi':
  kimi-k2.6 -> reasoning_style=enable_thinking, reasoningOff allowed
  kimi-k2-thinking -> always on (reasoningAlwaysOn=true, no off)
  kimi-k2.5 (and anything else) -> no reasoning controls
- chat-adapter already forwards enable_thinking on the
  enable_thinking-style branch, so the user toggle reaches the
  backend without additional wiring there.
- external_provider stream_chat_completion now translates the
  boolean into Kimi's wire shape on the default OAI-compat path:
    enable_thinking=True  -> body['thinking'] = {type: enabled, keep: all}
    enable_thinking=False -> body['thinking'] = {type: disabled}
  kimi-k2-thinking ignores the toggle so the API never gets a
  disabled value it would reject. Other providers on the same
  path are unaffected (gated on provider_type == 'kimi').

Max tokens floor:
- New EXTERNAL_MIN_OUTPUT_TOKENS_BY_PROVIDER table and
  getExternalMinOutputTokens helper. Kimi entry = 16000 per docs:
  'Set max_tokens >= 16,000 to ensure the full reasoning_content
  and final content can be returned without truncation.'
- chat-adapter clamps the outbound max_tokens to
  min(max(stored, providerMin), EXTERNAL_MAX_OUTPUT_TOKENS),
  so a stored value of 4096 still becomes 16000 when sending to
  Kimi (other providers unaffected, min stays effectively 64).
- chat-settings-sheet's Max Tokens slider min mirrors the same
  floor when an external Kimi model is selected, so the slider
  cannot show a value lower than what we'd actually send.
- chat-page threads activeExternalProviderType down to the panel.

* fix: stabilize external reasoning controls for Anthropic 4.6 and OpenAI o3

normalize Anthropic 4.6 reasoning effort handling by accepting max as an alias and mapping it to xhigh, while keeping Sonnet/Opus 4.6 in default model suggestions.
broaden reasoning effort typing across backend/frontend and migrate persisted max selections to xhigh for compatibility.
remove reasoning.summary=\"auto\" from OpenAI /v1/responses payloads to avoid o3 eligibility/gating errors.
tighten provider model filtering to hide retired gpt-5.3 IDs and add exact/prefix filtering support in provider routes.

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

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

* studio: add openrouter/free + full reasoning passthrough on OpenRouter

Four-layer wire-up so the OpenRouter free-router model (which picks
a free model at random per request, filtered by needed capabilities)
shows up in the picker and its reasoning channel surfaces in the
chat UI.

Registry:
- providers.py: openrouter/free seeded at the top of openrouter
  default_models. Curated list, so picker shows it immediately.

Frontend capability map:
- provider-capabilities.ts: getExternalReasoningCapabilities now
  treats openrouter as enable_thinking style with off support. The
  Think dropdown appears for every OpenRouter model; the gateway
  silently no-ops the parameter for models that do not reason, so
  surfacing one toggle on every model is safe.

Backend reasoning passthrough:
- external_provider.py stream_chat_completion (default OAI-compat
  branch): for provider_type=='openrouter', translate the request:
    reasoning_effort in {low,medium,high} -> body['reasoning'] =
        {'effort': <level>}
    enable_thinking=True  -> body['reasoning'] = {'enabled': True}
    enable_thinking=False -> body['reasoning'] = {'enabled': False}
  Matches the documented shape at
  https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
  with effort and max_tokens mutually exclusive.

Frontend SSE reader:
- chat-adapter.ts: OpenRouter streams reasoning as a third shape we
  did not handle yet: delta.reasoning_details is an array of parts
  like {type: 'reasoning.text', text: '...'}. Pull text from every
  part, merge with the existing delta.reasoning_content channel
  used by Kimi/DeepSeek, and feed the combined string through the
  same <think>...</think> wrap path so parseAssistantContent lifts
  it into the reasoning panel. Anthropic/OpenAI Responses paths
  already wrap on the backend, so they never set this field — no
  cross-provider interference.

* studio/backend: surface OpenRouter SSE errors and router-chosen model in logs

The frontend showed 'Provider returned error' for some openrouter/free
requests with nothing on the backend side to triage from — the
existing 4xx error log only fires when the upstream returns a non-200
status code, but OpenRouter (and most OAI-compat providers) return
200 OK and emit the actual failure as an SSE error event mid-stream,
which our default-path stream loop forwarded verbatim without
logging.

Best-effort diagnostics on the default OpenAI-compat stream path:
- Peek at every `data:` line in the inner forward loop, parse JSON
  best-effort (silently skip on failure so nothing is dropped).
- Count event types: delta / error / done.
- On any chunk containing an `error` field, emit a structlog WARNING
  with the provider type and the error payload — same trail the
  user would otherwise have to dig out of browser devtools.
- Latch the first non-empty `chunk.model` field. OpenRouter reports
  the router-picked underlying model there per request, so the
  finally-block summary log shows which free model handled the call.

In the finally block:

    'openrouter stream complete (model=openrouter/free,
     chosen=google/gemini-2.5-flash, events={delta: 47, done: 1})'

Zero overhead for non-error streams (a json.loads per chunk +
dict-key lookups). The structlog logger is already configured at
INFO; ERROR and WARNING surface in JSON logs without further setup.

Hoists `import json as _json` to module top so the default path can
reuse it; the existing in-function imports in _stream_anthropic and
_stream_openai_responses are now redundant but harmless.

* studio/chat: show router-picked model after 'openrouter/free:' in chip

When the user picks openrouter/free, the gateway routes each request
to a different underlying free model. Until now there was no way to
tell which one actually replied without reading the backend logs.

Surface the picked model in the active-model chip:

- chat-runtime-store gains lastOpenRouterChosenModel: string|null
  plus a setter. Reset on every model switch unless the user stays
  on openrouter/free.
- chat-adapter SSE loop latches chunk.model into the store on
  every chunk whose top-level model differs from
  openrouter/free, gated on the active checkpoint being
  openrouter/free under an OpenRouter provider.
- chat-page externalModels useMemo appends :<chosen> to the display
  name for the openrouter/free option when the store has a value,
  so ModelSelector renders e.g.
    'openrouter/free:google/gemini-2.5-flash'
  in the chip. Other models unaffected.
- Model-switch callback in chat-page clears the cached value when
  the user moves to any model other than openrouter/free, so the
  chip never shows a stale suffix from a previous session.

* studio/chat: shorten openrouter/free chip to openrouter:<short-chosen>

The full display name in use was:
  openrouter/free:inclusionai/ring-2.6-1t-20260508:free

The `:free` suffix on the underlying id already conveys 'free model',
which made the leading `/free` on the router id redundant, and the
`inclusionai/` org prefix was just noise crowding the chip.

Trim both. Now the chip renders as:
  openrouter:ring-2.6-1t-20260508:free

Strictly a display change in chat-page externalModels useMemo — the
backend wire id stays `openrouter/free`, the runtime store still
caches the full `inclusionai/...:free` value, and the model-switch
clearing logic is unchanged.

* studio/providers: switch OpenRouter to remote listing with org allowlist + cap

Same shape as Hugging Face Inference. The curated list had only four
entries; remote listing fetches OpenRouter's full ~300-model
catalog via /v1/models and the new allowlist + limit scope it back
down to a usable picker.

- model_list_mode: remote (was curated)
- model_id_allowlist matches the prefixes:
    openrouter | openai | anthropic | google | meta-llama | qwen
    | mistralai | deepseek | moonshotai | inclusionai | zai-org
    | z-ai
  Anything outside drops out.
- model_id_limit: 20 — first 20 post-filter matches from the live
  fetch; default_models stays seeded so the most useful canonical
  ids are always visible regardless of API response order.
- default_models seed extended from 4 to 6 (openrouter/free,
  openai/gpt-4o, anthropic/claude-sonnet-4-5, google/gemini-2.5-flash,
  mistralai/mistral-large-2411, deepseek/deepseek-r1).
  openrouter/free remains the first entry, so the dialog's
  loadModels() union-merge (registryDefaults first, then remote,
  deduped via Set) keeps it at the top of the picker.

* feat: external mistral thinking toggle

* studio/chat: fix TS2540 by replacing readonly ContentPart instead of mutating

The ContentPart type from @assistant-ui/react marks `text` as readonly,
so the coalesce-adjacent-same-type-part optimization in
parseAssistantContent failed the tsc build with:

  parse-assistant-content.ts(15,10): error TS2540: Cannot assign to
      'text' because it is a read-only property.
  parse-assistant-content.ts(25,10): error TS2540: ...

This broke npm run build, the Studio installer's `building frontend...`
step, and every downstream CI job that runs against an installed
Studio (Mac/Windows/Linux variants of Studio API CI, GGUF CI, UI CI,
Tauri CI, Wheel CI).

Replace the last element with a fresh merged object instead of
mutating its `text` field. Same allocation profile as the previous
path (one object swap per merge), type-safe under the readonly
declaration. Behaviour unchanged.

* studio/backend: restore summary='auto' on OpenAI Responses reasoning body

A recent refactor dropped the `summary: 'auto'` field from the
reasoning config we send to /v1/responses. Without it OpenAI does
not emit reasoning summary events on most reasoning models, which
means our SSE handler has no <think>…</think> to wrap and the chat
reasoning panel stays blank for any gpt-5.x / o3 response.

The expected wire shape is:
    body['reasoning'] = {'effort': '<level>', 'summary': 'auto'}

Two backend tests pin this:
- test_responses_reasoning_effort_included_when_requested (high)
- test_responses_reasoning_effort_xhigh_passthrough (xhigh)
Both were failing with AssertionError because the produced body
omitted `summary: auto`.

Restore the field. Skip it only for the explicit "off" case
(effort: 'none'), where summaries serve no purpose. The
enable_thinking=True fallback (no explicit effort) also pairs
medium effort with summary='auto' so that branch produces
reasoning text too.

* chat: external reasoning, OpenRouter curation, Think toggle fixes

* fix: opus and sonnet 4.6 xhigh --> max

* [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: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-14 16:13:59 +04:00
Daniel Han
ef9f672fe8
security: NOT affected by Mini Shai-Hulud (May-12 wave) -- forward-looking hardening only (#5397)
* scripts/scan_*: add Mini Shai-Hulud May-12 IOC strings and pin-blocklists

Append the May-12 2026 wave indicators (git-tanstack.com, transformers.pyz,
/tmp/transformers.pyz, "With Love TeamPCP", "We've been online over 2 hours")
to all three scanner IOC tables, add BLOCKED_NPM_VERSIONS (42 TanStack pkgs,
4 opensearch versions, 3 squawk pkgs) in scan_npm_packages.py and
lockfile_supply_chain_audit.py (kept byte-identical), add BLOCKED_PYPI_VERSIONS
(guardrails-ai 0.10.1, mistralai 2.4.6, lightning 2.6.2/2.6.3) plus
RE_MAY12_IOC wiring across check_py_file/check_shell_file/check_workflow_file
in scan_packages.py. The npm orchestrator and the lockfile auditor now
short-circuit on a blocked entry before fetching the tarball, and the
PyPI download pipeline drops blocked specs before pip download is invoked.

* tests/security: regression suite for supply-chain scanners

Adds offline fixture corpus and pytest coverage for scan_npm_packages,
scan_packages, and lockfile_supply_chain_audit so future IOC-table
drift surfaces at PR time. Pytest scope narrowed to tests/security so
GPU smoke tests are not picked up by default.

* ci(security-audit): drop continue-on-error on pip-scan and npm-scan jobs

Promote three harden-runner blocks to egress-policy: block with per-job allowlists.
Add tests-security job running pytest tests/security as a hard gate.

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

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

* scripts: harden third-party downloads, pip resolver pins, atomic writes

Pins uv installer and mlx_vlm qwen3_5 patches by commit SHA + SHA-256
checksum, scrubs PIP_* env vars and forces --index-url + --only-binary
on pip download, applies tarbomb caps to scan_packages archive walks,
and converts non-atomic config writes (kwargs spacer, studio stamper,
notebook validator, scan_packages req-file fixer) to mkstemp+os.replace.

Also adds host allowlist to notebook_to_python downloader, threads an
--allow-shell flag through its shell=True emission with reviewer warning
comments, locks both MLX installer scripts to set -euo pipefail, and
extends CODEOWNERS so colab snapshot data files require notebook-owner
review.

* ci(workflows): harden release-desktop / smoke / notebooks workflows

Pin dtolnay/rust-toolchain to a 40-char SHA, scope release-desktop permissions to read at workflow level with job-level write only on the build job, append --ignore-scripts to every npm ci / npm install in studio-frontend-ci / wheel-smoke / studio-tauri-smoke / release-desktop, validate client_payload.ref shape via an env-var-isolated regex on every notebooks-ci job, and add step-security/harden-runner in audit mode as the first step of release-desktop and mlx-ci.

* scripts: promote silent scanner failures to non-zero exit codes
scan_packages now returns 2 on pip-download failure and emits a CRITICAL archive_corrupted finding on truncated wheels/sdists.
notebook_to_python exits 1 on per-notebook failures; notebook_validator wraps the stash/pop in try/finally; lockfile audit rejects bare UNSLOTH_LOCKFILE_AUDIT_SKIP=1 with a loud GitHub Actions warning.

* Add npm cooldown + new-install-script gate + Dependabot cooldown

Pins min-release-age=7 (npm 11.10+) in repo-root and studio/frontend
.npmrc, adds scripts/check_new_install_scripts.py to fail PRs that
add a postinstall dep, ships a new security-audit job for npm audit
signatures plus the diff, and extends .github/dependabot.yml with
cooldown stanzas. Pin @tanstack/react-router to 1.169.9 per GHSA-
g7cv-rxg3-hmpx; lockfile regen deferred until that release lands on
npm. tests/security gains 4 new tests; full suite 26/26 green.

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

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

* ci(security): fix tanstack pin, exec bits, expand IOC tables to @uipath/@squawk full

- Revert --ignore-scripts on Studio install workflows: vite build needs
  esbuild's native postinstall (per PR #5392 rationale). Keep
  --ignore-scripts on security-audit.yml's standalone npm audit job.
- Pin @tanstack/react-router to the actual published 1.169.2 (was a
  forward-looking 1.169.9 that does not exist on npm; broke npm ci).
- Drop redundant repo-root .npmrc; studio/frontend/.npmrc covers the
  only npm project today (root cooldown re-instate via dependabot.yml).
- Restore exec bits on 7 files my filesystem stripped during cherry-pick.
- Expand BLOCKED_NPM_VERSIONS with full safedep.io + Aikido enumeration:
  22 @squawk/* packages with 5 versions each (110 entries; previously
  3 entries with 1 version each), and 66 @uipath/* packages (entirely
  missing before). Mirror in scripts/lockfile_supply_chain_audit.py.

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

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

* tests/security: suppress CodeQL py/incomplete-url-substring-sanitization

The two flagged 'X' in Y assertions are NOT URL sanitization checks.
They verify our scanner WROTE a known IOC literal into its stdout /
Finding.evidence, which is the opposite of an attack surface --
matching the scanner's output is precisely what catches the worm.
Inline lgtm[] suppression with a 4-line rationale comment above each.

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

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

* scripts/scan_*: expand IOC tables with Aikido full 169-pkg enumeration

Per Aikido 2026-05-12 disclosure (373 malicious package-version entries
across 169 npm package names), add to BLOCKED_NPM_VERSIONS:

  - @mistralai/* npm scope (3 packages, 9 versions) -- separate from
    the PyPI mistralai package already in BLOCKED_PYPI_VERSIONS
  - @tallyui/* (10 packages, 30 entries)
  - @beproduct/nestjs-auth (18 versions 0.1.2..0.1.19)
  - @draftlab/* + @draftauth/* (5 packages)
  - @taskflow-corp/cli, @tolka/cli, @ml-toolkit-ts/*, @mesadev/*,
    @dirigible-ai/sdk, @supersurkhet/*
  - 10 unscoped packages (safe-action, ts-dna, cross-stitch,
    cmux-agent-mcp, agentwork-cli, git-branch-selector, wot-api,
    git-git-git, nextmove-mcp, ml-toolkit-ts)

Also add to KNOWN_IOC_STRINGS / NPM_IOC_STRINGS:

  - router_init.js SHA-256 ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c
  - tanstack_runner.js SHA-256 2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96
  - bun run tanstack_runner.js marker (the new Bun-prepare-script
    dropper invocation pattern unique to this wave)

Total: 170 packages, 401 versions blocklisted. Studio lockfile still
scans clean (0 findings, 0 hard errors).

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

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

* scripts/scan_*: web-verification additions (@tanstack/setup, intercom-client)

Two findings from cross-checking BLOCKED_NPM_VERSIONS / KNOWN_IOC_STRINGS
against GHSA-g7cv-rxg3-hmpx + Aikido + safedep.io + Socket + Semgrep.

  - Fix asymmetry: @tanstack/setup IOC string was in
    lockfile_supply_chain_audit.py's NPM_IOC_STRINGS but missing from
    scan_npm_packages.py's KNOWN_IOC_STRINGS. The literal is the malicious
    optional-dependency name used by the May-12 TanStack wave; no
    legitimate npm package of this name exists.

  - Add intercom-client@7.0.4: the npm counterpart of the lightning
    2.6.2/2.6.3 PyPI compromise (Apr-30 wave). Same threat actor
    (TeamPCP). Confirmed by Semgrep, Aikido, OX Security, Resecurity,
    Kodem. Safe version is 7.0.3 and earlier.

Total BLOCKED_NPM_VERSIONS: 171 packages / 402 versions. Both files
remain byte-identical. Studio lockfile still scans clean.

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

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

* ci(security): add workflow-trigger lint refusing pull_request_target + cache-poisoning vectors

The two patterns that together powered GHSA-g7cv-rxg3-hmpx (TanStack
Mini Shai-Hulud) are now gated at PR time:

  1. pull_request_target -- the worm chain started with a fork PR that
     ran in the base-repo context. Every workflow in this repo today
     uses 'pull_request' (safe); the lint refuses any new
     pull_request_target additions outright. workflow_run is
     restricted, allowed only with an explicit allow-comment.

  2. Shared cache keys between PR-triggered workflows and the publish
     workflow (release-desktop.yml). The TanStack attack chain poisoned
     a shared Actions cache from a fork PR; the legitimate release
     workflow then restored the poisoned cache. The lint refuses any
     cache key that appears in both a PR-triggered workflow and a
     workflow_dispatch-only / publish workflow.

Current tree is clean: 0 pull_request_target, 0 workflow_run, 0
PR-publish cache-key collisions across all 24 workflows. The lint
locks that invariant in place.

Files:
  + scripts/lint_workflow_triggers.py (~200 LOC, stdlib + PyYAML)
  + tests/security/test_lint_workflow_triggers.py (5 tests covering
    current-tree pass, pull_request_target reject, workflow_run
    restricted, justified workflow_run accept, cache-key collision
    reject)
  ~ .github/workflows/security-audit.yml: new workflow-trigger-lint
    job, no continue-on-error, harden-runner block-mode, PyYAML only
    runtime dep.

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

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

* security: fix tests-security CI job + CodeQL false-positives

Two CI failures on the prior push:

1. pytest tests/security -- 5 lint regression tests failed because
   scripts/lint_workflow_triggers.py imports PyYAML which is not in
   the bare runner's Python env. Added pyyaml==6.0.2 to the pip
   install step alongside pytest. (29 scanner tests already passed.)

2. CodeQL py/incomplete-url-substring-sanitization fired on two
   test assertions that check the scanner WROTE the IOC literal
   to its own stdout/stderr. The rule pattern-matches on
   `"<host>" in <var>` and cannot distinguish a URL sanitizer from
   a regression-test evidence check. Previous `# lgtm[...]` inline
   suppressions were detached from the operator when pre-commit
   reformatted the assert across multiple lines. Rebuilt the IOC
   literals at runtime (`"git-tanstack." + "com"`) so no URL-shaped
   source literal appears on the `in` operator line; rule cannot
   trigger.

Verified locally: `pytest tests/security -v` -> 34 passed in 2.70s.

* security(studio): defensive .npmrc cooldown aliases + save-exact

Two additions to studio/frontend/.npmrc to harden the existing
`min-release-age=7` (Mini Shai-Hulud defence):

1. `minimum-release-age=10080` (minutes) -- defensive alias for the
   same 7-day floor. Some npm versions / wrappers consult one key but
   not the other; setting both prevents a single upstream setting-name
   parse change from silently disabling the cooldown. The two keys
   MUST agree (do not let them drift).

2. `save-exact=true` -- refuses to write back `^x.y.z` ranges into
   package.json when a maintainer runs `npm install <pkg>` locally.
   Does NOT rewrite already-present ranges; stops NEW carets from
   creeping into the manifest as patch-version footguns.

Verified: pytest tests/security -> 34 passed in 2.63s.

* chore(dependabot): remove dead bun entry for /studio/frontend

`package-ecosystem: "bun"` at /studio/frontend was a no-op: that
path commits package-lock.json, not bun.lock / bun.lockb, so
Dependabot's bun ecosystem silently skipped it. The actual
behaviour is unchanged -- the npm entry below the cargo block
already owns npm_and_yarn security advisories for /studio/frontend
with `open-pull-requests-limit: 0` (version-update PRs suppressed,
security PRs flow through).

This commit:

  - Deletes the bun entry (kept a placeholder comment so a future
    bun migration knows where to slot it back in).
  - Rewrites the npm /studio/frontend entry comment to explain the
    real intent: lockfile is the authoritative pin, .npmrc
    `min-release-age=7` already blocks fresh tarballs at install
    time, dependabot only needs to surface security advisories.

No functional change: same set of dependabot PRs as before (zero
version updates, security advisories grouped weekly with cooldown).

Verified: pytest tests/security -> 34 passed in 2.67s; YAML
parses cleanly via PyYAML.

* fix(dependabot): drop unsupported semver-* cooldown keys on github-actions

Dependabot's validator rejected the config with:

  The property '#/updates/0/cooldown/semver-minor-days' is not
  supported for the package ecosystem 'github-actions'.
  The property '#/updates/0/cooldown/semver-patch-days' is not
  supported for the package ecosystem 'github-actions'.

The `semver-minor-days` / `semver-patch-days` cooldown knobs are
only valid for semver-aware ecosystems (npm, cargo, etc.). The
github-actions ecosystem pins via git tags / SHAs, not semver, so
only `default-days` is honored. Pre-existing bug on main; surfaced
on this PR because the prior commit re-validated the file.

Behaviour: github-actions PRs now respect the 7-day cooldown floor
(was already the intent), without the no-op semver bands.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-13 04:58:12 -07:00
Etherll
d1f9ab659f
fix: harden Studio IME composer sends (#5327)
Some checks are pending
Backend CI / Backend ruff lint (non-blocking) (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Studio GGUF CI / Studio boots, loads a GGUF, answers a chat completion (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
* fix: harden Studio IME composer sends

* fix: address IME composer review feedback
2026-05-07 18:29:10 +04:00
Wasim Yousef Said
858ba9ba20
Fix Studio chat history and attachments with newer assistant-ui (#5296)
Pass Studio history, dictation, and attachment adapters directly into useLocalRuntime instead of relying on assistant-ui's unstable_Provider ordering, which fixes blank chat threads on reload and broken image upload / drag-drop on fresh PyPI and curl installs that resolved @assistant-ui/react to the newer _RuntimeBinder path.

Also pins @assistant-ui/react, @assistant-ui/react-markdown, @assistant-ui/react-streamdown, and assistant-stream to exact versions in package.json so future installs cannot silently re-float onto a newer pre-1.0 release. The lockfile alone only fixes resolution for the install that consumes it -- a future bun add / npm install <other-pkg> rewrites the lockfile and is free to drift carets within their range, which is exactly the path that pulled @assistant-ui/react from 0.12.19 to 0.12.28 and broke 2026.5.1.

Adds studio/frontend/package-lock.json so npm fallback / fresh installs have deterministic resolution.

Tests:
- bun run typecheck
- npm ci on a clean tree (1083 packages)
- npm run build (bundle no longer contains the unstable_Provider Studio call site; only assistant-ui internals reference unstable_Provider)
2026-05-05 17:22:11 -07:00
Wasim Yousef Said
726abd5e6b
Add Tauri native notifications (#5273) 2026-05-05 00:09:48 -07:00
Wasim Yousef Said
507417579f
Fix Studio desktop tray installer and titlebar and bux fixes (#5179)
* fix(tauri): dedupe tray and brand nsis installer

* feat(tauri): add linux windows custom titlebar

* Fix desktop auth gate after backend startup

* Fix desktop installer assets and setup script skew

* Scope setup failure exit to Tauri installer

* fix desktop updater production channel

* fix desktop auth runtime installer regressions

* fix desktop dev cors retry

* fix tauri process generation race

* feat desktop diagnostics support report

* fix tauri apt update best effort

* Fix Windows desktop NSIS installer upgrades

* Start managed backend after desktop install

* Improve NSIS installer branding resolution

* Fix assistant-ui internal import

* Fix desktop release workflow

* Keep desktop auth retry on cached backend

---------

Co-authored-by: wasimysaid <wasimysaid@users.noreply.github.com>
2026-04-30 08:40:39 -07:00
Lee Jackson
4ab5378d28
Studio: Pin assistant-ui core for fresh installs (#5229)
* fix(studio): pin assistant-ui core for fresh installs

* fix(studio): use assistant-ui internal export
2026-04-30 13:50:23 +02:00
Wasim Yousef Said
a5eb2e3d50
Add tauri (#5144)
* add unsloth studio desktop app

* Fix review findings

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

* Fix review findings (loop 2)

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

* Add review tests

* Consolidate review tests

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

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

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

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

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

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

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

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

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Daniel Han <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-04-23 04:50:10 -07:00
Wasim Yousef Said
b01e9af124
feat(studio): replace navbar with collapsible sidebar (#4936)
* feat(studio): replace navbar navigation with collapsible sidebar

Add an app-wide sidebar with hover-expand and pin-to-dock behavior.
Navigation items (Studio, Recipes, Export, Chat) move from the center
pill navbar to the sidebar. Chat threads and recipes render as
collapsible sub-lists. Navbar simplified to logo + update + close.

- Extend SidebarProvider with pinned/hovered state model
- New AppSidebar with animated active indicator, sloth profile menu,
  theme toggle, guided tour, back/forward navigation
- Chat page refactored to URL-driven view state via search params
- Extract reusable hooks for chat thread and recipe sidebar data
- Guard startViewTransition for browser compatibility
- Wrap chat deletions in Dexie transaction for data integrity

* feat(studio): move logo to sidebar and make navbar overlay

- Sidebar is now full-height with logo in SidebarHeader
- Collapsed sidebar shows sticker.png, expanded shows full logo
- Navbar is absolute-positioned overlay (no layout space)
- Main content extends to top, aligning with navbar controls

* feat(studio): full-height sidebar with recents, edge-to-edge nav buttons

- Sidebar outside max-w-7xl, pinned to left edge
- Remove sidebar rounding, menu buttons rounded-md
- Nav buttons flush to sidebar edges with no left rounding
- Replace collapsible recipes/chat with flat nav items
- Add Recents section with chat history (1 item when not on chat, full on chat)
- New Chat as first nav item with PencilEdit02Icon
- Cursor pointer on all sidebar buttons
- Navbar temporarily hidden for screenshots

* fix(studio): fix chat scroll, action bar hover, collapsible recents

- Fix sticky composer by removing `relative` override on viewport footer
- Action bar buttons only show on hover (autohide=always)
- Remove floating border/shadow from action bar
- Add scroll space above composer for last message actions
- Back/forward buttons use router history (stay in-app)
- Recents section collapsible with chevron on chat route
- Set html/body/#root height for proper h-full chain

* fix(studio): address review feedback, clean up unused code

- Unhide navbar (was left hidden from screenshot)
- Remove unused imports: SidebarMenuSub*, BubbleChatIcon, ColumnInsertIcon
- Remove unused vars: recipeItems, activeRecipeId, canCompare, recipesOpen
- Include compare query id in active sidebar selection
- Use store type for contextUsage instead of inline type
- Simplify noop in sidebar.tsx
- Remove empty className prop

* feat(studio): add mobile sidebar, recent runs section, and misc UX fixes

* feat(studio): scaffold settings feature module with dialog store

* feat(studio): add tri-state theme store for settings

* feat(chat): add clear-all-chats and export-chat-history utils

* feat(studio): add settings dialog shell with tab rail

* feat(studio): add appearance tab with theme and sidebar pin

* feat(studio): add settings general tab with hf token, auto-title, reset prefs

* feat(studio): add settings chat tab with export and clear

* feat(studio): add api keys tab with list and revoke flow

* feat(studio): add create-key form and reveal dialog

* feat(studio): add usage examples panel to api keys tab

* feat(studio): add settings about tab with update and shutdown

* feat(studio): add settings dropdown item and cmd-comma shortcut

* feat(studio): remove legacy api-keys route and chat-sheet preference rows

* fix(studio): settings dialog a11y + polish pass

* feat(studio): inline api key reveal card replacing nested dialog

* fix(studio): hide revoked keys from settings list

* refactor(studio): strip navbar and hoist training unload guard

* feat(studio): explicit sidebar toggle, remove hover-open and pin icons

* fix(studio): use SidebarRight01Icon for collapsed sidebar open toggle

* fix(studio): address code review findings for settings dialog

* feat(studio): collapsible navigate group with standalone new-chat and compare

* fix(studio): chat-only standalone actions, use ColumnInsertIcon for compare

* fix(studio): sidebar new-chat/compare state reset and icon-mode collapsible

* feat(studio): add compact logo assets for sidebar header

* Fixed sidebar design

* fix(studio): sidebar delete icon hover contrast and sizing

* feat(studio): route-gate sidebar recents (chats off /studio, runs on /studio)

* feat(studio): add chat search store

* feat(studio): add chat search index hook with snapshot-on-open

* feat(studio): add chat search command dialog with global shortcut

* feat(studio): wire chat search into sidebar

* fix(studio): trim hf token on save, add show/hide toggle, commit on close

* revert(studio): restore original sidebar/border colors, brighten sidebar

* feat(studio): forward overlayClassName through CommandDialog

* fix(studio): wrap search dialog in Command context, redesign as flat 635px card

* fix(studio): reserve right padding on recent items so delete icon stops overlapping title

* fix(studio): skip hf token unmount-commit during reset-prefs reload

* chore(studio): drop unused icon import and unreachable runs navigate branch

* fix(studio): chat search index filters archived before limit, batches message query, picks up reasoning text

* fix(studio): keep CommandEmpty in tree so empty state renders correctly

* fix(studio): cap system prompt and chat template textareas so they scroll instead of growing

* fix(studio): attach chat-compare tour anchor to sidebar compare button

* fix(studio): persist system theme explicitly so next-themes does not clobber on reload

* fix(studio): auto-switch to history tab when selecting a recent run from sidebar

* UI overhaul: chatbox, scrollbar, sidebar, and compare view

UI Changes:
- Redesigned the Compare UI with general cleanup
- Redesigned the Chatbox UI
- Reduced the width of the user chat bubble for improved readability
- Narrowed the user chat box across the content page
- Adjusted thinking-box text color to be slightly darker
- Removed faded text effect from chat messages
- Removed faded text effect from the thinking box
- Added a small LLM chat safety note at the bottom of the chatbox
- Restyled the scrollbar

Layout & Behavior:
- Reworked the scrollbar to span the full height of the page (no top/bottom padding) and remain persistently visible when content is scrollable, rather than only on hover
- Reworked the Configuration sidebar to span full height — removed rounded corners and borders, with the scrollbar adjusted to match the full top-to-bottom layout
- Adjusted the top menu and bottom chatbox content areas to work correctly with the new full-page scroll behavior
- Made chat content match the chatbox width, with content sliding slightly behind the chatbox when scrolling
- Aligned chat text width with the chatbox for visual consistency, including how far the text extends behind the chatbox

Fixes:
- Fixed the chatbox not auto-expanding when typing multi-line input while bottom-positioned during an active chat (previously only worked before a chat had started)
- Fixed positioning and design of the user chat hover menu buttons to match the assistant chat box — now displayed below the chat bubble instead of on the left side

* Fix user message layout in thread component

* swap code icon

* fix compare layout

* fix compare pane flex

* Sidebar improvements and fixes

- Added scrolling support to the sidebar so menus and recent chats no longer get hidden
- Recent chats are now always visible in the sidebar, not hidden when in Studio, Recipes, or Export
- Recent chat is now deselected when selecting other navigations
- Fixed sidebar glitch where browser resize could make the sidebar and expand button disappear completely
- Fixed glitch where the open-sidebar hover tooltip appeared above the logo when clicking expand sidebar
- Reduced sidebar width on mobile to around 2/3 of the screen (was too wide)
- Made the close-sidebar hover tooltip consistent with the rest of the design
- Removed sidebar collapse/expand animation
- Small adjustment to chat width

* Fix route scrolling, polling, and theme sync issues

* Fix Studio page scrolling

---------

Co-authored-by: sneakr <hauzin@hotmail.com>
2026-04-16 08:46:16 -07:00
dependabot[bot]
5fa8683b27
build(deps): bump the bun-frontend group across 1 directory with 16 updates (#4586)
* build(deps): bump the bun-frontend group across 1 directory with 16 updates

Bumps the bun-frontend group with 16 updates in the /studio/frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@dagrejs/dagre](https://github.com/dagrejs/dagre) | `2.0.4` | `3.0.0` |
| [@dagrejs/graphlib](https://github.com/dagrejs/graphlib) | `3.0.4` | `4.0.1` |
| @hugeicons/core-free-icons | `3.3.0` | `4.0.0` |
| [@streamdown/cjk](https://github.com/vercel/streamdown/tree/HEAD/packages/streamdown-cjk) | `1.0.2` | `1.0.3` |
| [@streamdown/code](https://github.com/vercel/streamdown/tree/HEAD/packages/streamdown-code) | `1.0.2` | `1.1.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.577.0` | `1.6.0` |
| [recharts](https://github.com/recharts/recharts) | `3.7.0` | `3.8.0` |
| [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) | `3.8.5` | `4.1.0` |
| [streamdown](https://github.com/vercel/streamdown/tree/HEAD/packages/streamdown) | `2.3.0` | `2.5.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `1.9.4` | `2.4.8` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.12.0` | `25.5.0` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.1.0` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.4.26` | `0.5.2` |
| [globals](https://github.com/sindresorhus/globals) | `16.5.0` | `17.4.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |



Updates `@dagrejs/dagre` from 2.0.4 to 3.0.0
- [Release notes](https://github.com/dagrejs/dagre/releases)
- [Changelog](https://github.com/dagrejs/dagre/blob/master/changelog.md)
- [Commits](https://github.com/dagrejs/dagre/compare/v2.0.4...v3.0.0)

Updates `@dagrejs/graphlib` from 3.0.4 to 4.0.1
- [Release notes](https://github.com/dagrejs/graphlib/releases)
- [Changelog](https://github.com/dagrejs/graphlib/blob/master/changelog.md)
- [Commits](https://github.com/dagrejs/graphlib/compare/v3.0.4...v4.0.1)

Updates `@hugeicons/core-free-icons` from 3.3.0 to 4.0.0

Updates `@streamdown/cjk` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/vercel/streamdown/releases)
- [Changelog](https://github.com/vercel/streamdown/blob/main/packages/streamdown-cjk/CHANGELOG.md)
- [Commits](https://github.com/vercel/streamdown/commits/@streamdown/cjk@1.0.3/packages/streamdown-cjk)

Updates `@streamdown/code` from 1.0.2 to 1.1.1
- [Release notes](https://github.com/vercel/streamdown/releases)
- [Changelog](https://github.com/vercel/streamdown/blob/main/packages/streamdown-code/CHANGELOG.md)
- [Commits](https://github.com/vercel/streamdown/commits/@streamdown/code@1.1.1/packages/streamdown-code)

Updates `lucide-react` from 0.577.0 to 1.6.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.6.0/packages/lucide-react)

Updates `recharts` from 3.7.0 to 3.8.0
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.7.0...v3.8.0)

Updates `shadcn` from 3.8.5 to 4.1.0
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.1.0/packages/shadcn)

Updates `streamdown` from 2.3.0 to 2.5.0
- [Release notes](https://github.com/vercel/streamdown/releases)
- [Changelog](https://github.com/vercel/streamdown/blob/main/packages/streamdown/CHANGELOG.md)
- [Commits](https://github.com/vercel/streamdown/commits/streamdown@2.5.0/packages/streamdown)

Updates `@biomejs/biome` from 1.9.4 to 2.4.8
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.8/packages/@biomejs/biome)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 24.12.0 to 25.5.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.39.4 to 10.1.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.1.0)

Updates `eslint-plugin-react-refresh` from 0.4.26 to 0.5.2
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.26...v0.5.2)

Updates `globals` from 16.5.0 to 17.4.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.4.0)

Updates `typescript` from 5.9.3 to 6.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: "@dagrejs/dagre"
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: "@dagrejs/graphlib"
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: "@hugeicons/core-free-icons"
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: "@streamdown/cjk"
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: bun-frontend
- dependency-name: "@streamdown/code"
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: bun-frontend
- dependency-name: lucide-react
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: recharts
  dependency-version: 3.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: bun-frontend
- dependency-name: shadcn
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: streamdown
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: bun-frontend
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.8
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: eslint
  dependency-version: 10.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-frontend
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: bun-frontend
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: bun-frontend
...

Signed-off-by: dependabot[bot] <support@github.com>

* Revert dagrejs upgrades

Keep @dagrejs/dagre at ^2.0.4 and @dagrejs/graphlib at ^3.0.4.

* Revert biome, eslint, typescript, and recharts upgrades

These upgrades break studio/frontend locally:

- @biomejs/biome 2.4.10 fails to parse the existing biome.json
  (files.ignore and organizeImports keys removed in v2; schema
  version mismatch).
- typescript 6.0.2 emits TS5101 on tsconfig.app.json baseUrl
  ("Option 'baseUrl' is deprecated and will stop functioning in
  TypeScript 7.0"), so tsc -b exits 2.
- eslint 10.2.0 conflicts with eslint-plugin-react-hooks@7.0.1,
  which peers on eslint ^9; npm install fails with ERESOLVE.
- recharts 3.8.1 widened LegendPayload.dataKey to include a
  function type, which breaks the React key={item.dataKey} usage
  in src/components/ui/chart.tsx (TS2322).

Hold these at their current pinned versions until the upstream
peer deps and config migrations are ready.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-04-08 04:34:33 -07:00
Etherll
d69d60ff19
perf(studio): upgrade to Vite 8 + auto-install bun for faster frontend builds (#4522)
* perf(studio): upgrade to Vite 8 + auto-install bun for 3x faster frontend builds

* fix(studio): make bun-to-npm fallback actually reachable

setup.sh used run_quiet() for the bun install attempt, but run_quiet
calls exit on failure. This killed the script before the npm fallback
could run, making the "falling back to npm" branch dead code.

Replace the run_quiet call with a direct bun invocation that captures
output to a temp file (same pattern, but returns instead of exiting).

Also clean up partial node_modules left by a failed bun install before
falling back to npm, in both setup.sh and build.sh. Without this, npm
inherits a corrupted node_modules tree from the failed bun run.

* fix(studio): restore commonjsOptions for dagre CJS interop

The previous commit removed build.commonjsOptions, assuming Vite 8's
Rolldown handles CJS natively. While optimizeDeps.include covers the
dev server (pre-bundling), it does NOT apply to production builds.

The resolve.alias still points @dagrejs/dagre to its .cjs.js entry,
so without commonjsOptions the production bundle fails to resolve
the CJS default export. This causes "TypeError: e is not a function"
on /chat after build (while dev mode works fine).

Restore the original commonjsOptions block to fix production builds.

* fix(studio): use motion/react instead of legacy framer-motion import

* fix(studio): address PR review findings for Vite 8 + bun upgrade

Fixes:
  - Remove bun.lock from repo and add to .gitignore (npm is source of truth)
  - Use & bun install *> $null pattern in setup.ps1 for reliable $LASTEXITCODE
  - Add Remove-Item node_modules before npm fallback in setup.ps1
  - Print bun install failure log in setup.sh before discarding
  - Add Refresh-Environment after npm install -g bun in setup.ps1
  - Tighten Node version check to ^20.19.0 || >=22.12.0 (Vite 8 requirement)
  - Add engines field to package.json
  - Use string comparison for _install_ok in build.sh
  - Remove explicit framer-motion ^11.18.2 from package.json (motion pulls
    framer-motion ^12.38.0 as its own dependency — the old pin caused a
    version conflict)

* Fix Colab Node bypass and bun.lock stale-build trigger

Gate the Colab Node shortcut on NODE_OK=true so Colab
environments with a Node version too old for Vite 8 fall
through to the nvm install path instead of silently proceeding.

Exclude bun.lock from the stale-build probe in both setup.sh
and setup.ps1 so it does not force unnecessary frontend rebuilds
on every run.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Shine1i <wasimysdev@gmail.com>
2026-03-25 04:27:41 -07:00
Daniel Han
9c95148045
Fix tool call parsing, add tool outputs panel and UI improvements (#4416)
* Add elapsed timer to tool status pill in Studio

Show a count-up seconds timer (0s, 1s, 2s, ...) next to the tool status
text in the composer area. Helps users gauge how long a tool call (web
search, code execution) has been running. Timer resets when a new tool
starts and disappears when all tools finish.

* Fix tool call parsing, add tool outputs panel and reasoning copy button

Backend:
- Rewrite tool call XML parser to use balanced-brace JSON extraction
  instead of greedy regex, fixing truncation on nested braces in
  code/JSON arguments
- Handle optional closing tags (</tool_call>, </function>, </parameter>)
  that models frequently omit
- Support bare <function=...> tags without <tool_call> wrapper
- Strip tool call markup from streamed content so raw XML never leaks
  into the chat UI
- Use a persistent ~/studio_sandbox/ working directory for tool
  execution so files persist across calls within a session
- Emit tool_start/tool_end SSE events so the frontend can display
  tool inputs and outputs

Frontend:
- Add collapsible "Tool Outputs" panel below assistant messages showing
  each tool call's input and output with copy buttons
- Add copy button to reasoning blocks
- Add elapsed timer to tool status pill
- Update project URLs in pyproject.toml (http -> https, add docs link)

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

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

* Add interactive HTML preview with fullscreen toggle for code blocks

HTML code fences now render an interactive sandboxed iframe preview
below the syntax-highlighted code, similar to how SVG fences show
an image preview. The iframe uses sandbox="allow-scripts" to allow
JavaScript execution while blocking access to the parent page.

Includes a fullscreen toggle (enlarge/minimize button) that expands
the preview into a viewport overlay, dismissible via button, Escape
key, or backdrop click. A streaming placeholder prevents partial
HTML from rendering mid-stream.

* Add tool call settings: auto-heal toggle, max iterations, timeout

Add three user-configurable tool call settings to the Studio Settings panel:

- Auto Heal Tool Calls: toggle to control fallback XML parsing of malformed
  tool calls from model output (default: on)
- Max Tool Calls Per Message: slider 0-40 + Max to cap tool call iterations
  per message (default: 10)
- Max Tool Call Duration: slider 1-30 minutes + Max to set per-tool-call
  execution timeout (default: 5 minutes)

All settings persist to localStorage and flow through the full stack:
frontend store -> API request -> Pydantic model -> route -> llama_cpp -> tools.

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

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

* Fix tool call timeout: respect no-limit and apply to web search

- Use a sentinel to distinguish timeout=None (no limit) from the default
  (300s). Previously None was silently replaced with _EXEC_TIMEOUT.
- Pass the configured timeout to DDGS() for web searches so the setting
  applies uniformly to all tool types.

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

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

* Add input validation bounds and per-thread sandbox isolation

- Add ge=0 constraint to max_tool_calls_per_message (rejects negative values)
- Add ge=1 constraint to tool_call_timeout (minimum 1 second)
- Thread session_id from frontend through backend to tool execution
- Scope sandbox directories per conversation: ~/studio_sandbox/{thread_id}/
- Backwards compatible: API callers without session_id use ~/studio_sandbox/

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

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

* Fix non-monotonic streaming and Python temp script path

- Split tool markup stripping into closed-only (mid-stream) and full
  (final flush) to prevent cumulative text from shrinking mid-stream
- Enforce monotonicity: only emit when cleaned text grows, so the
  proxy's delta logic (cumulative[len(prev_text):]) never breaks
- Place Python temp scripts in the sandbox workdir instead of /tmp so
  sys.path[0] points to the sandbox and cross-call imports work

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

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

* Sanitize session_id to prevent path traversal in sandbox

Strip path separators and parent-dir references from session_id before
using it as a directory name. Verify the resolved path stays under
~/studio_sandbox/ as a second guard.

* feat(chat): proper assistant-ui tool call UIs with sources

Replace custom metadata-based ToolOutputsGroup with native assistant-ui
tool-call content parts. Backend SSE tool_start/tool_end events now emit
proper { type: "tool-call" } parts from the adapter, enabling per-tool
UIs registered via tools.by_name in MessagePrimitive.Parts.

- Web search: Globe icon, Source badges with favicons, auto-collapse
  when LLM starts responding
- Python: Code icon, syntax-highlighted code via Streamdown/shiki,
  output block with copy
- Terminal: Terminal icon, command in trigger, output with copy
- ToolGroup wraps consecutive tool calls (skips for single calls)
- Sources component renders URL badges at end of message
- Flattened code block CSS (single border, no nested boxes)

* fix(inference): respect empty enabled_tools allowlist

`if payload.enabled_tools:` is falsy for [], falling through to
ALL_TOOLS. Use `is not None` so an explicit empty list disables
all tools as intended.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Shine1i <wasimysdev@gmail.com>
2026-03-18 08:28:02 -07:00
Wasim Yousef Said
e280b0bebc
miscallenous studio (#4293)
* miscallenous studio

* chore: upload dataset misc

* chore: redudancy studio cleanup

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

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

* fix: adress the pr comments

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

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

* fix: adress comments about recipes

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-15 14:42:11 +04:00
Shine1i
a2dde15367 merge nightly 2026-03-09 00:32:33 +01:00
Roland Tannous
f4393ed3e5 fix: pin streamdown package versions to avoid type mismatch 2026-03-08 16:29:28 +00:00
Shine1i
95dd202ab3 merge nightly into feature/data-reciper-enchansments 2026-03-03 11:14:18 +01:00
Shine1i
bdc825298d feat(seed): backend unstructured seed reader + server-side chunking, remove client chunk splitter 2026-03-03 11:11:26 +01:00
Shine1i
ce33d673b9 feat(markdown): fix Mermaid integration with error handling and copy button 2026-02-27 20:02:40 +01:00
Shine1i
2548720c01 Merge branch 'feature/canvas-lab' of https://github.com/unslothai/new-ui-prototype into feature/canvas-lab
# Conflicts:
#	studio/frontend/bun.lock
2026-02-25 00:19:19 +01:00
Shine1i
929c7f86e4 feat: add animated theme toggler and refine dark mode styling 2026-02-25 00:18:20 +01:00
Roland Tannous
c0f6012d77 Merge remote-tracking branch 'origin/nightly' into feature/canvas-lab
# Conflicts:
#	studio/frontend/bun.lock
#	studio/frontend/package.json
2026-02-24 09:02:54 +00:00
imagineer99
6cedc339c6 feat: add Upload / Save / Reset training config from local YAML 2026-02-23 18:55:08 +00:00
Shine1i
424b00b701 refactor: improve seed source handling with additional type support, enhanced parsing logic, and text chunking optimization 2026-02-23 19:29:54 +01:00
Wasim Yousef Said
6dd0e11439 Merge branch 'nightly' into feature/canvas-lab 2026-02-20 01:23:44 -08:00
Shine1i
3dce0d475d rm vitest 2026-02-15 21:13:15 +01:00
imagineer99
f285b5379a feat: VRAM-based model filtering in frontend 2026-02-15 19:12:28 +00:00