Commit graph

55 commits

Author SHA1 Message Date
Daniel Han
5211b506e1
Studio: opt-in OpenAI /v1 model auto-switch and idle keep-warm (#6392)
* Studio: opt-in OpenAI /v1 model auto-switch and idle keep-warm

The OpenAI-compatible endpoints serve whichever GGUF is loaded and ignore the
request model field, so an OpenAI client that changes model never reloads. Add
an opt-in setting that, when a /v1 request names a downloaded local GGUF
different from the loaded one, loads it before serving by reusing the existing
/load path (its dedup, tensor fallback, and threading apply). Unknown names
still serve the loaded model, so drop-in compatibility is preserved and no
remote download is triggered.

Also add an optional idle auto-unload (TTL keep-warm): a pure-ASGI middleware
tracks in-flight inference requests so a stream is never unloaded mid-response,
and a lifespan loop unloads the model after the configured idle seconds. Both
settings default off and live in the app_settings store, exposed via
GET/PUT /api/settings/openai-auto-switch.

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

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

* Studio: variant-aware auto-switch, /v1/responses coverage, keep-warm load stamp

Follow-ups from review of the opt-in OpenAI auto-switch path:

1. Variant-aware dedup. _maybe_auto_switch_model compared only the repo id, so
   requesting another quant of the loaded repo (e.g. Q4_K_M loaded, Q8_0 asked)
   was served by the old quant. Compare hf_variant too, matching /load dedup.

2. Streaming /v1/responses now calls the auto-switch hook. It went straight into
   _responses_stream and only checked is_loaded, so stream=True could serve the
   old model or 400. Non-streaming already routed through chat completions; the
   hook is idempotent once loaded.

3. resolve_local_gguf tries an exact id match before splitting a trailing
   :VARIANT, so local ids that contain a colon (e.g. a Windows path) resolve
   instead of being cut at the drive letter.

4. Idle keep-warm stamps activity on a load/swap transition. _last_active was
   only refreshed by inference requests, so a model loaded after the server sat
   idle past the TTL could be unloaded before its first request.

Tests cover each case.

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

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

* Studio: make the /v1/responses auto-switch test order-independent

The new streaming-responses test passed in isolation but failed under the CI's
randomized collection order with "object has no attribute 'state'": it passed a
bare object() as the request and stubbed only one dispatcher, so an ordering
where the real dispatcher ran hit request.state. Give the request a state and
stub both dispatchers; the test still asserts the hook fires before dispatch.

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

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

* Studio: assert /v1/responses auto-switch wiring on source, not at runtime

The behavioral version executed openai_responses and relied on stubbing its
callees, which a randomized collection order in CI could defeat (the real
dispatcher ran and hit request attributes). Assert on the function source that
the hook precedes both dispatchers instead; the hook's runtime behavior is
already covered by the direct _maybe_auto_switch_model tests.

* Studio: auto-switch on /v1/embeddings, GGUF-only targets, idle-unload race gate

Second-pass review follow-ups on the opt-in auto-switch path:

1. /v1/embeddings now calls the auto-switch hook before the loaded-state check,
   matching the other model-bearing OpenAI endpoints (the keep-warm middleware
   already treats embeddings as inference).

2. The resolver index is now GGUF-only. The local-model scanners also surface
   Transformers/safetensors repos; without a filter, auto-switch could unload
   the GGUF and route a request into the non-GGUF loader. _has_local_gguf checks
   a direct .gguf, a models-dir folder, and the HF-cache snapshots layout.

3. Idle keep-warm now holds an asyncio gate across the idle check and the
   unload, and a request bumps inflight under the same gate, so the loop can no
   longer unload in the window between "looks idle" and the kill.

Tests cover each. Broader local-model source parity (LM Studio, Ollama, legacy
caches, custom scan folders) is a follow-up; missing one of those today just
falls through to the loaded model.

* Studio: variant-aware local resolver, count_tokens + audio auto-switch coverage

Third-pass review follow-ups on the opt-in auto-switch path:

1. The resolver is now variant-aware via list_local_gguf_variants. It indexes
   only the quants actually on disk, recursing snapshots and quant subdirs such
   as the nested per-quant folders, so a requested repo:VARIANT resolves only
   when that quant is local and a bare repo resolves to a concrete local quant.
   This fixes two gaps: the previous shallow glob rejected nested-variant GGUF
   repos, and a request for an uncached quant could send /load down the remote
   download path, breaking the local-only contract.

2. /v1/messages/count_tokens now auto-switches like its sibling /v1/messages, so
   a count uses the requested model's tokenizer.

3. /api/inference/audio/generate (direct GGUF TTS) is now tracked as in-flight
   inference, so the idle loop cannot unload the model mid-generation.

Tests cover each. Two reviewer items are left as follow-ups: indexing the
remaining local sources (LM Studio, Ollama, legacy/default caches, custom scan
folders), which fails safe today by falling through to the loaded model; and
fully serializing concurrent different-model requests, an inherent limit of the
single-slot llama backend that the opt-in feature is not designed around.

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

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

* Studio: make local GGUF resolver fail-safe so a bad model name cannot 500

The auto-switch hook calls resolve_local_gguf without its own guard, and
/v1/completions and /v1/embeddings pass body.get("model") through unchanged.
A non-string model (e.g. {"model": 123}) or any internal scan failure would
then raise out of the resolver and turn a request that would otherwise be
served by the loaded model into a 500, breaking the drop-in compatibility the
feature is built on.

Guard the resolver at its boundary: reject non-string input up front and wrap
the lookup so any failure returns None (fall through to the loaded model).
Add regression tests for both paths.

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

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

* Studio: per-model launch flags for auto-switched GGUF models

* Studio: list switch-eligible GGUFs in /v1/models when auto-switch is on

* Studio: settings UI for OpenAI model auto-switch and idle auto-unload

* Studio: show save error over the disabled-idle hint in auto-switch settings

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

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

* Studio: address gemini review (case-insensitive /v1/models retrieve, idle-input empty guard)

* Studio: address codex review (deterministic override args, exclude probe/embedding models from discovery)

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

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

* Studio: keep-warm count_tokens, gate idle on auto-switch, drop hidden models

Three hardening fixes to the opt-in auto-switch path surfaced while reviewing
the work that builds on it:

1. count_tokens keep-warm. /v1/messages/count_tokens counts via the loaded
   tokenizer and already auto-switches, but the keep-warm middleware did not
   track it, so idle auto-unload could free the model mid-count. It is now a
   tracked in-flight path.

2. "Off means unchanged" for idle unload. get_auto_unload_idle_seconds now
   reports 0 while auto-switch is disabled. Idle unload only makes sense with
   auto-switch on (an unloaded model returns only via the next request's swap),
   so a stray TTL can no longer trigger a destructive unload while the feature
   is off, keeping the disabled state identical to pre-feature behavior.

3. Hidden models are not switch targets. The resolver index now skips what
   Studio hides from its own pickers (the llama.cpp validation probe, RAG
   embedding weights) via _is_hidden_model, so they can never be auto-switched
   to by name.

Tests added for each.

* Studio: bare-id reuse, responses validation order, in-flight tracking

Review follow-ups after folding in the per-model overrides and discovery work:

1. A bare model id (no :VARIANT) is now satisfied by any loaded quant of that
   repo. Previously a bare name resolved to the largest local quant, so it could
   force a slow reload when a different quant of the same repo was already
   serving. An explicit repo:VARIANT request still honors the quant.

2. /v1/responses now runs the auto-switch hook after the empty-input validation
   so a request that 400s can no longer trigger a multi-minute model load before
   being rejected. The hook still precedes both dispatchers, so streaming
   requests switch.

3. The keep-warm middleware now tracks in-flight requests whenever auto-switch
   is enabled rather than only when the idle TTL is already positive, so a stream
   that starts with the TTL at 0 is still protected if idle-unload is enabled
   mid-stream. Off still passes straight through.

Tests added for each.

* Studio: tighten auto-switch code comments

Comment/docstring-only pass over the OpenAI auto-switch feature: collapse
multi-line blocks, drop a comment that restated the gate it sits next to, and
trim verbose docstrings on internal helpers while keeping the load-bearing
rationale (concurrency, API behavior, drop-in compat, gotchas). No logic
change: verified comment-only with the AST/printer signature check.

* Studio: bind auto-switch locks per running loop

Review follow-up. The auto-switch swap lock and the keep-warm unload gate were
module-level asyncio.Lock objects. That is safe under the single uvicorn loop
and on Python 3.10+ (the Lock resolves the running loop lazily on acquire), but
a module-level Lock binds to one loop on pre-3.10, which can raise a loop
mismatch in multi-loop runners. Resolve each lock through a per-loop accessor
backed by a WeakKeyDictionary so every running loop gets its own Lock and stale
loops are collected. No behavior change under the server's single loop.

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

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

* Studio: auto-switch re-review fixes (body codes, coverage, swap, alias, tracking)

Follow-ups from a second review pass over the opt-in OpenAI auto-switch feature:

1. OFF-state status codes: /v1/completions and /v1/embeddings moved the body
   read ahead of the loaded-state check, so a malformed/empty body with no model
   loaded returned 500 instead of the prior 503. A shared helper reads the body
   defensively (an unparseable/non-dict body yields no model), and the handler
   re-reads after the 503 gate to surface the original parse error exactly as
   before. OFF behavior is unchanged.

2. Local-model coverage: the resolver index only scanned ./models and the active
   HF cache, while the model picker also lists the legacy/default HF caches, LM
   Studio dirs, and user scan folders. A request for one of those named models
   silently served the loaded model instead. _build_index now scans the same
   roots (Ollama's symlink-creating scanner is skipped on the request path), and
   resolution is offloaded with asyncio.to_thread so the wider scan never blocks
   the event loop.

3. Swap vs in-flight stream: a cross-model swap killed the llama-server while
   another client was still streaming from it. The hook now tracks how many
   requests are streaming on the loaded model (in-flight minus those still inside
   the hook) and returns 409 instead of swapping while one is active. Concurrent
   same-model requests never reach this path, so they are unaffected.

4. Idle-unload + alias: after idle-unload freed the model, an unknown/alias name
   resolved to nothing and 503'd, though it served the active model before the
   TTL. Idle-unload now remembers the freed id and an alias request reloads it
   (only an already-local model, so no remote download), cleared once a model is
   loaded again.

5. In-flight tracking: the keep-warm middleware tracked in-flight only while the
   feature was on, so a stream started while off could be unloaded if idle-unload
   was enabled mid-stream. It now tracks on every inference path; counting is
   cheap and invisible to clients.

Tests added for each.

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

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

* Remove stray async_task_outputs files committed by mistake

* Studio: auto-switch review round 3 (revert swap guard, hardening)

Addressing a third review pass:

- Revert the cross-model swap guard. It counted keep-warm in-flight (which
  includes external-provider calls that never touch the local model) and so
  could 409 a local swap spuriously, and it still left a same-model request able
  to start streaming on the model a concurrent swap was unloading. A correct fix
  needs a request-lifetime reader/writer barrier; a partial guard was worse than
  the honest single-slot behavior, so concurrent different-model use is back to
  being serialized (documented), like llama-swap's single slot.
- Non-string request model (e.g. {"model": 123} on a raw-body endpoint) is now
  treated as absent, so it falls through instead of raising in the membership
  checks once an idle-unload stash exists.
- Idle-unload now stashes and replays the freed quant: an alias reload restores
  the exact (id, variant) that was freed rather than the largest local quant.
- Anthropic /v1/messages validates max_tokens before the auto-switch hook, so a
  request that 400s never triggers a model load.
- Keep-warm tracks a pending count for requests waiting on the unload gate, so
  the idle loop cannot unload the model out from under a request that is blocked
  on the gate but not yet counted as in-flight.
- The idle-unload task is awaited after cancel on shutdown to avoid pending-task
  warnings.
- The resolver's HF cache scan is None-safe and logs at debug instead of letting
  a bad root abort the whole index build.
- upsert_app_setting_map_entry rolls back explicitly on error.

Tests updated/added for each.

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

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

* Studio: keep saved idle-unload seconds when auto-switch is toggled off

* Studio: auto-switch hardening (thread-safe lock maps, body validation)

Defensive fixes from review:

- Guard the per-loop WeakKeyDictionary get-or-create for both the unload gate and
  the auto-switch lock with a threading lock, since WeakKeyDictionary mutation is
  not thread-safe when two event loops run on different threads.
- Build the resolver index under the cache lock so concurrent callers with an
  expired cache don't all run the multi-dir scan at once.
- /v1/completions and /v1/embeddings return a clean 400 for a valid JSON body
  that is not an object (e.g. a list), instead of a 500 from body.get(...).
- The keep-warm middleware only tracks POST requests (inference is always POST),
  so CORS preflight (OPTIONS) is not counted, and tolerates a None path.

Tests added for the list-body 400 and the non-POST skip.

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

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

* Studio: auto-switch review round 4 (local-path load, swap guard, idle fixes)

From a 10-reviewer pass:

- HF-cache entries now load by a concrete local path, not the bare repo id. The
  resolver records a load_path (the snapshot dir for a models--* cache repo, the
  file/dir otherwise) so /load takes the local branch and can never trigger a
  download to satisfy a partial cache. The advertised loader_id (repo id) is kept
  as the launch-override key. resolve_local_gguf now returns
  (load_path, variant, loader_id).
- Re-add a single-slot swap guard: a cross-model swap returns 409 model_switch_busy
  while another inference request is active rather than killing its stream (the
  caller is excluded from the count), and holds the keep-warm gate across the load
  so no new inference starts mid-swap. Concurrent same-model requests never reach
  this path. A residual spurious 409 is possible while a concurrent or external-
  provider request is active; that is the documented single-slot tradeoff.
- Idle keep-warm tracks (model_identifier, hf_variant): reloading the same repo at
  a different quant counts as a fresh model, so it is not unloaded before one TTL.
- Track Studio's own /api/inference/generate/stream so the idle loop can't unload
  the model mid-stream on that route.
- A successful manual /load clears the idle-unload reload stash synchronously, not
  only on the next idle poll.

Also merged origin/main (the branch had fallen behind, which would have reverted
unrelated files on merge). Tests added/updated for each.

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

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

* Studio: auto-switch review round 5 (concurrency, identity, load gate)

From a 10-reviewer pass (9 request-changes, 1 approve):

- Concurrent same-target requests load once instead of each returning 409. The
  count-based busy guard could not tell "another request wants the same model"
  (safe, load once) from "another request is using the loaded model" (refuse).
  Track in-flight auto-switch requests per (target, variant) and subtract
  same-target waiters from the busy count; a cross-model swap still 409s while a
  genuinely different request is active.
- Fix the identity confusion introduced when round 4 began loading by concrete
  local path: the backend identifier became a filesystem path. Record the
  advertised repo id on the backend after an auto-switch load and use it so
  (a) a model loaded manually by repo id is recognized as already serving
  (no spurious reswap/409), (b) /v1/models reports the repo id, never a host
  path or a duplicate, and (c) the idle-unload stash keeps the override keyed by
  the repo id, so an alias reload after TTL keeps the user's saved launch flags.
- Gate the manual /load route with the keep-warm lifecycle gate so idle
  auto-unload can't unload a model mid-load. load_model now wraps _load_model_impl
  in the gate; auto-switch calls _load_model_impl directly since it already holds
  the gate.
- Restore default-off parity on Anthropic /v1/messages: an unloaded backend with
  auto-switch disabled 503s before the max_tokens 400 check, as it did pre-feature.
  When the feature is on, request-shape validation still runs before any load.

Tests added for each; full backend suite diff vs baseline is unchanged.

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

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

* Studio: auto-switch review round 6 (concurrency ordering, leaks, unload gate)

From a second 10-reviewer pass (8 request-changes, 2 approve):

- Same-target concurrency: register a waiter by the raw requested model before
  the (slow) resolve, and exclude pending requests from the swap busy count. The
  middleware counts a concurrent same-model request as in-flight before it
  resolves and joins the resolved-target waiter map, so the prior fix could still
  409 it. The guard now subtracts max(same resolved-target, same raw-request)
  waiters and ignores pending (a pending request is blocked in the middleware,
  not generating, so a swap can't interrupt it).
- External-provider requests no longer block a local swap. The keep-warm
  middleware counts every inference-path POST, but external-provider chat returns
  before the auto-switch hook and never touches the local GGUF. The chat handler
  now untracks itself before proxying, so its in-flight stream can't trip
  model_switch_busy on a concurrent local auto-switch. The middleware skips its
  own end-decrement for an untracked request.
- Manual /unload is gated like load and idle-unload: it holds the lifecycle gate
  and returns 409 rather than tearing down llama-server while an inference request
  is in flight.
- Response model id no longer leaks the load path. /v1/models already advertised
  the repo id; chat, completions, embeddings, Anthropic messages, and audio
  response bodies now use the same _llama_public_model_id helper instead of the
  concrete on-disk model_identifier.
- Chat completions validates the non-system-message requirement before the
  auto-switch hook (as /responses and /messages already do), so an invalid
  request can't swap the resident model before returning 400.

Tests added for each; full backend suite diff vs baseline is unchanged.

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

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

* Studio: auto-switch review round 7 (teardown policy, Unsloth-active swap, training)

From a third 10-reviewer pass (9 request-changes, 1 approve), all on the same
asymmetric-teardown theme. Resolved per the intended policy that only automatic
paths defer to an active stream; deliberate user actions stay interrupting:

- Revert the manual /unload in-flight guard added last round. A manual /load or
  /unload is a deliberate action and tears down immediately, as before; only the
  automatic idle-unload loop and auto-switch defer to an active request. This
  removes the asymmetry the reviewers flagged (manual /load, the /unload Unsloth
  branch, and the opposite-backend swaps inside _load_model_impl) by not
  extending the guard to deliberate paths, rather than spreading it.
- Auto-switch now refuses a swap whenever another inference request is in flight,
  not only when a GGUF is already loaded. _load_model_impl also unloads an active
  Unsloth/transformers backend before loading a GGUF, so the busy guard must cover
  that case too; otherwise an Unsloth stream could be killed by an auto-switch.
- Refuse API-initiated training while inference is active. When Studio is driven
  as an inference API (sk-unsloth key auth), POST /api/training/start returns 409
  if a request is in flight, since training frees VRAM by unloading the chat
  model and would kill the stream. The Studio UI (session auth) still starts
  training and coexists/frees VRAM as before. A mixed UI+API session is not yet
  special-cased. Adds auth.authentication.authenticated_via_api_key.

Tests added/updated for each; full backend suite diff vs baseline is unchanged.

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

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

* Studio: add UNSLOTH_MODEL_IDLE_TTL env override for idle-unload

Borrowed from PR 6517: a startup env var that sets the idle-unload TTL without
the settings UI. Unlike the stored setting (gated on auto-switch), the env value
is a standalone default that enables idle-unload even with auto-switch off, for
headless/container deploys. An explicit UI/API value still overrides it and stays
gated. The settings GET reflects the env default when nothing is stored.

* Studio: auto-switch fixes from review (paths, embeddings input, env idle reload)

- /v1/models advertises a client-facing alias instead of a filesystem path:
  the ./models and LM Studio scanners report the on-disk path as the model id,
  so the index now prefers model_id/display_name as the advertised/override id
  and keeps the concrete path internal as load_path, still resolvable by path.
- /v1/embeddings validates input before auto-switch: a request with a model but
  no input now 400s before the hook (like chat/responses/messages), so an
  invalid embeddings request cannot unload or swap the resident model.
- Standalone UNSLOTH_MODEL_IDLE_TTL reloads the freed model: the hook now runs
  when auto-switch or idle-unload is active, and with auto-switch off it skips
  the resolver and only restores the idle-unloaded model, so the first idle
  timeout no longer leaves later /v1 requests with nothing loaded.
- Do not resurrect a stale GGUF over an active Unsloth model: the reload-stash
  path bails when a non-GGUF backend is loaded, so an unknown /v1 name cannot
  tear down a live Transformers/Unsloth model.
- Defensive HF cache scan: each cache root's resolve/dedup is wrapped so a
  missing or malformed root skips that root rather than aborting the index.
- Single-model retrieve checks the id is a string before lowercasing.

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

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

* Studio: fix automatic-load asymmetry, audio reload, preview, idle timer

The standalone UNSLOTH_MODEL_IDLE_TTL reload is a second automatic-load
trigger, but several validate-before-switch guards and reload hooks only
checked the auto-switch toggle. Add a shared _automatic_model_load_may_run()
(auto-switch on, or idle TTL > 0) and route every guard through it.

- /v1/completions validates prompt before any automatic load (it was the one
  model-bearing route with no pre-check).
- /v1/chat/completions and /v1/embeddings pre-checks gate on the shared
  predicate so a standalone idle TTL cannot reload then reject.
- /v1/messages no longer 503s before the reload hook can restore an idle-freed
  model when auto-switch is off.
- Raw completions/embeddings with no model field pass a non-empty sentinel so
  the idle-stash reload runs, restoring the legacy "omit model, use loaded" path.
- /api/inference/audio/generate gains the reload hook (after message validation)
  so an idle-freed audio GGUF is restored.
- Public preview opts out of auto-switch via a request-scope flag, so a caller's
  model field cannot swap away from the pinned checkpoint; preview chat streams
  are now matched by _is_inference_path so idle-unload cannot kill them.
- Keep-warm no longer stamps activity on request start, and external-provider
  untracking decrements without restamping, so periodic external traffic can no
  longer keep the local GGUF warm forever.

Merges origin/main (the branch had fallen behind, which also brought in the
preview route the review flagged).

* Studio: surface model auto-switch in the API tab and demo it in examples

The OpenAI auto-switch toggle previously lived only in Settings -> General.
Add the same toggle to the API tab's usage-examples panel (it shares the
settings cache), and make the examples reflect it: when on, the Python
examples append a second call naming a different downloaded GGUF (so the
model field visibly selects which model serves), and the curl examples gain
a one-line note. Reuses the existing settings API client and i18n keys.

* Studio: harden OpenAI auto-switch reload-only path and Anthropic tool validation

- Omitted-model raw-body requests pass a reload-only sentinel so the idle-stash
  reload still restores an idle-freed model, but the resolver never matches a
  downloaded GGUF literally named "default".
- Reject malformed Anthropic client tools before _maybe_auto_switch_model so an
  invalid request can no longer evict the loaded model.

* Studio: extend auto-switch reload-only and tool validation to schema endpoints

- Schema-backed endpoints (chat completions, responses, count_tokens, messages,
  audio) defaulted an omitted model to "default" and passed it to the switch
  hook, so a downloaded GGUF named "default" could be swapped to. Route the hook
  through a helper that switches only on an explicitly set model, else reload-only.
- Propagate the explicit-set status when building the chat request from a
  Responses request, so the non-streaming chat re-check stays reload-only too.
- Validate Responses function tools before the switch hook so a malformed tool
  returns 400 without evicting the loaded model.

* Studio: serialize auto-switch swaps across event loops with a process-wide gate

The auto-switch lock is a per-event-loop asyncio.Lock, so two /v1 swaps on
different loops in one process could both pass it and race the single model slot
(the backend and _load_model_impl are process-wide). Add a process-wide
threading gate around the swap, acquired off the loop so a cross-loop wait never
blocks it, layered with the existing per-loop lock. Add a cross-loop test that
fails without the gate (two slow loads overlap) and passes with it.

* Studio: make the auto-switch swap gate wait cancellation-safe

_acquire_swap_gate awaited asyncio.to_thread(lock.acquire) when another loop held
the process-wide gate. to_thread cancellation doesn't stop the worker thread, so a
/v1 request cancelled mid-wait (client disconnect during a cross-loop swap) would
have its thread acquire the gate after the fact, while the finally that releases it
never runs -- permanently deadlocking later auto-switch swaps.

Poll a non-blocking acquire off a short asyncio.sleep instead: it still keeps the
wait off the loop and serializes across loops, but a cancel now lands during the
sleep, when the gate is not held, so nothing leaks. Add a test that deadlocks the
to_thread variant (it times out) and passes with the poll.

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

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

* Studio: validate modality and tool-confirmation before auto-switch

Two more request shapes could load a named GGUF and only then 400, evicting the
resident model:

- An image request naming a different text-only GGUF. The switch hook now takes
  require_vision and rejects a swap to a non-vision target before loading it; a
  GGUF's vision capability is its companion mmproj, knowable without a load, and
  matches the post-load guard. Only the resolver branch is checked, never the
  reload-stash restore.
- confirm_tool_calls=true with stream=false and local tools. /v1/chat/completions
  now rejects that shape before the hook, mirroring the local tool path's
  bypass_permissions exemption and intent signal.

The vision probe threads the ambient HF token to keep the capability-probe
invariant. Reload-only and idle-reload paths are unaffected.

* Studio: extend validate-before-switch and make the lifecycle gate process-wide

- /v1/messages/count_tokens now rejects malformed client tools before the switch
  hook, like /messages (shared _validate_anthropic_client_tools helper), so a
  count request can't evict the loaded model.
- /v1/chat/completions rejects a malformed tool_choice forcing object (a
  {"type":"function","function":{}} with no name) before the switch hook.
- The inference lifecycle gate that blocks new inference during a swap is now
  process-wide (a poll-acquired threading lock, cancellation-safe), not a
  per-loop asyncio lock, so a request on another event loop can't start inference
  while a swap tears the single backend down.
- Usage examples no longer hard-code a switch-demo repo most users lack; the
  model is an explicit placeholder the user replaces.

* Studio: extend the auto-switch modality guard to /v1/responses and /v1/messages

The pre-load vision check that guards /v1/chat/completions now also runs on
/v1/responses and /v1/messages, so an image request naming a text-only GGUF is
rejected before the swap and never evicts the resident vision model. Run the
vision capability probe off the event loop. Make the /v1/models retrieve
loaded fast-path case-insensitive, and never advertise a host path from the
resolver. Remove the dead list_switch_eligible_ids helper, superseded by the
/v1/models catalog.

* Studio: filter /v1/models to GGUF, per-loop catalog lock, reject system-only Responses

Address review findings on the auto-switch path:
- /v1/models advertises only GGUF models the API can actually switch to; a
  safetensors/LoRA entry would be selectable but never loadable via llama.cpp.
- The /v1/models catalog cache uses a per-loop lock (like the auto-switch path)
  so a second event loop awaiting it can't hang in a multi-loop process.
- /v1/responses rejects system/developer-only input before the switch, mirroring
  chat, so an invalid request can't evict the resident model.
- _build_index guards each scan source on its own so one bad root drops only
  that source; the vision probe logs a real detection failure instead of
  swallowing it.

* Studio: list cached GGUFs in /v1/models by inspecting files, not model_format

The HF-cache scanner leaves model_format unset for GGUF snapshots, so the
previous model_format == "gguf" filter dropped every downloaded HF-cache GGUF
from /v1/models and the retrieve fallback. Decide GGUF-ness from the on-disk
files via the resolver (info_has_local_gguf) instead, run off the event loop, so
the catalog advertises exactly what /v1 can serve.

* Studio: fix /v1/messages/count_tokens route binding plus auto-switch review fixes

The @router.post decorator for /messages/count_tokens had been separated from
anthropic_count_tokens by the _validate_anthropic_client_tools helper, so the
route bound to the validator and dropped its auth dependency. Move the decorator
back onto the handler. Add route-binding tests asserting each /v1 endpoint maps
to its handler with the auth dependency, so a decorator/handler split is caught
at the route level (the direct-call tests missed it).

Also from review:
- update_openai_auto_switch writes both settings keys in one transaction so a PUT
  can't leave one updated and the other stale (drop the now-unused single setters).
- max_seq_length override rejects 0 at the boundary (ge=1) instead of accepting
  then silently dropping it.
- Document that embeddings auto-switch is best-effort: GGUF pooling has no cheap
  pre-load probe like vision's mmproj, so a guard would false-reject GGUF embedders.
- Add a positive idle-unload test (loop frees the model and stashes it for reload).

* Studio: validate Responses tool_choice + Anthropic mixed tools before switch, filter Ollama from catalog

More auto-switch review findings:
- /v1/responses rejects a forcing-function tool_choice with no name before the
  switch, mirroring chat, so a malformed request can't evict the resident model.
- /v1/messages rejects mixing Anthropic server tools with custom client tools
  before the switch (the check depends only on the payload, so it moves up cleanly).
- /v1/models no longer advertises Ollama-link models: info_has_local_gguf excludes
  .studio_links / ollama_links entries, which the resolver skips and can't switch
  to, so an advertised id never silently falls through.

* Studio: guard chat audio input before switch; surface env-backed idle unload in settings UI

A chat request carrying audio_base64 rides the same companion mmproj
projector as a vision request, so a text-only target cannot serve it
either. Flag require_vision for audio input as well so the multimodal
probe runs before the switch and a rejected request never evicts the
working model. Generalize the reject message to cover image and audio.

The settings response now reports idle_unload_active (effective TTL > 0)
so the UI can distinguish idle-unload that is active via the
UNSLOTH_MODEL_IDLE_TTL env var from the case where it needs the toggle
enabled.

* Studio: harden auto-switch eviction guards (count_tokens vision, TTS reload-only, mmproj/stash)

Four eviction/correctness fixes on the opt-in /v1 auto-switch path:

- /v1/messages/count_tokens now carries the same require_vision guard as
  /messages, so an image count naming a text-only GGUF can't evict a loaded
  vision model for a swap that can't serve the request.

- /audio/generate is now reload-only. A local GGUF's audio-input capability
  is not a cheap pre-load probe (the companion mmproj signal can't tell an
  audio projector from a vision one, and codec TTS ships no projector), so
  resolving the client model could load a text/vision-only target and evict
  the working audio model before the audio check fails. Only the idle-stash
  restore runs here; switching TTS models is an explicit /load.

- The resolver no longer treats a standalone mmproj .gguf as a servable
  model. _scan_models_dir's standalone-file pass does not filter mmproj the
  way its directory scan does, so /v1/models could advertise a projector and
  a switch could load it over the real weights.

- A non-GGUF (Transformers/Unsloth) load and a deliberate /unload now clear
  the idle reload stash, so a manual load/unload is never superseded by a
  stale idle-freed GGUF that the next /v1 request resurrects.

* Studio: report advertised repo id consistently after an auto-switch

Two model-id reporting fixes so an auto-switched cached HF GGUF is named by
its repo id everywhere, not its snapshot path:

- Streamed /v1/responses envelopes now derive the model id from
  _llama_public_model_id (which prefers _openai_advertised_id) instead of the
  raw model_identifier. After an auto-switch the identifier is the snapshot
  path while the repo id lives in _openai_advertised_id, so the stream used to
  report a snapshot basename while /v1/models, chat completions, and
  non-streaming Responses all reported the repo id.

- When an advertised alias already resolves to the loaded model (a model
  loaded by local path, requested by its repo or LM Studio id), the
  already-serving early return now records the alias as the advertised id, so
  /v1/models and responses report the alias and mark it loaded instead of the
  path-derived basename. Resolver branch only; safe lock-free because an
  in-flight request blocks any concurrent swap via the single-slot busy guard.

* Studio: validate request shapes before auto-switch (prompt/input/audio/mcp confirm)

Four more validate-before-switch guards so a deterministic client error never
evicts the resident model on the opt-in /v1 auto-switch path:

- /v1/completions rejects an object/number prompt (only a string or array is
  valid) before the switch, instead of loading the named GGUF and letting
  llama-server reject the shape afterward.

- /v1/embeddings rejects an object/number input the same way.

- Chat rejects an oversized audio_base64 upload (413) before the switch. The
  size cap is a cheap, target-independent length check; the decode itself
  stays post-switch to avoid decoding a valid upload twice.

- The chat confirm-without-stream pre-switch guard now mirrors the tool loop's
  actual enablement: _effective_enable_tools (honoring a CLI --enable-tools
  policy) and mcp_enabled (which opens the tool loop on its own but defers to a
  CLI --disable-tools policy). Previously a confirm+no-stream request with only
  mcp_enabled slipped past and 400'd after the swap.

* Studio: fix model-id retrieval, streaming n>1, resolver cache TTL, keep-warm auth

Four fixes from review:

- GET /v1/models/{id} legacy raw-path fallback now maps the raw identifier to
  the same public id its /v1/models entry uses. After an auto-switch load the
  identifier is the snapshot path while the entry is keyed by the advertised
  repo id, so a client that cached the old absolute path no longer 404s on a
  model that is in fact loaded.

- stream=true with n>1 is now rejected before the switch. Only the
  non-streaming GGUF path returns multiple choices, so streaming n>1 is invalid
  on every local serving path; both fields are known pre-switch, so it must not
  load model B only to 400 and evict model A. Non-streaming n>1 stays
  post-switch where the serving path decides.

- The resolver index cache is stamped after _build_index, not with the pre-scan
  timestamp. On installs with enough local models for the multi-root scan to
  exceed the 5s TTL, the cache was stored already expired and every request
  rebuilt it.

- The keep-warm middleware no longer stamps model activity for 401/403
  responses. It runs before FastAPI auth, so unauthenticated probes used to
  refresh the idle timer without touching llama.cpp; they now decrement the
  in-flight count without keeping the model warm.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-07-01 06:42:23 -07:00
Daniel Han
80d3434d61
Studio: require signed capability tokens for /p preview links (#6666)
* Studio: require signed capability tokens for /p preview links

The public /p preview routes added in #6486 run model load and chat
generation as the admin user with no authentication. The only gate is the
preview ref, a deterministic outputs-root path (run or run/checkpoint) that
is guessable rather than secret. On a network-reachable Studio (--secure
tunnel or -H 0.0.0.0), an unauthenticated caller who guesses a ref can
consume GPU and probe a private fine-tuned checkpoint.

Make the share link an unguessable, revocable capability:

- Sign the canonical ref with a dedicated server-side secret (HMAC-SHA256,
  stored in app_secrets, independent of the JWT/login secret).
- Require a valid token on every /p chat, models, and page request before
  resolving a checkpoint or loading a model; missing or invalid tokens get a
  generic 404 so the surface never confirms a ref exists.
- Accept the token via ?k= (browser link and preview page) or
  Authorization: Bearer (OpenAI-compatible clients).
- Rotate the secret to revoke every outstanding link
  (POST /api/settings/preview-links/rotate).
- Clamp preview generation (max_tokens/max_completion_tokens <= 1024, n = 1)
  and set Referrer-Policy: no-referrer on the page so the token is not
  leaked via Referer.

Training history hands the authenticated owner the signed token, and the
copy-link button builds /p/{ref}?k={sig}.

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

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

* Studio: honor a lower caller token limit in the preview clamp

Codex review: when only the legacy max_tokens was sent, the clamp left
max_completion_tokens at the 1024 default, and _effective_max_tokens prefers
max_completion_tokens, so a request like max_tokens=16 could still generate up
to 1024 tokens. Derive one effective limit (max_completion_tokens wins, else the
legacy max_tokens) and pin both fields to it so a caller's lower limit is kept.

* Studio: add preview kill switch, rate limit, and revoke-links UI

Follow-ups to the /p preview capability work:

- Public-sharing kill switch: a persisted setting (default on) gates the public
  /p surface. When off, every preview request 404s even with a valid token, and
  the owner UI stops offering share links. GET/PUT /api/settings/preview-sharing;
  enforced in _verify_or_404.
- Per-IP rate limit on the preview chat route: a coarse in-process sliding-window
  limiter (20 req/min/IP) returns 429 + Retry-After before the GPU lock is taken.
  Client IP honors X-Forwarded-For only when UNSLOTH_STUDIO_TRUST_FORWARDED is
  set, matching the login limiter's trust model.
- Settings UI: a "Preview sharing" section with the public-sharing toggle and a
  "Revoke all preview links" button (confirm dialog) that rotates the secret.

Tests cover the kill switch (404 when off), the 429 path, the sliding window,
client-IP trust behavior, and the setting default.

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

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

* Studio: fix preview-fields sharing arg and refresh sigs after revoke

Codex review:
- P1: get_training_run_detail and update_training_run called _preview_fields
  with only output_dir after it gained a required sharing_on parameter, raising
  a 500 TypeError once get_run succeeded. Pass get_preview_sharing_enabled() at
  both sites; add a detail-endpoint regression test.
- P2: after rotating the preview secret from settings, the history grid still
  held stale preview_sig values, so a freshly copied link would 404. Emit
  emitTrainingRunsChanged() after a successful revoke so the grid refetches
  freshly signed refs.

* Studio: harden preview sharing controls (Codex review)

- Fail closed: a read failure on the preview-sharing kill switch now returns
  False instead of defaulting to enabled, so an unavailable settings DB can't
  reopen the public surface. A missing key still defaults to enabled.
- Per-IP rate limit behind the managed Cloudflare tunnel: client_ip now honors
  CF-Connecting-IP when the socket peer is loopback, so tunneled visitors are
  keyed by their real IP instead of collapsing onto the local cloudflared peer.
- GET /p no longer mints key/share_url when sharing is disabled; it returns
  sharing_enabled=false so clients don't distribute links that 404.
- Settings UI: toggling public sharing emits the training-runs-changed event so
  the history grid shows/hides Copy preview link without a manual refresh.

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

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

* Studio: harden preview rate limiter and IP keying (Opus review)

From a two-agent review of the PR:

- Rate limiter no longer evicts an active bucket when the table is full: a flood
  of distinct keys could otherwise cycle out a throttled bucket and reset its
  counter. Evict only aged-out buckets; if the table is full of live clients,
  fail closed (deny the new key) instead.
- client_ip keys on the rightmost (proxy-appended) X-Forwarded-For hop when the
  trust env is set; the leftmost is client-spoofable. Documented the
  append/overwrite-proxy assumption.
- _verify_or_404 checks the capability token before the kill-switch DB read, so
  unauthenticated /p spam can't be used as an unbounded settings-DB sink and the
  response is identical regardless of the sharing on/off state.

Tests: nested run/checkpoint happy path + wrong-ref rejection, the eviction
fail-closed behavior, and route-level coverage for the rotate / preview-sharing
settings endpoints.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-25 21:40:48 -07:00
Wasim Yousef Said
e25e7895a5
Polish Studio desktop chrome (#6332)
* Polish Studio desktop chrome

* Fix desktop chrome chat header overlap

* Blend desktop titlebar with sidebar

* Refine desktop chrome alignment

* Fix desktop chrome review items

* Reserve mac sidebar chrome space

* Fix mac chrome review items

* Polish macOS desktop chrome

* Align macOS desktop chrome controls

* Lower macOS traffic lights

* Remove mac sidebar logo from chrome row

* Match Tauri update banner styling

* Update Tauri updater public key

* Fix Tauri startup screen spacing

* Work around AppImage WebKitGTK blank screen

* Mark Linux AppImage as experimental

* Address true desktop chrome review issues

* Fix remaining desktop chrome review issues

* Fix desktop titlebar inset review issues

* Refresh desktop platform after backend auth
2026-06-24 17:56:25 -07:00
Michael Han
75166f2043
Studio: confirm saved Hugging Face token with a tick (#6630)
* Studio: confirm saved Hugging Face token with a tick

Pasting a token and clicking away saved it silently with no feedback.
Show a green tick in the field once a non-empty token is committed and
the input still matches the stored value, so the save is visible. Adds
the tokenSaved label to the en and zh-CN locales.

* Studio: let clicks pass through the saved-token tick

The decorative tick sat over the input and swallowed clicks, so clicking
it would not focus the field. Add pointer-events-none so clicks reach the
input; drop the now-unreachable title and keep an aria-label via role=img.
2026-06-24 03:47:32 -07:00
dylanschroers
d94834fbab
Studio: add sidebar update button with installed-version display (#6545)
* studio: add sidebar update button (static design only)

Adds a clock-icon update card above the account button in the sidebar footer. Visual/layout only; update detection and click behavior are wired in follow-ups.

* studio: show installed version in sidebar update card + collapse to icon

Replaces the placeholder version with the real installed app version via @tauri-apps/api/app getVersion() (Tauri-only; hidden in browser). Collapsed sidebar now shows just the clock icon instead of hiding the card.

* studio: open Settings About (update section) when the sidebar update card is clicked

* studio: i18n the sidebar update button label and add aria-label

Address Gemini Code Assist review on #6545:
- wrap the hardcoded "Update available" label in t() (shell.updateAvailable,
  en + zh-CN), matching the rest of the sidebar
- add aria-label so the collapsed icon-only button has an accessible name

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* studio: hide sidebar update card unless an update is available

Gates the card on useWebUpdateCheck so it stays hidden by default on both web and desktop, appearing only when the installed PyPI version is behind the latest release. Includes a TEMP localStorage dev override (devForceUpdateCard) to preview the card where there is no real update; remove before merge. Keeps the i18n label/aria-label; desktop (Tauri updater) detection not wired yet.

* Polish Studio sidebar update affordance

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
2026-06-23 14:26:51 +01:00
Daniel Han
494e0e6fe4
studio: let users change their password from Settings (#6520)
* studio: let users change their password from Settings

The only day-to-day way to change credentials was the destructive console command
'unsloth studio reset-password' (it deletes auth.db); the in-app change-password
page is the forced first-login flow and bounces non-forced users to /login.

Add a Change password control to Settings > General > Account: a small dialog
that takes the current and new password and calls the existing
POST /api/auth/change-password, then stores the rotated tokens it returns.
Username changes remain out of scope.

The dialog uses authFetch, so an expired access token is refreshed and the
request retried instead of failing with a spurious expired-token error for a user
who left Studio open past the token lifetime. The row is hidden in the Tauri
desktop app, which authenticates via desktop auto-auth with a generated secret:
there is no user-entered password to change there, and changing it would clear
the desktop secret.

* studio: harden settings password change

* studio: harden settings password dialog UX

---------

Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-22 07:49:13 -07:00
Michael Han
44d6727c65
Studio: redesign Select model dropdown to match Hub design (#6364)
* Studio: redesign Select model dropdown to match Hub design

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

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

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

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

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

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

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

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

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

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

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

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

* Studio: show local providers under Downloaded, Recommended first

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

* Studio: make Recommended a sortable live Unsloth listing

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

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

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

* Studio: detect model capabilities and family from HF tags

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

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

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

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

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

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

* Studio: extract the PillTabs toggle into a shared module

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

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

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

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

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

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

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

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

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

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

* Studio: address selector review feedback

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also catch CogVideoX in the diffusion name fallback.

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

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

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

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

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

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

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

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

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

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

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

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

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

Drop Downloads and Most likes from the sort dropdown.

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

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

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

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

* Fix model selector section toggle proportions

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

* Tighten model selector width and tab padding

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

* Refine Recommended formats, sort width and tab padding

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

* Flush section toggle and match dropdown font to Search Hub

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

* Fix sort menu checkmark overlap and lock dropdown widths

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

* Keep section toggle and dropdowns on one row

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Remove the old inference load settings dialog.

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

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

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

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

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

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

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

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

* Studio: reorder chat settings and rename the model section

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

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

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

* Studio: top-align the Load on selection toggle

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

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

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

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

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

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

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

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

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

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

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

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

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

* Studio: scope Show all quantizations to On Device only

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

* Studio: tidy On Device GGUF rows

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

* Studio: pin the eject button and tidy General settings

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

* Studio: add left padding before the gear example

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

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

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

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

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

* Studio: give the eject button a solid background

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

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

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

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

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

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

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

* Make LM Studio and Local models sections collapsible

* Fade the eject footer instead of a solid block

* Wrap the vision badge in a bordered pill

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

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

* Reduce the model list height

* Make the eject button inline with no background block

* Match the vision badge color to the Hub indigo tone

* Shorten the model list and square off the format tags

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

* Give the floating eject button a tinted background

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

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

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

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

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

* Nest dropdown hover radius inside the menu corners

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

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

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

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

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

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

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

* Nudge the model list slightly taller

* Remove the eject button shadow

* Align the eject button to the right

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

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

* Reduce the Search Hub and dropdown width by 4px

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

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

* Tighten section tab padding to cut the grey between tabs

* Revert section tab padding back to px-3

* Remove the section toggle trailing padding

* Add an eject button beside the model selector trigger

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

* Raise the in-list eject button

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

* Revert eject back to the labeled button on the right

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

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

* Widen the gap between the toggle and dropdowns slightly

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

* Narrow the model menu for symmetric padding

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

* Inset the list so the right padding matches the left

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

* Pack section toggle and dropdowns with a uniform gap

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

* Trim model menu right padding to match the left

* Nudge model list scrollbar inward

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

* Shorten show all quantizations description

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

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

* Align eject button with the format tag edge

* Right-align Connected layout so Search Hub meets Trending

* Download selected models through the Hub download manager

* Add Other models section for non-Unsloth downloads

* Add directions icon and shortcut for Other models section

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

* Use direction-right icon for Other models

* Use flag icon for Other models

* Widen Connected menu so dropdowns align with Search Hub

* Model selector: truncate long quant labels and tidy layout

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

* Model selector: drop stale custom section on restore

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Model selector: classify suffixless local GGUF folders consistently

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

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

Adds tests/test_local_model_format.py covering the classification rule.

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

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

* Studio model selector: tighten section spacing

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

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

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

* Hub: hide the RAG embedding model from browse previews

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

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

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

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

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

* Fix/adjust model selector handling for PR #6364

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* Fix/adjust personalization sync for PR #6516

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

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

* Fix Studio personalization sync edge cases

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-22 04:09:48 -07:00
Michael Han
d8abd5b704
(feat) Add a setting to toggle the chat model disclaimer (#6513)
* Add a setting to toggle the chat model disclaimer

* Move disclaimer toggle into Chat menu section and refine description
2026-06-20 06:58:23 -07:00
oobabooga
420799b61e
Studio: add an Open button to reveal the models folder in the file manager (#6452)
* Studio: add an Open button to reveal the models folder in the file manager

* Studio: report models folder creation failures

---------

Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
2026-06-19 05:14:58 -07:00
Michael Han
ecbb64b492
Studio: polish Bypass permissions toggle and add it to chat menu settings (#6442)
* Studio: polish Bypass permissions toggle and add it to chat menu settings

- Recolor the pill, menu item and settings caption from alarm red to a
  bright yellow accent via a new --bypass token (light and dark).
- Use the shield icon on the composer pill (X on hover, like other pills).
- Expand the composer to two rows the moment Bypass permissions is on.
- Keep Bypass leftmost among tool pills; Compare still sits ahead of it.
- Add a Bypass permissions row to Settings > Chat menu so it can be pinned.
- Widen the "More" submenu so the label fits on one line.

* Studio: use shield-ban icon and lighten the Bypass permissions pill

- Swap the lucide shield-off icon for the Hugeicons shield-ban across the
  pill, menu item and settings row.
- Lighten the pill background and deepen the yellow text a touch so the
  label stays readable on a near-white tint.

* Studio: drop the Bypass permissions resting fill, keep it on hover

Show only the yellow icon and label at rest; the rounded hover pill picks
up the yellow accent like the other toggles.

* Studio: lighten the Bypass permissions hover fill

Drop the hover tint so the yellow label stays readable on hover.

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
2026-06-18 08:53:38 -07:00
Etherll
c0cd3a506c
Use system theme and apply theme on mount (#6405)
Set ThemeProvider defaultTheme to "system" and enable system preference so the app follows the OS theme. In the theme store, apply the resolved theme to the document immediately on mount so the store is the single source of truth and avoids an initial light flash on fresh origins (e.g. empty localStorage). Minor comment and formatting tweaks in setTheme were also made.

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-06-17 17:35:14 +01:00
Eyera
435cdbc372
feat(hub): full-page redesign with trending feed, search, and persisted state (#6349)
* fix(hub): stop demoting cached gguf variants on mmproj or filename mismatch

- bug: a quant with its bytes on disk was marked not fully downloaded when the API-preferred main filenames did not match or the mmproj adapter was absent
- fix: fall back to the on-disk quant byte signal, the same one inventory uses for on-device, so a present quant is no longer demoted
- broaden mmproj detection to accept any mmproj-looking cached file, not only the API-preferred name

* feat(hub): full-page redesign with trending feed, search, and persisted state

- convert the hub to a full-page view with a new layout, model cards, and a sortable models table
- add a trending/latest/finetune feed with model and section deep-link params validated on the hub route
- add recent searches and rework model and dataset search with pagination and infinite scroll
- persist feed and token state through a dedicated store and persist-storage layer

* fix(hub): browse sorting, deep-link presets, dataset URLs, and persistence

- sort dropdown: drive HF-wide browse across all repos by the chosen sort, respecting format and capability filters
- section deep-link: apply the section preset (format and sort) on refresh and deep-link, not only on click
- dataset detail URL: persist the resource kind so refresh and share resolve datasets correctly
- gguf card: show a Loading state for hub-cache dir-path repos via repo-id match
- active-model CTA: New Chat now actually opens a fresh chat
- persist-storage: fix throttle keying with a Map and dedupe a duplicated util
- transport-toggle: drop redundant controlled-tooltip state

* Studio: polish hub redesign UI and unify segmented tabs

Refinements on top of the hub full-page redesign:

- Unify every segmented control (Discover/On Device, models/datasets,
  Unsloth/All, recent/name/size, settings tabs, train dataset source,
  profile shape, theme, OS toggle) on one filled-pill design.
- Hub list now loads in larger batches with a shorter fetch interval so
  results fill in fast instead of dripping one row at a time.
- Disable remote avatar fetches in list rows and brighten the colored
  initial fallbacks so they read clearly without network calls.
- Add a split master-detail view for model lists and make it the default.
- Left-align the split "Showing GGUF models" header with the rows below.
- Round the "Load more" footer box and tidy On Device stats layout.
- Show recent trainings on Recipes and Export, falling back to recent
  chats when there is no training history.

* Studio: address hub review comments (filter warning + scrollMargin)

- DiscoverFetchMoreFooter only shows the "results may be hidden by your
  filters" note when a filter is actually active, instead of always.
- Use the destructured scrollMargin prop directly in the row transform
  rather than reaching into virtualizer.options.scrollMargin.

* Fix/adjust Hub metadata and deep links for PR #6349

* Studio: drop avatar ring in hub split view

The split master-pane rows (discover + on device) added a ring-1 around
the owner avatar that read as a shadow. Remove it so split-view avatars
match the flat avatars elsewhere; grid cards and the full list keep theirs.

* Studio: hub sort + scope as dropdown pills beside view tabs

Recent/Name/Size and Unsloth/All were segmented controls that dropped to
their own row in the narrow split pane. Make each a compact dropdown pill
(HubOptionMenu) that sits in the header actions slot next to the view-mode
tabs in every layout, so split view no longer needs a separate row.

* Studio: align hub list header with the view tabs and rows

- Vertically center the "On device" / "Showing GGUF models" title with the
  dropdown pill and view-mode tabs (items-center instead of items-end), so a
  short title no longer sits low against the taller tab row.
- Nudge the back chevron 2px further left (-ml-2) so its glyph edge lines up
  with the start of the row hover below it.

* Studio: align back chevron tip with the row hover edge

The arrow glyph is inset ~6px inside its centered icon box, so an
edge-aligned button left the visible chevron sitting in from the column.
Pull the button out (-ml-3.5) so the chevron tip lands on the row hover's
left edge instead of floating to its right.

* Studio: unify every bare tick on the shared check mark

Point all plain checkmarks at the canonical @/lib/tick-icon tick (the one
already used in the chat composer and menus), so there is a single tick
across the app:

- Hub: model-inspector, hub-option-menu, path-info-button were importing
  the stock hugeicons Tick02Icon; switch them to the shared icon.
- Chat / assistant-ui: artifact-surface, prompt-storage-dialog, reasoning,
  tool-ui-python, tool-ui-terminal, tool-ui-code-execution, and the
  tool-fallback status map used lucide CheckIcon; render the shared tick
  via HugeiconsIcon instead (tool-fallback wraps it to fit its icon map).

The circular CheckmarkCircle success badges are intentionally left as-is.
No bare CheckIcon/stock Tick02Icon references remain; verified the tick
renders in every converted spot via typecheck + build.

* Studio: nudge back chevron 2px right

-ml-3.5 pushed the chevron a touch too far left; -ml-3 sits it just
inside the row hover edge, aligned with the avatars below.

* Studio: search base-model chips across all publishers

Clicking a Base model chip searches the Hub for the upstream repo, which
lives under another publisher (google, meta, etc.). It left ownerScope at
the default "unsloth", so the search hard-restricted to the Unsloth org and
could never surface the base model. Switch the scope to "all" for this action.

* Studio: label the safetensors list header "Safetensors"

The focused list heading showed "Showing Checkpoint ... models" while the
format dropdown labels the same checkpoint filter value "Safetensors". Match
the dropdown so the header reads "Showing Safetensors ... models".

* Studio: simplify the focused list heading to "Models"

Drop the format/capability composition (e.g. "Showing Safetensors Reasoning
models") so the focused list heading just reads "Models" (or "Datasets").
Search keeps its "Results for ..." label.

* Studio: drop the header refresh button to the text baseline

The refresh button sat at the heading's vertical centre. Nudge it down so
it lines up with the bottom of the title text instead.

* Studio: hide redundant "Back to Hub" in the split detail pane

In split view on large screens the master list sits beside the detail, so
the back button is redundant. Hide it there (lg) and reclaim the top space.
It stays on the small-screen overlay and the full-page detail, where the
list is hidden and back is the only way out.

* Studio: match the readme scroll fade to the left column

The detail pane relied on the sticky back-bar's fade, which is now hidden in
split view. Add the same hub-scroll-fade overlay the master list uses so the
readme fades consistently at the top when scrolled. The back-bar, when shown,
sits above and covers it.

* Studio: align Hub refresh button to the heading text bottom

* Studio: nudge Hub refresh button up to the heading text

* Studio: optically centre the HF token shield in its circle

* Studio: preview the first visible on-device row in split view

* Studio: calm the on-device row colour and fix size tooltip contrast

* Studio: fix Hub reset tab and clear search when opening a section

* Studio: fix Hub feed defaults, filter sync, and GGUF vision download state

* Studio: condense Hub redesign code comments

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: shimmyshimmer <info@unsloth.ai>
Co-authored-by: Michael Han <107991372+shimmyshimmer@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-06-17 04:50:34 -07:00
alkinun
14188d6f45
Add API server monitor in Studio (#5558)
* Add Studio API activity monitor

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

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

* Mark disconnected monitor streams cancelled

* Fix monitor lifecycle, parsing, and clock issues for PR #5558

Backend:
- Finalize the monitor entry as error on the chat-completions validation
  reject paths (addresses the codex P2 comment around line 2337). Adds a
  small _reject helper next to the api_monitor.start so each early-raise
  in the GGUF tool-passthrough, non-empty-messages, GGUF vision/audio,
  PIL image decode, and text-only model branches no longer leaves the
  entry stuck running until eviction.
- _monitor_openai_chunk is now defensive about malformed shapes
  (non-list choices, non-dict choice/delta) so a misbehaving provider
  chunk does not raise into the streaming generator and abort the
  user's response.
- _monitor_openai_sse_line accepts both data:value and data: value per
  SSE spec; previously the single-space-only prefix silently dropped
  tokens from compact emitters.
- openai_completions stream now keeps a residual buffer between
  aiter_bytes chunks so a data: line whose newline lands in the next
  TCP frame is reconstructed before parsing, instead of being dropped
  by the per-chunk splitlines call.

api_monitor:
- duration_ms is derived from time.monotonic anchors and clamped at 0,
  so NTP / manual clock steps no longer produce negative durations.
- finish() and fail() are idempotent: a second call (for example [DONE]
  arriving after the generator's finally block already ran) no longer
  moves finished_at or finished_monotonic.
- set_usage only derives total_tokens when no authoritative total has
  been recorded, so a later partial-usage chunk does not clobber a
  provider-reported total from an earlier chunk.
- _trim guards limit < 3 so the slice cannot underflow.

Tests:
- Adds regression coverage for the new idempotency, total preservation,
  monotonic clock, and _trim guard behaviour.

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

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

* Mark monitored stream failures correctly

* Fix completions monitor failures

* Fix responses stream monitor cleanup

* Fix audio input API monitor lifecycle

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

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

* Fix API monitor review follow-ups

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

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

* Fix completions monitor usage accounting

* Fix API monitor cancellation and usage gaps

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

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

* Move API monitor into settings

* Fix API monitor review followups

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

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

* Lazy load API monitor details

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

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

* Fix API monitor review issues

* Finalize passthrough monitor on clean EOF

* Finalize monitor on chat validation rejects

* Fix API monitor review follow-ups

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

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

* Fix API monitor follow-up review issues

* Monitor embeddings and tool-call replies

* Cover tool-call monitor replies and cancellations

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

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

* Scope API monitor entries by subject

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

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

* Finalize monitor entries on cancellation

* fix: address PR 5558 CI failures

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

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

* fix: update provider proxy test stub

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-06-16 20:26:13 +01:00
Lee Jackson
5fa253c99e
Studio: add Studio version info to General settings (#5675)
* chore: add Studio version info to General settings

* chore: cache Studio version fetch

* fix: clear cached Studio version request on failure

* fix: retry incomplete Studio version fetch

* fix: remove cached Studio version fetch

* chore: move connections below API

* fix: localize studio version rows
2026-06-15 11:41:12 -07:00
oobabooga
785d446fc1
Studio: show llama.cpp version and GPU specs in the About panel (#6261)
* Studio: show llama.cpp version and GPU specs in the About panel

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

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

* Studio: run the hardware endpoint off the event loop

* Studio: show the full llama.cpp release tag (incl -mix-<sha>) in the About panel

* Studio: order About-panel GPUs by visible ordinal and skip the llama.cpp probe during updates

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

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

* Fix hardware info refresh and endpoint scope

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
2026-06-15 15:25:08 +03:00
Daniel Han
40c8ad78b9
Studio: add --secure Cloudflare-only mode and revamp API usage examples (#6300)
* Studio: add --secure Cloudflare-only mode and revamp API usage examples

--secure / --not-secure on `unsloth studio` and `unsloth studio run`:
- --secure binds 127.0.0.1, requires the Cloudflare tunnel, and advertises only
  the Cloudflare link. cloudflared reaches the server over localhost, so the raw
  port is never exposed on a public interface.
- If the tunnel cannot start, fail closed with a clear message instead of
  silently leaving a raw 0.0.0.0 link.
- Default stays not-secure (no behavior change); coexists with the existing
  --cloudflare/--no-cloudflare flag. Host defaults are unchanged.
- /api/health (authed) now reports the live tunnel URL.

API usage examples (Profile > API):
- Example tabs for curl, Python, curl + tools, Python + tools, plus an OS row
  (Linux/macOS/WSL vs Windows) auto-detected from the platform.
- Windows curl passes the JSON body via a file so PowerShell does not strip the
  quotes when calling curl.exe.
- Python + tools forwards enable_tools/enabled_tools through extra_body and
  guards chunk.choices, since tool-lifecycle events carry no choices.
- Shows the loaded model name and the real API key while it is still revealed.
- A Cloudflare Tunnel toggle (default on) shows the public tunnel URL and uses
  it as the base_url in the examples when a tunnel is running.

Tests cover the tunnel start gate and the --secure flag on both commands.

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

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

* Studio: gate --secure tools on public exposure and harden API examples

In secure mode the server binds loopback but is reachable via the public
Cloudflare tunnel, so resolve the tool policy against the public exposure
(0.0.0.0) rather than the loopback bind. This keeps server-side tools off by
default and prompts before enabling them, instead of inheriting the loopback
default of on. The startup tool notice now names the public surface.

Also reject --secure with --no-cloudflare directly in run_server and the
run.py argparse (not only the CLI), JSON-encode interpolated model names so
Windows paths and quotes cannot produce invalid JSON or broken snippets, and
force-refresh /api/health on the API panel so a tunnel that starts after the
first health read still surfaces its URL.

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

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

* Studio: API examples show direct host when tunnel toggle is off; move Copy onto code

The Cloudflare Tunnel toggle had no visible effect when Studio was opened
through the tunnel: the off state fell back to window.location.origin, which
equals the tunnel URL in that case. /api/health now reports the direct
host:port (server_url), and the API panel uses it for the off state so it shows
the real non-tunnel base. Also move the Copy button out of the tab row and onto
the code block.

* Studio: highlight API examples, add advanced tabs, fix tunnel toggle row

Syntax-highlight the curl/PowerShell/Python snippets with the app's shared
shiki plugin (bash/powershell/python). Add 'curl + advanced' and
'Python + advanced' tabs that set temperature/top_p/top_k/min_p/
repetition_penalty/max_tokens, enable thinking, and turn on all tools.

The Cloudflare Tunnel row no longer shifts the code block: the tunnel URL is
always rendered (dimmed when off) so toggling keeps the row height constant.
Key the highlighted block on its content so it remounts when only the base URL
changes (the renderer's block memo otherwise kept a stale URL).

* Studio: rename API tunnel toggle to Secure HTTPS, hint --secure when exposed

Rename the API examples toggle from Cloudflare Tunnel to Secure HTTPS. When the
server was not launched with --secure, show an info tooltip noting the raw
0.0.0.0 port is still globally reachable and pointing at --secure. /api/health
now reports whether --secure was used so the hint is hidden in secure mode.

* Studio: force tools off for plain network/secure launches

The plain 'unsloth studio --secure' (and '-H 0.0.0.0') launcher re-execs run.py
and never installed a tool policy, so the process default (honor per-request
enable_tools) let any API-key holder run Python/terminal tools over the public
endpoint. Force the policy off at the run.py entrypoint when network-reachable
(0.0.0.0 or --secure); 'unsloth studio run' still installs its own resolved
policy and does not go through this path.

* Studio: apply default tool policy in run_server, not the run.py entrypoint

The plain launcher runs from the studio venv and calls run_server directly, so
it never hit the run.py __main__ guard. Move the network/secure default-off tool
policy into run_server so every launch path (plain, --secure, direct run.py)
gets it; the run subcommand still overrides it with its resolved policy.

* Studio: clarify --secure help text on the network exposure tradeoff

Spell out in --help (both unsloth studio and unsloth studio run, plus the
run.py argparse) that --not-secure also serves the raw 0.0.0.0 port reachable
from anywhere on the network, matching the API panel's Secure HTTPS hint.

* Studio: cache API-key PBKDF2 derivation to cut per-request /v1 auth overhead

validate_api_key re-ran the 100k-round PBKDF2 on every authenticated
request, adding ~15ms to each /v1 call made with an sk-unsloth- key.
Benchmarked against the bare llama-server it proxies to, API-key requests
carried ~22ms of fixed overhead vs ~7ms for the JWT path; the gap was
entirely this redundant key derivation (Pydantic validation measured
0.005ms, so it is not a factor).

The raw-key to hash mapping is a pure deterministic function of the fixed
server salt, so memoize it per process, keyed by a salted HMAC of the key
(never the key or a recoverable digest). The cached value equals what is
already stored at rest. Revocation and expiry remain enforced by the
SQLite read on every call, so a cache hit only skips the KDF, never the
active or expiry checks. Only keys that exist in the DB are cached, so
unknown-key spam cannot grow it.

After the change the API-key /v1 overhead drops to ~8ms, at parity with
JWT, while the at-rest PBKDF2 hashing is unchanged.

Adds test_api_key_expiry.py covering API-key and JWT expiry enforcement
and the new cache: it skips the KDF on repeat and still rejects revoked
or expired keys.

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

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

* Studio: tighten comments across the secure-tunnel and API-key changes

Condense multi-line comments and docstrings to one or two lines, drop the
ones that restate obvious code, and remove an orphaned test section header.
Comment-only: verified with comment_tools.py check (9/9 code unchanged), the
auth/secure-tunnel/CLI test suites, and a clean frontend typecheck and build.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-15 04:18:15 -07:00
Michael Han
2262e02b1a
Studio: UI polish for sidebar, menus, hub and toasts (#6288)
* Studio: UI polish for sidebar, menus, hub and toasts

- Pinned chats: add a Pinned section above Recents with a Pin/Unpin menu
  action and a hover unpin button on each pinned row
- Sidebar: align the Recents and Pinned section labels with Train, and
  switch the chat Archive icon to archive-03
- Dropdown menus: pull separators in to match item width, and even out the
  projects menu padding and radius to match the composer menu
- Account menu: match the divider width to the row hover width
- Hub toolbar: equalize the format, capability and sort filter widths so
  the search field has more room
- Model run bar: make the quant selector hover a full pill
- Tooltips: slightly larger corner radius
- Auth: reduce horizontal padding on the submit button
- Toasts: center the model load content so top and bottom padding match

* Studio: shrink pinned unpin icon, match projects menu to composer menu

* Studio: inset dropdown separators slightly from item width

* Studio: move archived chats from the sidebar into chat settings

Archive now shows a toast pointing to Settings. Archived chats are listed
and managed (open, unarchive, delete) under Settings, Chat, Data.

* Studio: make the archive toast open the archived chats list

* Studio: keep chats archived when opened from the archived list

* Chat settings: optional delete confirmation and menu cleanup

Add a "Confirm before deleting" toggle in Settings > Chat > Data. When
turned off, deleting a chat from the sidebar skips the confirm dialog
and removes it instantly. The preference is stored client-side.

Add a separator above Archive in the chat row menu so Archive and
Delete read as a group separate from Export.

Drop the duplicate divider above "Clear all chats". The section already
draws a divide-y separator between rows, so the destructive row no
longer adds its own top border.

* Hub, dialog and page polish

Hub catalog:
- Drop "inference" from the Hub subtitle.
- Make the HF token shield a circle in the header.
- Equal, narrower filter triggers that stay a fixed width; long labels
  truncate with an ellipsis instead of clipping.
- Tighter gap between the label and the dropdown chevron so one more
  character fits.
- Keep the toolbar on one row at desktop widths.
- Align the model list and row hover with the Discover tab, add right
  side breathing room.
- Make the dark-mode scrollbar lighter so it is easier to see.
- Filled size and quant chips no longer draw a border.
- Column divider uses the sidebar border color.
- Hub card extends into the bottom space.

Filters and channels:
- Add an MLX format option; detect MLX repos by library or tag.
- "Fine-tune ready" now includes 16bit safetensors, not only bnb-4bit.
- Shorten the curated channel descriptions.

Shared UI:
- New standard chevron icons (down and submenu right) shared across the
  Hub, dropdown menu, menubar, project switcher and model selector.
- Dialogs get more top and bottom padding and a larger heading.
- Guided Tour hides itself on pages with no tour.

Other pages:
- Export and Data Recipes use the same page padding as the Hub.

* Soften popup overlays and refine hub filters

Dialogs, alert dialogs and sheets now use a light blur with a slight
darken instead of a heavy black scrim, so popups stay legible without
dimming the whole screen. Edit System Prompt and Edit Chat Template
drop their custom overlays and share the same look.

Also limit the Fine-tune ready channel to checkpoints Unsloth can
actually fine-tune (drops fp8, nvfp4, w4a16 and similar), revert chip
borders so filled chips stay visible, nudge the Run icon to center it,
and shrink the submenu chevron a touch.

* Inline chat rename and review-feedback fixes

Rename a chat inline as a rounded pill on the row instead of opening a
dialog. Enter saves, Escape cancels, clicking away saves. Projects and
training runs keep the existing dialog.

Address review feedback:
- Route the no-confirm chat delete through the shared cleanup helper so
  it gets the same error toast and pin removal as the confirmed path.
- Archived chats now confirm before deleting (respecting the setting)
  and pass the active thread, so deleting the open chat resets to a new
  chat instead of leaving a stale thread URL.
- Only offer the MLX format filter on Discover, since downloaded
  inventory rows are never tagged mlx and would filter to empty.
- Tighten the non-finetunable name match so a dot-delimited token such
  as .tflite is also excluded from the Fine-tune ready channel.

* Sidebar: borderless inline rename, no name flash, align titles

Rename now edits the title as plain highlighted text with no pill or
box. While the debounced sidebar refresh catches up after a rename the
row shows the new name optimistically, so the old name no longer flashes
back in. Recent chat titles now line up flush under the Recents label.

* Profile: preferred name and avatar shape options

Add a "What should Unsloth call you?" preferred name that drives the
new-chat greeting, falling back to the first name then the login id.
Add a toggle to show the avatar as a full circle or a rounded
rectangle, applied everywhere the avatar appears.

* Settings: show version block at the top of General

Surface the Unsloth and package versions at the top of General, not
just in About. Both tabs share one StudioVersionSection so the version
fetch and markup live in a single place.

* Profile: pick a sloth sticker as your avatar

Add a curated set of sloth stickers users can choose as a profile
picture, shown below the picture shape toggle in Settings > Profile.

- Only squarish, low-whitespace stickers are listed so each one fills
  the avatar frame cleanly.
- Selecting one reuses the existing avatar persistence (localStorage),
  so it sits alongside the photo upload with no backend changes.
- Image avatars now get a neutral background so transparent stickers
  read cleanly in both shapes.

* Profile: keep transparent avatars transparent

A transparent upload is now kept as WebP and shrunk to fit instead of
falling back to JPEG, which has no alpha and was painting a background
behind the image. The image avatar is also explicitly transparent.

* Profile: fall back to PNG for transparent avatars on Safari

Safari cannot encode WebP via canvas, so a transparent upload was
throwing instead of saving. Fall back to PNG, which keeps alpha and
works in every browser, before giving up. Still never uses JPEG.

* Archived chats: reset nav when deleting the open compare too

Compare panes do not write the active thread to the store, so the pair
id only lives in the route search. Derive the open chat id from the
route (thread or compare) like the sidebar does, so deleting the open
archived compare resets to a new chat instead of a dead URL.

* Profile: trim three sloth stickers from the picker

Drop the duplicate computer sloth without text on the screen (keeping
the Local one), the nature-screen sloth, and the gift sloth.

* Chat: tighten model selector right padding

Use less right padding than left so the trailing chevron does not leave
a heavy gap on the right of the trigger.

* Address review feedback on profile, reset and rename

- Give each sloth avatar button an accessible name so screen readers
  can tell the options apart.
- Reset all local preferences now clears the delete-confirmation pref,
  so it returns to the safe confirm default.
- A no-op Enter in inline rename now closes the editor instead of
  leaving the row stuck as an input with its blur suppressed.

* Chat: rebalance model selector padding and chevron

More left padding than right and pull the chevron close to the label so
the trigger reads balanced around the text instead of heavy on the right.

* Hub: shrink the external link dialog icon

Set the icon to size-6 so it sits proportionally in the media circle
instead of filling it.

* Hub: shrink the external link dialog media circle

Override the media circle to size-12 with a size-5 icon on this dialog
only, so it is more compact without changing the shared component.
2026-06-13 03:50:36 -07:00
Michael Han
587e59ca83
Studio: clearer check mark and even plus-menu hover padding (#6253)
* Studio: use a clearer check mark for the app-wide tick

Route every check mark through a shared tick-icon module with a plain
tick geometry, sized slightly larger than the stock icon so it reads
clearly in menus. No Pro icon assets are vendored.

* Studio: even out plus-menu hover padding on all sides

Trim the plus-menu side gutter to 9px so it matches the 0.5rem top/bottom
padding, and drop the container radius to 21px to keep corners concentric.

* Studio: bake the tick fallback stroke at 1.5 to match the icon set

---------

Co-authored-by: shimmyshimmer <info@unsloth.ai>
2026-06-12 06:44:08 -07:00
Michael Han
99704ffe47
Studio: project sources backed by RAG (#6205)
* Studio: make project sources work with RAG and polish project UI

Projects had a disabled Sources tab with an Add sources placeholder.
This wires it up end to end on top of the RAG engine:

- Add a project scope to the RAG store, ingestion and retrieval
- New endpoints: POST/GET /api/rag/projects/{id}/documents
- search_knowledge_base resolves kb, project and thread scopes; an
  explicit KB stays exclusive, project and thread scopes combine
- Multi-scope search: FTS uses scope IN (...), vec0 KNN runs per
  scope and merges by cosine score
- Lazy ALTER TABLE adds documents.project_id on existing databases
- Deleting a project also removes its indexed sources
- Sources tab now uploads with progress chips and drag and drop
- Chats inside a project auto-enable retrieval over project sources
  when the project has indexed documents (cached probe, no Docs pill
  needed); external providers still never receive rag_scope

UI polish:
- Rounder project cards with folder icon chip and softer shadow
- Project header icon in a rounded chip
- Chats/Sources pills and Add sources button without borders

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

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

* Studio: match Add sources button shadow to the chat composer in light mode

* Studio: round project switcher hover pill and pad the folder icon

* Studio: remove border from project sources box

* Studio: grey hover on project cards and menu, move search into header, widen page spacing

* Studio: shorten sources copy, white header pills with composer shadow, fixed-width search, hub-size page headings

* Studio: align project landing blocks to the composer width

* Studio: restore muted background and flat look on projects header controls

* Studio: darker grey hover on project cards in light mode

* Studio: soften project card hover grey

* Studio: keep project card menu button visible while its menu is open

* Studio: drop focus outlines and rings on buttons and clickable icons, keep input focus styles

* Studio: address review feedback on project sources

- Remove uploaded files from disk when a project is deleted, confined
  to the uploads root
- 404 project uploads when the project does not exist, matching the KB
  endpoint
- Guard lexical search against an empty scope list
- Re-invalidate the project sources probe after uploads and removals
  settle so a chat sent mid-upload cannot cache a stale negative
- Keep keyboard focus rings: only mouse focus drops the Tailwind ring,
  the browser default outline stays removed

* Studio: add a green New badge to the project Sources tab

* Studio: unify New pills, fully round with soft emerald fill and no border

* Studio: a touch more vertical padding on New pills

* Fix project RAG source edge cases for PR #6205

* Fix duplicate RAG upload cleanup for PR #6205

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

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

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-12 15:42:51 +02:00
Michael Han
c836228de8
Studio: pinnable plus menu items and saved prompt pins (#6237)
Adjustable items (Chat with Files, MCP, Saved prompts, Compare chat,
Export chat, Canvas, Projects) can be pinned to the top level of the
composer plus menu from Settings -> Chat. Unpinned items move into the
More submenu, which hides itself when empty. Saved prompts can be
pinned individually so they surface in the Saved prompts submenu.
2026-06-12 04:17:45 -07:00
Michael Han
40cb0a71b4
Studio: model selector and settings polish (#6240)
Removes the empty band under Eject loaded model, centers the trigger
chevrons, moves the model reload Apply and Reset buttons above Chat
Template so they sit under the settings they apply, matches the
settings nav divider to the sidebar line, and bumps the settings tab
headings slightly.
2026-06-12 04:13:22 -07:00
Michael Han
b2b1dcd6ad
Studio: llama.cpp update banner redesign, About tab license info, UI polish (#6196)
* Studio: llama.cpp update banner redesign, About tab license info, inline system prompt editing, naming cleanup

- Redesign the llama.cpp update banner to match the chat composer surface
  (borderless rounded card, composer shadow, Hellix Medium title), rename
  actions to Update and add a 15 minute Remind me later snooze
- Keep the banner up until the user explicitly acts on it; drop the
  outside click dismissal
- Add a Settings > General > Notifications toggle to disable the banner
  for training-only setups (on by default)
- Rename the Help settings tab to About and add a License section
  (Unsloth Studio AGPL-3.0, Unsloth Core Apache-2.0) linking to the
  license files in this repo
- Make the run settings system prompt box an inline editable textarea;
  the popup editor opens when the prompt overflows the box
- Pointer cursor on the preset dropdown chevron
- Dark mode toasts use the chat composer surface color
- Replace standalone Studio with Unsloth in user facing strings; keep
  Unsloth Studio, LM Studio, Fine-tuning Studio, Recipe Studio and CLI
  commands unchanged

* Studio: open the system prompt popup on box click, balance banner padding

- The system prompt box opens the Edit System Prompt dialog on click,
  matching the pencil action
- Slightly more bottom padding on the llama.cpp update banner so the
  spacing reads even next to the action pills

* Studio: replace unsloth studio update with the installer commands in update guidance

- The unsloth studio update command no longer works, so the About tab
  update section now shows the one-line installer (curl or irm) for
  PyPI and unknown installs, and git pull plus the local installer for
  checkouts
- Add a short note that unsloth studio update is no longer supported
- Link the Installation, Updating and Windows install docs pages
- The package update banner now copies the platform installer command
  instead of unsloth studio update

* Studio: rounder account menu, inline system prompt box with popup from the label

- Account menu corners go from 14px to 18px via a specific override,
  since list menus pin border-radius globally
- llama.cpp banner bottom padding 22px
- System prompt is an inline editable textarea again; clicking the
  System Prompt label opens the popup editor, and an overflowing
  prompt opens it on box click

* Studio: show the standard install commands in the About update section

- Both one-line install commands (MacOS/Linux/WSL and Windows
  PowerShell) are always shown, labeled like the docs, since running
  them again updates an existing install
- Drop the unsloth studio update deprecation note
- Add the Mac install guide to the docs links

* Studio: clearer platform toggle and layout in the About update section

- Section heading is Update
- Platform picker is a pair of pill buttons, MacOS / Linux and Windows,
  and only the selected platform's install command is shown
- Intro reads: To install or update Unsloth
- Local update heading separates checkout guidance from the standard
  install command

* Studio: report GitHub branch instead of dev for source checkouts

A source checkout not on an exact release tag now shows
GitHub <branch> (e.g. GitHub main) as the Studio version in About.
Detached or unusual HEADs still fall back to dev.

* Studio: tighten the About update section copy and toggle styling

- Platform toggle buttons are borderless pills
- Shorter local update wording and restart note
- Docs links read Mac and Windows

* Studio: tighten line spacing in the sidebar account button

* Studio: fix vanishing compact MCP icon on hover, single line pill tooltips

- Compact caret pills (MCP, RAG) keep their icon on hover for inactive
  pills too; the off switch hover rules hid the icon while compact mode
  hid the X, leaving an empty slot
- Compact icon tooltips and single line compact tooltips render as full
  pills; wrapped tooltips keep the 9px corners. TooltipContent measures
  line count in a ref callback since Radix mounts portal content
  without re-rendering the wrapper
- 1px gap between the name and Unsloth lines in the sidebar account
  button

* Studio: Projects hover plus button, align recents with the label

- Hovering the Projects nav item reveals a plus button that opens the
  New project dialog, with the same circular hover treatment as the
  chat row actions
- Recent chat titles start at the same x as the Recents label
- The system prompt overflow lock only engages for a non-empty prompt
  with a laid-out box, so a mis-measure cannot turn clicks into the
  popup

* Clip system prompt overflow inside the rounded box

Wrap the inline system prompt textarea in a rounded overflow-hidden
surface so scrolled text and the scrollbar stay inside the box. The
focus ring moves to the wrapper via focus-within.

* Add updating progress bar to llama banner and shorten settings copy

While an update is applying, the banner action row becomes an
indeterminate progress bar that keeps animating under reduced motion,
matching the other loading indicators. Settings descriptions across
General, Profile, Appearance, Chat, Connections, API, and About are
trimmed without losing meaning.

* Address review: desktop update note, server platform detection, zh-CN keys

The About tab no longer shows terminal install commands in the desktop
app, where the bundled backend updates through the built-in updater;
it shows a short note and the docs links instead.

fetchDeviceType now sends the auth token to /api/health, which only
reports the server platform to authed callers, and caches only a
server-reported value. Copied install commands then match the host
platform rather than the browser when they differ (WSL, SSH).

zh-CN gains translations for the new notification and license keys,
the renamed About tab title, and the desktop update note.

* Real download progress for llama.cpp updates, prompt and sidebar polish

The update worker now streams the installer output and parses its
download percent lines into job progress, exposed via the update-status
API. The installer emits finer non-tty milestones when
UNSLOTH_PROGRESS_PERCENT_STEP is set; the worker requests 5 percent
steps. The banner renders a determinate bar from the reported fraction
and falls back to the sweep until the first percent arrives.

Also removes the focus ring on the inline system prompt box and
slightly shrinks the Projects hover plus icon.
2026-06-11 09:27:34 -07:00
Daniel Han
dfb49fd2cb
Studio: bulk export and import in Settings Chat Data, MCP pill off switch (#6141)
* Studio: bulk export and import in Settings Chat Data, MCP pill off switch

- Settings -> Chat -> Data gains Export Recents and Projects (Recents or
  Recents + Projects, Raw JSONL / CSV / ShareGPT, combined or per chat)
  and Import chats, reusing the sidebar Recents menu actions
- Extract bulkExportConversationsByScope so the sidebar and Settings share
  one implementation; expose the export and import helpers via the chat
  feature index
- MCP composer pill icon now swaps to an X on hover like Search, Code and
  RAG; clicking it turns MCP off without opening the server menu
- en and zh-CN locale strings added (parity check passes)

* Reveal the pill X on hover for off-switch icons regardless of active look

The X was gated on data-active, so an MCP pill with no servers enabled
(or a RAG pill without a model) closed on icon click but never showed
the affordance. Off-switch pills only render while their feature is on,
so hover now always reveals the X.

* Replace the Recents hamburger menu with an Export all chats link to Settings

Bulk export and import now live in Settings -> Chat -> Data, so the
sidebar Recents header menu is gone. Each chat's Export submenu gains
Export all chats, which opens Settings on the Chat tab.
2026-06-10 05:11:15 -07:00
Michael Han
f542ffb023
Studio: unify shadows, backgrounds and dark mode consistency in chat UI (#6116)
* Studio: refine chat UI shadow, background and sidebar divider

- Give both chat composers a Gemini style drop shadow with a short
  transition, and add a visible shadow in dark mode.
- Set the main chat surface to a soft off white (#fbfbfc) in light mode
  so the white composer reads as a card; dark mode is unchanged.
- Remove the divider line between the side menu and the page.

* Studio: lighten chat background to #fcfcfd

* Studio: make dark mode composer shadow visible

* Studio: drop composer shadow in dark mode

* Studio: unify all light mode shadows on the chatbox shadow

* Studio: unify dark mode surface colors and make hover states visible

* Studio: fix barely visible dark mode hover in account and plus menus

* Studio: lift settings dialog off the page background in dark mode

* Studio: drop greeting periods and restyle projects page to match the chatbox

* Studio: chatbox shadow on search dialog, grey sort pill on projects

* Studio: slimmer sidebar profile row, borderless login card

* Studio: match select popups to their trigger, taller profile hover target

* Studio: bigger projects search pill with the original chatbox glow

* Studio: keep select trigger shape while open

* Studio: center and narrow the projects search pill like Gemini

* Studio: borderless export card with the chatbox shadow

* Studio: chatbox shadow for selectable pills, warmer composer, search pill tweaks

* Studio: one warm background token for every page, softer search glow

* Studio: keep off white depth when warming the page background

* Studio: distinct sidebar surface from the page background

* Studio: white sidebar on the warm page background

* Studio: soften projects search shadow

* Studio: nudge the chat zero state up 5px

* Studio: darker dark mode page background, 28.5vh welcome offset

* Studio: dark mode shadows match the chatbox geometry, borderless recipes empty state

* Studio: revert dark mode shadows, desaturate dark recipe cards

* Studio: lift dark recipe cards, no hover shadow in dark

* Studio: drop the pale hover halo on recipe cards

* Studio: lighter dark recipe text, flat dark menus, flush select popups

* Studio: page-bg shadow on model selector, flush popovers and dropdowns

* Studio: zero menu offsets so dropdowns sit flush against triggers

* Studio: one 14px radius for list menus and sidebar buttons

* Studio: 14px buttons, pill hover shapes, narrower slider inputs

* Studio: pill buttons, keep profile row rectangular

* Studio: pill Save and Delete, small gap under account menu

* Studio: skinnier dropdown popups, larger account menu gap

* Studio: dropdown popups slightly wider than their trigger

* Restore light mode sidebar separator line

* Size panel number input pills to their content

* Lighten light mode page background

* Pure white composer, nudge page background whiter

* Use inline ch width for panel number pills

* Fix panel number pills at uniform 4ch width

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-06-10 03:09:37 -07:00
Wasim Yousef Said
0d6d7dd4b3
Studio: make Helper LLM startup pre-cache opt in (#6113)
* Studio: make Helper LLM startup pre-cache opt in

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-09 15:28:34 +02:00
Daniel Han
85314ed162
Studio frontend: reduce and tighten code comments (#6099)
Trim and tighten code comments across studio/frontend TS/JS. Comment-only: every changed file verified code-identical to main via the TypeScript printer signature comparison.
2026-06-08 23:10:35 -07:00
Michael Han
37fd76a02f
studio: redesign chat composer (#5891)
* studio: redesign chat composer

Reworks the new-chat composer and the compare composer into a single
rounded pill surface with a softer, lighter look.

- New welcome screen with a time-of-day sloth mascot and a lighter
  heading.
- One rounded composer surface with a soft drop shadow. The input grows
  inline as you type and collapses back to a single row when cleared.
- Tools and attachments live in a single plus menu; the thinking control
  is a compact pill with a reasoning-effort submenu.
- Inlined glyphs for the thinking, send, and dictate controls, kept in
  sync across the main and compare composers.
- Toast notifications match the composer surface: no border line, the
  same drop shadow, and the same dark surface color, with a ring-less
  close button.
- Dark mode: the side-menu shadow blends into the background, hovered
  menu rows read clearly, and their roundness matches light mode.
- Composer styles use dedicated unsloth- prefixed classes so compare
  mode keeps its own stacked layout.

* studio: sync compare-composer reasoning state and harden compare id

- Compare composer: keep "Preserve thinking" consistent with reasoning,
  matching the main composer. Enabling it now turns reasoning on, and
  disabling reasoning (the None option or the Thinking toggle) turns it
  off, so the invalid "preserve on while thinking off" state can't occur.
- Guard crypto.randomUUID in the Compare action. It is undefined in
  non-secure contexts (HTTP over a LAN IP) and would throw; fall back to
  a timestamped random id, matching createNavigationNonce.

* studio: reflect pre-selected Search/Code tools when no model is loaded

The Search and Code pills only lit up when the tool was usable right now
(a model loaded and capable), so a tool turned on from the + menu showed
as off in the pill while the menu showed it on. toolsEnabled is persisted
and takes effect once a capable model loads, so the pill should reflect it.
The pills now disable only when a loaded model lacks the capability, and
otherwise reflect the selected state. Applied to the main and compare
composers.

* Studio: link MCP Servers heading to its PR and fix composer pill cursors

Make the "MCP Servers" heading in the chat Configuration sheet link to the
MCP PR, keeping the chevron as the toggle. The label and chevron are rendered
as siblings so we don't nest an <a> inside a <button>.

Also add cursor-pointer to the composer pills and the thinking pill so hovering
a clickable pill shows the hand cursor instead of the default arrow.

* Studio: refine chat composer and add compare-mode parity

- Composer expands to two rows only once the input wraps to a second line,
  not on the first keystroke. Re-measure the autosize textarea on the width
  swap so expanding no longer leaves a stray blank row.
- Light-mode composer shadow now matches Gemini's soft elevation.
- Plus menu: replace Canvas with a More submenu (Canvas, Compare chat, RAG)
  and add Code above MCP. Active Web search/Code items use medium weight.
- Compare mode: the plus side menu, Search/Code toggles, and a Compare exit
  pill now match single chat, with the thinking control on the right.
- Projects menu entries link to their tracking PR (#5725).
- Add cursor-pointer to the composer plus button.

* studio: refine composer controls and chat search shadow

- Active tool pills show an x on hover to signal click-to-disable
- Plus button rotates into an x when the tools menu opens
- Composer surface uses a 32px radius and a taller single-line height
- Even, ChatGPT-style spacing between the plus and tool pills in both the single and compare composers
- Send and mic circles resized and spaced, with the arrow centered in the circle
- Chat search box gets a borderless, soft Gemini-style shadow

* studio: size the pill hover x to match the icon it replaces

Cross-engine checks (Chromium, Firefox, WebKit) flagged the active-pill
hover x as a fixed 14px, so it popped smaller than the 19px Code icon.
Fill the glyph slot instead so the x tracks whatever icon it covers.

* studio: do not persist Kimi search/thinking mutual-exclusion in single composer

The single-chat composer flipped the other control off when toggling
search or thinking on Kimi, but without { persist: false }, so it
overwrote the user's saved preference. Match shared-composer and keep
the side effect session-only.

* studio: pointer cursor on model selector trigger and menu items

Add scoped marker classes so the model picker trigger and every
clickable element in its menu (tabs, model rows, delete, eject) show a
pointer cursor; disabled items stay not-allowed.

* studio: pass baseUrl when resolving reasoning caps in single composer

The docked composer omitted baseUrl, so a custom Gemini OpenAI-compat
gateway still advertised the native thinking ladder the backend cannot
honor. Pass selectedExternalProvider.baseUrl like the compare composer
so the resolver hides it.

* studio: grey side-menu hover, green pill hover, thinking hover x

- Plus side-menu items hover grey in light mode, not the green accent
- Thinking pill hovers green like the Search and Code pills
- The plain Thinking toggle shows an x on hover when active, matching
  Search and Code; the effort dropdown trigger keeps its bulb

* studio: make the pill hover x a uniform size

The x filled the icon slot, so the wider Code chevron gave a bigger x
than Search and Compare. Pin it to a fixed 15px, centered, so every
pill's x matches.

* studio: broaden chat attachments, fix active hover color, gemini shadow

- Accept svg, source code and many text/config files as drag-and-drop
  or picked attachments, matched by extension since their MIME is
  unreliable; html keeps its own adapter
- Active (green) side-menu items keep their text and icon color on
  hover instead of switching to the accent color
- Composer surface uses Gemini's soft centered shadow 0 0 20px rgba(0,0,0,0.04)

* studio: keep the thinking pill full height when icon-only

The inactive thinking pill has no label, so its flex row collapsed to
the icon height and the hover box looked short. Reserve one text line
(min-height: 1lh + padding) so it matches the Search and Code pills.

* studio: refine composer menu, drop overlay and greetings

- Open the MCP servers dialog directly from the composer plus menu
- Redesign the drag-and-drop affordance Gemini style, drop the badge and border, make the whole chat page a drop target
- Swap in Hugeicons for the RAG, attachment chip and new project icons
- Add time-based randomized welcome greetings, each matched to a fitting sloth

* studio: rename artifacts toggle to Canvas and make it opt-in

- Label the toggle Canvas everywhere, matching the plus menu
- Stop greying out the Canvas menu item; it toggles like the other items
- Only show the Canvas pill in the composer row once it is turned on, since it is less central than Search and Code

* studio: wire Canvas and MCP composer toggles, even out the pill row

- Open the MCP servers dialog from the menu, or toggle MCP on/off once a server is enabled
- Force MCP off when no server is enabled, so the toggle stays honest
- Show Canvas and MCP as opt-in pills that appear in the order they were toggled on
- Expand the composer and light up the pill when Canvas or MCP is on, like Search and Code
- Keep Compare directly after Code in the compare composer
- Use the same Code icon on both composers and give every pill an even icon slot

* studio: tidy composer toggle row and fix MCP enable/disable lifecycle

- Enable MCP automatically after a server is configured via the toggle flow
- Force MCP off everywhere once the last enabled server is removed
- Collapse the pill labels to icons only when more than 4 pills show, keeping Compare labelled
- Order Compare first in compare mode, before Search and Code
- Use the same Code icon and an even 19px icon slot across both composers
- Match the compare composer surface padding and send button inset to normal chat

* studio: revert compare composer padding change that cramped the input

Matching the surface padding to normal chat clipped the textarea text and
left a white strip on top. Restore the compare composer's own padding, which
gives proper top spacing. The send button inset fix stays.

* studio: center welcome greeting and soften composer scrollbar

Center the sloth and title together over the composer instead of
shifting the row left, which left the greeting sitting off to the side.

Keep the composer textarea scroll thumb faint by default and only darken
it when the thumb is hovered or dragged, so a tall draft no longer shows
a heavy dark rail.

* studio: match composer plus-menu tool gating to the pills

The new plus-menu tool entries did not carry the gating the visible pills
already enforce, so the menu and pills could disagree about a loaded
model's capabilities.

- Web search and Code menu items now disable when a loaded model lacks
  the capability, while still allowing preselection with no model loaded.
- Enabling Web search from the menu on a Kimi model now flips thinking
  off as a session-only change, since Kimi forbids search and thinking
  together. This matches the Search pill.
- Added an Images menu item, shown only for image-generation models and
  disabled until a model loads, so a short prompt has an entry point.

Applied to both the single-chat and compare composers.

* studio: round the active-pill hover x and even out pill padding

The hover x sat bare and the trailing label was tighter to the pill edge
than the leading icon, so the pill looked lopsided.

- Give the hover x a soft circular background that fills the icon slot,
  matching the ChatGPT-style toggle and the icon it replaces.
- Add a little more trailing padding so the label and the leading icon
  have even breathing room, and keep icon-only compact pills symmetric.

* studio: nudge the thinking bulb icon up by 0.5px

Bump the thinking lightbulb from 15px to 15.5px in the single-chat and
compare composers so it sits a touch larger next to the other controls.

* studio: drop the hover x circle on icon-only pills

When pills collapse to icon-only, the circle around the hover x is too
cramped in the small chip, so show a bare x there and keep the circle
only on the full-width labelled pills.

* studio: space the compare send button like normal chat

In compare mode the Thinking control sat right against the send button.
Match the normal composer's control spacing (gap-1.5 plus a send margin)
so Thinking has the same breathing room before send. The send button
keeps its 14px inset, so its position is unchanged.

* studio: make collapsed pill hover a circle, not a wide pill

Icon-only pills were wider than tall, so their rounded-full hover
highlight read as a fat rounded rectangle. Make the compact button a
square and center the glyph so the hover (and the x it reveals) sits in
a clean circle.

* studio: fix compare pane drops and audio picker lifetime

- Skip the page-level drop handler when the composer is hidden, so files
  dropped on a compare pane are not swallowed by a hidden composer; the
  shared compare composer keeps handling drops through its own dropzone.
- Build the audio file input on document.body instead of inside the plus
  menu, so the menu closing on select no longer unmounts the input before
  the OS picker returns and drops the file.

* studio/chat: stop projects list from white-screening on older backends

The projects list API returned data.projects directly, so a backend that
omits the field handed back undefined. useChatProjects cached that value,
then the next mount read undefined.length and crashed the whole chat page.

Default the projects and threads list APIs to an empty array and keep the
hook null-safe so a bad response can never poison the cache.

* studio/chat: align MCP dropdown with the + menu and add a chevron

Reuse the + menu surface (unsloth-plus-menu) for the MCP dropdown: rounded
corners, narrower width, neutral grey hover, and enabled rows shown as green
text with a right-aligned check instead of the emerald underlay. Add a
chevron to the MCP pill so it reads as openable, matching the Thinking pill.

* studio/chat: make MCP an opt-in pill and fix its dropdown placement

- MCP is back in the + menu as a toggle. The pill now only shows in the
  composer when MCP is on, matching Canvas, instead of always sitting there.
- The dropdown follows the composer side like the + menu (opens down in the
  welcome composer, up when docked) rather than always opening upward.
- Drop the dropdown caret when pills collapse so the icon is not squished.
- Stop force-syncing mcpEnabledForChat to the server count; the + menu owns it.

* studio/chat: MCP expands the composer, drop sidebar Compare, tidy scrollbars

- Toggling MCP now expands the composer and shows the tool pills, the same as
  Canvas, instead of leaving the row collapsed.
- Remove the Compare item from the sidebar now that it lives in the + menu, and
  point the compare tour step at the side-by-side view instead of the old button.
- Both sidebars only show their scrollbar on hover, and run settings reserves
  the scrollbar gutter so the close button no longer shifts when it appears.

* studio/chat: tighten toggle gap, fix run-settings close button, collapsed Train

- Reduce the composer toggle gap by 2px (gap-1 to gap-0.5) in both composers.
- Move the run settings header out of the scroll area so the close button keeps
  its position whether or not the scrollbar shows, and sits flush with the
  topbar open button again instead of shifting left.
- Surface Train as an icon in the collapsed sidebar (it already has a labelled
  section when expanded).

* studio/chat: tighten Thinking pill X padding, create projects inline

- The Thinking pill used px-2.5, so the hover X sat further in than the left
  pills. Match their pl-2 so the X lines up.
- The + menu New project now opens a create dialog and jumps straight to the
  new project, instead of routing to the projects list. Shared by both
  composers via a small NewProjectDialog.

* studio/chat: soften account menu, hover scrollbars, show collapsed chevrons

- Account menu drops its border ring for the composer's soft shadow and opens
  centered over its trigger.
- Settings and search reuse the hover-only scrollbar via a shared
  hover-scrollbar class, matching the sidebars.
- Train and Recents keep their chevron visible while collapsed so it is clear
  they can be expanded.

* studio/chat: roomier, more rounded account menu

Widen the account menu, add more left and right padding on the rows, bump the
row height and text a touch, and round the corners more, closer to the GPT
account menu.

* studio/chat: trim account menu width and nudge it up 2px

Pull the account menu in slightly on the left and right (narrower box, a touch
less row padding) and lift it 2px higher above the trigger.

* studio/settings: drop outline ring, circular close hover, pointer cursors

- Remove the settings dialog outline ring, keeping just the soft shadow.
- The close button hover is now a circle instead of a rounded rectangle.
- Every clickable control in the settings dialog uses a pointer cursor.

* studio/chat: bump MCP pill icon to 14.5px

Nudge the MCP icon up 0.5px so it sits even with the other pill glyphs.

* studio/chat: bump MCP pill icon to 15px

Nudge the MCP icon up another 0.5px.

* studio/settings: add a Settings title above the tabs

Put a Settings heading at the top of the sidebar so the tabs sit below it,
matching the Claude settings layout. Hidden on mobile where the nav is a row.

* studio/settings: rounder tab hover, bigger title, less-round search dialog

* studio/sidebar: round nav row hover boxes 2px more (10px to 12px)

* studio: drop settings dark shadow + divider, add tab left padding, tune hover roundness

* studio/model-selector: roomier padding, borderless box, rounder hover rows; settings divider light-only

* studio/search: match chat box shadow (soft light, none dark)

* studio/sidebar: borderless chat context menus, rename submenu to Projects with folder-export icon

* studio/model-selector: match light corner radius in dark, drop dark shadow, more visible dark hover

* studio/sidebar: chat context menu matches + side menu styling; relabel submenu Move to project

* studio: borderless message export menu (no dark shadow), match dark corner radius to light on export menu and settings

* studio/sidebar: open chat options menu GPT-style (down-right) and widen so Move to project fits one line

* studio/chat: message export menu uses the chatbox shadow in light mode

* studio/sidebar: narrow chat options menu slightly (w-60 to w-56)

* studio: unify all download icons to Hugeicons download-01; round profile button hover 1px more

* studio/run-settings: bump header to 16px

* studio/sidebar: trim chat options menu width slightly (w-56 to 216px)

* studio/sidebar: trim chat options menu width to w-52

* studio/profile: camera-01 Hugeicons glyph and chatbox shadow on avatar button

* studio: match dark-mode corner radius to light globally (single --radius token)

* studio/recipes: borderless New Recipe menu with chatbox shadow in light, none in dark

* studio: borderless dropdowns globally, chatbox shadow in light, none in dark

* studio: extend borderless + chatbox/none shadow to select, combobox and popover overlays

* studio/mcp: nudge MCP dropdown radius to 20px so its wider box reads as round as the + menu

* studio: restore dark dropdown shadow to avoid same-color merge; greet name ~1/3 of lines; bigger sloth + more gap

* studio/train: active tab is a borderless pill (no underline), roomier padding, more tab gap and bottom spacing

* studio/chat: nudge welcome up ~5px (still vh-based) and trim sloth image to 44px

* studio/train: active tab pill is white with chatbox shadow in light, taller padding

* studio/chat: welcome offset to calc(30vh - 10px)

* studio/chat: welcome offset to 28vh (drop the -10px)

* studio/chat: tighten sloth-to-text gap by 1px (16px to 15px)

* studio/train: revert light active pill to grey fill, drop white bg + shadow

* studio: app-wide hand cursor on every clickable control (disabled excluded)

* studio/chat: welcome offset to 26vh

* studio/chat: welcome offset to 28vh

* studio/chat: harden project and thread list guards against non-array payloads

* studio/sidebar: give the profile row more height and breathing room

* studio/sidebar: trim the profile row top and bottom padding slightly

* studio/sidebar: reduce Train and Recents section label size slightly

* studio/sidebar: trim the profile row top and bottom padding a touch more

* studio/sidebar: enlarge the profile hover area top and bottom

* studio/sidebar: increase profile hover roundness by 1px

* studio/sidebar: trim the profile row top and bottom padding slightly

* studio/sidebar: trim the profile row top and bottom padding slightly

* studio/chat: cache composer line metrics so wrap detection runs once, not per keystroke

* studio/chat: restore the prior view when exiting compare opened from the + menu

* studio/tests: drive Compare from the composer + menu after it moved out of the sidebar

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

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

* studio/tests: open Compare from the composer + menu in the extra UI suite too

* studio: fix chat dictation microphone access

* studio: snappier plus-to-x spin and steady composer expand gap

Speed up the composer plus icon morph from 480ms to 300ms.

Add row-gap on the expanded composer line so the space between the text
and the controls row stays the same whether the box expanded from
wrapped text or from a toggle being on. The gap sits on the line, not the
input, so the placeholder max-height clamp never crops it.

* studio: only show composer tool pills once a model is loaded

Persisted Search/Code/Canvas/MCP toggles were surfacing the composer pill row on a fresh page load before any model was selected, so an empty composer looked different from the clean just-ejected state. Gate the composerExpanded tool checks on modelLoaded so a model-less composer stays collapsed, while saved preferences still apply the moment a model loads.

* studio: hide RAG composer menu item temporarily

Hide the placeholder RAG entry from the composer plus menu in both single chat and compare until the feature is ready, and drop the now-unused DatabaseIcon import.

* studio: let composer tools pre-select before a model loads

Selecting Web search, Code, Canvas or MCP from the + menu with no model
loaded did nothing visible: the toggle turned on but the composer never
expanded, so the pill stayed hidden. Drop the model-loaded gate from the
expand check so an active tool always surfaces its pill.

Align MCP with the Search/Code pattern too: grey it out only when a loaded
model lacks tool support, so MCP stays toggleable and the pill stays
clickable before a model is loaded instead of looking disabled.

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
2026-06-03 06:07:30 -07:00
Wasim Yousef Said
7381958225
Configurable upload Cap studio (for training) (#5808)
* studio: cap training dataset uploads

* studio: clean up failed dataset uploads

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

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

* studio: raise upload limits to 500MB

* studio: make upload limit configurable

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

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

* studio: stream upload routes

* studio: split recipe upload caps

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

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

* studio: tighten upload limit handling

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

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

* studio: import settings router directly

* studio: polish upload cap setting control

* studio: cap settings request bodies

* studio: stub settings route in desktop auth test

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-02 08:52:19 -07:00
Wasim Yousef Said
dfba4cc5ca
Studio: add HTML artifacts to chat (#5772)
* Studio: add chat HTML artifact primitives

* Studio: add local render_html tool support

* Studio: wire render_html artifacts in chat UI

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

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

* Studio: add chat artifact surface

* Studio: mount chat artifact panel and overlay

* Studio: fix chat artifact review regressions

* Studio: fix chat artifact panel and sandbox previews

* Studio: address chat artifact review follow-ups

* Studio: polish chat artifact UI affordances

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

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

* Studio: scope artifact IDs by message to prevent cross-turn collisions

* Studio: fix artifact panel for local threads and surface tool errors

* Studio: restrict artifact frame embedding to same-origin

* Studio: stop local chat thread remount loop

* Studio: fix chat artifact store cleanup regressions

* Studio: shim artifact preview storage in sandbox

* feat(chat): add artifact rendering controls

* fix(chat): show artifact progress during tool calls

* fix(chat): refine artifact preview behavior

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

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

* fix(chat): ignore tool markers inside arguments

* feat(chat): polish artifact preview panel

* fix(chat): stabilize artifact panel behavior

* fix(inference): merge duplicate Anthropic tool starts

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-01 08:35:18 +02: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
Lee Jackson
61ed4cac51
Studio: persist chat history in backend storage (#5272)
* feat: Persist chat history in backend storage

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

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

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

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

* Address chat tombstone batching review

* fix: update desktop auth routes stub

* chat db settings storage

* chat db settings routes

* chat db settings client

* chat db settings store

* chat db settings wiring

* chat db history storage

* chat db settings migration

* chat db settings fallback

* chat db container metadata

* chat db legacy migration fixes

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

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

* chat ci auth background reads

* chat auth storage fixes

* chat migration final fixes

* chat export batch message lookup

* chat history review fixes

* chat prune sync fix

* chat settings hydration retry

* gate settings persistence

* Scope chat-history rows by subject; fix hijack, clear-confirm, hydrate race

Backend storage and routes:
- chat_threads / chat_messages / chat_settings carry a NOT NULL subject
  column with composite PRIMARY KEY (id, subject). Two authenticated
  identities can no longer see or wipe each other's data.
- Pre-existing rows on an existing studio.db migrate under sentinel
  subject __legacy_unscoped__ via rename + rebuild + copy; single-user
  installs see no behavior change.
- ON CONFLICT(id, subject) DO UPDATE ... WHERE chat_messages.thread_id =
  excluded.thread_id refuses cross-thread re-parenting via upsert.
  upsert_chat_message + sync_chat_messages now raise
  ChatMessageThreadMismatch which the routes map to HTTP 409.
- replace_thread_messages rejects body messages whose threadId does not
  match the URL thread (HTTP 400) instead of silently rewriting them.
- DELETE /api/chat requires ?confirm=true, returns row count, logs the
  subject and count.
- upsert_chat_settings_merge does read + deep-merge + write inside a
  single BEGIN IMMEDIATE so concurrent writers no longer drop each
  other's updates. The route delegates to this helper.
- New POST /api/chat/messages:batch returns {thread_id -> messages[]}
  for many threads in one HTTP call. Subject-scoped. Unknown ids return
  empty lists instead of 404 so the sidebar/search caller can rebuild
  atomically.

Frontend:
- chat-runtime-store: hydrate-failure catch sets settingsHydrated:true
  so a transient backend blip no longer permanently disables
  persistence. setParams bumps inferenceParamMutationVersions
  unconditionally so a slow hydration response cannot clobber a
  pre-hydrate user edit. saveSettingsPatch replaces the serial chain
  with a debounced pendingPatch + deep merge; flush on beforeunload.
- chat-history-storage: clearStoredChats returns ClearStoredChatsResult
  distinguishing backend / legacy / both outcomes.
  listStoredChatThreadsWithMessages uses the batched fetch (one HTTP
  call) instead of Promise.all per-thread; legacy Dexie fallback only
  fires when the batch result is empty.
- chat-api: batchListChatMessages with graceful 404 / 405 fallback to
  per-thread listChatMessages for older servers.
- chat-thread-tombstones: store {id, deletedAt} tuples with 90-day GC
  and a 5000-entry cap so localStorage stays bounded. Back-compat reads
  pre-fix plain strings. Adds removeChatThreadTombstones (rollback) and
  clearAllChatThreadTombstones (post-legacy-purge clean-up).
- use-chat-sidebar-items: deleteChatItem tombstones synchronously
  BEFORE the backend round-trip and rolls back on failure (restores
  pre-PR optimistic UX). 300 ms trailing debounce on
  CHAT_HISTORY_UPDATED_EVENT plus requestSeq guard so stream-time event
  bursts produce at most one fetch per quiet window.

Tests:
- studio/backend/tests/pr5272_sim/ adds 64 regression tests covering
  schema migration from pre-fix shape, subject scoping, cross-thread
  hijack, bulk-replace mismatch, clear-confirm, concurrent settings,
  unicode + 2MB content + SQL-injection-safe binding, chunking
  boundary at 900 and 901 ids, batched endpoint (multi-subject + 1200
  ids + per-thread order), and grep contracts for the frontend patches.
  test_chat_history_storage.py updated to pass subject.

Verified locally on Linux + macOS + Windows GitHub Actions runners
(staging fork): 64 pass + 2 from the PR's own backend test on all
three OSes.

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

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

* Drop subject scoping and clear-confirm gate (Studio is single-user)

Per maintainer feedback: subject scoping, cross-thread message hijack
guard, and DELETE /api/chat ?confirm=true gate are unnecessary because
Studio is intentionally single-user (the client already shows a confirm
dialog before clear-all).

This commit reverts those backend changes and keeps only the
non-multi-user pieces from the earlier fix commit:

- studio_db.py: restored to pre-fix shape; adds upsert_chat_settings_merge
  which does atomic read + deep-merge + write under BEGIN IMMEDIATE so
  two concurrent slider drags cannot drop one another's updates.
- routes/chat_history.py: restored; put_settings now calls the atomic
  merge instead of doing the read-merge-write across three separate
  connections. Adds POST /api/chat/messages:batch to collapse the
  sidebar/search rebuild from N round-trips to 1.
- frontend/api/chat-api.ts: align batchListChatMessages request and
  response keys with the backend (threadIds / messagesByThreadId).
- tests/test_chat_history_storage.py: add atomic-merge concurrency test,
  deep-merge nested-key test, and 901-id chunking-boundary test.
- Drop the pr5272_sim test directory (those tests covered the reverted
  subject-scoping/hijack/confirm behavior).

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

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

* Fix sidebar delete crash, keepalive on settings beforeunload flush, search rebuild race

Two correctness bugs and one perf race surfaced by a fresh code review of
the prior fix commit:

- chat-api.ts: notifyChatHistoryUpdated was declared as a non-exported
  function, but use-chat-sidebar-items.ts imports it. The import would
  fail tsc with TS2305 and at runtime the optimistic-delete and
  delete-failure rollback paths would both throw.
- chat-runtime-store.ts + chat-settings-api.ts + chat-settings-storage.ts:
  the beforeunload settings flush is now actually keepalive. Without it
  the browser cancels the in-flight PUT on tab close, so the last slider
  drag is silently dropped (which is exactly the case the
  debounce+beforeunload combination was meant to protect against).
- use-chat-search-index.ts: rebuilds now coalesce with a 300ms trailing
  debounce and discard out-of-order responses via a requestSeq guard.
  Matches the sibling pattern in use-chat-sidebar-items.ts so two rapid
  CHAT_HISTORY_UPDATED_EVENTs (run-start + run-end save during a turn)
  cannot land with stale data winning.
- chat-thread-tombstones.ts: drop dead clearAllChatThreadTombstones with
  no call sites; Dexie is never wiped so the function has no use.

* fix(studio): protect chat persistence writes

* fix(studio): align chat history clear semantics

* fix(studio): show partial chat clear feedback

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

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

* fix(studio): preserve chat persistence fallbacks

* fix(studio): harden chat thread persistence checks

* Preserve chat message timestamps

* Gate chat stream on history save

* Make chat thread backfill best effort

* Avoid chat message 404 probe

* Tighten chat legacy fallbacks

* chat: server-side ledger so legacy Dexie import is recoverable

The boolean localStorage sentinel
(unsloth_chat_legacy_imported_to_studio_db) made importLegacyChatsIfNeeded
non-recoverable: deleting studio.db while the browser keeps the flag
silently hides every legacy Dexie thread from the sidebar (verified by
the 3-GPU validation probe; matches the third review comment on PR
#5272). Same trap fires for browser-profile sync to a fresh machine
and any other path that wipes studio.db while keeping IndexedDB.

Source of truth moves into studio.db itself via a new
chat_legacy_import_log table keyed by legacy thread id. The ledger
disappears together with studio.db, so the next launch re-runs the
import from whatever Dexie still holds. localStorage stays as a
per-session perf hint only.

Performance, all bounded by the three new fast-paths before any
backend work:

  A) localStorage hint says "imported earlier in this session" -- 0
     network, ~0 ms. Covers the warm sidebar mount.

  B) indexedDB.databases() reports no "unsloth-chat" DB -- 0 network,
     ~1 ms. Covers every new user who never had the old browser-only
     Studio (the common case after launch).

  C) db.threads.count() + db.messages.count() are both 0 -- 0 network,
     ~5 ms. Covers returning users who migrated long ago and Dexie was
     never repopulated.

Only when all three miss does the code talk to the backend
(GET /api/chat/import-ledger -> diff vs Dexie -> existing import path
-> POST /api/chat/import-ledger to record what was just imported).
Per-thread tracking is enough because Dexie is read-only after this
PR; a thread's message set does not grow.

Backend deployments that predate the import-ledger routes are
handled transparently: the client treats 404/405 as an empty ledger
and re-runs the (idempotent via UPSERT) import on next launch.

Changes:
- storage/studio_db.py: new chat_legacy_import_log table (WITHOUT
  ROWID, PK on legacy_thread_id) + list_chat_legacy_import_log() +
  record_chat_legacy_import_log() (idempotent batch UPSERT).
- routes/chat_history.py: GET + POST /api/chat/import-ledger with the
  obvious request/response models.
- frontend api/chat-api.ts: listChatImportLedger() (returns a Set for
  O(1) diff) + recordChatImportLedger(), both with 404/405 fallback.
- frontend utils/chat-history-storage.ts: importLegacyChatsIfNeeded
  gains three fast-paths, ledger fetch on the slow path, and writes
  the ledger after a successful import. The localStorage helper is
  unchanged on the surface; it just stops being authoritative.
- tests: 5 new test_legacy_import_log_* cases (empty default, record
  + list round-trip, idempotency, input dedup, empty/null ignore).
  All 9 pre-existing tests still pass.

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

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

* Make the legacy-import recovery actually recoverable

The previous commit added a server-side ledger to make Dexie -> studio.db
import recoverable after a studio.db wipe, but the localStorage perf hint
still short-circuited the import gate before the ledger was ever consulted.
After a wipe, the hint stayed "true" and the bulk re-import never ran -- the
ledger sat empty and only the per-thread lazy materialize-on-continue path
restored data.

Changes:

- Remove the localStorage short-circuit from importLegacyChatsIfNeeded so
  the ledger is checked on every fresh tab. legacyChatImportPromise keeps
  the per-session cache; the hint now only matters for the listing paths.
- Batch the slow path: one db.messages.where().anyOf().toArray() and one
  batchListChatMessages() instead of 2N round-trips. At 1k threads this
  drops a multi-second blocking import to a single request pair.
- recordChatImportLedger returns {accepted, inserted, supported}. The
  localStorage hint is only flipped when supported is true, so old
  backends (404 / 405 / 501) no longer permanently poison recovery.
- Ledger backfill: threads already present in chat_threads but missing
  from the ledger now get added too, so old-FE-then-new-FE deployments
  don't redo the diff every launch.
- Backend response field renamed recorded -> {accepted, inserted}.
  accepted is the deduped non-empty input count; inserted is the rows
  actually new (via INSERT ... RETURNING). Bounded by Field(max_length=
  10_000) on the request payload.
- Storage helpers renamed: chat_legacy_import_log -> chat_legacy_imports,
  record_* -> upsert_* to match the existing noun/verb conventions.
- DEXIE_DB_NAME exported from db.ts; duplicate constant in
  chat-history-storage.ts removed.
- 3 new route-level tests for /api/chat/import-ledger covering the
  round-trip, the (accepted, inserted) split, and the 10k payload cap.

All 18 chat-history tests pass.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-22 06:18:05 -07:00
Daniel Han
3dbddc39c2
studio/frontend: settings dialog fits viewport at tablet widths (#5600)
* studio/frontend: settings dialog fits viewport at tablet widths

The dialog used a fixed w-[820px] with sm:w-[820px] override, so any
viewport between 640px and 820px (iPad portrait at 768px is the
canonical case) saw the dialog overflow horizontally by 26px on each
side -- the right-edge scroll arrow and the active-tab chevron got
clipped against the viewport.

Replace the hard 820 with min(820px, calc(100vw-2rem)) on both max-w
and w so the dialog caps at the original 820px on desktop and shrinks
to fit (with a 1rem gutter) on narrower screens. max-sm: still drives
the full-bleed h-dvh/w-dvw layout under 640px.

* studio/frontend: keep mobile full-bleed override !important

Bot review: base !max-w-[min(...)] is !important so the regular
max-sm:max-w-none never wins, leaving a 1rem gutter on phones where
the previous code rendered a true full-bleed dialog. Bump the mobile
override to !important too.

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-05-19 06:57:08 -07:00
Daniel Han
cf53ff6861
studio: restore focus to opener when settings dialog closes (#5612)
The settings dialog opens via a global Ctrl+, keydown handler in
__root.tsx, not via a <DialogTrigger>. Radix's FocusScope tries to
capture document.activeElement at mount as the focus-restore target,
but settings-dialog.tsx schedules a requestAnimationFrame that focuses
the active tab button right after mount, racing FocusScope's previous-
focus capture. On Escape or close-button click, focus then lands on
<body> instead of the textarea (or button, or wherever the user was).

A Playwright focus-management probe confirmed: open dialog, press Tab
15 times (trap holds), press Escape, document.activeElement === BODY.
This is a WCAG 2.4.3 (Focus Order) violation: keyboard-only users
have to re-Tab from the start of the page after every settings visit.

Fix: capture document.activeElement in the Zustand store at the moment
openDialog() runs, then restore via onCloseAutoFocus on DialogContent.
Use opener.isConnected so a stale node from a re-rendered tree falls
back to Radix's default. closeDialog deliberately does NOT clear the
opener slot - onCloseAutoFocus reads it on the render after open=false,
so clearing in the same set() would null it before restoration.

Probe re-run confirms focus restored to the TEXTAREA opener after
Escape, after close-button click, on both repeats. Tab + Shift+Tab
trap still holds (unchanged Radix behaviour).

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-05-19 06:56:48 -07:00
Daniel Han
0fb86e15b9
studio/frontend: keep theme classes mutually exclusive on <html> (#5580)
* studio/frontend: keep theme classes mutually exclusive on <html>

The Sonner Toaster reads next-themes (mounted at provider.tsx with
attribute="class" defaultTheme="light"), so on first mount next-themes
adds a "light" class to <html>. Studio's own setTheme path
(features/settings/stores/theme-store.ts) only toggled "dark", so
after the user picked Dark in settings the document ended up with
html.className = "light dark". Harmless in CSS cascade because the
dark variables override, but reads as a UI defect in devtools and trips
CSS-aware tooling that branches on class lists.

Toggle "light" alongside "dark" in applyToDocument so the two classes
stay mutually exclusive regardless of how next-themes seeded the
initial class.

* studio/frontend: shorten theme-toggle comment

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-05-19 06:56:15 -07:00
Daniel Han
8d24405440
studio/frontend: widen settings sidebar so 'Connections' label fits (#5607)
The settings dialog sidebar was fixed at w-[200px], which left only
~92px of horizontal space for tab labels after icon, gap, and the
'New' badge for Connections/API. 'Connections' (11 chars at the
14.5px font weight medium) overflowed and rendered as 'Connectio...',
matching the paper-cut reported in issue #5572.

Bump the sidebar to w-[216px] -- 16 more pixels of label space, fully
within the existing dialog width and unchanged on mobile
(max-sm:w-full still drives the responsive layout).

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-05-19 06:40:25 -07:00
Daniel Han
2fbbf8ade6
studio: expose launcher capability bits on unauth /api/health (#5486)
* studio: expose launcher capability bits on unauth /api/health

PR #5375 reduced the unauthenticated /api/health response to {status,
timestamp} only, on the theory that the rest of the payload was useful
fingerprinting. That was too aggressive: the Tauri watchdog reads
`service == "Unsloth UI Backend"` and `studio_root_id` to re-adopt its
own backend across restarts (src-tauri/src/desktop_backend_owner.rs
and commands.rs), and the SPA bootstrap fetches the same payload
unauth to detect chat-only mode and native path lease support before
any token is available (frontend src/config/env.ts and
features/native-intents/use-native-readiness.ts). With the post-#5375
shape, the watchdog kills its own healthy backend, the SPA never
flips out of "full Studio" mode on chat-only Linux/Windows, and the
About tab shows "dev" in place of the real version.

The actual fingerprint-ish fields are `version` / `studio_version` /
`device_type` (and to a lesser extent the hostname inside
`device_type`). `service`, `studio_root_id` (already a hex digest of
the install path, not the raw path), `chat_only`, the desktop_*
capability flags, and `native_path_leases_supported` do not leak the
install path or version.

This patch keeps the auth gate but rebalances which fields sit on
each side of it:

  unauth      service, studio_root_id, chat_only, desktop_protocol_version,
              desktop_manageability_version, supports_desktop_auth,
              supports_desktop_backend_ownership, native_path_leases_supported,
              desktop_owner (when present)

  authed      + version, studio_version, device_type

Existing must-change-password sessions still fall through to the base
payload because get_current_subject (strict) rejects them; that
matches prior behaviour.

test_middleware.py is updated to pin the new contract: launcher bits
present unauth, fingerprint fields present only with a valid bearer.

* studio: complete launcher-bits health unauth contract on Tauri + About tab

Reviewer follow-ups to the unauth /api/health launcher bits split.

Tauri preflight:
backend_capability_stale_reason() fell through to
backend_version_stale_reason(health.version.as_deref()) when capability
bits were present but version was absent. With the unauth payload now
exposing service + studio_root_id + desktop_* bits but gating version
behind a bearer, the desktop watchdog was reading the new payload,
parsing all capability bits, then classifying the same-root backend
as desktop_backend_version_missing and refusing to adopt it.

A backend that exposes desktop_protocol_version=1,
desktop_manageability_version>=1, supports_desktop_auth=true and
supports_desktop_backend_ownership=true was introduced together with
MIN_DESKTOP_BACKEND_VERSION=2026.5.3 in #5341, so a present capability
bitset is itself a version-compatibility signal. Skip the version
sub-check when version is None/empty; keep it for non-empty values
so genuinely-too-old backends that do echo a version still get
desktop_backend_version_too_old.

About tab:
fetchStudioVersions() did a bare fetch(apiUrl("/api/health")), which
the unauth payload no longer carries version/studio_version for, so
Settings -> About kept rendering "dev"/"dev" for any logged-in user.
Attach Authorization: Bearer <token> when getAuthToken() returns one;
fall back to bare fetch (still 200, just truncated payload) for the
not-logged-in case. No new endpoint.

Comment:
studio_root_id is no longer a hex digest of the install path; it is
an opaque per-install id written by the launcher. Updated the inline
comment to match.

Test:
  - python -m pytest studio/backend/tests/test_middleware.py::TestHealthAuthGate studio/backend/tests/test_desktop_auth.py -q
    -> 29 passed
  - npm run typecheck clean, npm run build produces fresh dist

* Trigger CI rerun for flaky Mac Chat UI step
2026-05-17 21:29:24 -07:00
Lee Jackson
920920592e
Polish/cloud to providers (#5450)
* polish: update provider dropdown and rename cloud

* fix: tighten custom provider fallback handling

* fix: external provider fallback typing

* studio: wire the chat Search button to OpenAI's built-in web_search tool

When the active model is an OpenAI external provider and the user
clicks the existing Search pill in the composer, the chat-completion
request now carries the unified enable_tools shorthand:

    enable_tools: true
    enabled_tools: ["web_search"]

The backend's stream_chat_completion threads enabled_tools through
to _stream_openai_responses, which translates it into the Responses
API tool schema:

    body["tools"] = [{"type": "web_search"}]

per the OpenAI Responses tool spec
(https://developers.openai.com/api/docs/guides/tools). OpenAI then
runs the search server-side before the model replies; the search-
informed answer streams back through the existing
response.output_text.delta path. web_search_call lifecycle events
are silently ignored for now — sources / status indicators are
follow-up scope.

Frontend:
- provider-capabilities.ts: new providerSupportsBuiltinWebSearch()
  helper. Returns true only for `openai` today; Anthropic
  (web_search_20250305), Gemini grounded-search, and OpenRouter
  variants can be added later with matching backend translation.
- chat-page.tsx: both model-switch paths (the onChange handler and
  the inferenceParams.checkpoint useEffect) set supportsTools to
  match the new helper, and force toolsEnabled=false on every
  external switch so the Search toggle is opt-in by default.
- chat-adapter.ts: external branch adds enable_tools +
  enabled_tools=["web_search"] to the request body when the
  toggle is on AND the active provider supports built-in
  web-search. Local-model branch is unchanged — it continues to
  route the same shorthand through our local tool runtime.

Backend:
- routes/inference.py: forwards payload.enabled_tools to
  stream_chat_completion at the proxy site (line 1599).
- external_provider.py: stream_chat_completion gains an
  enabled_tools parameter; _stream_openai_responses appends
  {"type": "web_search"} to body["tools"] when the list contains
  "web_search". Other tools (file_search, code_interpreter,
  image_generation, computer_use_preview) are easy follow-ups in
  the same block.

Reuses the existing pydantic ChatCompletionRequest.enabled_tools
field, so no schema migrations.

* studio/backend: surface OpenAI server-side web_search in the chat UI

When the user has the chat Search button toggled on and OpenAI's
/v1/responses invokes the built-in web_search tool, _stream_openai_responses
now translates the tool's lifecycle events and citation annotations
into the same _toolEvent shape that local-tool calls use. The result:
the chat UI shows a web_search tool-call card mid-stream, then lists
the cited sources at the end of the message — identical to how local
web_search renders.

SSE event translation:

- response.output_item.added with item.type=web_search_call ->
  emit _toolEvent tool_start. Carries item.action.query as args
  when OpenAI ships it on the added event.
- response.output_item.done with item.type=web_search_call ->
  backfill the query if it only arrives on the done variant. The
  existing reasoning branch on the same event is preserved as an
  if/elif under a shared isinstance guard.
- response.output_text.annotation.added with type=url_citation ->
  collect into the most-recent web_search_call.citations list.
- response.output_text.delta with inline annotations[] (older
  API variant) -> same collection path, so both wire shapes work.
- response.completed -> emit _toolEvent tool_end per call with
  citations formatted as
    Title: <title>\nURL: <url>\nSnippet: <snippet>
  blocks joined by `\n---\n`. The frontend's
  parseSourcesFromResult already lifts this format into source
  content parts at end-of-stream.
- response.incomplete -> close out web_search cards with whatever
  citations had landed, so a truncated response does not leave a
  perpetually "running" tool card in the UI.

Both reasoning and web_search work simultaneously on the same turn —
the body sends `reasoning: {effort, summary}` and `tools: [{type:
"web_search"}]` independently, and the SSE handler tracks them
through separate channels.

Diagnostic: finally-block logger now reports per stream

  web_search_requested  - whether the client asked for it
  web_search_invocations - how many calls OpenAI actually made
  citations - total URLs cited
  queries - the search queries the model issued
  reasoning_emitted - whether <think> content was streamed

so reports of "I clicked Search and nothing happened" can be triaged
from the backend log without browser devtools.

* studio/backend: fix empty query + per-card '(no sources cited)' on OpenAI web_search

Two display bugs on the OpenAI Responses web_search → chat-UI bridge:

1. Tool cards showed "Searching for ''" — query missing.
   OpenAI's response.output_item.added for web_search_call does not
   reliably populate action.query across API versions; the canonical
   place is output_item.done. The previous code emitted tool_start
   at added with empty args and tried to backfill at done, but the
   frontend's _toolEvent: tool_start is a one-shot push (no update
   mechanism), so the args stayed empty.

   Fix: defer both tool_start *and* a placeholder tool_end emission
   to output_item.done, where action.query is guaranteed populated.
   added now just initialises tracking. Frontend then renders one
   card per call with the right "Searching for: <query>" label.

2. Every card showed "(no sources cited)".
   The previous code tried to attribute url_citation annotations
   to individual web_search_call invocations, but OpenAI's
   annotations carry no link back to a specific search call —
   they're just URLs the model cited from the aggregated search
   pool. With N invocations and M annotations, the previous logic
   bucketed all M into the last call and stamped "(no sources
   cited)" on the rest.

   Fix: collect citations into a single shared all_url_citations
   list, dedup by URL. At response.completed (and
   response.incomplete) overwrite the *last* web_search_call's
   tool_end result with the aggregated Title:/URL:/Snippet:
   blocks. The frontend's parseSourcesFromResult already flatMaps
   every web_search result, so one non-empty result is enough to
   surface the full source-pill set at the message tail. Other
   tool cards get an empty result string (no '(no sources)' text).

Diagnostic log unchanged in shape; total_citations now reads
len(all_url_citations) directly.

* studio/chat: split Code and Search pill gates so external models cannot enable Code

The previous wire-up set supportsTools=true for OpenAI external
models to light up the Search pill, but supportsTools also gates the
Code pill, so Code became clickable for OpenAI even though external
providers have no local code execution.

Separate the two gates so each pill reflects what's actually
available:

- chat-runtime-store: new `supportsBuiltinWebSearch: boolean` flag.
  Distinct from supportsTools — that one still means "runtime has a
  local tool sandbox" (Code, python, our DuckDuckGo web_search).
  This one means "the active external provider exposes a server-side
  web_search tool we can opt into" (OpenAI's /v1/responses today).
- chat-page model-switch (both code paths): for external models,
  supportsTools is now forced to false (no local Code path) and
  supportsBuiltinWebSearch follows providerSupportsBuiltinWebSearch.
  Local-model paths are unaffected — they only set supportsTools.
- shared-composer: Search pill gates on
  `searchDisabled = !modelLoaded || !(supportsTools ||
  supportsBuiltinWebSearch)`. Code pill gates on
  `codeDisabled = !modelLoaded || !supportsTools` — strictly the
  local runtime, so external models keep Code greyed out.
  A `toolsDisabled = codeDisabled` alias is left in place for any
  later-touched call site that may still reference the old name.

No backend changes — chat-adapter already calls
providerSupportsBuiltinWebSearch directly, independent of the store
flags, so the request shape and the backend translation are
unchanged.

* studio/chat: default external reasoning effort to medium, not the carry-over

When switching to an external model with reasoning support, the effort
dropdown was inheriting whatever value the user had set on a prior
model — frequently "xhigh" left over from a previous Opus/gpt-5
session. That meant every fresh OpenAI/Anthropic selection started at
Extra High, burning tokens unintentionally.

Both model-switch sites in chat-page (the useEffect on
inferenceParams.checkpoint and the onChange callback) now pick
"medium" whenever the new model's level list contains it, instead of
the clamped carry-over. The clamp still fires as a fallback for the
narrow case where a model doesn't expose medium (e.g. gpt-5.3-chat-
latest which only has medium anyway — no change there). Users can
still pick another level explicitly via the Think dropdown.

* studio/chat: also light the Search pill in the welcome-screen composer

There are two composers in the chat feature. shared-composer.tsx
renders inside an active thread, and assistant-ui/thread.tsx has its
own WebSearchToggle / CodeToolsToggle that ship the welcome-screen
"Send a message…" composer (visible before the first user message).

The previous fix split supportsTools and supportsBuiltinWebSearch in
shared-composer but never touched the welcome-screen toggles in
thread.tsx — they both still gated on supportsTools alone, so the
Search pill stayed greyed on the welcome screen even for OpenAI
external models that legitimately support web_search server-side.

Mirror the shared-composer rule in WebSearchToggle:

    disabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch)

CodeToolsToggle is left as-is — its current
`disabled = !(modelLoaded && supportsTools)` is correct: external
models have no local code-execution sandbox, so Code stays greyed
when supportsTools=false (which is what chat-page now writes for
external selections).

* studio/backend: wire Anthropic server-side web_search end-to-end

Mirrors the OpenAI web_search integration for Anthropic's
web_search_20250305 tool. When the user toggles Search on with an
Anthropic model selected, the request now carries the documented
tool entry:

    tools: [{type: "web_search_20250305", name: "web_search",
             max_uses: 5}]

on /v1/messages, and the SSE translation surfaces tool cards +
source pills in the chat UI exactly the same way as OpenAI.

stream_chat_completion now forwards enabled_tools into the
Anthropic branch (was only doing this for the OpenAI Responses
branch). _stream_anthropic gains an enabled_tools parameter and
the web_search request-body block plus three additional event
handlers:

- content_block_start with type=server_tool_use, name=web_search:
  start tracking a new call. id becomes the tool_call_id.
- content_block_delta with type=input_json_delta inside a
  server_tool_use block: buffer the partial_json so we can read
  out the search query when the block closes.
- content_block_start with type=web_search_tool_result: capture
  the per-call result list (urls + titles) that Anthropic ships
  inline.
- content_block_stop: closes whichever block we're inside —
    * server_tool_use -> emit _toolEvent: tool_start with the
      parsed query as args.
    * web_search_tool_result -> emit _toolEvent: tool_end with
      Title:/URL: blocks the frontend's parseSourcesFromResult
      lifts into source pills.
    * thinking block -> existing </think> close.

Unlike OpenAI we get per-call results directly, so no aggregated-
last-call fallback is needed — each tool card carries its own
citations.

Diagnostic log on stream completion now reports
web_search_requested / invocations / total_results / queries,
matching the OpenAI shape.

Frontend providerSupportsBuiltinWebSearch returns true for
'anthropic' as well, so the Search pill lights up on Claude
models the same way it does on OpenAI. The existing chat-adapter
external branch already sends enabled_tools=['web_search'] based
on this helper — no adapter changes needed.

* studio: wire OpenRouter built-in web search via :online model suffix

OpenRouter exposes a universal "add web search to any model" shortcut:
append `:online` to the model id and the gateway runs the search
server-side, streaming citations back as annotations on text deltas.
Documented at https://openrouter.ai/docs/features/web-search

Hook the existing Search toggle into that path:

Backend (external_provider.py, default OAI-compat branch):
- When provider_type == 'openrouter' and enabled_tools contains
  'web_search', rewrite body['model']:
    openai/gpt-4o            -> openai/gpt-4o:online
    anthropic/claude-sonnet-4-5:free -> anthropic/claude-sonnet-4-5:online
  Any existing `:variant` (`:free`, `:nitro`, etc.) is replaced —
  OpenRouter variants are mutually exclusive.
- `openrouter/free` is skipped: it's a meta-router and `:online` is
  not a valid suffix on it (the gateway 400s).
- A one-line INFO log fires whenever the rewrite happens so the
  diagnostic backend log shows exactly which model id the request
  was promoted to.

Frontend (provider-capabilities.ts):
- providerSupportsBuiltinWebSearch now returns true for 'openrouter'
  alongside 'openai' and 'anthropic'. The Search pill lights up and
  the existing chat-adapter external branch already forwards
  enabled_tools=['web_search'] based on this helper — no adapter
  changes needed.

No new SSE event handling: OpenRouter does not emit a separate
web_search_call event the way OpenAI/Anthropic do. Citations come
back as text annotations via the existing reasoning_details path
the adapter already parses, so source data flows through without
extra translation. A per-call tool-card UX ("Searching for: …")
would require synthesizing one client-side; deferred to a follow-up
if the bare-citation flow feels too minimal.

* studio: wire Mistral built-in web search connector

Same shape as OpenAI's web_search tool, lives on
/v1/chat/completions instead of /v1/responses. When the chat
Search pill is toggled on with a Mistral model selected, the
backend now appends

    {"type": "web_search"}

to body["tools"] before the request goes out. Idempotent —
won't double-append if a future call site adds it first. Models
in the registry allowlist that don't support the connector
(codestral, devstral, ministral, mistral-tiny) will surface a
400 from upstream; the existing default-path error log captures
it. Mistral's docs:
  https://docs.mistral.ai/capabilities/agents/connectors/websearch

Frontend providerSupportsBuiltinWebSearch returns true for
'mistral' now, alongside openai / anthropic / openrouter. The
Search pill lights up for Mistral models and the existing
adapter branch already sends enabled_tools=['web_search'] off
this helper — no adapter changes.

No SSE translation yet — Mistral streams citations inline as
text annotations or `references` in the final assistant content,
not as a separate web_search_call event. Citations flow through
to the message body as text; a per-call tool-card UX with
"Searching for: …" indicators is a follow-up if needed.

* studio/backend: fix OpenRouter web_search to use plugins shape + synthesize tool card

Two changes against the actual OpenRouter docs at
https://openrouter.ai/docs/guides/features/plugins/web-search:

Request shape:

The previous commit appended :online to the model id, which works on
concrete model ids but rejects on meta-routers like openrouter/free —
and that's exactly the model the user was testing with, so neither
the request rewrite nor the diagnostic log fired. Switch to the
universal plugins shape:

    body["plugins"] = [{"id": "web"}]

Per the docs this is "exactly equivalent" to :online but works on
every model id including openrouter/free and openrouter/auto. No
model suffix manipulation, idempotent if added twice.

Tool-card synthesis:

OpenRouter doesn't emit a structured web_search_call event the way
OpenAI/Anthropic do — citations come back only as `annotations` of
type=url_citation on delta/message objects. To match the chat-UI
tool-card UX the user expects ("Searching for: …" indicator,
source pills at message tail), synthesize the events client-side
in the default OAI-compat stream loop:

- On stream open (after the 200 status check): yield a synthetic
  _toolEvent: tool_start with tool_name=web_search, fixed id
  "openrouter_web_search". The chat-UI then renders the running
  tool card before any text streams.
- During the SSE loop: scan every chunk's choices[].delta and
  choices[].message for `annotations: [{type: "url_citation",
  url_citation: {url, title, content}}]` entries. Dedup by URL
  into a citations list. Handles both the nested-url_citation
  shape OpenRouter documents and the flat-on-annotation shape
  some upstreams ship.
- On [DONE] (or stream-close without [DONE]): emit synthetic
  tool_end carrying the citations as
    Title: …\nURL: …\nSnippet: …\n---\n…
  blocks the existing parseSourcesFromResult lifts into source
  pills at message tail.

Diagnostic log on completion now also reports
web_search_requested + citation count alongside the existing
chosen-model / event-count telemetry.

* studio: drop Mistral built-in web_search — connector lives on Agents API only

Mistral's web_search is exclusively on /v1/agents + /v1/conversations;
sending it on /v1/chat/completions returns
"WebSearchTool connector is not supported". Wiring it would require a
dedicated Agents streaming path. Remove from the frontend capability map
and revert the chat-completions tool injection.

* studio: wire Kimi $web_search builtin via two-call round-trip

Kimi's $web_search lives on /v1/chat/completions but requires a client
round-trip per https://platform.kimi.ai/docs/guide/use-web-search:
the first call returns tool_calls with function.arguments populated;
the caller echoes those arguments back as a role=tool message; the
second call streams the final answer with search results incorporated.
The docs also mandate thinking=disabled while the builtin is active.

Backend: new _stream_kimi_web_search helper dispatched from
stream_chat_completion when provider_type=='kimi' and 'web_search' in
enabled_tools. Buffers tool_calls across deltas, falls back to a plain
stream if the model declines to search, and synthesizes tool_start
(with parsed query) / tool_end (with any url_citation annotations) so
the chat UI's web-search card behaves the same as other providers.

Frontend: kimi added to providerSupportsBuiltinWebSearch so the Search
pill lights up in the composer.

* studio/chat: mutual exclusion of Think + Search on Kimi composer

Kimi's $web_search builtin requires thinking=disabled per
https://platform.kimi.ai/docs/guide/use-web-search, so the two states
cannot coexist. Make the pills mutually exclusive in both composers
(shared and welcome-screen): clicking Search turns Think off; clicking
Think back on turns Search off. Default Think to on when a Kimi model
is selected — k2.6/k2.5 ship with thinking enabled out of the box.

* studio/chat: fix wrong provider var name in onChange branch

selectedProvider, not provider — TS2304 in tsc -b.

* studio/backend: add diagnostics to Kimi $web_search round-trip

Log the actual function.arguments from the first call (so we can see
the model's search query) and the second call's usage.prompt_tokens +
any annotation type names that came through. prompt_tokens spiking
above the input message length is direct proof the server injected
search results into context. annotation_types lets us learn the shape
Kimi uses for citations if/when they emit any.

* studio: per-provider defaults — Anthropic xhigh + Search on, OpenAI high + Search on, Opus 4.7 gains max

Anthropic: Think effort defaults to the highest level the model
supports (xhigh on 4.6/4.7, high on 4.5) and Search starts on, since
the web_search_20250305 tool returns structured citations end-to-end.

OpenAI: Think effort defaults to 'high' (the gpt-5.x reasoning sweet
spot for /v1/responses + web_search) and Search starts on.

Opus 4.7: 'max' added as an effort level above 'xhigh' in both
backend (_ANTHROPIC_THINKING_SPECS) and frontend (ANTHROPIC_REASONING_MODELS).

Kimi diagnostics: emit tool_end immediately after tool_start so the
web-search card transitions to 'complete' before the second-call
answer streams, log first-call args + second-call usage/prompt_tokens
+ any annotation type names, request stream_options.include_usage so
the second call exposes usage in SSE.

* studio/backend: harden Kimi fallback path with HTTPError handler + manual aiter_lines loop

Addresses PR review feedback (#5443): the no-search fallback streaming
path was using `async for response.aiter_lines()` and had no
`httpx.HTTPError` guard around the POST. Switch to the manual
__anext__ loop pattern used elsewhere in this module (avoids the
Python 3.13 + httpcore 1.0.x GeneratorExit propagation issue) and wrap
the whole request in a try/except so network failures surface as a
proper SSE error frame instead of a raw traceback.

* feat: prompt caching frontend for openai/anthropic

* studio/chat: route vLLM provider to /v1/chat/completions, not /v1/responses

vLLM's /v1/responses rebuilds messages through the loaded model's chat
template, which 400s on strict-alternation templates like Gemma 3
("Conversation roles must alternate user/assistant/..."). Stop collapsing
vllm -> openai in the frontend so the backend sees the real provider type
and falls through to the standard chat-completions path. Register vllm as
a hidden entry in PROVIDER_REGISTRY so supports_vision and provider-create
validation work without surfacing it in the cloud-provider dropdown.

* studio/chat: wire prompt caching for OpenAI and Anthropic external providers

Backend half of the prompt_caching toggle that already exists in the chat
settings panel. Scoped to OpenAI cloud (/v1/responses) and Anthropic
(/v1/messages); every other provider plumbs the flag as a no-op.

- Anthropic: attach cache_control={type:ephemeral} to the system block so
  the static prefix is reused across turns. Without the marker Anthropic
  caches nothing, so this is the only way to make the toggle do real work
  on /v1/messages.
- OpenAI: opt into prompt_cache_retention="24h" — same price as the
  default in_memory policy per the OpenAI docs, but the cache survives
  ~24 hours of idle instead of ~5-10 minutes. The model picker is
  registry-scoped to gpt-5.x / o3 / gpt-4.5, all of which accept the
  parameter (gpt-5.5+ already defaults to "24h" so it's a no-op there).
- Treats `enable_prompt_caching=None` as enabled to match the frontend
  default for both providers; pass `false` explicitly to opt out.

* studio/chat: log cache token counts on OpenAI and Anthropic stream completion

Surface cache usage in the existing "stream complete" info logs so
prompt-caching behavior can be verified by tailing the studio backend
log instead of opening the provider dashboard.

- Anthropic: latch usage from message_start (input + cache_creation +
  cache_read counts) and message_delta (output_tokens), then include in
  the per-request summary. cache_read_input_tokens > 0 confirms the
  cache_control marker on the system block is doing its job.
- OpenAI Responses: latch usage from response.completed and
  response.incomplete, extract usage.input_tokens_details.cached_tokens
  (the /v1/responses field name, not prompt_tokens_details). A non-zero
  value on turn N proves prompt_cache_retention="24h" let the prefix
  hit the cache instead of being recomputed.

* studio/backend: strip temperature/top_p for Claude 4.7 family

Anthropic Opus 4.7 removed temperature, top_p, and top_k as a launch
breaking change ("Sampling parameters removed" in the 4.7 release notes
at https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7).
Setting any of them to a non-default value returns 400
"<param> is deprecated for this model". The existing guard only handled
top_k; temperature was still being sent unconditionally and is now
breaking opus-4-7 requests.

Rename _ANTHROPIC_TOP_K_DEPRECATED to _ANTHROPIC_4_7_SAMPLING_REMOVED to
reflect the broader scope, omit temperature from the base body on 4.7,
and skip the thinking-mode temperature=1 override on 4.7 (still applied
on 4.5/4.6 where it's required). Existing thinking_translation tests
target 4.5/4.6 / mock the wire so they're unaffected.

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

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

* studio/chat: anchor Anthropic prompt cache on the latest message too

A system-only cache_control marker is a no-op when the system prompt is
empty or shorter than Anthropic's ~1024-token cache floor — caching
silently does nothing (both cache_creation and cache_read return 0).

Add a second cache_control breakpoint on the final block of the latest
conversation message so the entire prefix (system + prior turns + new
user turn) becomes eligible for caching. On turn N+1, Anthropic
rehydrates everything up through turn N's marker instead of recomputing
it. Up to 4 breakpoints are allowed per request; we use at most 2
(system + tail). Tail rebuild avoids mutating the caller's content list
so an image-bearing turn still slots cleanly into the cached prefix.

* studio/chat: gate vLLM reasoning toggle on provider config

Add a "This server runs a reasoning model" checkbox on the vLLM
provider config. When off (default), the chat Think pill stays
hidden and no enable_thinking ever reaches vLLM. When on, the
pill renders, per-turn state flows through the existing
enable_thinking plumbing, and the backend proxy lifts it onto
chat_template_kwargs.enable_thinking so vLLM's Jinja template
honours it.

* chore: clean vLLM reasoning-toggle comments

* studio/chat: gate prompt_cache_retention to actual OpenAI cloud requests

Addresses Codex P1 review on _stream_openai_responses. The frontend
only sends enable_prompt_caching for the openai/anthropic UI provider
types, so ollama/llama.cpp/"custom" requests reach this helper with
the flag as None. The previous `is not False` check treated None as
enabled and injected prompt_cache_retention="24h" into every request
including those bound for non-OpenAI servers, which would 400 on
servers that implement /v1/responses but not the retention parameter.

Match the public OpenAI host (api.openai.com) on the client base_url
before adding the field so it only lands on actual OpenAI cloud
requests. Studio's openai picker is already registry-scoped to
gpt-5.x / o3 / gpt-4.5, all of which accept the parameter.

---------

Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-15 19:29:21 +04: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
Wasim Yousef Said
23cebfaf98
Add Studio web update banner and release version display (#5308)
* Add Studio web update and release version display

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

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

* Show package version in Studio settings

* Break training unload guard barrel cycle

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-05-11 18:24:01 +04:00
Lee Jackson
b65a7450ca
Studio: Dark theme refactor, right sidebar redesign, and chat UI polish (#5150)
* Dark theme refactor, right sidebar redesign, and chat UI polish

- Dark theme refactor
- Redesign right sidebar
- Further left sidebar adjustments
- Wider chat and content area; layout tweaks for chat content
- Rounded corners across elements for consistency
- Show chat message menu icons on menu-area hover, not only on message hover
- Assistant message menu icons now always visible; user messages keep on-hover
- Redesigned copy icon used consistently across chat blocks and messages
- Redesigned trash icon, applied consistently
- Unified icon sizing and style with the sidebar
- Adjusted icon colors across chat
- Fix on-hover background design for chat icons
- Fix tooltip from 'more' button staying visible after clicking elsewhere
- Adjust position and design of generation speed info text below messages
- Adjust design of token speed info popup
- Adjust sidebar scrollbar to cover recent chats only

* Recents sidebar rename, UI/theme refactor, layout and chat polish

UI & Theme:
- Dark theme refactor
- Consistent rounded corners across elements
- CSS polish and cleanup
- Remove unused logo image assets

Recents sidebar:
- Add 'more' button for options menu
- Support renaming conversations and training runs
- Confirmation dialog before deleting chats
- Add optional display_name column to training_runs (idempotent ALTER TABLE) so renaming doesn't lose model_name/dataset_name from the run config
- New PATCH /api/train/runs/{run_id} endpoint accepts { display_name: string | null }; empty/whitespace clears the override
- Sidebar shows display_name ?? model_name and exposes Rename in the row's More menu, mirroring the chat rename flow
- Cache last list response in localStorage and hydrate from it on mount, so recents paint instantly on F5 / route revisit; cached items are shape-validated and dropped if malformed
- Optimistic updates on rename and delete (apply locally + cache before background refresh)
- Visible toast on rename/delete failure instead of swallowed errors

Layout:
- Redesigned right sidebar
- Further left sidebar adjustments
- Updated chat content layout; chat and content area slightly widened
- Sidebar scrollbar covers recent chats only

Icons:
- Redesigned copy icon, unified across chat blocks and messages
- Redesigned trash icon to match
- Consistent icon sizing and style across chat and sidebar
- Adjusted icon colors across chat
- Fix icon on-hover background design

Chat messages:
- Menu icons now appear on hover over the menu area, not just the message
- Assistant message menu icons always visible; user messages keep on-hover (next/previous response stays visible for edited prompts)
- Repositioned and restyled generation speed info text below messages
- Restyled token generation speed popup

Tooltips:
- Removed tooltip on hover for previous/next assistant response icons
- Unified tooltip design across sidebars and chat
- Removed tooltip animations (also fixes related lag)

Model & Chat Template config:
- Merged Chat Template config into Model Configuration section
- Added revert-to-original for chat template
- Fix Chat Template config disappearing on page refresh until model reload

Performance & scroll:
- Removed chatbox movement animations across pages/navigation (fixes related UI lag)
- Fix scroll flicker at end of streaming when a code block is the final element
- Additional chat scroll improvements

Bug fixes:
- Fix 'more' button tooltip remaining visible after clicking elsewhere

* Remove sidebar localStorage cache and optimistic updates

Drops the localStorage hydration and optimistic rename/delete logic from the recents sidebar; reverts to fetching fresh on mount.

* Fix missing cn import in shared-composer (regression from merge)

* chore(sidebar): import sidebar deps from feature indexes

Re-export deleteChatItem / renameChatItem / useChatSidebarItems / SidebarItem / useChatSearchStore / ChatSearchDialog from @/features/chat, and removeTrainingUnloadGuard from @/features/training. Switch app-sidebar.tsx to consume them via the public feature indexes instead of deep paths, clearing the no-restricted-imports eslint errors. No behavior or UX change.

* fix(studio/frontend): reload training Recents sidebar after F5 refresh

The Recents sidebar showed empty after a hard refresh. The hook's inFlightRef dedup guard collided with React StrictMode's double-mount in dev: the second mount's fetch returned silently with no error, no retry, and no toast — leaving the sidebar empty until navigation.

Replace skip-if-busy dedup with abort-previous via a hook-level AbortController. This also fixes a latent race where a slow poll could resurrect a just-deleted row by clobbering the optimistic update.

Changes (all in use-training-history-sidebar.ts):
- fetchRuns aborts any in-flight request before starting a new one; post-await signal.aborted check drops stale responses.
- Optimistic helpers (applyRunUpdate, removeRun) abort in-flight fetches so they don't depend on caller discipline to invalidate stale data.
- Initial load gets bounded retry-with-backoff (500ms / 1.5s / 3.5s) and surfaces a sonner toast with a Retry action on final failure.
- Failure toast auto-dismisses on any successful load (initial retry, Retry click, or polling recovery).
- Polling pauses while the tab is hidden and catches up on visible, avoiding wasted requests during long training runs.
- Both effects own their teardown explicitly (abort + clear timer).

* Apply unified tooltip design and behavior across remaining pages for consistency

* UI polish: spacing, tooltip on source icons, letter spacing, smaller icons, consistent edit icon

- Adjust tiny spacing between elements around the UI for subtle polish
- Redesign tooltip on source icons for web search / tool use, consistent with the new design
- Adjust chat text letter spacing
- Smaller icon sizes
- Replace 'edit message' icon in chat with the new Rename icon used in Recents for consistency

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

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

* Adjust CSS for right sidebar

* Fix scrollbar UI compatibility across browsers

* fix: preserve chat preset settings on model load

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

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

* fix(studio): remove duplicate chat template status field

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

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

* chore: remove creative preset assumption

* fix(studio): align speculative decoding default

* fix(studio/chat): snap numeric param inputs to step grid

- Type a value in any param input (Temperature, Top K, Max Tokens, etc.)
  now clamps to [min, max] and snaps to the slider's step grid, killing
  off-grid values like 1.051234 and FP residue from slider drags.
- Branch picker chevrons share the action bar's 32px height + 10px radius
  via a new .aui-branch-chevron-btn utility; hover area aligns visually
  while staying narrower than the sibling icon buttons.

* fix(studio/chat): keep training-run polls converging and drop dead preset code

- Keep training-run polls converging when responses outrun the 5s interval
  (don't unconditionally abort prior in-flight; skip if one is still pending,
  mutation race still guarded).
- Drop dead Creative/Precise preset code paths (remove 'builtin-fixed' source
  variant + unreachable branches).

* fix(studio): training-run cards show custom name + model + dataset

- Training-run cards now display custom display_name + model + dataset,
  with cross-view sync on rename/delete.
- Enhance clarity of borders and colors in dark theme on export etc.

* fix(studio): match active state green to unsloth brand color

* fix(studio): preserve can_resume on training rename

* fix(studio): keep GGUF chat template override distinct

* fix(studio): treat audio input models as multimodal

* fix(studio): cancel numeric draft on Escape

* fix(studio): use default speculative mode on toggle

* fix(studio): detect GGUF audio VLM input models

* fix(studio): address final PR review findings

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

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

* fix(studio): refresh sidebar/history when a new training run starts so it appears without a manual reload

* fix: API and svg

* fix(studio/sidebar): align run rename dirty check with displayed baseline

* fix(studio/sidebar): use leading-tight on account block to prevent descender clipping with truncate

---------

Co-authored-by: sneakr <hauzin@hotmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
2026-05-07 14:33:31 +04:00
Lee Jackson
4ab096970d
Studio: API settings overflow with long Colab URLs (#5286)
* fix: API settings overflow with long Colab URLs

* fix: gentle wrapping for API usage snippets

---------

Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-07 13:17:23 +04:00
Lee Jackson
fac2dc09b0
fix: restore API and Help menu labels (#5310) 2026-05-06 15:55:37 +04:00
Manan Shah
d65149795b
feat(studio): MLX training tab on Apple Silicon (LoRA / full FT, VLM, export) (#5265)
* Add Apple Silicon MLX routing

Rewrite __init__.py: detect MLX on macOS arm64 before any torch imports
Extract original GPU init to _gpu_init.py (unchanged)
MLX path imports FastMLXModel from unsloth_zoo, skips all GPU code
GPU path unchanged: from ._gpu_init import *

* Add Apple Silicon MLX routing

- Rewrite __init__.py: detect MLX on macOS arm64 before any torch imports
- Extract original GPU init to _gpu_init.py (unchanged)
- MLX path imports FastMLXModel from unsloth_zoo, skips all GPU code
- GPU path unchanged: from ._gpu_init import *

* mlx with studio

* mlx with studio

* updating temporary install.sh

* updating temporary install.sh

* adding t_v5 path

* adding t_v5 path

* fixing vision training

* fixing vision training

* adding chat

* adding chat

* minor

* minor

* Adding export and fixing training issues, inference with lora adaptors

* Adding export and fixing training issues, inference with lora adaptors

* fix: MLX worker pass load_in_4bit, override is_vlm based on dataset, streaming for VLM

* fix: MLX worker pass load_in_4bit, override is_vlm based on dataset, streaming for VLM

* Merge mlx-apple-silicon into main

* update install.sh to point to main branch

* update install.sh to point to main branch

* fix: export returns 3 values (success, message, output_path) matching upstream worker

* fix: export returns 3 values (success, message, output_path) matching upstream worker

* fix(mlx): show training-process peak memory in Studio UI, not system-wide

Studio UI was showing ~95 GB during MLX training because get_gpu_utilization
read "In use system memory" from IORegistry's AGXAccelerator — system-wide
GPU memory across all processes (training + backend + browser + Display).

Now the trainer's mx.get_peak_memory value is forwarded through the
progress event and surfaced via /api/train/hardware while training is
active. Falls back to the system-wide reading when training is not running.

* fix(mlx): show training-process peak memory in Studio UI, not system-wide

Studio UI was showing ~95 GB during MLX training because get_gpu_utilization
read "In use system memory" from IORegistry's AGXAccelerator — system-wide
GPU memory across all processes (training + backend + browser + Display).

Now the trainer's mx.get_peak_memory() value is forwarded through the
progress event and surfaced via /api/train/hardware while training is
active. Falls back to the system-wide reading when training is not running.

* fix(mlx): make is_bfloat16_supported detect M1/M2 (no native bf16)

M1 and M2 chips emulate bf16 in software on the GPU, causing 40-70%
slower prefill compared to native fp16. M3+ have native bf16 (macOS
Sonoma+ MPSGraph). Replaces the always-True stub with chip-aware
detection via mx.device_info.

* fix(mlx): make is_bfloat16_supported() detect M1/M2 (no native bf16)

M1 and M2 chips emulate bf16 in software on the GPU, causing 40-70%
slower prefill compared to native fp16. M3+ have native bf16 (macOS
Sonoma+ MPSGraph). Replaces the always-True stub with chip-aware
detection via mx.device_info().

* feat(mlx): wire training_type="Full Finetuning" through MLX worker

Compute use_lora from the UI's training_type before loading the model,
pass full_finetuning=not use_lora to FastMLXModel.from_pretrained, and
let the existing 'if use_lora' branch skip get_peft_model. Matches the
GPU worker's flow.

* feat(mlx): wire training_type="Full Finetuning" through MLX worker

Compute use_lora from the UI's training_type before loading the model,
pass full_finetuning=not use_lora to FastMLXModel.from_pretrained, and
let the existing 'if use_lora' branch skip get_peft_model. Matches the
GPU worker's flow.

* fix(mlx): pass save_method='merged_16bit' from Studio's export page

Previously the MLX path called save_pretrained_merged with no
save_method, which fell through to a no-op that didn't actually fuse
LoRA into the base. Now Studio's "Merged Model" export properly
fuses LoRA + dequantizes any 4-bit base to bf16, matching the GPU
behavior for the same UI option.

* fix(mlx): pass save_method='merged_16bit' from Studio's export page

Previously the MLX path called save_pretrained_merged() with no
save_method, which fell through to a no-op that didn't actually fuse
LoRA into the base. Now Studio's "Merged Model" export properly
fuses LoRA + dequantizes any 4-bit base to bf16, matching the GPU
behavior for the same UI option.

* fix(studio): pass private to MLX push, return 3-tuples consistently

MLX push_to_hub branch now forwards private=private (matches GPU)
Existing 2-tuple early-returns ('repo_id+token required', 'PEFT model
needed') were tripping the route's 3-tuple unpack. Added a None
output_path so the unpack always succeeds.

* fix(studio): pass private to MLX push, return 3-tuples consistently

- MLX push_to_hub branch now forwards private=private (matches GPU)
- Existing 2-tuple early-returns ('repo_id+token required', 'PEFT model
  needed') were tripping the route's 3-tuple unpack. Added a None
  output_path so the unpack always succeeds.

* studio wirings

* studio wirings

* Merge pull request #5 from Manan17/feat/quant_config

studio wirings

* fix(mlx): wire train_on_completions for VLM via per-template lookup

Mirror the GPU worker: stop excluding VLMs and stop hardcoding
template detection. Look up the model in MODEL_TO_TEMPLATE_MAPPER and
fetch the per-template instruction/response markers from
TEMPLATE_TO_RESPONSES_MAPPER. The frontend already force-disables
train_on_completions for vision+image and audio cases, so backend
just trusts the flag.

* fix(mlx): wire train_on_completions for VLM via per-template lookup

Mirror the GPU worker: stop excluding VLMs and stop hardcoding
template detection. Look up the model in MODEL_TO_TEMPLATE_MAPPER and
fetch the per-template instruction/response markers from
TEMPLATE_TO_RESPONSES_MAPPER. The frontend already force-disables
train_on_completions for vision+image and audio cases, so backend
just trusts the flag.

* wire in lora rslora, init lora weights, random_state

* wire in lora rslora, init lora weights, random_state

* loftq studio error message fix

* loftq studio error message fix

* handle unknown optim and lr scheduler

* handle unknown optim and lr scheduler

* Merge pull request #6 from Manan17/update/peftkwargs

Update/peftkwargs

* feat(mlx): pass finetune_language/attention/mlp/vision flags to FastMLXModel

Studio's four UI checkboxes now actually flow through to MLX get_peft_model
(which was just updated in unsloth-zoo to honor them). Also drops the
incorrect train_projector wiring that tied projector LoRA to the
attn/mlp flags — those are language-side toggles, not projector toggles.

Co-Authored-By: Manan17 <shahmanan170602@gmail.com>

* feat(mlx): pass finetune_language/attention/mlp/vision flags to FastMLXModel

Studio's four UI checkboxes now actually flow through to MLX get_peft_model
(which was just updated in unsloth-zoo to honor them). Also drops the
incorrect train_projector wiring that tied projector LoRA to the
attn/mlp flags — those are language-side toggles, not projector toggles.

Co-Authored-By: Manan17 <shahmanan170602@gmail.com>

* feat(mlx,ux): auto-imply finetune_language_layers when user picks attn/mlp

UI guardrail. The four checkboxes (vision/language/attention/MLP) carry
"scope × module-type" semantics that aren't obvious — picking just
"Attention modules" + "MLP modules" without "Language layers" naturally
reads as "fine-tune attn/mlp" but our backend reads it as "fine-tune
attn/mlp modules in *no* tower" → empty target_modules → zero
trainable params → crash inside value_and_grad.

If user selected attn or mlp module types but no layer scope, default
to language scope. Power users can still explicitly choose
language=False, vision=True if they want vision-only fine-tuning of
attn/mlp.

Co-Authored-By: Manan17 <shahmanan170602@gmail.com>

* feat(mlx,ux): auto-imply finetune_language_layers when user picks attn/mlp

UI guardrail. The four checkboxes (vision/language/attention/MLP) carry
"scope × module-type" semantics that aren't obvious — picking just
"Attention modules" + "MLP modules" without "Language layers" naturally
reads as "fine-tune attn/mlp" but our backend reads it as "fine-tune
attn/mlp modules in *no* tower" → empty target_modules → zero
trainable params → crash inside value_and_grad.

If user selected attn or mlp module types but no layer scope, default
to language scope. Power users can still explicitly choose
language=False, vision=True if they want vision-only fine-tuning of
attn/mlp.

Co-Authored-By: Manan17 <shahmanan170602@gmail.com>

* fix(mlx): wire top_k, repetition_penalty, and VLM top_p through to mlx-lm/mlx-vlm

Inference UI sliders for top_k and repetition_penalty had no effect on
MLX, and VLM top_p was also silently dropped. Plus a latent pre-existing
bug: mlx_vlm.generate_step expects temperature= (long form), but we
were passing temp= which silently fell into **kwargs — every VLM chat
was effectively greedy regardless of the temperature slider.

Text path (_generate_text):
make_sampler now receives top_k in addition to temp/top_p
make_logits_processors built and forwarded when repetition_penalty is
non-trivial (skip when 0.0/1.0 to avoid pointless overhead)

VLM path (_generate_vlm):
Pass top_p, top_k, repetition_penalty as kwargs (mlx_vlm.stream_generate
forwards them to generate_step's sampler/logits_processor builders)
Rename temp= → temperature= so it's actually consumed

Verified end-to-end with a smoke test on Qwen2.5-0.5B-Instruct (text) and
Qwen2.5-VL-3B-Instruct (VLM): each of {greedy, top_p=0.5, top_k=10,
rep_pen=1.5} now produces a distinct output, proving the parameters
reach the sampler.

Co-Authored-By: Manan17 <shahmanan170602@gmail.com>

* fix(mlx): wire top_k, repetition_penalty, and VLM top_p through to mlx-lm/mlx-vlm

Inference UI sliders for top_k and repetition_penalty had no effect on
MLX, and VLM top_p was also silently dropped. Plus a latent pre-existing
bug: mlx_vlm.generate_step expects temperature= (long form), but we
were passing temp= which silently fell into **kwargs — every VLM chat
was effectively greedy regardless of the temperature slider.

Text path (_generate_text):
- make_sampler now receives top_k in addition to temp/top_p
- make_logits_processors built and forwarded when repetition_penalty is
  non-trivial (skip when 0.0/1.0 to avoid pointless overhead)

VLM path (_generate_vlm):
- Pass top_p, top_k, repetition_penalty as kwargs (mlx_vlm.stream_generate
  forwards them to generate_step's sampler/logits_processor builders)
- Rename temp= → temperature= so it's actually consumed

Verified end-to-end with a smoke test on Qwen2.5-0.5B-Instruct (text) and
Qwen2.5-VL-3B-Instruct (VLM): each of {greedy, top_p=0.5, top_k=10,
rep_pen=1.5} now produces a distinct output, proving the parameters
reach the sampler.

Co-Authored-By: Manan17 <shahmanan170602@gmail.com>

* feat(mlx): map format_type to MLX save_method, reuse local save dir for hub push

export_merged_model: format_type="4-bit (FP4)" → save_method="merged_4bit"
(was hardcoded merged_16bit, ignoring the UI choice).
Both export_merged_model and export_base_model now pass save_directory=
to push_to_hub_merged so it reuses the just-written local folder
instead of re-saving under a relative "username/model" directory.

Co-Authored-By: Manan17 <shahmanan170602@gmail.com>

* feat(mlx): map format_type to MLX save_method, reuse local save dir for hub push

- export_merged_model: format_type="4-bit (FP4)" → save_method="merged_4bit"
  (was hardcoded merged_16bit, ignoring the UI choice).
- Both export_merged_model and export_base_model now pass save_directory=
  to push_to_hub_merged so it reuses the just-written local folder
  instead of re-saving under a relative "username/model" directory.

Co-Authored-By: Manan17 <shahmanan170602@gmail.com>

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

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

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

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

* restore install

* restore install

* fix(mlx): restore FastVisionModel as a distinct class

unsloth/__init__.py was assigning `FastVisionModel = FastLanguageModel`
right after defining `class FastVisionModel(FastLanguageModel)` with a
`for_training` static method. The alias erased the class binding, so
the documented `FastVisionModel.for_training(model)` call from upstream
Unsloth's VLM notebooks raised `AttributeError` on MLX.

Remove the offending alias. `FastVisionModel` is now a real subclass of
`FastLanguageModel` again — inherits `from_pretrained` /
`get_peft_model` / `for_inference`, exposes `for_training` as a no-op
pass-through (no-op because MLX doesn't have a train/eval mode flag;
the call exists purely for GPU/MLX notebook parity).

Verified end-to-end: Qwen3-VL-2B + LaTeX_OCR LoRA + vision LoRA via
FastVisionModel.from_pretrained → get_peft_model → for_training →
MLXTrainer.train runs 10 steps cleanly (loss 1.10 → 0.12, no NaNs,
peak 5.89 GB).

Studio's path (FastLanguageModel.from_pretrained for any repo,
auto-detect VLM in the loader) is unaffected. Tier-1 review finding #8.

* fix(mlx): restore FastVisionModel as a distinct class

unsloth/__init__.py was assigning `FastVisionModel = FastLanguageModel`
right after defining `class FastVisionModel(FastLanguageModel)` with a
`for_training` static method. The alias erased the class binding, so
the documented `FastVisionModel.for_training(model)` call from upstream
Unsloth's VLM notebooks raised `AttributeError` on MLX.

Remove the offending alias. `FastVisionModel` is now a real subclass of
`FastLanguageModel` again — inherits `from_pretrained` /
`get_peft_model` / `for_inference`, exposes `for_training` as a no-op
pass-through (no-op because MLX doesn't have a train/eval mode flag;
the call exists purely for GPU/MLX notebook parity).

Verified end-to-end: Qwen3-VL-2B + LaTeX_OCR LoRA + vision LoRA via
FastVisionModel.from_pretrained → get_peft_model → for_training →
MLXTrainer.train() runs 10 steps cleanly (loss 1.10 → 0.12, no NaNs,
peak 5.89 GB).

Studio's path (FastLanguageModel.from_pretrained for any repo,
auto-detect VLM in the loader) is unaffected. Tier-1 review finding #8.

* Studio: harden MLX training and export, restore GPU init guards

Studio export
Restore Tuple[bool, str, Optional[str]] contract on export_merged_model,
export_base_model, export_gguf, and export_lora_adapter, populating
output_path on successful local saves so routes/worker/CLI/frontend
details.output_path is non-empty again.
Lift the GPU save_method assignment out of the local-save branch so
Hub-only merged exports (save_directory='', push_to_hub=True) no longer
hit UnboundLocalError on the push branch.
For MLX merged and base hub-only export, stage to a tempfile.TemporaryDirectory
before push_to_hub_merged instead of passing save_directory=''.
Source _IS_MLX from unsloth instead of recomputing the platform check
(single source of truth, also enforces mlx-package availability).

Studio MLX training/inference
Pass token=hf_token into FastMLXModel.from_pretrained for gated/private
models, matching the inference path.
Strip hf_token and wandb_token from wandb.init(config=...) so secrets
do not leak into the W&B run config.
Replace load_from_disk(local_datasets[0]) with the existing
UnslothTrainer._resolve_local_files / _loader_for_files helpers so
uploaded JSON/JSONL/CSV/Parquet files train through the normal datasets
loader (load_from_disk still used for HF save_to_disk directories).
Make the dataset slice helper inclusive at the end and treat 0 as a real
index instead of "unset", matching the GPU and embedding paths.
Add a status_message -> message alias inside _send so the existing parent
pump (training.py) renders MLX status updates instead of blanks.
Forward min_p through generate_chat_response into _generate_text /
_generate_vlm and into make_sampler / vlm_kwargs so the sampling control
is no longer a no-op on MLX.
Wrap unsloth_zoo.mlx_loader / mlx_trainer imports with a clearer
ImportError pointing users at install.sh for Apple Silicon.
Exit the MLX stop-polling thread on EOFError/OSError instead of
busy-looping when the queue/pipe is permanently closed (one-line
why-safe rationale inline).

Studio frontend
ParamsSection subscribes to platform deviceType via the Zustand hook so
the gradient checkpointing dropdown re-renders after the async device
fetch completes.

Studio hardware
get_gpu_utilization MLX branch now reads _read_apple_gpu_stats once and
derives VRAM totals from psutil, removing the second ioreg subprocess
per utilization poll.

Unsloth core
Restore the os.geteuid == 0 guard around the CUDA ldconfig recovery
that was lost when GPU initialization moved into _gpu_init.py, plus the
non-root manual-fix warning branch. Non-root CUDA users no longer shell
out to ldconfig at import time.
Load dataprep/raw_text via importlib so the MLX import path no longer
pulls torch in through dataprep/__init__.py -> synthetic.py.
FastVisionModel.from_pretrained overrides the inherited delegator only
to inject text_only=False; this is an extension, not a duplication, and
is needed so VLM checkpoint loads keep the vision tower.
Wrap the MLX-branch unsloth_zoo import with a clearer ImportError.

* Studio: regression tests for MLX training/export and GPU init ldconfig guard

tests/python/test_gpu_init_ldconfig_guard.py asserts the geteuid root
check still wraps the ldconfig recovery and the non-root branch warns
bnb users; AST + source-text inspection so the test runs without torch.
tests/studio/test_export_output_path_contract.py covers the
Tuple[bool, str, Optional[str]] return contract on every export method,
the output_path assignment after successful local save, the Hub-only
GPU save_method binding fix, the MLX hub-only TemporaryDirectory
staging, and the single-source `_IS_MLX` import from unsloth.
tests/studio/test_mlx_training_worker_behaviors.py covers token
forwarding to FastMLXModel.from_pretrained, wandb config secret
stripping, file-aware local dataset loading, status_message ->
message aliasing, inclusive slice semantics, EOFError/OSError stop
thread exit, and the friendly mlx_loader / mlx_trainer ImportError.

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

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

* fix(mlx): cap inference memory + release wired on unload + tame worker pre-pin

Three memory-hardening fixes for Studio's MLX path:

1. Inference applies the same Metal caps as the trainer.
   load_model previously only called set_wired_limit(100% of recommended)
   with no upper memory_limit, leaving large VLM checkpoints unbounded
   during the loader allocation. Add _configure_memory_limits() that sets
   memory_limit to 85% of recommended and wired_limit to min(recommended,
   memory_limit) — matching MLXTrainer's defaults so behavior is the same
   whether the user trains or just runs inference.

2. unload_model releases pinned memory back to the OS — but only when
   the cache is empty. Without this, pinned wired bytes stayed allocated
   to MLX after the model was gone, starving other apps. The release is
   guarded on `not self.models` so unloading one of several cached
   models doesn't un-pin weights still in use.

3. Worker pre-cap is conservative instead of aggressive.
   The previous pre-pin set_wired_limit(100% of recommended) competed
   with MLXTrainer's later more conservative cap. Replace with the same
   85%-memory / min(rec, memory) pair that the trainer applies later
   (idempotent re-apply). Bounds the model load + LoRA setup window
   without over-pinning.

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

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

* tests/studio: regression tests for the _IS_MLX dispatch gate

Two gates drive every MLX-vs-CUDA dispatch decision in Studio:

  1. unsloth._IS_MLX in unsloth/__init__.py — evaluated once at import
     time, read by Studio worker code to choose the GPU vs MLX trainer
     and inference paths. Defined as
        Darwin AND arm64 AND find_spec("mlx") is not None.

  2. utils.hardware.detect_hardware() — runtime probe with priority
     CUDA > XPU > MLX > CPU. The MLX branch is reached only when both
     CUDA and XPU are unavailable and the host is Apple Silicon and
     mlx is importable.

Neither gate had a direct test. Adds tests/studio/test_is_mlx_dispatch_gate.py
with six tests:

  test_is_mlx_gate_uses_three_required_predicates
      AST-walks unsloth/__init__.py and asserts the _IS_MLX assignment
      is a BoolOp(And) of platform.system()=="Darwin",
      platform.machine()=="arm64", and find_spec("mlx") is not None.
      Catches accidental rewrites that drop a predicate.

  test_is_mlx_gate_true_on_apple_silicon_with_mlx_present
      Spoofs platform to Darwin/arm64, injects a fake mlx module so
      find_spec returns a real ModuleSpec, re-evaluates the gate
      expression. Verifies it flips True under the exact conditions
      Studio expects.

  test_is_mlx_gate_false_when_mlx_missing
      Spoofs Apple Silicon but with mlx absent. Verifies the gate stays
      False (so a Mac without mlx installed does not pretend to have
      MLX support).

  test_is_mlx_gate_false_on_non_apple_silicon
      Canary on the actual Linux+CUDA / AMD / Intel test host: the gate
      must remain False regardless of whether mlx happens to be
      importable. Protects existing GPU users from accidental MLX
      hijack when MLX support evolves.

  test_detect_hardware_picks_mlx_when_only_apple_silicon_available
      Forces torch.cuda and torch.xpu off, spoofs Apple Silicon, injects
      fake mlx and mlx.core. detect_hardware() must return DeviceType.MLX.

  test_detect_hardware_picks_cuda_on_real_host
      Canary: on a real CUDA host detect_hardware() must return
      DeviceType.CUDA. Protects against the MLX branch shadowing CUDA
      dispatch on NVIDIA / AMD ROCm hosts.

Uses the same monkeypatch.setitem(sys.modules, ...) fake-mlx pattern as
the existing test_mlx_inference_backend.py — no new test infrastructure,
no real mlx install required.

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

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

* Add AGPL-3.0 SPDX header to Studio MLX regression tests

Four Studio MLX test files shipped without an SPDX-License-Identifier:

  studio/backend/tests/test_mlx_training_worker_config.py
  tests/studio/test_mlx_training_worker_behaviors.py
  tests/studio/test_export_output_path_contract.py
  tests/studio/test_is_mlx_dispatch_gate.py

They sit in or alongside studio/backend/, which is governed by
studio/LICENSE.AGPL-3.0, and exercise AGPL Studio code. Add the same
"# SPDX-License-Identifier: AGPL-3.0-only" header that's already on
test_mlx_inference_backend.py so the license declaration matches
the code under test rather than defaulting to the repo-root
Apache-2.0.

* Wrap MLX submodule imports with friendly install hint

The _IS_MLX block at the top of unsloth/__init__.py already catches the
missing-package case with a friendly install hint, but the follow-up
"from unsloth_zoo.mlx_trainer import ..." and "from unsloth_zoo.mlx_loader import ..."
lines run unguarded. An Apple Silicon user who has unsloth-zoo installed
but on an older version (e.g. the current PyPI release, before the MLX
modules ship) sees a raw ImportError on the submodule rather than the
hint that points at install.sh.

Wrap the two submodule imports in the same try/except shape so the
friendly install message fires whether the package is missing entirely
or just predates the MLX submodules. No-op once both packages release
together; smooths the transitional window where unsloth/main has merged
but unsloth-zoo on PyPI has not.

---------

Co-authored-by: DoubleMathew <mmathew23@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-05 23:54:58 -07:00
Lee Jackson
832f48c41a
Chore/help svg (#5283)
* fix: developer to api

* fix: help svg and Unsloth text

* svg fix

---------

Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-05 05:22:52 -07:00
Lee Jackson
d741cc928b
fix: developer to api (#5281) 2026-05-05 16:11:52 +04:00
Lee Jackson
5533bdb8b6
Studio: Change API Keys settings to API Access (#5268)
* chore: change API Keys settings to API Access

* chore: replace key with plug svg

* chore: replace API access with developer and related SVG

* chore: rename API keys menu to developer

* fix: focus active settings tab on open

* fix: prevent settings autofocus on open

* Revert "fix: prevent settings autofocus on open"

This reverts commit 4b64d73eda.
2026-05-04 17:56:38 +04:00
Lee Jackson
820b5c2e20
Studio: Always show API usage examples and docs links (#5270)
* chore: always show API usage examples and docs links

* chore: add colon to API setup docs label

* settings: move help links, add API shortcut, and update API copy
2026-05-04 17:29:26 +04:00
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
Lee Jackson
c20959dbf4
Studio: Improve chat composition, fix scroll behaviour, and refine sidebar UX (#5089)
* Chatbox, scroll, and menu fixes

- Fixed chatbox auto-expand height for multi-line text on the compare page
- Fixed chatbox UI to be consistent across compare and new chat
- Fixed scrolling being enabled on pages with no content, which also triggered the scroll-to-bottom button
- Fixed scroll-to-bottom button to only appear after scrolling up a reasonable amount instead of instantly
- Added shutdown studio button to the menu for easier access
- Fixed pop-up menu width to match the user button width

(cherry picked from commit cd4e390dfa84fe311fae79a781b96cc0ef5970a9)

* fix: correct compare scroll viewport and clean up chat composer UI polish

* Dark theme refactor and sidebar/chat UI refinements

- Complete refactoring of dark theme
- Replaced square rounded-corner user profile image with a circular bordered one
- Replaced user profile icon with 'U' initial and renamed label from 'Studio' to 'User'
- Chat bubbles now have a pointy top-right edge
- Sidebar menu tab line color selection is now consistent across all menus
- Tab-selection color animation now also applies to recent chats
- Removed 'Compare' menu autoselect when a compare chat conversation is selected
- Fixed UI consistency in Compare to match New Chat
- Removed sidebar animation and tab line, replaced with rounded selection for consistency
- Further adjustments to sidebar UI
- Further adjustments to compare chat UI

* Fixed sidebar collapse/expand for recent chats and recent runs not being clickable

* Chatbox, scroll, and menu fixes

- Fixed chatbox auto-expand height for multi-line text on the compare page
- Fixed chatbox UI to be consistent across compare and new chat
- Fixed scrolling being enabled on pages with no content, which also triggered the scroll-to-bottom button
- Fixed scroll-to-bottom button to only appear after scrolling up a reasonable amount instead of instantly
- Added shutdown studio button to the menu for easier access
- Fixed pop-up menu width to match the user button width

* Sidebar, fonts, and chat UI refinements

- Replaced logo PNG with real font text for 'unsloth' and 'BETA' label
- Added Hellix font and applied it across menus and UI elements
- Lighter scrollbar in the sidebar compared to other areas of the app
- Adjusted chat font and chat bubble styling
- Adjusted app menu design to stay consistent with the sidebar
- Adjusted text style for 'New Chat' and repositioned content/chatbox
- Adjusted model selector and top area UI
- Fixed footer text from 'LLM's' to 'LLMs'
- Fixed active selection border color incorrectly appearing on page refresh and during general navigation
- Logo now defaults to 'New Chat' when clicked

* Sidebar, model selector, and mobile UI fixes

- Further adjustments to sidebar UI and logo
- Changed right bar icon
- Model selector adjustments
- Collapsed sidebar now matches the content area background
- Adjusted Hellix font spacing across pages
- Fixed sidebar icon overlap on mobile screens

* Adjust sidebar icons

* Adjust sidebar icons

* Fixed compare chat UI and scrolling issues

* Fixed inference settings icon behavior and context info positioning

- Fixed top right inference settings icon to move into sidepanel during expand/collapse, matching left sidebar behavior
- Adjusted context information element positioning

* Fix: textarea overflow in system prompt editor

* Code block redesign, font, and chat bubble adjustments

- Redesigned code block colors and theme
- Changed code block font to Fira Code
- Fixed scrollbar disappearing when expanding/collapsing tool calls in chats
- Adjusted chat bubble background color

* Fix chat bubble background color in dark theme

* fix: restore textarea auto-sizing and scope prompt editor sizing

* fix: add explicit textarea field sizing for prompt editor overflow

* fix: generate chat nonce on click instead of render

* fix: respect training lock on logo navigation

* Refactor compare page dual chat scrolling behavior

* Revert "Refactor compare page dual chat scrolling behavior"

This reverts commit d056ec09f2.

---------

Co-authored-by: sneakr <hauzin@hotmail.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-04-21 02:20:45 +04:00