``, which also close ````."""
+ barriers = _CLOSE_BARRIERS.get(tag, ())
+ while True:
+ close_at = None
+ for i in range(len(self._open_tags) - 1, -1, -1):
+ name = self._open_tags[i]
+ if tag in _IMPLICIT_CLOSERS.get(name, ()):
+ close_at = i
+ break
+ # A barrier container re-scopes the item; stop before it.
+ if name in barriers:
+ break
+ if close_at is None:
+ break
+ del self._open_tags[close_at:]
+ while self._hidden_marks and self._hidden_marks[-1] >= close_at:
+ self._hidden_marks.pop()
+
+ def _enter_tag(self, tag: str, attr_dict: dict) -> bool:
+ """Track open/hidden/scope state; return True when the tag's content
+ should be rendered (False = suppressed). Caller runs ``_close_implicit``
+ first so recovery also fires for skipped tags."""
+ if tag not in _VOID_TAGS:
+ self._open_tags.append(tag)
+ if _is_hidden_element(attr_dict):
+ self._hidden_marks.append(len(self._open_tags) - 1)
+ elif _is_hidden_element(attr_dict):
+ # Void elements never join the stack, so suppress a hidden one inline.
+ return False
+ if self._scope_tags is not None and tag in self._scope_tags:
+ if self._scope_depth == 0:
+ self._scope_seg_start = len(self._out)
+ self._scope_depth += 1
+ if self._hidden_marks:
+ return False
+ if self._scope_tags is not None and self._scope_depth == 0:
+ return False
+ return True
+
+ def _exit_tag(self, tag: str) -> bool:
+ """Pop to the matching open tag; return True when the end tag should
+ be rendered (False = it closed inside a hidden / out-of-scope region)."""
+ suppressed = bool(self._hidden_marks) or (
+ self._scope_tags is not None and self._scope_depth == 0
+ )
+ if tag not in _VOID_TAGS:
+ # Pop to the innermost matching open tag (recovers omitted closes).
+ for i in range(len(self._open_tags) - 1, -1, -1):
+ if self._open_tags[i] == tag:
+ del self._open_tags[i:]
+ while self._hidden_marks and self._hidden_marks[-1] >= i:
+ self._hidden_marks.pop()
+ break
+ if self._scope_tags is not None and tag in self._scope_tags and self._scope_depth > 0:
+ self._scope_depth -= 1
+ if self._scope_depth == 0 and self._scope_seg_start is not None:
+ self.scope_segments.append("".join(self._out[self._scope_seg_start :]))
+ self._scope_seg_start = None
+ return not suppressed
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
+ if self._skip_depth:
+ # Inside a skipped subtree: only track nested skip depth.
+ if tag in _SKIP_TAGS:
+ self._skip_depth += 1
+ return
+
+ # Recover optional end tags before the skip decision: a skipped
+ # / still implicitly closes an open , releasing its
+ # hidden mark so following siblings render.
+ self._close_implicit(tag)
+
if tag in _SKIP_TAGS:
self._skip_depth += 1
return
- if self._skip_depth:
- return
attr_dict = dict(attrs)
+ if not self._enter_tag(tag, attr_dict):
+ return
if tag in _HEADING_TAGS:
level = int(tag[1])
@@ -250,6 +484,9 @@ class _MarkdownRenderer(HTMLParser):
if self._skip_depth:
return
+ if not self._exit_tag(tag):
+ return
+
if tag in _HEADING_TAGS:
self._emit("\n\n")
@@ -308,8 +545,13 @@ class _MarkdownRenderer(HTMLParser):
# ------------------------------------------------------------------
# Text / entity handlers
# ------------------------------------------------------------------
+ def _text_suppressed(self) -> bool:
+ if self._skip_depth or self._hidden_marks:
+ return True
+ return self._scope_tags is not None and self._scope_depth == 0
+
def handle_data(self, data: str) -> None:
- if self._skip_depth:
+ if self._text_suppressed():
return
if self._in_pre:
self._pre_parts.append(data)
@@ -326,12 +568,12 @@ class _MarkdownRenderer(HTMLParser):
self._emit(text)
def handle_entityref(self, name: str) -> None:
- if self._skip_depth:
+ if self._text_suppressed():
return
self._emit(html.unescape(f"&{name};"))
def handle_charref(self, name: str) -> None:
- if self._skip_depth:
+ if self._text_suppressed():
return
self._emit(html.unescape(f"{name};"))
@@ -366,6 +608,14 @@ class _MarkdownRenderer(HTMLParser):
else:
self._out.append("\n\n" + prefixed + "\n\n")
+ # A scope left open by truncated HTML never reached _exit_tag, so its output
+ # never joined scope_segments and would score 0. Flush the still-open segment
+ # here (after the side-buffers) so a truncated main-content page is scored.
+ if self._scope_seg_start is not None:
+ self.scope_segments.append("".join(self._out[self._scope_seg_start :]))
+ self._scope_seg_start = None
+ self._scope_depth = 0
+
# Post-processing
def _cleanup(text: str) -> str:
@@ -399,17 +649,124 @@ def _cleanup(text: str) -> str:
return "\n".join(out).strip()
-# Public API
-def html_to_markdown(source_html: str) -> str:
- """Convert HTML to Markdown (headings, links, emphasis, lists, tables, blockquotes, code, entities).
+# Known boilerplate fragments stripped from main-content conversions, matched
+# only against short lines. Sources: GitHub page furniture / client-side error
+# placeholders, skip-links, cookie banners.
+_BOILERPLATE_FRAGMENTS = (
+ "skip to content",
+ "skip to main content",
+ "there was an error while loading",
+ "please reload this page",
+ "you can't perform that action at this time",
+ "you signed in with another tab or window",
+ "you signed out in another tab or window",
+ "you switched accounts on another tab or window",
+ "reload to refresh your session",
+ "you must be signed in to change notification settings",
+ "uh oh!",
+ "{{ message }}",
+ "this website uses cookies",
+ "we use cookies",
+ "accept all cookies",
+ "manage cookie preferences",
+)
+# Only shorter lines are eligible for boilerplate dropping; real content
+# sentences quoting a fragment run longer.
+_BOILERPLATE_MAX_LINE_CHARS = 300
- ``") is False
+ )
+ assert rh(" ") is False
+ assert rh(" ") is False
+ assert rh(" ") is True
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is True
+ assert rh(" ") is True
+ # Worker / SharedWorker constructors run an off-thread script the scan cannot
+ # see (a module worker from a CORS CDN, or a blob/same-origin worker that
+ # fetches/importScripts) under worker-src http: https: blob:, so they ask.
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is False # not a ctor
+ assert rh("") is False # unrelated class, not a real Worker
+ # Resource-loading forms beyond a direct fetch also reach the network.
+ assert rh("") is True
+ assert rh("") is True
+ assert rh(" ") is True
+ assert rh(" ") is True # root-relative resolves to origin
+ assert rh(" ") is True # protocol-relative
+ # Self-navigation sinks exfiltrate by navigating the frame away.
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is False # reload is not navigation
+ assert rh("") is False
+ # Obfuscated egress: a block comment splitting fetch(, or bracket access.
+ assert rh("") is True
+ assert rh("") is True
+ # A computed bracket key spliced from string fragments on a global host object.
+ assert rh("") is True
+ assert rh("") is True
+ # A computed key on a plain object (not a global host) stays a static canvas.
+ assert rh("") is False
+ assert rh("") is False # comment only
+ # A meta-refresh with a url navigates the frame to an external origin.
+ assert rh(' ') is True
+ assert rh(" ") is True
+ assert rh(' ') is False # self-reload, no url
+ assert rh('
Hi ') is False # ordinary meta stays safe
+
+
+def test_unknown_tools_fail_closed():
+ assert is_potentially_unsafe_tool_call("mystery_tool", {}) is True
+
+
+def test_is_always_safe_tool():
+ from core.inference.tools import is_always_safe_tool
+ for name in ("web_search", "search_knowledge_base"):
+ assert is_always_safe_tool(name) is True
+ # render_html is no longer unconditionally safe: a networked canvas can prompt,
+ # which cannot be judged before its arguments stream.
+ for name in ("python", "terminal", "mystery_tool", "mcp__srv__read", "render_html"):
+ assert is_always_safe_tool(name) is False
+
+
+@pytest.mark.parametrize(
+ ("tool", "unsafe"),
+ [
+ ("get_weather", False),
+ ("list_files", False),
+ ("search", False),
+ ("send_email", True),
+ ("create_issue", True),
+ ("delete_row", True),
+ ("get_or_create_issue", True), # mutating verb overrides read prefix
+ ("read_and_delete_file", True),
+ ("find_and_update_row", True),
+ ("get_and_commit_changes", True), # commit/save/archive are mutating
+ ("read_and_save_file", True),
+ ("list_and_archive", True),
+ ("list_and_clone_repo", True), # clone/checkout/comment are mutating
+ ("fetch_and_comment_issue", True),
+ ("get_and_checkout_branch", True),
+ ("read_and_append_file", True), # append/prepend are mutating
+ ("prepend_line", True),
+ ("get_and_upsert_row", True), # upsert/assign are mutating
+ ("list_and_assign_issue", True),
+ ("read_and_copy_file", True), # copy-style verbs create/overwrite state
+ ("get_and_copy_resource", True),
+ ("read_and_duplicate_entry", True),
+ ("fetch_and_download_asset", True), # download writes local state
+ ("list_and_export_data", True), # import/export/backup/restore/snapshot
+ ("get_and_snapshot_volume", True),
+ ("get_and_mark_read", True), # mark/subscribe change external state
+ ("get_and_subscribe", True),
+ ("list_and_unsubscribe", True),
+ ("get_and_reply_email", True), # reply/notify send/change external state
+ ("list_and_notify_users", True),
+ ("read_secret", True), # credential noun: a read that discloses a secret
+ ("list_tokens", True),
+ ("get_credentials", True),
+ ("fetch_api_key", True), # scoped *_key noun
+ ("read_access_key", True),
+ ("get_password", True),
+ ("read_passphrase", True),
+ ("read_report", False), # plain read stays safe
+ ("get_primary_key", False), # a schema key is not a credential
+ ("search_keyboard_shortcuts", False), # 'key' inside another word stays safe
+ ("list_bookmarks", False), # 'mark' substring in a token stays safe
+ ("list_notifications", False), # 'notify' is a different token than 'notifications'
+ ],
+)
+def test_mcp_classifier(tool, unsafe):
+ name = f"{MCP_TOOL_PREFIX}srv1__{tool}"
+ assert is_potentially_unsafe_tool_call(name, {}) is unsafe
+
+
+@pytest.mark.parametrize(
+ ("args", "unsafe"),
+ [
+ ({"path": "/etc/passwd"}, True), # read-named tool at a credential path
+ ({"path": "../../.ssh/id_rsa"}, True),
+ ({"nested": {"file": "~/.aws/credentials"}}, True),
+ ({"name": "OPENAI_API_KEY"}, True), # explicit credential env-var read
+ ({"name": "AWS_SECRET_ACCESS_KEY"}, True),
+ ({"key": "DATABASE_PASSWORD"}, True),
+ (
+ {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"},
+ True,
+ ), # AWS instance-metadata host
+ (
+ {"url": "http://metadata.google.internal/computeMetadata/v1/"},
+ True,
+ ), # GCP metadata host
+ ({"path": "notes.txt"}, False), # ordinary path stays safe
+ ({"path": "data/report.csv"}, False),
+ ({"name": "PATH"}, False), # a non-secret env var stays safe
+ ({"name": "HOME"}, False),
+ ({"url": "https://example.com/api"}, False), # ordinary URL stays safe
+ ({"url": "http://localhost:8080/health"}, False), # localhost app stays safe
+ ],
+)
+def test_mcp_sensitive_arguments(args, unsafe):
+ name = f"{MCP_TOOL_PREFIX}fs__read_file"
+ assert is_potentially_unsafe_tool_call(name, args) is unsafe
+
+
+@pytest.mark.parametrize(
+ ("args", "unsafe"),
+ [
+ ({"query": "DELETE FROM runs"}, True), # read-named tool, mutating query
+ ({"sql": "DROP TABLE users"}, True),
+ ({"query": "UPDATE t SET x=1"}, True),
+ ({"query": "INSERT INTO t VALUES (1)"}, True),
+ ({"query": "SELECT * FROM runs"}, False), # read query stays safe
+ ({"query": "how to delete old files"}, False), # NL text with 'delete' stays safe
+ ({"query": "find the created_at column"}, False), # 'created' substring stays safe
+ ({"query": "DELETE/**/FROM runs"}, True), # inline SQL comment as whitespace
+ ({"query": "UPDATE/**/t SET x=1"}, True),
+ ({"query": "DROP/**/TABLE users"}, True),
+ ({"query": "SELECT * FROM runs -- delete later"}, False), # trailing comment stays safe
+ ({"query": "COPY users FROM '/tmp/u.csv'"}, True), # bulk load writes the table
+ ({"query": "COPY users (id, name)\nFROM STDIN"}, True), # multiline COPY FROM
+ ({"query": "COPY (SELECT 1) TO '/tmp/o.csv'"}, True), # COPY TO writes a server file
+ ({"query": "SELECT copy_count FROM t"}, False), # 'copy' substring column stays safe
+ ({"query": "mutation { deleteIssue(id: 1) }"}, True), # GraphQL mutation
+ ({"query": "mutation DelIssue { deleteIssue(id: 1) }"}, True), # named GraphQL mutation
+ ({"query": "mutation # note\n { deleteIssue(id: 1) }"}, True), # comment before body
+ ({"query": "mutation # c\n Del { deleteIssue(id: 1) }"}, True), # comment before name
+ ({"query": "query { issue(id: 1) { title } }"}, False), # GraphQL read query stays safe
+ ({"query": "{ issue(id: 1) { title } }"}, False), # shorthand GraphQL query stays safe
+ ({"query": "query # note\n { issue(id: 1) }"}, False), # commented read query stays safe
+ ({"query": "CREATE OR REPLACE VIEW v AS SELECT 1"}, True), # DDL with a modifier
+ ({"query": "CREATE UNIQUE INDEX idx ON t(x)"}, True), # DDL with UNIQUE
+ ({"query": "CREATE TEMP TABLE t (id int)"}, True), # DDL with TEMP
+ ({"query": "CREATE MATERIALIZED VIEW mv AS SELECT 1"}, True), # materialized view DDL
+ ({"query": "CREATE FUNCTION f() RETURNS int AS $$ $$"}, True), # function DDL
+ ({"query": "ALTER SYSTEM SET work_mem = '1GB'"}, True), # persists server config
+ ({"query": "alter system reset all"}, True), # ALTER SYSTEM RESET
+ ({"query": "SELECT * FROM system_logs"}, False), # 'system' as a table name stays safe
+ ({"query": "SELECT * FROM created_view"}, False), # 'create' substring stays safe
+ ({"query": "CALL delete_all_users()"}, True), # stored procedure invocation
+ ({"query": "EXEC purge_queue"}, True), # EXEC procedure
+ ({"query": "EXECUTE sp_drop"}, True), # EXECUTE procedure
+ ({"query": "VACUUM INTO 'backup.db'"}, True), # VACUUM rewrites the database
+ ({"query": "please call me back later"}, False), # NL 'call' stays safe
+ ({"query": "ATTACH DATABASE '/tmp/x.db' AS x"}, True), # attaches a database file
+ ({"query": "DETACH DATABASE x"}, True), # detaches a database
+ ({"query": "PRAGMA user_version = 42"}, True), # write-form PRAGMA
+ ({"query": "PRAGMA journal_mode=WAL"}, True), # write-form PRAGMA (no spaces)
+ ({"query": "PRAGMA foreign_keys(0)"}, True), # call-form PRAGMA write
+ ({"query": "SELECT load_extension('/tmp/evil.so')"}, True), # loads native code
+ ({"query": "PRAGMA journal_mode"}, False), # read-form PRAGMA stays safe
+ ({"query": "can you attach the report to the email"}, False), # NL 'attach' stays safe
+ ({"query": "ATTACH '/tmp/x.db' AS x"}, True), # ATTACH without DATABASE keyword
+ ({"query": "PRAGMA main.user_version = 1"}, True), # schema-qualified write PRAGMA
+ ({"query": "attach it as draft"}, False), # NL 'attach ... as' stays safe
+ ({"query": "DROP FUNCTION f()"}, True), # DROP of a non-table object
+ ({"query": "ALTER INDEX idx RENAME TO idx2"}, True), # ALTER of a non-table object
+ ({"query": "DROP MATERIALIZED VIEW mv"}, True), # DROP with a modifier
+ ({"query": "ALTER USER bob WITH PASSWORD 'x'"}, True), # ALTER USER mutates
+ ({"query": "SELECT dropped_at FROM t"}, False), # 'drop' substring column stays safe
+ ({"query": "mutation M @audit { deleteIssue(id: 1) }"}, True), # directive GraphQL mutation
+ (
+ {"query": "query Q @cached { issue(id: 1) { title } }"},
+ False,
+ ), # directive GraphQL read stays safe
+ ({"query": 'UPDATE "users" SET admin=1'}, True), # double-quoted UPDATE target
+ ({"query": "UPDATE public.users SET admin=1"}, True), # schema-qualified UPDATE
+ ({"query": "UPDATE ONLY public.users SET admin=1"}, True), # ONLY-qualified UPDATE
+ ({"query": "UPDATE `users` SET admin=1"}, True), # backtick-quoted UPDATE
+ ({"query": "UPDATE [users] SET admin=1"}, True), # bracket-quoted UPDATE
+ ({"query": "please update the documentation set"}, False), # NL 'update ... set' stays safe
+ ({"query": "SELECT pg_terminate_backend(123)"}, True), # state-changing SQL function
+ ({"query": "SELECT setval('s', 1)"}, True), # sequence mutation function
+ ({"query": "SELECT pg_write_file('/tmp/p', 'x')"}, True), # server-side file write
+ ({"query": "SELECT lo_export(123, '/tmp/p')"}, True), # large-object export to a file
+ ({"query": "SELECT setval_col FROM t"}, False), # 'setval' column prefix stays safe
+ (
+ {"query": "SELECT secret INTO OUTFILE '/tmp/leak' FROM users"},
+ True,
+ ), # INTO OUTFILE write
+ ({"query": "SELECT x INTO DUMPFILE '/tmp/d' FROM t"}, True), # INTO DUMPFILE write
+ (
+ {"query": "SELECT count(*) INTO cnt FROM t"},
+ False,
+ ), # PL/pgSQL SELECT INTO var stays safe
+ ({"query": "REFRESH MATERIALIZED VIEW mv"}, True), # materialized view rewrite
+ ({"query": "REINDEX INDEX idx"}, True), # index rebuild
+ ({"query": "REINDEX TABLE t"}, True), # table reindex
+ ({"query": "SELECT refresh_count FROM t"}, False), # 'refresh' column stays safe
+ ({"query": "please refresh the page"}, False), # NL 'refresh' stays safe
+ ({"query": "COMMENT ON TABLE users IS 'owned'"}, True), # catalog metadata write
+ ({"query": "LOCK TABLE users IN ACCESS EXCLUSIVE MODE"}, True), # explicit lock
+ ({"query": "SECURITY LABEL FOR x ON TABLE t IS 'z'"}, True), # security label write
+ ({"query": "CREATE POLICY p ON accounts USING (true)"}, True), # row-security policy DDL
+ ({"query": "SELECT comment FROM t"}, False), # 'comment' column stays safe
+ ({"query": "SELECT * FROM locks"}, False), # 'locks' table stays safe
+ ({"query": "SELECT nextval('billing_seq')"}, True), # sequence advance mutates
+ ({"query": "SELECT pg_advisory_lock(42)"}, True), # advisory lock changes state
+ ({"query": "SELECT pg_notify('jobs', 'wake')"}, True), # server-side notification
+ ({"query": "SELECT set_config('x', 'y', false)"}, True), # session config write
+ ({"query": "SELECT nextval_col FROM t"}, False), # 'nextval' column prefix stays safe
+ ({"query": "TRUNCATE users"}, True), # multi-char table name (bare TRUNCATE)
+ ({"query": "TRUNCATE TABLE accounts"}, True), # multi-char TRUNCATE TABLE
+ ({"query": 'TRUNCATE TABLE "users"'}, True), # quoted TRUNCATE target
+ ({"query": "TRUNCATE accounts RESTART IDENTITY"}, True), # TRUNCATE with options
+ ({"query": "SELECT truncate_log FROM t"}, False), # 'truncate' column stays safe
+ ({"query": "UPDATE users AS u SET admin=1"}, True), # aliased UPDATE target (AS)
+ ({"query": 'UPDATE "users" AS u SET x=1'}, True), # quoted+aliased UPDATE
+ ({"query": "UPDATE public.users AS u SET x=1"}, True), # schema-qualified aliased UPDATE
+ ({"query": "SELECT * FROM users AS u"}, False), # aliased SELECT stays safe
+ ({"query": "please update the documentation set"}, False), # NL, no AS, stays safe
+ ({"query": "GRANT SELECT ON t TO u"}, True), # privilege grant (multi-word)
+ ({"query": "REVOKE ALL ON t FROM u"}, True), # privilege revoke (multi-word)
+ ({"query": "SELECT * FROM grants"}, False), # 'grants' table stays safe
+ ({"url": "http://x", "method": "DELETE"}, True), # mutating HTTP verb arg
+ ({"method": "POST"}, True),
+ ({"verb": "PUT"}, True), # alternate method-key name
+ ({"method": "GET"}, False), # read HTTP verb stays safe
+ ({"method": "HEAD"}, False),
+ ],
+)
+def test_mcp_mutating_arguments(args, unsafe):
+ name = f"{MCP_TOOL_PREFIX}db__query_database"
+ assert is_potentially_unsafe_tool_call(name, args) is unsafe
+
+
+# ── loop behavior ───────────────────────────────────────────────────
+
+_DEFAULT_TOOLS = [
+ {"type": "function", "function": {"name": "python"}},
+ {"type": "function", "function": {"name": "web_search"}},
+]
+
+
+class _FakeExecuteTool:
+ def __init__(self):
+ self.calls = []
+ self.disable_sandbox_seen = []
+
+ def __call__(
+ self,
+ name,
+ arguments,
+ *,
+ cancel_event = None,
+ timeout = None,
+ session_id = None,
+ thread_id = None,
+ rag_scope = None,
+ disable_sandbox = False,
+ ):
+ self.calls.append((name, arguments))
+ self.disable_sandbox_seen.append(disable_sandbox)
+ return f"RESULT[{name}]"
+
+
+def _tool_call(name, args_json):
+ return f'{{"name": "{name}", "arguments": {args_json}}} '
+
+
+def _multi_turn(turns):
+ turn_iter = iter(turns)
+
+ def _gen(_messages):
+ try:
+ yield next(turn_iter)
+ except StopIteration:
+ return
+
+ return _gen
+
+
+def _drive(turns, decisions, **loop_kwargs):
+ """Run the loop, resolving each gated tool_start with the next decision."""
+ decision_iter = iter(decisions)
+ exec_fn = _FakeExecuteTool()
+ # A per-call session id so a leaked pending approval from another test can
+ # never collide with this run's approval registry entries.
+ session = f"{_SESSION}-{uuid.uuid4().hex}"
+ gen = run_safetensors_tool_loop(
+ single_turn = _multi_turn(turns),
+ messages = [{"role": "user", "content": "hi"}],
+ tools = _DEFAULT_TOOLS,
+ execute_tool = exec_fn,
+ session_id = session,
+ **loop_kwargs,
+ )
+ events = []
+ for ev in gen:
+ events.append(ev)
+ if ev["type"] == "tool_start" and ev.get("awaiting_confirmation"):
+ resolve_tool_decision(ev["approval_id"], next(decision_iter), session_id = session)
+ return events, exec_fn
+
+
+def _tool_starts(events):
+ return [e for e in events if e["type"] == "tool_start"]
+
+
+def _diag(events, exec_fn):
+ """A compact dump of what the loop actually did, attached to the loop-driving
+ assertions so a full-suite-only failure on CI (which does not reproduce when
+ the file runs alone) reports the real event stream instead of a bare diff."""
+ return (
+ f"calls={exec_fn.calls} sandbox_seen={exec_fn.disable_sandbox_seen} "
+ f"events={[(e.get('type'), e.get('awaiting_confirmation'), e.get('tool_name')) for e in events]}"
+ )
+
+
+def test_auto_mode_does_not_gate_safe_calls():
+ events, exec_fn = _drive(
+ [_tool_call("python", '{"code": "print(1)"}'), "final"],
+ [],
+ confirm_tool_calls = True,
+ permission_mode = "auto",
+ )
+ starts = _tool_starts(events)
+ assert starts and starts[0]["awaiting_confirmation"] is False, _diag(events, exec_fn)
+ assert starts[0]["approval_id"] == ""
+ assert exec_fn.calls == [("python", {"code": "print(1)"})], _diag(events, exec_fn)
+ assert exec_fn.disable_sandbox_seen == [False], _diag(
+ events, exec_fn
+ ) # sandbox stays on in auto
+
+
+def test_auto_mode_gates_unsafe_calls():
+ events, exec_fn = _drive(
+ [_tool_call("python", '{"code": "import os; os.remove(\\"x\\")"}'), "final"],
+ ["allow"],
+ confirm_tool_calls = True,
+ permission_mode = "auto",
+ )
+ starts = _tool_starts(events)
+ assert starts and starts[0]["awaiting_confirmation"] is True, _diag(events, exec_fn)
+ assert starts[0]["approval_id"]
+ assert len(exec_fn.calls) == 1, _diag(events, exec_fn)
+ assert exec_fn.disable_sandbox_seen == [False], _diag(events, exec_fn)
+
+
+def test_ask_mode_gates_even_safe_calls():
+ events, _ = _drive(
+ [_tool_call("python", '{"code": "print(1)"}'), "final"],
+ ["allow"],
+ confirm_tool_calls = True,
+ permission_mode = "ask",
+ )
+ starts = _tool_starts(events)
+ assert starts and starts[0]["awaiting_confirmation"] is True
+
+
+def test_unset_mode_behaves_as_ask():
+ events, _ = _drive(
+ [_tool_call("python", '{"code": "print(1)"}'), "final"],
+ ["allow"],
+ confirm_tool_calls = True,
+ )
+ starts = _tool_starts(events)
+ assert starts and starts[0]["awaiting_confirmation"] is True
+
+
+def test_off_mode_never_gates_and_keeps_sandbox():
+ # "Off": no prompts even for unsafe calls, but the sandbox stays on.
+ events, exec_fn = _drive(
+ [_tool_call("python", '{"code": "import os; os.remove(\\"x\\")"}'), "final"],
+ [],
+ confirm_tool_calls = True, # off must win over a stray confirm flag
+ permission_mode = "off",
+ )
+ starts = _tool_starts(events)
+ assert starts and starts[0]["awaiting_confirmation"] is False, _diag(events, exec_fn)
+ assert starts[0]["approval_id"] == ""
+ assert exec_fn.disable_sandbox_seen == [False], _diag(events, exec_fn)
+
+
+def test_full_mode_never_gates_and_drops_sandbox():
+ events, exec_fn = _drive(
+ [_tool_call("python", '{"code": "import os; os.remove(\\"x\\")"}'), "final"],
+ [],
+ confirm_tool_calls = True, # full must win over the confirm gate
+ permission_mode = "full",
+ )
+ starts = _tool_starts(events)
+ assert starts and starts[0]["awaiting_confirmation"] is False, _diag(events, exec_fn)
+ assert exec_fn.disable_sandbox_seen == [True], _diag(events, exec_fn)
+
+
+def test_bypass_flag_implies_full_mode():
+ # Legacy callers that only set bypass_permissions keep the same behavior.
+ events, exec_fn = _drive(
+ [_tool_call("python", '{"code": "print(1)"}'), "final"],
+ [],
+ confirm_tool_calls = True,
+ bypass_permissions = True,
+ )
+ starts = _tool_starts(events)
+ assert starts and starts[0]["awaiting_confirmation"] is False, _diag(events, exec_fn)
+ assert exec_fn.disable_sandbox_seen == [True], _diag(events, exec_fn)
+
+
+def test_bypass_permissions_folds_to_full_on_request_models():
+ # A legacy bypass caller that also sends a stale ask/auto mode normalizes to
+ # full, so the route guards (which reject ask/auto) don't 400 the request.
+ for cls in (ChatCompletionRequest, AnthropicMessagesRequest):
+ req = cls(
+ messages = [{"role": "user", "content": "hi"}],
+ bypass_permissions = True,
+ permission_mode = "auto",
+ )
+ assert req.permission_mode == "full"
+ assert req.bypass_permissions is True
+
+
+def test_unknown_permission_mode_normalizes_to_ask_on_request_models():
+ # An unrecognized mode from a newer UI/client must degrade to the safest gate
+ # ("ask") at the API boundary instead of a 422, so the forward-compat fallback
+ # the tool loops already apply (unknown -> ask) is reachable. None stays unset;
+ # the four known modes pass through untouched.
+ for cls in (ChatCompletionRequest, AnthropicMessagesRequest):
+ for unknown in ("paranoid", "readonly", "bogus", ""):
+ req = cls(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = unknown,
+ )
+ assert req.permission_mode == "ask", (cls.__name__, unknown)
+ assert (
+ cls(messages = [{"role": "user", "content": "hi"}], permission_mode = None).permission_mode
+ is None
+ )
+ for known in ("ask", "auto", "off", "full"):
+ req = cls(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = known,
+ )
+ # 'full' folds to bypass but the mode string is preserved.
+ assert req.permission_mode == known, (cls.__name__, known)
+
+
+def test_ask_auto_self_enable_confirm_on_chat_request():
+ # "Ask" gates every call, so a direct /chat/completions caller that requests
+ # ask but omits the legacy confirm flag self-enables it when Studio's own tool
+ # loop is requested. Only the router's loop-entry signals count (enable_tools /
+ # mcp_enabled); enabled_tools alone never starts the loop.
+ for loop in ({"enable_tools": True}, {"mcp_enabled": True}):
+ req = ChatCompletionRequest(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = "ask",
+ **loop,
+ )
+ assert req.confirm_tool_calls is True
+ # "auto" is NOT folded: it only prompts for a classifier-flagged call, so
+ # leaving confirm unset lets the route apply the safe-only-selection exception
+ # (a safe-only auto request needs no stream) instead of an explicit confirm
+ # forcing stream=true. The mode still drives the loop's per-call gate.
+ for loop in ({"enable_tools": True}, {"mcp_enabled": True}):
+ req = ChatCompletionRequest(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = "auto",
+ **loop,
+ )
+ assert req.confirm_tool_calls is None
+ # enabled_tools by itself is a passthrough filter, not a loop-entry signal:
+ # a client-tool passthrough that also lists enabled_tools must route verbatim
+ # (confirm stays unset), else the confirm-without-stream guard 400s it.
+ for mode in ("ask", "auto"):
+ req = ChatCompletionRequest(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = mode,
+ enabled_tools = ["terminal"],
+ tools = [{"type": "function", "function": {"name": "f"}}],
+ )
+ assert req.confirm_tool_calls is None
+ # An explicit confirm_tool_calls=False wins over the ask mode (opts out of the
+ # gate), matching _permission_mode_confirm and the Anthropic pre-switch guard;
+ # the fold only self-enables when the flag is unset, so a caller cannot get a
+ # different answer on the chat path than the Anthropic path for the same body.
+ req = ChatCompletionRequest(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = "ask",
+ enable_tools = True,
+ confirm_tool_calls = False,
+ )
+ assert req.confirm_tool_calls is False
+ # A plain client-tool passthrough (client-supplied tools that Studio does not
+ # execute) must NOT self-enable confirm, or the route rejects the passthrough.
+ req = ChatCompletionRequest(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = "ask",
+ tools = [{"type": "function", "function": {"name": "f"}}],
+ )
+ assert req.confirm_tool_calls is None
+ # ask/auto without any tool request has nothing to gate; confirm stays unset.
+ req = ChatCompletionRequest(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = "ask",
+ )
+ assert req.confirm_tool_calls is None
+ # Legacy callers with no permission_mode keep their confirm flag untouched.
+ req = ChatCompletionRequest(
+ messages = [{"role": "user", "content": "hi"}],
+ confirm_tool_calls = False,
+ )
+ assert req.confirm_tool_calls is False
+ # External-provider requests are not folded (the provider branch rejects
+ # confirm_tool_calls with tools, and permission_mode is a local concept).
+ for extra in ({"provider_id": "p1"}, {"provider_type": "openai"}):
+ req = ChatCompletionRequest(
+ messages = [{"role": "user", "content": "hi"}],
+ permission_mode = "ask",
+ enable_tools = True,
+ **extra,
+ )
+ assert req.confirm_tool_calls is None
+
+
+def test_permission_mode_confirm_derivation():
+ # The route derives the effective confirm gate from permission_mode so that a
+ # tool loop forced on by CLI policy (no request-level tool flag) still honors
+ # the documented "unset behaves as ask" default.
+ from routes.inference import _permission_mode_confirm
+
+ def req(**kw):
+ return ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}], **kw)
+
+ # An explicit confirm flag always wins (True gates, False opts out).
+ assert _permission_mode_confirm(req(confirm_tool_calls = True, stream = False)) is True
+ assert _permission_mode_confirm(req(confirm_tool_calls = False, permission_mode = "ask")) is False
+ # Explicit ask/auto always engage the gate (a non-streaming one is rejected
+ # by the guard that reads this).
+ assert _permission_mode_confirm(req(permission_mode = "ask", stream = False)) is True
+ assert _permission_mode_confirm(req(permission_mode = "auto", stream = False)) is True
+ # off/full never prompt.
+ assert _permission_mode_confirm(req(permission_mode = "off")) is False
+ assert _permission_mode_confirm(req(permission_mode = "full")) is False
+ # An unset mode defaults to ask, but only realizably on a streaming request;
+ # a non-streaming unset request keeps the legacy run-without-gate behavior.
+ assert _permission_mode_confirm(req(stream = True)) is True
+ assert _permission_mode_confirm(req(stream = False)) is False
+
+
+def test_confirm_gate_needs_stream():
+ # auto only prompts for a classifier-flagged call, so an auto request that can
+ # only select always-safe tools (web_search / RAG) needs no stream and must not
+ # be rejected by the confirm-without-stream guard.
+ from routes.inference import _confirm_gate_needs_stream
+
+ def req(**kw):
+ return ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}], **kw)
+
+ safe = ["web_search", "search_knowledge_base"]
+ # auto + a safe-only selection never prompts -> no stream needed.
+ assert _confirm_gate_needs_stream(req(permission_mode = "auto", enabled_tools = safe)) is False
+ assert (
+ _confirm_gate_needs_stream(req(permission_mode = "auto", enabled_tools = ["web_search"]))
+ is False
+ )
+ # render_html can prompt when its canvas reaches the network, so a selection
+ # that includes it needs a stream to deliver that prompt.
+ assert (
+ _confirm_gate_needs_stream(
+ req(permission_mode = "auto", enabled_tools = ["web_search", "render_html"])
+ )
+ is True
+ )
+ # But a selectable unsafe tool, an unrestricted (omitted) selection, MCP, or an
+ # explicit confirm flag all still require streaming under auto.
+ assert (
+ _confirm_gate_needs_stream(req(permission_mode = "auto", enabled_tools = ["terminal"])) is True
+ )
+ assert _confirm_gate_needs_stream(req(permission_mode = "auto", enable_tools = True)) is True
+ assert (
+ _confirm_gate_needs_stream(
+ req(permission_mode = "auto", enabled_tools = ["web_search"], mcp_enabled = True)
+ )
+ is True
+ )
+ assert (
+ _confirm_gate_needs_stream(
+ req(permission_mode = "auto", enabled_tools = ["web_search"], confirm_tool_calls = True)
+ )
+ is True
+ )
+ # An explicit empty selection runs no built-in tool, so nothing can prompt and
+ # no stream is needed (distinct from an omitted list, which means all tools).
+ assert (
+ _confirm_gate_needs_stream(req(permission_mode = "auto", enable_tools = True, enabled_tools = []))
+ is False
+ )
+ # ask prompts for every call, so even a safe-only selection needs streaming.
+ assert _confirm_gate_needs_stream(req(permission_mode = "ask", enabled_tools = safe)) is True
+ # off/full never prompt; unset non-streaming keeps the legacy run-without-gate.
+ assert _confirm_gate_needs_stream(req(permission_mode = "off", enabled_tools = safe)) is False
+ assert _confirm_gate_needs_stream(req(permission_mode = "full", enabled_tools = safe)) is False
+ assert _confirm_gate_needs_stream(req(enabled_tools = safe, stream = False)) is False
diff --git a/studio/backend/tests/test_personalization_settings.py b/studio/backend/tests/test_personalization_settings.py
index c80bfa196b..7b5e70decc 100644
--- a/studio/backend/tests/test_personalization_settings.py
+++ b/studio/backend/tests/test_personalization_settings.py
@@ -16,15 +16,21 @@ if str(_BACKEND) not in sys.path:
import utils.personalization_settings as pers # noqa: E402
from auth.authentication import get_current_subject # noqa: E402
from routes import settings as settings_routes # noqa: E402
-from routes.settings import PersonalizationPayload # noqa: E402
+from routes.settings import ( # noqa: E402
+ MAX_SIDEBAR_MENU_INPUT_ITEMS,
+ PersonalizationPayload,
+ SIDEBAR_MENU_ITEM_DEFAULTS,
+)
def test_defaults_fill_missing_fields():
p = PersonalizationPayload.model_validate({})
assert p.version == pers.PERSONALIZATION_VERSION
assert p.appearance.theme == "system"
+ assert p.appearance.palette == "standard"
assert p.profile.avatarShape == "circle"
assert p.profile.displayName == ""
+ assert p.profile.showGreetingSloth is True
def test_unknown_keys_are_ignored():
@@ -39,6 +45,213 @@ def test_invalid_theme_rejected():
PersonalizationPayload.model_validate({"appearance": {"theme": "neon"}})
+def test_invalid_palette_rejected():
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate({"appearance": {"palette": "neon"}})
+
+
+def test_customization_defaults():
+ p = PersonalizationPayload.model_validate({})
+ c = p.appearance.customization
+ assert c.contrast == 50
+ assert c.reduceMotion == "system"
+ assert c.fontSmoothing is True
+ assert c.pointerCursors is False
+ assert c.colors.light.accent is None
+ assert c.headingFont is None
+ assert c.chatFont is None
+ assert c.uiFontSize is None
+ assert [(i.id, i.visible) for i in c.sidebarMenu] == [
+ ("api", True),
+ ("darkMode", True),
+ ("guidedTour", True),
+ ("profile", False),
+ ("appearance", False),
+ ("resources", False),
+ ("chat", False),
+ ("connections", False),
+ ]
+
+
+def test_customization_invalid_values_rejected():
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate(
+ {"appearance": {"customization": {"colors": {"light": {"accent": "red"}}}}}
+ )
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate({"appearance": {"customization": {"uiFontSize": 99}}})
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate({"appearance": {"customization": {"contrast": 500}}})
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate(
+ {"appearance": {"customization": {"reduceMotion": "sometimes"}}}
+ )
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate(
+ {"appearance": {"customization": {"sidebarMenu": [{"id": "chats"}]}}}
+ )
+
+
+def test_customization_sidebar_menu_normalized():
+ p = PersonalizationPayload.model_validate(
+ {
+ "appearance": {
+ "customization": {
+ "sidebarMenu": [
+ {"id": "guidedTour", "visible": False},
+ {"id": "guidedTour", "visible": True},
+ {"id": "api"},
+ ]
+ }
+ }
+ }
+ )
+ # Duplicates keep the first entry; missing ids are appended with their
+ # default visibility.
+ assert [(i.id, i.visible) for i in p.appearance.customization.sidebarMenu] == [
+ ("guidedTour", False),
+ ("api", True),
+ ("darkMode", True),
+ ("profile", False),
+ ("appearance", False),
+ ("resources", False),
+ ("chat", False),
+ ("connections", False),
+ ]
+
+
+def _sidebar(items):
+ return {"appearance": {"customization": {"sidebarMenu": items}}}
+
+
+def test_customization_sidebar_menu_dedupes_oversized_payload():
+ # A stale/duplicated payload carries more items than there are distinct ids.
+ # It must reach the dedupe validator and normalize to exactly one entry per
+ # id, not be rejected by the length cap before dedupe runs.
+ ids = list(SIDEBAR_MENU_ITEM_DEFAULTS)
+ doubled = [{"id": i} for i in ids] + [{"id": i} for i in ids]
+ assert len(doubled) > len(SIDEBAR_MENU_ITEM_DEFAULTS)
+ p = PersonalizationPayload.model_validate(_sidebar(doubled))
+ result = [i.id for i in p.appearance.customization.sidebarMenu]
+ assert result == ids
+ assert len(result) == len(SIDEBAR_MENU_ITEM_DEFAULTS)
+
+
+def test_customization_sidebar_menu_rejects_pathological_length():
+ # The generous input cap still refuses an absurdly long list outright.
+ huge = [{"id": "api"} for _ in range(MAX_SIDEBAR_MENU_INPUT_ITEMS + 1)]
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate(_sidebar(huge))
+
+
+def test_customization_imported_fonts_validated():
+ ok = PersonalizationPayload.model_validate(
+ {
+ "appearance": {
+ "customization": {
+ "importedFonts": [{"name": "My Font", "dataUrl": "data:font/woff2;base64,AAAA"}]
+ }
+ }
+ }
+ )
+ assert ok.appearance.customization.importedFonts[0].name == "My Font"
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate(
+ {
+ "appearance": {
+ "customization": {
+ "importedFonts": [
+ {"name": "Evil", "dataUrl": "https://example.com/font.woff2"}
+ ]
+ }
+ }
+ }
+ )
+ with pytest.raises(ValidationError):
+ PersonalizationPayload.model_validate(
+ {
+ "appearance": {
+ "customization": {
+ "importedFonts": [
+ {"name": f"Font {i}", "dataUrl": "data:font/ttf;base64,AAAA"}
+ for i in range(4)
+ ]
+ }
+ }
+ }
+ )
+
+
+def _imported(fonts):
+ return {"appearance": {"customization": {"importedFonts": fonts}}}
+
+
+def test_imported_font_name_rejects_css_characters():
+ # Includes backslash (escapes the quoted family), comma/slash (extra
+ # fallbacks / comment start), and a control character.
+ for bad in ['Ev"il', "Ev;il", "Ev{il", "Ev as bare "[" on transformers 5.x while the standalone
+ encoding gives "▁[", so the marker must anchor on .
+ starling - trailing space after "GPT4 Correct Assistant:" folds
+ into the next content token ("▁Hello").
+ glm - "[gMASK]" renders once at text start, never before
+ later user turns; "" is generation scaffolding
+ that non-final turns render as a lone " ".
+ qwen3-thinking - "" is stripped from non-final assistant turns
+ (Qwen3-Thinking-2507) or never rendered (QwQ).
+ zephyr - role tags are plain text, and SentencePiece tokenizes
+ "<|assistant|>" differently at text start than after
+ " \\n" mid-conversation; the markers need the leading
+ newline anchor to tokenize like a real turn boundary.
+
+Literal assertions run everywhere; the token-level masking checks need the
+representative tokenizers plus unsloth_zoo and skip when either is
+unavailable (offline CI).
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+import pytest
+
+_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
+if _BACKEND_DIR not in sys.path:
+ sys.path.insert(0, _BACKEND_DIR)
+
+# model_mappings is dependency-free: load it directly so these tests run
+# without the studio venv / package import side effects.
+_MM_PATH = Path(_BACKEND_DIR) / "utils" / "datasets" / "model_mappings.py"
+_mm_spec = importlib.util.spec_from_file_location("_marker_test_mm", _MM_PATH)
+model_mappings = importlib.util.module_from_spec(_mm_spec)
+_mm_spec.loader.exec_module(model_mappings)
+
+T2R = model_mappings.TEMPLATE_TO_RESPONSES_MAPPER
+
+
+# ── Fixed entries: markers derived from what each representative tokenizer
+# actually renders (see PR for the token-level derivation). ──
+EXPECTED_FIXED = {
+ "mistral": {"instruction": "[INST]", "response": "[/INST]"},
+ "llama": {"instruction": "[INST]", "response": "[/INST]"},
+ "starling": {"instruction": "GPT4 Correct User:", "response": "GPT4 Correct Assistant:"},
+ "glm": {"instruction": "<|user|>", "response": "<|assistant|>"},
+ "qwen3-thinking": {"instruction": "<|im_start|>user\n", "response": "<|im_start|>assistant\n"},
+ "zephyr": {"instruction": "\n<|user|>\n", "response": "\n<|assistant|>\n"},
+}
+
+# Spot-pin some known-good entries so a refactor cannot silently change them.
+EXPECTED_UNCHANGED = {
+ "qwen3": {"instruction": "<|im_start|>user\n", "response": "<|im_start|>assistant\n"},
+ "llama-3.1": {
+ "instruction": "<|start_header_id|>user<|end_header_id|>\n\n",
+ "response": "<|start_header_id|>assistant<|end_header_id|>\n\n",
+ },
+ "phi-4": {
+ "instruction": "<|im_start|>user<|im_sep|>",
+ "response": "<|im_start|>assistant<|im_sep|>",
+ },
+ "gemma-3": {"instruction": "user\n", "response": "model\n"},
+ "gpt-oss": {
+ "instruction": "<|start|>user<|message|>",
+ "response": "<|start|>assistant<|channel|>final<|message|>",
+ },
+}
+
+
+@pytest.mark.parametrize("template", sorted(EXPECTED_FIXED))
+def test_fixed_marker_literals(template):
+ assert T2R[template] == EXPECTED_FIXED[template]
+
+
+@pytest.mark.parametrize("template", sorted(EXPECTED_UNCHANGED))
+def test_unchanged_marker_literals(template):
+ assert T2R[template] == EXPECTED_UNCHANGED[template]
+
+
+def test_no_marker_is_empty_or_whitespace():
+ for template, parts in T2R.items():
+ assert parts["instruction"].strip(), template
+ assert parts["response"].strip(), template
+
+
+# ── Token-level checks: markers must select exactly the assistant turns on a
+# rendered two-turn fixture, and the final EOS label must never be -100. ──
+
+REPRESENTATIVES = {
+ "mistral": ["unsloth/mistral-7b-instruct-v0.3"],
+ "llama": ["unsloth/llama-2-7b-chat"],
+ "starling": ["unsloth/Starling-LM-7B-beta"],
+ "glm": ["unsloth/GLM-4.7-Flash"],
+ "qwen3-thinking": ["unsloth/Qwen3-4B-Thinking-2507", "Qwen/QwQ-32B"],
+ "zephyr": ["unsloth/zephyr-sft"],
+}
+
+FIXTURE = [
+ {"role": "user", "content": "zebra alpha question one?"},
+ {"role": "assistant", "content": "grape reply number one."},
+ {"role": "user", "content": "zebra beta question two?"},
+ {"role": "assistant", "content": "grape reply number two."},
+]
+
+
+def _load_tokenizer(repo):
+ try:
+ from transformers import AutoTokenizer
+ except Exception as e: # pragma: no cover
+ pytest.skip(f"transformers unavailable: {e}")
+ try:
+ return AutoTokenizer.from_pretrained(repo)
+ except OSError as e:
+ pytest.skip(f"tokenizer {repo} unavailable (offline?): {e}")
+ except Exception:
+ # Tokenizer class newer than this transformers (e.g. GLM-4.7's
+ # TokenizersBackend): build directly from tokenizer.json.
+ try:
+ import json as _json
+ from huggingface_hub import hf_hub_download
+ from transformers import PreTrainedTokenizerFast
+
+ with open(hf_hub_download(repo, "tokenizer_config.json"), encoding = "utf-8") as f:
+ cfg = _json.load(f)
+ tok_file = hf_hub_download(repo, "tokenizer.json")
+
+ def _tokval(v):
+ return v["content"] if isinstance(v, dict) else v
+
+ return PreTrainedTokenizerFast(
+ tokenizer_file = tok_file,
+ chat_template = cfg.get("chat_template"),
+ **{
+ k: _tokval(cfg[k])
+ for k in ("bos_token", "eos_token", "pad_token", "unk_token")
+ if cfg.get(k) is not None
+ },
+ )
+ except Exception as e:
+ pytest.skip(f"tokenizer {repo} unavailable (offline?): {e}")
+
+
+def _train_on_responses_only():
+ try:
+ from unsloth_zoo.dataset_utils import train_on_responses_only
+ except Exception as e:
+ pytest.skip(f"unsloth_zoo unavailable: {e}")
+ return train_on_responses_only
+
+
+@pytest.mark.parametrize(
+ "template,repo",
+ [(t, r) for t, repos in sorted(REPRESENTATIVES.items()) for r in repos],
+)
+def test_fixed_markers_token_level(template, repo):
+ tor = _train_on_responses_only()
+ tok = _load_tokenizer(repo)
+ parts = T2R[template]
+
+ msgs = [{"role": "system", "content": "You are a terse assistant."}] + FIXTURE
+ try:
+ ids = tok.apply_chat_template(msgs, tokenize = True, add_generation_prompt = False)
+ if hasattr(ids, "keys"):
+ ids = ids["input_ids"] # transformers 5.x returns a BatchEncoding
+ except Exception:
+ ids = tok.apply_chat_template(FIXTURE, tokenize = True, add_generation_prompt = False)
+ if hasattr(ids, "keys"):
+ ids = ids["input_ids"]
+
+ fn = tor(
+ None,
+ instruction_part = parts["instruction"],
+ response_part = parts["response"],
+ tokenizer = tok,
+ return_function = True,
+ )
+ labels = fn({"input_ids": [list(ids)]})["labels"][0]
+
+ n = len(ids)
+ trained = tok.decode([ids[i] for i in range(n) if labels[i] != -100])
+ masked = tok.decode([ids[i] for i in range(n) if labels[i] == -100])
+
+ # User and system content fully masked
+ assert "question one" not in trained and "question one" in masked
+ assert "question two" not in trained and "question two" in masked
+ assert "terse assistant" not in trained
+ # EVERY assistant turn trained, not just the last
+ assert "reply number one" in trained
+ assert "reply number two" in trained
+ # The final EOS (last non-whitespace token) must never be -100, or the
+ # fine-tuned model never learns to stop generating.
+ i = n - 1
+ while i > 0 and tok.decode([ids[i]]).strip() == "":
+ i -= 1
+ assert labels[i] != -100, f"final token {tok.convert_ids_to_tokens(int(ids[i]))!r} is masked"
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-v"]))
diff --git a/studio/backend/tests/test_safetensors_tool_loop.py b/studio/backend/tests/test_safetensors_tool_loop.py
index a8546b82c4..eae1a75161 100644
--- a/studio/backend/tests/test_safetensors_tool_loop.py
+++ b/studio/backend/tests/test_safetensors_tool_loop.py
@@ -1179,6 +1179,7 @@ class FakeExecuteTool:
cancel_event = None,
timeout = None,
session_id = None,
+ thread_id = None,
rag_scope = None,
disable_sandbox = False,
):
@@ -2591,6 +2592,50 @@ class TestLoopBasic:
assert tool_starts[0]["arguments"] == {}
assert "" in tool_starts[1]["arguments"]["code"]
+ def test_render_html_auto_mode_static_runs_without_prompt(self):
+ """permission_mode="auto" ships confirm_tool_calls=true. render_html is no
+ longer unconditionally safe (a networked canvas must ask), so its early
+ provisional card is suppressed under the confirm gate; a static canvas is
+ still classified safe and runs without an approval prompt."""
+ exec_fn = FakeExecuteTool(["Rendered HTML canvas."])
+ turn_iter = iter(
+ [
+ [
+ "",
+ "",
+ "Hi ",
+ ],
+ ["Done."],
+ ]
+ )
+
+ def _gen(_messages):
+ chunks = next(turn_iter)
+ acc = ""
+ for chunk in chunks:
+ acc += chunk
+ yield acc
+
+ loop = run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "make html"}],
+ tools = [{"type": "function", "function": {"name": "render_html"}}],
+ execute_tool = exec_fn,
+ confirm_tool_calls = True,
+ permission_mode = "auto",
+ session_id = "sess",
+ max_tool_iterations = 3,
+ )
+ events = _collect_events(loop)
+ tool_starts = [e for e in events if e["type"] == "tool_start"]
+
+ # No early provisional card under the auto confirm gate; just the real call.
+ assert len(tool_starts) == 1
+ assert tool_starts[0]["tool_name"] == "render_html"
+ assert "" in tool_starts[0]["arguments"]["code"]
+ # A static canvas is classified safe, so it runs without an approval gate.
+ assert tool_starts[0].get("awaiting_confirmation") in (False, None)
+
def test_render_html_provisional_card_closed_on_generator_exception(self):
"""If the model generator raises mid-stream after a provisional render_html
card was surfaced, the loop must close that card as errored before the
@@ -3673,6 +3718,26 @@ class TestGuardrails:
assert any(e.get("type") == "content" and e.get("text") == "plain answer" for e in events)
assert exec_fn.calls == []
+ def test_auto_mode_still_runs_rag_autoinject(self, monkeypatch):
+ # "auto" sends confirm_tool_calls=true so unsafe calls gate, but the
+ # safe search_knowledge_base retrieval never gates, so autoinject must
+ # still run (unlike ask mode above).
+ ran = {"called": False}
+
+ def fake_autoinject(*_args, **_kwargs):
+ ran["called"] = True
+ return None
+
+ monkeypatch.setattr("core.inference.tools.build_rag_autoinject", fake_autoinject)
+ loop, _exec_fn = _make_loop(
+ turns = [["plain answer"]],
+ confirm_tool_calls = True,
+ permission_mode = "auto",
+ rag_scope = {"thread_id": "t1"},
+ )
+ _collect_events(loop)
+ assert ran["called"] is True
+
def test_auto_heal_disabled_preserves_xml_on_final_no_tools_pass(self):
turns = iter(
[
diff --git a/studio/backend/tests/test_safetensors_toolcall_wiring.py b/studio/backend/tests/test_safetensors_toolcall_wiring.py
index 5c298a7966..8909e0c0c5 100644
--- a/studio/backend/tests/test_safetensors_toolcall_wiring.py
+++ b/studio/backend/tests/test_safetensors_toolcall_wiring.py
@@ -91,6 +91,7 @@ class StubExecutor:
cancel_event = None,
timeout = None,
session_id = None,
+ thread_id = None,
rag_scope = None,
disable_sandbox = False,
):
diff --git a/studio/backend/tests/test_sandbox_sitecustomize.py b/studio/backend/tests/test_sandbox_sitecustomize.py
new file mode 100644
index 0000000000..3ac427f9f1
--- /dev/null
+++ b/studio/backend/tests/test_sandbox_sitecustomize.py
@@ -0,0 +1,522 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Hermetic tests for the sandbox sitecustomize path-remap shim.
+
+The shim (``core/inference/sandbox_site/sitecustomize.py``) runs at interpreter
+startup inside every sandboxed tool subprocess and remaps ChatGPT
+code-interpreter habit paths (``/mnt/data`` etc.) onto the per-conversation
+working directory. Importing it calls ``_install()``, which monkeypatches
+``builtins.open`` / ``io.open`` / ``os.makedirs`` / ``os.mkdir`` /
+``pathlib.Path.mkdir`` process-wide, so these tests
+load it into a throwaway module and restore those globals immediately, then
+exercise the pure ``_remap()`` function directly -- no subprocess, and no real
+``/mnt`` or ``/tmp`` writes. The mkdir test keeps the patch installed under a
+``chdir`` into ``tmp_path`` so the only real writes land in that temp dir.
+"""
+
+from __future__ import annotations
+
+import builtins
+import importlib.util
+import io
+import os
+import pathlib
+from pathlib import Path
+
+import pytest
+
+_SHIM = (
+ Path(__file__).resolve().parent.parent
+ / "core"
+ / "inference"
+ / "sandbox_site"
+ / "sitecustomize.py"
+)
+
+
+def _save_patch_targets():
+ """Snapshot every global the shim patches, so tests can restore them.
+
+ On Python < 3.11 the shim also repoints ``pathlib._NormalAccessor.open``
+ (pathlib captured the original io.open at import there); the accessor is
+ absent on 3.11+, so the snapshot skips it.
+ """
+ accessor = getattr(pathlib, "_NormalAccessor", None)
+ return (
+ (builtins.open, io.open, os.open, os.makedirs, os.mkdir, pathlib.Path.mkdir),
+ accessor,
+ accessor.open if accessor is not None else None,
+ )
+
+
+def _restore_patch_targets(saved):
+ """Undo _save_patch_targets so the test process stays clean."""
+ globals_tuple, accessor, accessor_open = saved
+ (builtins.open, io.open, os.open, os.makedirs, os.mkdir, pathlib.Path.mkdir) = globals_tuple
+ if accessor is not None:
+ accessor.open = accessor_open
+
+
+def _load_shim():
+ """Import the shim without leaving its open()/mkdir patches installed."""
+ saved = _save_patch_targets()
+ spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_under_test", _SHIM)
+ mod = importlib.util.module_from_spec(spec)
+ try:
+ spec.loader.exec_module(mod) # runs _install(), patching the globals
+ finally:
+ # Undo the process-wide patch so the test process stays clean.
+ _restore_patch_targets(saved)
+ mod._notified = True # silence the one-shot stderr notice in tests
+ return mod
+
+
+def test_always_remap_prefixes_map_into_cwd(monkeypatch, tmp_path):
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+ assert mod._remap("/mnt/data/out.txt") == os.path.join(cwd, "out.txt")
+ assert mod._remap("/mnt/data") == cwd
+ # Unrelated absolute and relative paths pass straight through.
+ assert mod._remap("/etc/passwd") == "/etc/passwd"
+ assert mod._remap("relative.txt") == "relative.txt"
+
+
+def test_prefix_remap_contains_parent_traversal_inside_cwd(monkeypatch, tmp_path):
+ # A hallucinated habit path can carry '..' in its suffix. The remapped target
+ # must stay under the per-conversation CWD, never climbing into a sibling
+ # session's directory: '..' components are dropped, the rest of the subpath kept.
+ mod = _load_shim()
+ workdir = tmp_path / "session_current" / "work"
+ workdir.mkdir(parents = True)
+ monkeypatch.chdir(workdir)
+ cwd = os.getcwd()
+
+ for escaping in (
+ "/mnt/data/../other_session/file",
+ "/mnt/data/../../secrets.txt",
+ "/mnt/data/a/../../b/c.txt",
+ "/mnt/data/./sub/./x.txt",
+ ):
+ mapped = mod._remap(escaping)
+ # Never escapes the CWD subtree.
+ assert mapped == cwd or mapped.startswith(cwd + os.sep), (escaping, mapped)
+ assert os.path.realpath(mapped).startswith(os.path.realpath(cwd))
+ # '../other_session/file' collapses to CWD/other_session/file.
+ assert mod._remap("/mnt/data/../other_session/file") == os.path.join(
+ cwd, "other_session", "file"
+ )
+ # A bare '/mnt/data/..' with nothing left maps onto the CWD itself.
+ assert mod._remap("/mnt/data/..") == cwd
+
+
+def test_write_fallback_refuses_dotdot_basename(monkeypatch, tmp_path):
+ # basename('/no/such/tree/..') == '..'; joining that onto the CWD would target
+ # its parent (outside the sandbox). The fallback must refuse such non-filename
+ # basenames and return the path unchanged so the real open raises.
+ mod = _load_shim()
+ workdir = tmp_path / "work"
+ workdir.mkdir()
+ monkeypatch.chdir(workdir)
+ for escaping in ("/no/such/tree/..", "/no/such/tree/.", "/no/such/tree/"):
+ assert mod._remap_open(escaping, "w") == escaping
+
+
+def test_write_fallback_remaps_hallucinated_absolute_path(monkeypatch, tmp_path):
+ # Models invent absolute paths from their CWD (e.g. /home/ubuntu/Sandbox/x.html),
+ # which prefix lists cannot enumerate. A write/create-mode open on an absolute
+ # path outside the CWD whose parent is missing is redirected to the basename in the CWD.
+ mod = _load_shim()
+ workdir = tmp_path / "workdir"
+ workdir.mkdir()
+ monkeypatch.chdir(workdir)
+ cwd = os.getcwd()
+ hallucinated = "/home/ubuntu/Sandbox/flappy_bird.html"
+ for mode in ("w", "a", "x", "w+"):
+ assert mod._remap_open(hallucinated, mode) == os.path.join(cwd, "flappy_bird.html")
+ # A nested missing tree collapses to just the basename in the CWD.
+ assert mod._remap_open("/no/such/tree/report.txt", "w") == os.path.join(cwd, "report.txt")
+
+
+def test_write_fallback_never_touches_read_modes(monkeypatch, tmp_path):
+ # Reading a real (or genuinely missing) file must succeed/fail truthfully --
+ # the fallback is write-only.
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ for mode in ("r", "rb", "r+"):
+ assert mod._remap_open("/etc/definitely_missing_xyz.conf", mode) == (
+ "/etc/definitely_missing_xyz.conf"
+ )
+
+
+def test_write_fallback_passes_through_existing_external_dir(monkeypatch, tmp_path):
+ # A write to an absolute path whose parent dir exists is a deliberate, working
+ # target and must NOT be redirected.
+ mod = _load_shim()
+ external = tmp_path / "external"
+ external.mkdir()
+ workdir = tmp_path / "workdir"
+ workdir.mkdir()
+ monkeypatch.chdir(workdir)
+ target = str(external / "out.txt")
+ assert mod._remap_open(target, "w") is target
+
+
+def test_write_fallback_never_clobbers_same_basename(monkeypatch, tmp_path):
+ # A same-named CWD file is an unrelated persistent conversation file.
+ # Redirecting an invented absolute path (missing parent) onto it would clobber
+ # data the model never asked to touch, so the fallback refuses on collision for
+ # every create mode: it returns the original path and the real open() raises.
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+
+ existing = tmp_path / "report.txt"
+ existing.write_text("KEEP-ME")
+
+ requested = "/definitely_missing_parent_7083/report.txt"
+ for mode in ("w", "a", "x", "w+", "a+"):
+ # Refused: returns the original absolute path unchanged (no redirect).
+ assert mod._remap_open(requested, mode) == requested
+
+ # And opening the refused path really does raise, leaving the file intact.
+ with pytest.raises(FileNotFoundError):
+ open(mod._remap_open(requested, "w"), "w")
+ assert existing.read_text() == "KEEP-ME"
+
+ # No collision -> still healed into the working directory as before.
+ fresh = "/definitely_missing_parent_7083/brand_new.txt"
+ assert mod._remap_open(fresh, "w") == os.path.join(os.getcwd(), "brand_new.txt")
+
+
+def test_write_fallback_reserves_same_target_on_repeated_writes(monkeypatch, tmp_path):
+ # Iterative overwrite of the SAME invented path must keep landing on the CWD
+ # target the fallback first healed it to. Once ./app.html exists, a naive
+ # anti-clobber guard would return the original (parent-missing) path and every
+ # regenerate would raise; the fallback must recognise its own prior remap and re-serve it.
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+ invented = "/home/ubuntu/Sandbox/app.html"
+ target = os.path.join(cwd, "app.html")
+
+ # First write: healed into the CWD, and create the file so the collision guard
+ # would trigger on the next call without the fix.
+ assert mod._remap_open(invented, "w") == target
+ with open(mod._remap_open(invented, "w"), "w") as fh:
+ fh.write("v1")
+
+ # Repeated overwrites of the same invented path stay on the same target.
+ for _ in range(3):
+ assert mod._remap_open(invented, "w") == target
+ with open(mod._remap_open(invented, "w"), "w") as fh:
+ fh.write("v2")
+ assert Path(target).read_text() == "v2"
+
+ # A DIFFERENT invented source colliding on basename is still refused, so it can
+ # never clobber the artifact the first path owns.
+ other = "/opt/other/app.html"
+ assert mod._remap_open(other, "w") == other
+
+
+def test_write_fallback_reserves_healed_target_across_separate_runs(monkeypatch, tmp_path):
+ # Each tool call is a FRESH subprocess, so the in-process remap map is empty on
+ # the next run while the healed file persists in the working directory. A second
+ # run overwriting the SAME invented path (whose healed basename now exists) must
+ # still re-serve that target via the on-disk sidecar, else the model could never
+ # overwrite last turn's artifact. Each _load_shim() simulates a brand-new interpreter.
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+ invented = "/home/ubuntu/Sandbox/app.html"
+ target = os.path.join(cwd, "app.html")
+
+ # Run 1: heal the invented path, create the file, persist source->target to the sidecar.
+ run1 = _load_shim()
+ assert run1._remap_open(invented, "w") == target
+ with open(run1._remap_open(invented, "w"), "w") as fh:
+ fh.write("v1")
+
+ # Run 2: brand-new interpreter, nothing in memory -- still recognises its prior
+ # heal from the sidecar and re-serves it, even though ./app.html now exists
+ # (which without the sidecar would trip the anti-clobber guard and raise).
+ run2 = _load_shim()
+ assert run2._remapped_writes == {}
+ assert run2._remap_open(invented, "w") == target
+ with open(run2._remap_open(invented, "w"), "w") as fh:
+ fh.write("v2")
+ assert Path(target).read_text() == "v2"
+
+ # A DIFFERENT invented source colliding only on basename is still refused across
+ # runs: the sidecar records solely the source it healed, so an unrelated path
+ # can never adopt/clobber the artifact.
+ other = "/opt/other/app.html"
+ assert run2._remap_open(other, "w") == other
+
+ # A foreign CWD file (created directly, never healed) stays protected in a later
+ # run from an invented path sharing its basename.
+ (tmp_path / "notes.txt").write_text("KEEP-ME")
+ run3 = _load_shim()
+ assert run3._remap_open("/some/missing/notes.txt", "w") == "/some/missing/notes.txt"
+ with pytest.raises(FileNotFoundError):
+ open(run3._remap_open("/some/missing/notes.txt", "w"), "w")
+ assert (tmp_path / "notes.txt").read_text() == "KEEP-ME"
+
+
+@pytest.mark.parametrize("mode", ["r+", "rb+"])
+def test_read_update_modes_never_redirected_even_with_missing_parent(monkeypatch, tmp_path, mode):
+ # r+ / rb+ REQUIRE the target to exist and never create; a "+" must not qualify
+ # as creation, or a missing absolute path would be redirected onto a same-basename
+ # workspace file and corrupt it. The parent is missing, so only the mode predicate
+ # protects the victim.
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+
+ victim = tmp_path / "victim.txt"
+ victim.write_text("original")
+
+ requested = "/definitely_missing_parent_xyz/victim.txt"
+ assert mod._remap_open(requested, mode) == requested
+ with pytest.raises(FileNotFoundError):
+ open(mod._remap_open(requested, mode), mode)
+ assert victim.read_text() == "original"
+
+
+def test_existing_convention_prefix_is_not_shadowed(monkeypatch, tmp_path):
+ # A convention prefix (/mnt/data etc.) is remapped ONLY while absent. If a real
+ # host directory exists there it must pass through so its own filesystem semantics
+ # apply: a real read succeeds, and a missing file under it is created there by a
+ # write, never shadowed by a CWD file.
+ mod = _load_shim()
+ external = tmp_path / "real_prefix"
+ external.mkdir()
+ (external / "data.txt").write_text("real external content")
+
+ workdir = tmp_path / "conversation"
+ workdir.mkdir()
+ monkeypatch.chdir(workdir)
+ monkeypatch.setattr(mod, "_PREFIXES", (str(external),))
+ monkeypatch.setattr(mod, "_CONDITIONAL_PREFIXES", ())
+
+ target = str(external / "data.txt")
+ # Prefix exists -> pass through for read and write.
+ assert mod._remap(target) == target
+ assert mod._remap_open(target, "r") == target
+ assert mod._remap_open(target, "w") == target
+ # A missing file under the EXISTING real prefix is left alone (parent exists),
+ # so the real directory creates it -- not a CWD shadow.
+ missing = str(external / "new.txt")
+ assert mod._remap_open(missing, "w") == missing
+
+ # Remove the prefix directory -> healing resumes (absent prefix).
+ (external / "data.txt").unlink()
+ external.rmdir()
+ assert mod._remap(target) == os.path.join(os.getcwd(), "data.txt")
+
+
+def test_os_open_and_path_touch_remap_convention_path(monkeypatch, tmp_path):
+ # Path.touch() and other low-level creators go through os.open, not builtins/io.open.
+ # Keep the shim's patches installed under a chdir into tmp_path so os.open is
+ # patched, and confirm a convention path is healed into the CWD instead of raising.
+ saved = _save_patch_targets()
+ spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_osopen", _SHIM)
+ mod = importlib.util.module_from_spec(spec)
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+ try:
+ spec.loader.exec_module(mod) # installs the os.open patch
+ mod._notified = True
+ pathlib.Path("/mnt/data/touched.txt").touch()
+ assert os.path.isfile(os.path.join(cwd, "touched.txt"))
+ # Direct os.open with create flags is healed too.
+ fd = os.open("/mnt/data/via_os_open.txt", os.O_CREAT | os.O_WRONLY, 0o600)
+ os.close(fd)
+ assert os.path.isfile(os.path.join(cwd, "via_os_open.txt"))
+ finally:
+ _restore_patch_targets(saved)
+
+
+def test_path_write_read_text_remap_convention_path(monkeypatch, tmp_path):
+ # Path.open / write_text / read_text route through io.open (3.11+) or the captured
+ # accessor open (< 3.11). Keep the patches installed under a chdir into tmp_path
+ # and confirm a convention path is healed into the CWD on every version. This is
+ # the hermetic guard for the 3.10 accessor path a plain io.open patch misses.
+ saved = _save_patch_targets()
+ spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_writetext", _SHIM)
+ mod = importlib.util.module_from_spec(spec)
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+ try:
+ spec.loader.exec_module(mod) # installs the io.open / accessor patch
+ mod._notified = True
+ pathlib.Path("/mnt/data/note.txt").write_text("pathlib remap")
+ assert os.path.isfile(os.path.join(cwd, "note.txt"))
+ # read_text goes through the same mapped path and sees what was written.
+ assert pathlib.Path("/mnt/data/note.txt").read_text() == "pathlib remap"
+ # A real absolute path passes through both patches untouched.
+ real = tmp_path / "real.txt"
+ pathlib.Path(str(real)).write_text("verbatim")
+ assert real.read_text() == "verbatim"
+ finally:
+ _restore_patch_targets(saved)
+
+
+def test_write_fallback_leaves_relative_and_bytes_paths(monkeypatch, tmp_path):
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ # Relative paths are already inside the CWD.
+ assert mod._remap_open("out.txt", "w") == "out.txt"
+ # Bytes paths are left untouched (prefix remap skips non-str).
+ assert mod._remap_open(b"/no/such/tree/x.bin", "w") == b"/no/such/tree/x.bin"
+
+
+def test_remap_open_still_applies_prefix_remaps(monkeypatch, tmp_path):
+ # The prefix remap runs first and preserves subpaths. A write heals onto the CWD
+ # unconditionally; the write-mode fallback is only the last resort.
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+ assert mod._remap_open("/mnt/data/sub/out.txt", "w") == os.path.join(cwd, "sub", "out.txt")
+ # A read whose mapped target does NOT exist keeps the original path: a missing
+ # input stays truthful, not silently redirected into the CWD.
+ assert mod._remap_open("/mnt/data/sub/out.txt", "r") == "/mnt/data/sub/out.txt"
+
+
+def test_prefix_read_heals_only_when_mapped_target_exists(monkeypatch, tmp_path):
+ # A convention-prefix READ must not redirect onto the CWD when the mapped target
+ # is absent -- that masks a genuine missing-input error and could serve an
+ # unrelated same-basename workdir file. It heals only when the mapped CWD target
+ # exists, so re-reading an artifact an earlier write produced still works.
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+
+ # Mapped target absent: read keeps the original path (truthful miss).
+ assert mod._remap_open("/mnt/data/input.csv", "r") == "/mnt/data/input.csv"
+ with pytest.raises(FileNotFoundError):
+ open(mod._remap_open("/mnt/data/input.csv", "r"))
+
+ # r+ (never creates) behaves the same: no redirect while absent.
+ assert mod._remap_open("/mnt/data/input.csv", "r+") == "/mnt/data/input.csv"
+
+ # A write heals onto the CWD and creates the artifact...
+ mapped = mod._remap_open("/mnt/data/input.csv", "w")
+ assert mapped == os.path.join(cwd, "input.csv")
+ with open(mapped, "w") as fh:
+ fh.write("col\n1\n")
+
+ # ...and now a READ of the same convention path heals onto that existing artifact.
+ read_target = mod._remap_open("/mnt/data/input.csv", "r")
+ assert read_target == os.path.join(cwd, "input.csv")
+ with open(read_target) as fh:
+ assert fh.read() == "col\n1\n"
+
+
+def test_prefix_boundary_not_matched_by_similar_paths(monkeypatch, tmp_path):
+ # The prefix match is anchored on a segment boundary (prefix or prefix + '/'), so
+ # a sibling merely sharing the textual prefix must NOT be remapped: /workspace2
+ # is not /workspace, /mnt/database is not /mnt/data.
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ for unrelated in ("/workspace2/file.txt", "/mnt/database/x", "/home/sandboxed/y"):
+ assert mod._remap(unrelated) == unrelated
+ # And through open() for a read too (no silent redirect).
+ assert mod._remap_open(unrelated, "r") == unrelated
+
+
+def test_tmp_outputs_is_a_conditional_prefix():
+ mod = _load_shim()
+ assert "/tmp/outputs" in mod._CONDITIONAL_PREFIXES
+ # NOT in the always-remap set: /tmp exists on the host, so an unconditional remap
+ # could shadow a real /tmp/outputs the user code made.
+ assert "/tmp/outputs" not in mod._PREFIXES
+
+
+def test_tmp_outputs_remapped_only_while_absent(monkeypatch, tmp_path):
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+ # Point the conditional prefix at a real temp location so we can toggle its
+ # existence on disk instead of mocking os.path.exists.
+ cond = str(tmp_path / "cond_outputs")
+ monkeypatch.setattr(mod, "_CONDITIONAL_PREFIXES", (cond,))
+
+ # Absent: heal the habit path into the working directory (preserved/served).
+ assert not os.path.exists(cond)
+ assert mod._remap(cond + "/plot.png") == os.path.join(cwd, "plot.png")
+ assert mod._remap(cond) == cwd
+
+ # Present (the user's own code created it): pass through, never shadowed.
+ os.makedirs(cond)
+ assert mod._remap(cond + "/plot.png") == cond + "/plot.png"
+ assert mod._remap(cond) == cond
+
+
+def test_pathlib_mkdir_parents_remaps_convention_path(monkeypatch, tmp_path):
+ # `Path('/mnt/data').mkdir(parents=True, exist_ok=True)` is a stock setup line.
+ # pathlib drives it through os.mkdir per component and Path.is_dir()/os.stat on
+ # FileExistsError, so the shim must patch os.mkdir AND Path.mkdir for the whole
+ # parents/exist_ok dance to land in the CWD instead of raising. Keeps the mkdir
+ # patches installed under a chdir into tmp_path and restores them in finally.
+ saved = _save_patch_targets()
+ spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_mkdir", _SHIM)
+ mod = importlib.util.module_from_spec(spec)
+ monkeypatch.chdir(tmp_path)
+ cwd = os.getcwd()
+ try:
+ spec.loader.exec_module(mod) # installs the os.mkdir / Path.mkdir patches
+ mod._notified = True
+ # Bare convention path maps onto the CWD, which already exists: exist_ok=True
+ # must be honoured against the mapped location, not raise.
+ pathlib.Path("/mnt/data").mkdir(parents = True, exist_ok = True)
+ # A nested convention path is created inside the CWD, parents and all.
+ pathlib.Path("/mnt/data/plots/run1").mkdir(parents = True, exist_ok = True)
+ assert os.path.isdir(os.path.join(cwd, "plots", "run1"))
+ # Idempotent: exist_ok is evaluated on the mapped path (which now exists),
+ # not the never-present /mnt/data.
+ pathlib.Path("/mnt/data/plots/run1").mkdir(parents = True, exist_ok = True)
+
+ # Passthrough: real paths are created verbatim through both patches,
+ # never remapped into the CWD.
+ real_dir = tmp_path / "real_via_path"
+ pathlib.Path(str(real_dir)).mkdir()
+ assert real_dir.is_dir()
+ real_os = tmp_path / "real_via_os"
+ os.mkdir(str(real_os))
+ assert real_os.is_dir()
+ finally:
+ _restore_patch_targets(saved)
+
+
+def test_read_of_missing_prefix_path_emits_no_notice(monkeypatch, tmp_path, capsys):
+ # A read of a missing convention path keeps the original path and must not spend
+ # the one-shot notice; a genuine remap afterward still notifies.
+ mod = _load_shim()
+ monkeypatch.chdir(tmp_path)
+ mod._notified = False # re-arm the one-shot notice for this test
+ # Read of a missing prefixed path: original kept, no notice, flag unspent.
+ assert mod._remap_open("/mnt/data/missing.csv", "r") == "/mnt/data/missing.csv"
+ assert mod._notified is False
+ assert "does not exist" not in capsys.readouterr().err
+ # A committed write then heals and fires the notice exactly once.
+ assert mod._remap_open("/mnt/data/out.txt", "w") == os.path.join(os.getcwd(), "out.txt")
+ assert mod._notified is True
+ assert "/mnt/data does not exist in this sandbox" in capsys.readouterr().err
+
+
+def test_os_open_trunc_without_creat_missing_stays_truthful(monkeypatch, tmp_path):
+ # O_TRUNC / O_APPEND without O_CREAT cannot create a missing file, so the shim
+ # treats them as a read: a missing convention path stays truthful (the error
+ # names the caller's path) and nothing is created in the CWD.
+ saved = _save_patch_targets()
+ spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_trunc", _SHIM)
+ mod = importlib.util.module_from_spec(spec)
+ monkeypatch.chdir(tmp_path)
+ try:
+ spec.loader.exec_module(mod)
+ mod._notified = True
+ with pytest.raises(FileNotFoundError) as exc:
+ os.open("/mnt/data/missing_xyz.bin", os.O_WRONLY | os.O_TRUNC)
+ assert exc.value.filename == "/mnt/data/missing_xyz.bin"
+ assert not os.path.exists(os.path.join(os.getcwd(), "missing_xyz.bin"))
+ finally:
+ _restore_patch_targets(saved)
diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py
index 24b1da1772..2970b1a6bb 100644
--- a/studio/backend/tests/test_sandbox_tools.py
+++ b/studio/backend/tests/test_sandbox_tools.py
@@ -294,11 +294,16 @@ class TestSandboxEnvIsolation:
"LANG",
"TERM",
"PYTHONIOENCODING",
+ "PYTHONPATH",
"VIRTUAL_ENV",
"SystemRoot",
}
extras = set(env.keys()) - allowed
assert not extras, f"sandbox env added unexpected keys: {extras}"
+ # PYTHONPATH is whitelist-built, never inherited: only the sandbox
+ # sitecustomize shim dir (code-interpreter path remap).
+ assert env["PYTHONPATH"].endswith("sandbox_site")
+ assert "leak-me" not in env["PYTHONPATH"]
def test_home_points_at_sandbox_workdir(self, tmp_path):
from core.inference.tools import _build_safe_env
@@ -315,6 +320,23 @@ class TestSandboxEnvIsolation:
env = _build_safe_env(str(tmp_path))
assert env["TERM"] == "dumb"
+ def test_bypass_env_installs_sitecustomize_path_shim(self, tmp_path):
+ # Bypass mode must install the same /mnt/data path-remap shim as the safe
+ # env (finding 17), else /mnt/data writes work only in normal mode.
+ from core.inference.tools import _SANDBOX_SITE_DIR, _build_bypass_env
+ env = _build_bypass_env(str(tmp_path))
+ assert _SANDBOX_SITE_DIR in env["PYTHONPATH"].split(os.pathsep)
+
+ def test_bypass_env_prepends_shim_and_keeps_inherited_pythonpath(self, monkeypatch, tmp_path):
+ from core.inference.tools import _SANDBOX_SITE_DIR, _build_bypass_env
+
+ monkeypatch.setenv("PYTHONPATH", "/operator/libs")
+ env = _build_bypass_env(str(tmp_path))
+ parts = env["PYTHONPATH"].split(os.pathsep)
+ # Shim first so its open()/makedirs remap wins, operator entries kept.
+ assert parts[0] == _SANDBOX_SITE_DIR
+ assert "/operator/libs" in parts
+
class TestSandboxCpuRlimitDefault:
"""Pin the default so a regression below 600s without opt-in is caught."""
diff --git a/studio/backend/tests/test_secure_tunnel_gate.py b/studio/backend/tests/test_secure_tunnel_gate.py
index 1f7608a4fc..2c13e13bbb 100644
--- a/studio/backend/tests/test_secure_tunnel_gate.py
+++ b/studio/backend/tests/test_secure_tunnel_gate.py
@@ -21,7 +21,7 @@ from run import _cloudflare_tunnel_should_start as should_start # noqa: E402
@pytest.mark.parametrize(
"cloudflare,host,secure,api_only,is_colab,expected",
[
- # Non-secure wildcard binds tunnel by default.
+ # Non-secure wildcard binds tunnel only when --cloudflare is passed (True).
(True, "0.0.0.0", False, False, False, True),
(True, "::", False, False, False, True),
(True, "127.0.0.1", False, False, False, False),
@@ -33,6 +33,10 @@ from run import _cloudflare_tunnel_should_start as should_start # noqa: E402
(False, "0.0.0.0", False, False, False, False),
(False, "::", False, False, False, False),
(False, "127.0.0.1", True, False, False, False),
+ # Unset (None, no flag) behaves as off for non-secure binds.
+ (None, "0.0.0.0", False, False, False, False),
+ (None, "::", False, False, False, False),
+ (None, "127.0.0.1", False, False, False, False),
# Non-secure api-only never tunnels (Tauri).
(True, "0.0.0.0", False, True, False, False),
(True, "::", False, True, False, False),
@@ -155,11 +159,12 @@ def test_startup_output_emits_disabled_notice(capsys, monkeypatch):
def test_run_server_rejects_secure_without_cloudflare():
- # Direct backend callers (not just the CLI) must reject the contradictory combo.
+ # Direct backend callers (not just the CLI) must reject the contradictory
+ # combo: --secure asks for the tunnel, --no-cloudflare (cloudflare=False) forbids it.
import run
with pytest.raises(SystemExit) as exc:
run.run_server(secure = True, cloudflare = False)
- assert "A secure Cloudflare link is not allowed" in str(exc.value)
+ assert "do not combine it with --no-cloudflare" in str(exc.value)
def test_failclosed_message_present_in_source():
diff --git a/studio/backend/tests/test_shutdown_preserves_live_worker.py b/studio/backend/tests/test_shutdown_preserves_live_worker.py
new file mode 100644
index 0000000000..faf273411c
--- /dev/null
+++ b/studio/backend/tests/test_shutdown_preserves_live_worker.py
@@ -0,0 +1,145 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+"""_shutdown_subprocess returns whether the worker actually died, and preserves the
+live handle when it survives terminate/kill.
+
+A GPU worker wedged in an uninterruptible CUDA syscall can outlive SIGKILL. If shutdown
+nulled its handle anyway, is_worker_alive() would report False and the pre-swap liveness
+guard would let the destructive .venv_t5_latest rename proceed while a live worker still
+holds sidecar transformers modules (breaking the rename on Windows). The methods must keep
+the handle and return False so callers can refuse the swap.
+"""
+
+import pytest
+
+from core.export.orchestrator import ExportOrchestrator
+from core.inference.orchestrator import InferenceOrchestrator
+
+
+class _FakeProc:
+ """A subprocess handle that dies only on the requested step (or never)."""
+
+ def __init__(self, dies_on = None):
+ self._alive = True
+ self._dies_on = dies_on # None | "join" | "terminate" | "kill"
+
+ def is_alive(self):
+ return self._alive
+
+ def join(self, timeout = None):
+ if self._dies_on == "join":
+ self._alive = False
+
+ def terminate(self):
+ if self._dies_on == "terminate":
+ self._alive = False
+
+ def kill(self):
+ if self._dies_on == "kill":
+ self._alive = False
+
+
+def _bare_inference():
+ o = InferenceOrchestrator.__new__(InferenceOrchestrator)
+ o._stop_dispatcher = lambda: None
+ o._cancel_generation = lambda: None
+ o._drain_queue = lambda: []
+
+ class _Q:
+ def put(self, *a, **k):
+ pass
+
+ o._cmd_queue = _Q()
+ o._resp_queue = _Q()
+ o._cancel_event = None
+ o._drain_event = None
+ return o
+
+
+def _bare_export():
+ o = ExportOrchestrator.__new__(ExportOrchestrator)
+ o._drain_queue = lambda: []
+
+ class _Q:
+ def put(self, *a, **k):
+ pass
+
+ o._cmd_queue = _Q()
+ o._resp_queue = _Q()
+ return o
+
+
+@pytest.fixture(autouse = True)
+def _no_sleep(monkeypatch):
+ # _shutdown_subprocess sleeps 0.5s after cancelling; keep the tests instant.
+ import core.inference.orchestrator as inf_mod
+ monkeypatch.setattr(inf_mod.time, "sleep", lambda *_a, **_k: None)
+
+
+class TestInferenceShutdownReturn:
+ def test_worker_that_dies_returns_true_and_clears_handle(self):
+ o = _bare_inference()
+ o._proc = _FakeProc(dies_on = "terminate")
+ assert o._shutdown_subprocess(timeout = 0.01) is True
+ assert o._proc is None
+ assert o.is_worker_alive() is False
+
+ def test_survivor_returns_false_and_keeps_handle(self):
+ o = _bare_inference()
+ o._proc = _FakeProc(dies_on = None) # outlives terminate AND kill
+ assert o._shutdown_subprocess(timeout = 0.01) is False
+ assert o._proc is not None
+ # is_worker_alive stays truthful, so the pre-swap guard can refuse the swap.
+ assert o.is_worker_alive() is True
+
+ def test_already_dead_returns_true(self):
+ o = _bare_inference()
+ o._proc = _FakeProc(dies_on = "join")
+ o._proc._alive = False
+ assert o._shutdown_subprocess(timeout = 0.01) is True
+ assert o._proc is None
+
+
+class TestExportShutdownReturn:
+ def test_worker_that_dies_returns_true_and_clears_handle(self):
+ o = _bare_export()
+ o._proc = _FakeProc(dies_on = "terminate")
+ assert o._shutdown_subprocess(timeout = 0.01) is True
+ assert o._proc is None
+ assert o.is_worker_alive() is False
+
+ def test_survivor_returns_false_and_keeps_handle(self):
+ o = _bare_export()
+ o._proc = _FakeProc(dies_on = None)
+ assert o._shutdown_subprocess(timeout = 0.01) is False
+ assert o._proc is not None
+ assert o.is_worker_alive() is True
+
+
+class TestSpawnPathsHonorFailedShutdown:
+ """A fresh-load path must not spawn a second worker over one that outlived
+ terminate/kill: the survivor still holds GPU memory and its handle would be lost."""
+
+ def test_export_load_checkpoint_aborts_when_worker_survives(self, monkeypatch):
+ import threading
+
+ import utils.transformers_version as tv
+
+ o = ExportOrchestrator.__new__(ExportOrchestrator)
+ o._lock = threading.RLock()
+ o._proc = _FakeProc(dies_on = None) # survivor
+ o.clear_logs = lambda: None
+ o._cancel_requested = False
+ o._active_op_kind = None
+ o._export_active = False
+ o._ensure_subprocess_alive = lambda: True
+ o._shutdown_subprocess = lambda *a, **k: False
+ o._spawn_subprocess = lambda cfg: pytest.fail("must not spawn over a live survivor")
+ o._record_op_finished = lambda *a, **k: None
+ monkeypatch.setattr(tv, "sidecar_swap_in_progress", lambda: False)
+
+ ok, msg = o.load_checkpoint(checkpoint_path = "ckpt")
+
+ assert ok is False
+ assert "did not exit" in msg
+ # The finally cleared the op flags even though we returned early.
+ assert o._export_active is False
diff --git a/studio/backend/tests/test_think_prefill_reemit.py b/studio/backend/tests/test_think_prefill_reemit.py
new file mode 100644
index 0000000000..300ff92776
--- /dev/null
+++ b/studio/backend/tests/test_think_prefill_reemit.py
@@ -0,0 +1,89 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+Unit tests for detect_think_prefill.
+
+Reasoning templates (Qwen3.6-style) end the generation prompt with an open
+``\\n`` so the model starts reasoning immediately. skip_prompt
+streaming drops that opening tag, so the safetensors/MLX paths must re-emit
+it for the frontend's parser to render a thinking block.
+"""
+
+import os
+import sys
+
+_backend = os.path.join(os.path.dirname(__file__), "..")
+sys.path.insert(0, _backend)
+
+from core.inference.chat_template_helpers import detect_think_prefill
+
+
+QWEN_PROMPT = "<|im_start|>user\nHi!<|im_end|>\n<|im_start|>assistant\n"
+
+
+def test_open_think_prefill_reemitted():
+ """Qwen3.6-style enable_thinking=True prompt tail: \\n."""
+ assert detect_think_prefill(QWEN_PROMPT + "\n") == "\n"
+
+
+def test_bare_open_think_prefill_reemitted():
+ """Prefill without trailing newline still detected."""
+ assert detect_think_prefill(QWEN_PROMPT + "") == ""
+
+
+def test_closed_think_prefill_not_reemitted():
+ """enable_thinking=False prefills a closed, empty think block."""
+ assert detect_think_prefill(QWEN_PROMPT + "\n\n \n\n") == ""
+
+
+def test_prompt_without_think_untouched():
+ """Non-reasoning templates produce no prefix."""
+ assert detect_think_prefill(QWEN_PROMPT) == ""
+
+
+def test_historical_think_blocks_ignored():
+ """A closed think block in a prior assistant turn (preserve_thinking)
+ must not trigger re-emission when the generation tail is plain."""
+ prompt = (
+ "<|im_start|>user\nHi!<|im_end|>\n"
+ "<|im_start|>assistant\n\nprior reasoning\n \n\nHello!<|im_end|>\n"
+ "<|im_start|>user\nAgain?<|im_end|>\n<|im_start|>assistant\n"
+ )
+ assert detect_think_prefill(prompt) == ""
+
+
+def test_historical_blocks_plus_open_prefill():
+ """Prior closed blocks plus a fresh open prefill: only the tail matters."""
+ prompt = (
+ "<|im_start|>assistant\n\nprior\n \n\nHello!<|im_end|>\n"
+ "<|im_start|>assistant\n\n"
+ )
+ assert detect_think_prefill(prompt) == "\n"
+
+
+def test_content_after_open_tag_not_reemitted():
+ """If non-whitespace follows the tag it is not a plain prefill."""
+ assert detect_think_prefill(QWEN_PROMPT + "\npartial reasoning") == ""
+
+
+def test_empty_and_none_prompts():
+ assert detect_think_prefill("") == ""
+ assert detect_think_prefill(None) == ""
+
+
+def test_guard_suppresses_when_close_tag_is_special():
+ """If is a special token, skip_special_tokens strips the model's
+ close tag, so re-emitting the open would leave an unclosed block. Guard off."""
+ specials = ["<|im_end|>", "", " "]
+ assert detect_think_prefill(QWEN_PROMPT + "\n", specials) == ""
+
+
+def test_guard_emits_when_think_not_special():
+ specials = ["<|im_end|>", "<|endoftext|>"]
+ assert detect_think_prefill(QWEN_PROMPT + "\n", specials) == "\n"
+
+
+def test_guard_default_and_empty_keep_emitting():
+ assert detect_think_prefill(QWEN_PROMPT + "\n", None) == "\n"
+ assert detect_think_prefill(QWEN_PROMPT + "\n", []) == "\n"
diff --git a/studio/backend/tests/test_tool_call_parser_strict.py b/studio/backend/tests/test_tool_call_parser_strict.py
index c6da1e90e7..02f63c41a2 100644
--- a/studio/backend/tests/test_tool_call_parser_strict.py
+++ b/studio/backend/tests/test_tool_call_parser_strict.py
@@ -874,7 +874,13 @@ class TestHealerSignalAlignment:
def test_heal_signals_subset_of_promotable_formats(self):
from core.inference.passthrough_healing import _HEAL_SIGNALS
- assert set(_HEAL_SIGNALS) == {"", "<|tool_call>", "",
+ "<|tool_call>",
+ "",
+ }
def test_stream_healer_does_not_hold_llama_python_tag_text(self):
from core.inference.passthrough_healing import StreamToolCallHealer
diff --git a/studio/backend/tests/test_tool_confirm_loop.py b/studio/backend/tests/test_tool_confirm_loop.py
index 17ef697674..3db591f542 100644
--- a/studio/backend/tests/test_tool_confirm_loop.py
+++ b/studio/backend/tests/test_tool_confirm_loop.py
@@ -42,6 +42,7 @@ class _FakeExecuteTool:
cancel_event = None,
timeout = None,
session_id = None,
+ thread_id = None,
rag_scope = None,
disable_sandbox = False,
):
diff --git a/studio/backend/tests/test_tool_output_streaming.py b/studio/backend/tests/test_tool_output_streaming.py
new file mode 100644
index 0000000000..28bd79cc6e
--- /dev/null
+++ b/studio/backend/tests/test_tool_output_streaming.py
@@ -0,0 +1,1234 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Live tool-output streaming and heartbeats for server-side tool execution.
+
+Covers three invariants:
+
+* ``stream_tool_execution`` yields incremental ``tool_output`` events and
+ ``heartbeat`` events while a tool blocks, and returns the tool's result
+ byte-identical to a direct call;
+* ``_python_exec`` / ``_bash_exec`` produce the same result string with and
+ without an ``output_callback`` (the final tool message the model sees is
+ untouched by streaming);
+* the GGUF agentic loop emits ``tool_output`` between ``tool_start`` and
+ ``tool_end`` and feeds the model the same ``role=tool`` message as before.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import threading
+import time
+from pathlib import Path
+
+import pytest
+
+_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
+if _BACKEND_DIR not in sys.path:
+ sys.path.insert(0, _BACKEND_DIR)
+_TESTS_DIR = str(Path(__file__).resolve().parent)
+if _TESTS_DIR not in sys.path:
+ sys.path.insert(0, _TESTS_DIR)
+
+from core.inference.tool_stream_exec import (
+ TOOL_OUTPUT_STREAM_MAX_CHARS,
+ stream_tool_execution,
+)
+from core.inference.tools import _bash_exec, _python_exec
+
+from test_llama_cpp_tool_loop import _done, _make_backend, _sse
+
+
+def _run_stream(invoke, **kwargs):
+ """Drive the wrapper generator; return (events, result)."""
+ gen = stream_tool_execution(invoke, **kwargs)
+ events = []
+ while True:
+ try:
+ events.append(next(gen))
+ except StopIteration as stop:
+ return events, stop.value
+
+
+# ── stream_tool_execution ────────────────────────────────────────
+
+
+def test_result_returned_verbatim_without_output():
+ events, result = _run_stream(
+ lambda _cb: "final result",
+ tool_name = "web_search",
+ )
+ assert result == "final result"
+ assert [e for e in events if e["type"] == "tool_output"] == []
+
+
+def test_incremental_output_streams_as_tool_output_events():
+ def tool(callback):
+ callback("line 1\n")
+ callback("line 2\n")
+ return "line 1\nline 2\n"
+
+ events, result = _run_stream(tool, tool_name = "python", tool_call_id = "call_1")
+ assert result == "line 1\nline 2\n"
+ outputs = [e for e in events if e["type"] == "tool_output"]
+ assert outputs, "expected tool_output events"
+ assert "".join(e["text"] for e in outputs) == "line 1\nline 2\n"
+ assert all(e["tool_name"] == "python" for e in outputs)
+ assert all(e["tool_call_id"] == "call_1" for e in outputs)
+
+
+def test_heartbeats_emitted_while_tool_blocks():
+ release = threading.Event()
+
+ def tool(_cb):
+ release.wait(timeout = 5)
+ return "done"
+
+ gen = stream_tool_execution(
+ tool,
+ tool_name = "web_search",
+ heartbeat_interval_s = 0.04,
+ poll_interval_s = 0.02,
+ )
+ events = []
+ result = None
+ try:
+ while True:
+ event = next(gen)
+ events.append(event)
+ if len([e for e in events if e["type"] == "heartbeat"]) >= 2:
+ release.set()
+ except StopIteration as stop:
+ result = stop.value
+ assert result == "done"
+ assert len([e for e in events if e["type"] == "heartbeat"]) >= 2
+
+
+def test_output_resets_heartbeat_pacing():
+ # A steady output stream means no heartbeats are needed.
+ def tool(callback):
+ for i in range(5):
+ callback(f"tick {i}\n")
+ time.sleep(0.01)
+ return "ok"
+
+ events, result = _run_stream(
+ tool,
+ tool_name = "python",
+ heartbeat_interval_s = 10.0,
+ poll_interval_s = 0.02,
+ )
+ assert result == "ok"
+ assert [e for e in events if e["type"] == "heartbeat"] == []
+
+
+def test_tool_exception_propagates_after_stream():
+ def tool(_cb):
+ raise RuntimeError("boom")
+
+ gen = stream_tool_execution(tool, tool_name = "python")
+ try:
+ while True:
+ next(gen)
+ except RuntimeError as exc:
+ assert str(exc) == "boom"
+ else:
+ raise AssertionError("expected RuntimeError")
+
+
+def test_output_before_worker_raises_is_preserved():
+ # Output streamed before the worker raises survives; the exception still propagates.
+ def tool(callback):
+ callback("partial before crash\n")
+ time.sleep(0.02)
+ raise RuntimeError("late boom")
+
+ gen = stream_tool_execution(tool, tool_name = "python", poll_interval_s = 0.01)
+ events = []
+ with pytest.raises(RuntimeError, match = "late boom"):
+ while True:
+ events.append(next(gen))
+ streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
+ assert "partial before crash" in streamed
+
+
+def test_generator_close_cancels_observing_tool():
+ # gen.close() (SSE client disconnect) sets the shared cancel_event, so a
+ # cancel-observing tool returns at once.
+ cancel_event = threading.Event()
+ started = threading.Event()
+ returned = threading.Event()
+
+ def tool(_cb):
+ started.set()
+ cancel_event.wait(timeout = 5) # cancel-observing: unblocks on cancel
+ returned.set()
+ return "cancelled cleanly"
+
+ gen = stream_tool_execution(
+ tool,
+ tool_name = "web_search",
+ cancel_event = cancel_event,
+ heartbeat_interval_s = 0.02,
+ poll_interval_s = 0.01,
+ )
+ next(gen) # prime the worker; returns a heartbeat while the tool blocks
+ assert started.wait(timeout = 2)
+ gen.close() # GeneratorExit -> sets cancel_event, then bounded join
+ assert cancel_event.is_set()
+ assert returned.wait(timeout = 2) # the tool actually observed cancellation
+
+
+def test_generator_close_is_bounded_for_cancel_ignoring_tool(monkeypatch):
+ # A tool that ignores cancel_event must not stall teardown: gen.close() waits
+ # at most the bounded join, not the tool's full runtime.
+ monkeypatch.setattr("core.inference.tool_stream_exec._WORKER_JOIN_TIMEOUT_S", 0.2)
+ release = threading.Event()
+
+ def tool(_cb):
+ # Ignores cancel_event; stands in for a web_search/MCP call that never polls it.
+ release.wait(timeout = 30)
+ return "slow"
+
+ gen = stream_tool_execution(
+ tool,
+ tool_name = "web_search",
+ cancel_event = threading.Event(),
+ heartbeat_interval_s = 0.02,
+ poll_interval_s = 0.01,
+ )
+ next(gen)
+ started = time.monotonic()
+ gen.close()
+ elapsed = time.monotonic() - started
+ release.set() # let the daemon worker finish so no sleeper lingers
+ assert elapsed < 2.0 # bounded by _WORKER_JOIN_TIMEOUT_S, not the 30s tool
+
+
+def test_cancel_event_not_set_on_clean_finish():
+ # cancel_event is shared across a turn; a clean finish must leave it unset so
+ # the next tool in the same turn is not aborted.
+ cancel_event = threading.Event()
+
+ def tool(_cb):
+ return "ok"
+
+ events, result = _run_stream(
+ tool,
+ tool_name = "python",
+ cancel_event = cancel_event,
+ )
+ assert result == "ok"
+ assert not cancel_event.is_set()
+
+
+def test_no_worker_thread_leak_under_repeated_close(monkeypatch):
+ # Repeated start-then-close must not leak worker threads: each cancel-observing
+ # worker exits once close() signals it.
+ monkeypatch.setattr("core.inference.tool_stream_exec._WORKER_JOIN_TIMEOUT_S", 0.2)
+
+ def _live_tool_workers():
+ return [t for t in threading.enumerate() if t.name.startswith("tool-exec-")]
+
+ for _ in range(50): # let workers from earlier tests drain
+ if not _live_tool_workers():
+ break
+ time.sleep(0.02)
+ baseline = len(_live_tool_workers())
+
+ for _ in range(60):
+ cancel_event = threading.Event()
+
+ def tool(_cb, _ev = cancel_event):
+ _ev.wait(timeout = 5)
+ return "done"
+
+ gen = stream_tool_execution(
+ tool,
+ tool_name = "soak",
+ cancel_event = cancel_event,
+ heartbeat_interval_s = 0.02,
+ poll_interval_s = 0.01,
+ )
+ next(gen)
+ gen.close() # sets cancel_event -> tool returns -> worker exits
+
+ for _ in range(100):
+ if len(_live_tool_workers()) <= baseline:
+ break
+ time.sleep(0.02)
+ assert len(_live_tool_workers()) <= baseline
+
+
+def test_streamed_output_is_capped_but_result_is_not():
+ big = "x" * (TOOL_OUTPUT_STREAM_MAX_CHARS + 5000)
+
+ def tool(callback):
+ callback(big)
+ return big
+
+ events, result = _run_stream(tool, tool_name = "python")
+ assert result == big # final result untouched by the stream cap
+ streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
+ assert len(streamed) < len(big)
+ assert "further live output not streamed" in streamed
+
+
+def test_heartbeats_continue_while_capped_output_flows():
+ # After the cap, discarded chunks must not starve the keepalive: a chatty tool
+ # keeps the queue non-empty, so without the fix no heartbeat fires and the SSE
+ # stream stays silent past proxy idle timeouts.
+ release = threading.Event()
+
+ def tool(callback):
+ callback("x" * (TOOL_OUTPUT_STREAM_MAX_CHARS + 10)) # trip the cap
+ while not release.is_set():
+ callback("post-cap spam")
+ time.sleep(0.005)
+ return "done"
+
+ # Watchdog: on regressed code next(gen) blocks forever while spam flows; the
+ # timer ends the tool, turning that hang into a clean assertion failure.
+ watchdog = threading.Timer(8.0, release.set)
+ watchdog.start()
+ gen = stream_tool_execution(
+ tool,
+ tool_name = "python",
+ heartbeat_interval_s = 0.04,
+ poll_interval_s = 0.02,
+ )
+ events = []
+ result = None
+ try:
+ while True:
+ event = next(gen)
+ events.append(event)
+ if len([e for e in events if e["type"] == "heartbeat"]) >= 2:
+ release.set()
+ except StopIteration as stop:
+ result = stop.value
+ finally:
+ release.set()
+ watchdog.cancel()
+ assert result == "done"
+ assert len([e for e in events if e["type"] == "heartbeat"]) >= 2
+ streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
+ assert "further live output not streamed" in streamed
+ assert "post-cap spam" not in streamed # cap still enforced
+
+
+def test_drain_queue_bounds_the_over_cap_batch():
+ # _drain_queue stops concatenating once the cap is first exceeded and discards
+ # the rest in place, so a chatty tool's huge backlog never defeats the memory ceiling.
+ import queue as _queue
+
+ from core.inference.tool_stream_exec import _drain_queue
+
+ q: _queue.Queue = _queue.Queue()
+ sentinel = object()
+ chunk = "z" * 1000
+ for _ in range(5000): # 5 MB queued ahead of the drain
+ q.put(chunk)
+ q.put(sentinel)
+ text, hit_sentinel = _drain_queue(q, sentinel, max_chars = 100)
+ assert hit_sentinel is True
+ # At most cap + one chunk is joined, not the full 5 MB backlog.
+ assert len(text) <= 100 + len(chunk)
+ assert q.empty() # surplus still drained so completion is detected
+
+
+def test_drain_queue_does_not_materialize_surplus_crossing_chunk():
+ # The single chunk that first crosses the cap must not be materialized in full
+ # (a tool can emit one multi-megabyte line). Keep just one char past the budget
+ # to preserve the overflow signal and byte-identical truncation, even when the
+ # budget is already met (max_chars <= 0).
+ import queue as _queue
+
+ from core.inference.tool_stream_exec import _drain_queue
+
+ sentinel = object()
+ huge = "z" * 1_000_000
+
+ # Budget already met (non-positive): keep one char, a true prefix.
+ for cap in (0, -500):
+ q: _queue.Queue = _queue.Queue()
+ q.put(huge)
+ q.put("more")
+ q.put(sentinel)
+ text, hit_sentinel = _drain_queue(q, sentinel, max_chars = cap)
+ assert hit_sentinel is True
+ assert len(text) == 1
+ assert huge.startswith(text)
+ assert q.empty()
+
+ # Positive cap crossed by one huge chunk: bounded to cap + 1, prefix kept.
+ q = _queue.Queue()
+ q.put(huge)
+ q.put(sentinel)
+ text, hit_sentinel = _drain_queue(q, sentinel, max_chars = 100)
+ assert len(text) == 101
+ assert text == huge[:101]
+
+
+def test_drain_queue_unbounded_joins_everything():
+ # Without a cap the join is complete and ordered (the sub-cap path streams
+ # every chunk verbatim on this).
+ import queue as _queue
+
+ from core.inference.tool_stream_exec import _drain_queue
+
+ q: _queue.Queue = _queue.Queue()
+ sentinel = object()
+ for i in range(3):
+ q.put(f"c{i}")
+ q.put(sentinel)
+ text, hit_sentinel = _drain_queue(q, sentinel, max_chars = None)
+ assert hit_sentinel is True
+ assert text == "c0c1c2"
+
+
+def test_over_cap_crossing_batch_streams_capped_output():
+ # End-to-end: a burst crossing the cap in one drain still yields a capped live
+ # stream and an untouched final result.
+ chunk = "z" * 1000
+
+ def tool(callback):
+ for _ in range(3000): # ~3 MB, well past the cap, in one burst
+ callback(chunk)
+ return "final"
+
+ events, result = _run_stream(tool, tool_name = "python")
+ assert result == "final"
+ streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
+ assert len(streamed) <= TOOL_OUTPUT_STREAM_MAX_CHARS + len(
+ "\n... (further live output not streamed)\n"
+ )
+ assert "further live output not streamed" in streamed
+
+
+# ── python / terminal executors ──────────────────────────────────
+
+_PY_CODE = "for i in range(5):\n print('row', i)\n"
+
+
+def test_python_exec_result_identical_with_streaming():
+ baseline = _python_exec(_PY_CODE, timeout = 60)
+ chunks: list[str] = []
+ streamed = _python_exec(_PY_CODE, timeout = 60, output_callback = chunks.append)
+ assert streamed == baseline
+ assert "".join(chunks) == "".join(f"row {i}\n" for i in range(5))
+
+
+def test_python_exec_streams_lines_incrementally():
+ # The first of two sleep-separated prints must reach the callback well before exit.
+ code = (
+ "import time\n"
+ "print('first', flush=True)\n"
+ "time.sleep(1.0)\n"
+ "print('second', flush=True)\n"
+ )
+ first_seen_at: list[float] = []
+
+ def on_chunk(_text: str) -> None:
+ if not first_seen_at:
+ first_seen_at.append(time.monotonic())
+
+ started = time.monotonic()
+ result = _python_exec(code, timeout = 60, output_callback = on_chunk)
+ finished = time.monotonic()
+ assert "first" in result and "second" in result
+ assert first_seen_at, "callback never invoked"
+ # First line arrived before the sleep completed (margin for slow interpreter start).
+ assert first_seen_at[0] - started < finished - started - 0.5
+
+
+def test_python_exec_unflushed_print_streams_live_and_result_identical():
+ # A bare print() WITHOUT flush=True then a sleep. -u forces the child's stdout
+ # unbuffered so the line reaches the callback before exit (else CPython
+ # block-buffers the pipe and the live pane stays empty). -u changes timing only,
+ # so the joined result stays byte-identical to the non-streaming run.
+ code = (
+ "import time\n"
+ "print('progress')\n" # no flush=True
+ "time.sleep(1.0)\n"
+ "print('done')\n"
+ )
+ first_seen_at: list[float] = []
+
+ def on_chunk(_text: str) -> None:
+ if not first_seen_at:
+ first_seen_at.append(time.monotonic())
+
+ baseline = _python_exec(code, timeout = 60)
+ started = time.monotonic()
+ streamed = _python_exec(code, timeout = 60, output_callback = on_chunk)
+ finished = time.monotonic()
+ assert streamed == baseline
+ assert "progress" in streamed and "done" in streamed
+ assert first_seen_at, "callback never invoked for unflushed print"
+ # Unflushed line arrived before the sleep finished: streamed live, not at exit.
+ assert first_seen_at[0] - started < finished - started - 0.5
+
+
+def test_python_exec_error_exit_identical_with_streaming():
+ code = "print('before')\nraise SystemExit(3)\n"
+ baseline = _python_exec(code, timeout = 60)
+ streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
+ assert streamed == baseline
+ assert streamed.startswith("Exit code 3:")
+
+
+def test_python_exec_timeout_message_identical_with_streaming():
+ code = "import time\ntime.sleep(30)\n"
+ baseline = _python_exec(code, timeout = 1)
+ streamed = _python_exec(code, timeout = 1, output_callback = lambda _t: None)
+ assert streamed == baseline == "Execution timed out after 1 seconds."
+
+
+def test_python_exec_callback_errors_do_not_break_execution():
+ def bad_callback(_text: str) -> None:
+ raise ValueError("observer bug")
+
+ result = _python_exec("print('ok')", timeout = 60, output_callback = bad_callback)
+ assert result.strip() == "ok"
+
+
+def test_bash_exec_result_identical_with_streaming():
+ command = "echo one; echo two"
+ baseline = _bash_exec(command, timeout = 60)
+ chunks: list[str] = []
+ streamed = _bash_exec(command, timeout = 60, output_callback = chunks.append)
+ assert streamed == baseline
+ assert "".join(chunks) == "one\ntwo\n"
+
+
+def test_bash_exec_invalid_utf8_identical_with_streaming():
+ # Invalid UTF-8 must not kill either path: the pipe decodes with
+ # errors="replace", so the streaming reader thread cannot die on the
+ # UnicodeDecodeError readline raises, and both paths return the same replaced text.
+ command = "printf 'ok\\377bad\\n'" # \377 = 0xFF, invalid UTF-8
+ baseline = _bash_exec(command, timeout = 60)
+ chunks: list[str] = []
+ streamed = _bash_exec(command, timeout = 60, output_callback = chunks.append)
+ assert streamed == baseline
+ assert not baseline.startswith("Execution error")
+ assert "ok" in baseline and "bad" in baseline
+ assert "�" in baseline # replacement character, not a crash
+ assert "".join(chunks) == "ok�bad\n"
+
+
+def test_bash_exec_unlimited_timeout_waits_for_grandchild_output():
+ # A background grandchild holds the pipe open past the shell's exit and writes
+ # ~7s later. With timeout=None the drain must wait for EOF like
+ # communicate(timeout=None), so the late output is included.
+ command = "( sleep 7; echo late-grandchild-output ) & echo parent-done"
+ chunks: list[str] = []
+ result = _bash_exec(command, timeout = None, output_callback = chunks.append)
+ assert "parent-done" in result
+ assert "late-grandchild-output" in result
+ assert "late-grandchild-output" in "".join(chunks)
+
+
+def test_bash_exec_finite_timeout_kills_grandchild_holding_stdout(tmp_path):
+ # A backgrounded grandchild holds the pipe open past the finite timeout, then
+ # would write a sentinel. The parent shell has already exited, so killing only
+ # the reaped parent leaves the grandchild running; the drain must kill the
+ # process group captured before the wait so the grandchild never writes.
+ sentinel = tmp_path / "grandchild_ran"
+ command = f"( sleep 3; touch '{sentinel}' ) & echo parent-done"
+ result = _bash_exec(command, timeout = 1, output_callback = lambda _t: None)
+ assert "timed out" in result
+ time.sleep(4.0) # past the grandchild's 3s sleep
+ assert not sentinel.exists(), "grandchild survived the timeout process-group kill"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
+def test_bash_exec_nonstreaming_timeout_kills_grandchild(tmp_path):
+ # The NON-streaming path (communicate() + _kill_process_tree) short-circuits
+ # once the reaped leader has exited, so a stdout-holding grandchild survives
+ # unless the group captured right after spawn is killed too. Must match the
+ # streaming path's exited-leader handling.
+ sentinel = tmp_path / "grandchild_ran"
+ command = f"( sleep 3; touch '{sentinel}' ) & echo parent-done"
+ result = _bash_exec(command, timeout = 1) # no output_callback -> communicate path
+ assert "timed out" in result
+ time.sleep(4.0)
+ assert not sentinel.exists(), "non-streaming timeout leaked a stdout-holding grandchild"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
+def test_python_exec_nonstreaming_timeout_kills_grandchild(tmp_path):
+ sentinel = tmp_path / "grandchild_ran"
+ code = (
+ "import subprocess\n"
+ f"subprocess.Popen(['bash', '-c', \"sleep 3; touch '{sentinel}'\"])\n"
+ "print('parent-done')\n"
+ "import time; time.sleep(30)\n"
+ )
+ result = _python_exec(code, timeout = 1) # no output_callback -> communicate path
+ assert "timed out" in result
+ time.sleep(4.0)
+ assert not sentinel.exists(), "non-streaming timeout leaked a stdout-holding grandchild"
+
+
+def test_drain_process_output_without_posix_process_group_apis(monkeypatch):
+ # On Windows os.getpgid / os.killpg are absent; _drain_process_output must not
+ # raise AttributeError before reading the child's output. Removing the APIs and
+ # flipping os.name: the child still runs and is captured, only the group kill is skipped.
+ import subprocess as _sp
+
+ from core.inference.tools import _drain_process_output
+
+ monkeypatch.delattr(os, "getpgid", raising = False)
+ monkeypatch.delattr(os, "killpg", raising = False)
+ monkeypatch.setattr(os, "name", "nt")
+
+ proc = _sp.Popen(
+ [sys.executable, "-c", "print('ok-no-pgid')"],
+ stdout = _sp.PIPE,
+ stderr = _sp.STDOUT,
+ text = True,
+ )
+ output, timed_out = _drain_process_output(proc, 10, lambda _t: None)
+ assert not timed_out
+ assert "ok-no-pgid" in output
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
+def test_captured_group_survives_fast_leader_reap(tmp_path):
+ # Capture the group after spawn, reap the leader first (as a polling cancel
+ # watcher would), then drain: the pre-captured pgid must still reap the
+ # stdout-holding grandchild even though os.getpgid(pid) would now fail.
+ import subprocess as _sp
+
+ from core.inference.tools import _capture_process_group, _drain_process_output
+
+ sentinel = tmp_path / "grandchild_ran"
+ proc = _sp.Popen(
+ ["bash", "-c", f"( sleep 3; touch '{sentinel}' ) & echo parent-done"],
+ stdout = _sp.PIPE,
+ stderr = _sp.STDOUT,
+ text = True,
+ preexec_fn = os.setsid,
+ )
+ pgid = _capture_process_group(proc)
+ assert pgid is not None
+ proc.wait() # reap the leader before draining
+
+ output, timed_out = _drain_process_output(proc, 0.5, None, pgid = pgid)
+ assert timed_out
+ assert "parent-done" in output
+ time.sleep(4.0)
+ assert not sentinel.exists(), "pre-captured group failed to reap the grandchild"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
+def test_finite_drain_honors_cancel_after_leader_exit(tmp_path):
+ # Once the leader exits the cancel watcher (which loops on proc.poll()) is gone,
+ # so the finite-timeout drain itself must honor cancellation: a mid-drain
+ # cancel_event must break the drain promptly and kill the process group instead
+ # of draining a chatty grandchild for the whole large budget.
+ import subprocess as _sp
+ import threading as _th
+
+ from core.inference.tools import _capture_process_group, _drain_process_output
+
+ sentinel = tmp_path / "grandchild_late"
+ # Grandchild holds the pipe open, streams every 0.2s, and touches the sentinel
+ # only after 10s -- well past the cancel. The leader exits immediately, so the
+ # drain enters the finite branch with a live, chatty reader.
+ proc = _sp.Popen(
+ [
+ "bash",
+ "-c",
+ "( for i in $(seq 1 100); do echo tick-$i; sleep 0.2; done; "
+ f"touch '{sentinel}' ) & echo parent-done",
+ ],
+ stdout = _sp.PIPE,
+ stderr = _sp.STDOUT,
+ text = True,
+ preexec_fn = os.setsid,
+ )
+ pgid = _capture_process_group(proc)
+ assert pgid is not None
+ proc.wait() # leader exits at once; the cancel watcher would now be gone
+
+ cancel_event = _th.Event()
+ _th.Timer(0.6, cancel_event.set).start() # cancel shortly into the drain
+
+ started = time.monotonic()
+ # Large finite timeout (30s); without the cancel poll the drain keeps reading
+ # the grandchild until the pipe closes ~20s later.
+ output, timed_out = _drain_process_output(proc, 30, lambda _t: None, cancel_event, pgid = pgid)
+ elapsed = time.monotonic() - started
+ assert elapsed < 5.0, f"finite drain ignored cancel_event (took {elapsed:.1f}s)"
+ # Cancellation is not a timeout: the budget never elapsed.
+ assert not timed_out
+ assert "parent-done" in output
+ time.sleep(11.0) # past the grandchild's 10s sentinel write
+ assert not sentinel.exists(), "cancel did not kill the stdout-holding grandchild group"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
+def test_streamed_wait_timeout_kills_grandchild_when_leader_reaped(tmp_path, monkeypatch):
+ # The proc.wait() timeout branch normally kills the group via _kill_process_tree.
+ # But the leader can exit before _kill_process_tree samples its pgid, which then
+ # short-circuits on the reaped leader and leaves a stdout-holding grandchild.
+ # Model that race with _kill_process_tree as a no-op; the captured-pgid kill in
+ # the timeout branch must still reap the grandchild, matching non-streaming.
+ import subprocess as _sp
+
+ from core.inference import tools as _tools_mod
+ from core.inference.tools import _capture_process_group, _drain_process_output
+
+ monkeypatch.setattr(_tools_mod, "_kill_process_tree", lambda proc: None)
+
+ sentinel = tmp_path / "grandchild_ran"
+ # Leader sleeps past the timeout so proc.wait() genuinely times out; a same-group
+ # grandchild holds stdout and would touch the sentinel unless the group is killed.
+ proc = _sp.Popen(
+ ["bash", "-c", f"( sleep 3; touch '{sentinel}' ) & sleep 30"],
+ stdout = _sp.PIPE,
+ stderr = _sp.STDOUT,
+ text = True,
+ preexec_fn = os.setsid,
+ )
+ pgid = _capture_process_group(proc)
+ assert pgid is not None
+
+ output, timed_out = _drain_process_output(proc, 0.5, None, pgid = pgid)
+ assert timed_out
+ time.sleep(4.0) # past the grandchild's 3s sleep
+ assert not sentinel.exists(), (
+ "streamed wait timeout leaked a stdout-holding grandchild when the "
+ "process-tree kill short-circuited on the reaped leader"
+ )
+
+
+# ── GGUF loop regression: model-visible messages unchanged ───────
+
+
+def _run_gguf_tool_turn(monkeypatch, fake_execute_tool):
+ tool_stream = [
+ _sse(
+ {
+ "tool_calls": [
+ {
+ "id": "call_1",
+ "index": 0,
+ "function": {
+ "name": "python",
+ "arguments": json.dumps({"code": "print('hi')"}),
+ },
+ }
+ ]
+ }
+ ),
+ _done(),
+ ]
+ final_stream = [_sse({"content": "All done."}), _done()]
+ payloads: list[dict] = []
+ backend = _make_backend(monkeypatch, [tool_stream, final_stream], payloads)
+ monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
+ events = list(
+ backend.generate_chat_completion_with_tools(
+ messages = [{"role": "user", "content": "run it"}],
+ tools = [{"type": "function", "function": {"name": "python"}}],
+ max_tool_iterations = 1,
+ )
+ )
+ return events, payloads
+
+
+def test_gguf_loop_final_tool_message_unchanged_by_streaming(monkeypatch):
+ result_text = "hi\nline 2\n"
+
+ def plain_tool(name, arguments, **_kwargs):
+ return result_text
+
+ def streaming_tool(
+ name,
+ arguments,
+ output_callback = None,
+ **_kwargs,
+ ):
+ if output_callback is not None:
+ output_callback("hi\n")
+ output_callback("line 2\n")
+ return result_text
+
+ events_plain, payloads_plain = _run_gguf_tool_turn(monkeypatch, plain_tool)
+ events_streaming, payloads_streaming = _run_gguf_tool_turn(monkeypatch, streaming_tool)
+
+ def _tool_messages(payloads):
+ return [
+ msg for payload in payloads for msg in payload["messages"] if msg.get("role") == "tool"
+ ]
+
+ # The role=tool message fed to the model is byte-identical: streaming is purely
+ # observational and must not perturb parsing/nudging/healing.
+ assert _tool_messages(payloads_streaming) == _tool_messages(payloads_plain)
+ assert _tool_messages(payloads_streaming) == [
+ {
+ "role": "tool",
+ "name": "python",
+ "content": result_text,
+ "tool_call_id": "call_1",
+ }
+ ]
+
+ # tool_end results match too.
+ ends_plain = [e for e in events_plain if e["type"] == "tool_end"]
+ ends_streaming = [e for e in events_streaming if e["type"] == "tool_end"]
+ assert [e["result"] for e in ends_streaming] == [e["result"] for e in ends_plain]
+
+
+def test_gguf_loop_emits_tool_output_between_start_and_end(monkeypatch):
+ def streaming_tool(
+ name,
+ arguments,
+ output_callback = None,
+ **_kwargs,
+ ):
+ if output_callback is not None:
+ output_callback("progress 1\n")
+ output_callback("progress 2\n")
+ return "progress 1\nprogress 2\n"
+
+ events, _payloads = _run_gguf_tool_turn(monkeypatch, streaming_tool)
+ types = [e["type"] for e in events]
+ assert "tool_output" in types
+ start_idx = types.index("tool_start")
+ end_idx = types.index("tool_end")
+ output_indices = [i for i, t in enumerate(types) if t == "tool_output"]
+ assert all(start_idx < i < end_idx for i in output_indices)
+ streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
+ assert streamed == "progress 1\nprogress 2\n"
+ for e in events:
+ if e["type"] == "tool_output":
+ assert e["tool_name"] == "python"
+ assert e["tool_call_id"] == "call_1"
+
+
+def test_gguf_loop_plain_tool_yields_no_tool_output(monkeypatch):
+ def plain_tool(name, arguments, **_kwargs):
+ return "quiet"
+
+ events, _payloads = _run_gguf_tool_turn(monkeypatch, plain_tool)
+ assert [e for e in events if e["type"] == "tool_output"] == []
+
+
+# ── result truncation notice, env cap, missing-path healing ──────
+
+import os as _os
+import uuid as _uuid
+
+from core.inference.tools import (
+ PYTHON_TOOL,
+ TERMINAL_TOOL,
+ _MAX_OUTPUT_CHARS,
+ _env_int,
+ _missing_path_hint,
+ _truncate,
+ get_sandbox_workdir,
+)
+
+
+def test_truncate_notice_is_neutral_and_mentions_workdir():
+ out = _truncate("y" * 50, limit = 10)
+ assert out.startswith("y" * 10)
+ assert "truncated" in out and "50 chars total" in out
+ assert "persist in the working directory" in out
+ # The notice must NOT claim the user saw the output: this wrapper also serves
+ # non-streaming callers where no output_callback delivers anything.
+ assert "the user was shown the full output" not in out
+ assert "shown" not in out
+ # Under the limit: untouched.
+ assert _truncate("short", limit = 10) == "short"
+
+
+def test_truncated_result_identical_and_notice_neutral_with_streaming():
+ # The truncation notice must be byte-identical with and without an
+ # output_callback (the streaming vs non-streaming invariant a mode-dependent
+ # notice would break) and must not claim the user was shown the full output.
+ code = f"print('x' * {_MAX_OUTPUT_CHARS + 5000})"
+ baseline = _python_exec(code, timeout = 60)
+ streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
+ assert streamed == baseline
+ assert "truncated" in baseline
+ assert "the user was shown the full output" not in baseline
+ assert "persist in the working directory" in baseline
+
+
+def test_result_cap_env_override(monkeypatch):
+ monkeypatch.delenv("UNSLOTH_TOOL_RESULT_MAX_CHARS", raising = False)
+ assert _env_int("UNSLOTH_TOOL_RESULT_MAX_CHARS", 16000) == 16000
+ monkeypatch.setenv("UNSLOTH_TOOL_RESULT_MAX_CHARS", "50000")
+ assert _env_int("UNSLOTH_TOOL_RESULT_MAX_CHARS", 16000) == 50000
+ # Garbage and non-positive values fall back to the default.
+ monkeypatch.setenv("UNSLOTH_TOOL_RESULT_MAX_CHARS", "lots")
+ assert _env_int("UNSLOTH_TOOL_RESULT_MAX_CHARS", 16000) == 16000
+ monkeypatch.setenv("UNSLOTH_TOOL_RESULT_MAX_CHARS", "-5")
+ assert _env_int("UNSLOTH_TOOL_RESULT_MAX_CHARS", 16000) == 16000
+
+
+def test_missing_path_hint_detection():
+ err = "FileNotFoundError: [Errno 2] No such file or directory: '/mnt/data/x.html'"
+ hint = _missing_path_hint(err)
+ assert "working directory is writable" in hint
+ assert "relative path" in hint
+ # The hint echoes the actual failing path, not a canned example.
+ assert "'x.html', not '/mnt/data/x.html'" in hint
+ # A failure on a local path gets no hint.
+ assert _missing_path_hint("FileNotFoundError: 'local.txt'") == ""
+ # Mentioning /mnt/data without a file error gets no hint.
+ assert _missing_path_hint("saved to /mnt/data, all good") == ""
+ assert _missing_path_hint("") == ""
+
+
+def test_missing_path_hint_generalizes_beyond_convention_prefixes():
+ # A hallucinated absolute path outside the enumerated prefixes still earns the
+ # hint, echoing that path.
+ err = (
+ "FileNotFoundError: [Errno 2] No such file or directory: "
+ "'/home/ubuntu/Sandbox/flappy_bird.html'"
+ )
+ hint = _missing_path_hint(err)
+ assert "working directory is writable" in hint
+ assert "'flappy_bird.html', not '/home/ubuntu/Sandbox/flappy_bird.html'" in hint
+ # A bash-style error on an absolute path outside the workdir is echoed too.
+ bash_err = "cat: /var/data/report.csv: No such file or directory"
+ assert "'report.csv', not '/var/data/report.csv'" in _missing_path_hint(bash_err)
+
+
+def test_missing_path_hint_respects_project_workdir():
+ # Project-backed sessions run under a root OUTSIDE ~/studio_sandbox. A legitimate
+ # miss INSIDE that project workspace must not be misclassified as an external
+ # habit path and flattened to its basename; judged against the real workdir it
+ # gets no hint. The fabricated paths carry no convention prefix, so only the
+ # workdir judgement decides.
+ workdir = "/srv/projroot/session_area"
+ missing = "/srv/projroot/session_area/data/missing.csv"
+ output = f"FileNotFoundError: [Errno 2] No such file or directory: '{missing}'"
+ # Against the static sandbox root (no workdir) it looks external and wrongly earns the hint.
+ assert "working directory is writable" in _missing_path_hint(output)
+ # Against the real project workdir it is local -> no hint.
+ assert _missing_path_hint(output, workdir) == ""
+ # A path genuinely outside the project workdir still earns the hint.
+ outside_err = "FileNotFoundError: [Errno 2] No such file or directory: '/srv/other/x.html'"
+ assert "working directory is writable" in _missing_path_hint(outside_err, workdir)
+
+
+def test_missing_path_hint_project_workdir_under_convention_prefix():
+ # A project workdir can live under a convention prefix like /workspace (common in
+ # containers). A genuine miss INSIDE it carries the "/workspace" substring but is
+ # a real local path, not a habit path: the convention fast path must not fire and
+ # flatten it to a bare basename (which would drop the project subdirectory).
+ workdir = "/workspace/proj"
+ nested = "/workspace/proj/sub/data.csv"
+ output = f"FileNotFoundError: [Errno 2] No such file or directory: '{nested}'"
+ # Against the real project workdir the miss is local -> no hint, so
+ # /workspace/proj/sub is not flattened away.
+ assert _missing_path_hint(output, workdir) == ""
+ # A miss at the project root itself is likewise local.
+ at_root = "/workspace/proj/data.csv"
+ root_output = f"FileNotFoundError: [Errno 2] No such file or directory: '{at_root}'"
+ assert _missing_path_hint(root_output, workdir) == ""
+ # A convention path genuinely outside the project workdir still earns the
+ # hint (e.g. a /mnt/data habit path with a /workspace-rooted project).
+ outside = "FileNotFoundError: [Errno 2] No such file or directory: '/mnt/data/x.html'"
+ assert "'x.html', not '/mnt/data/x.html'" in _missing_path_hint(outside, workdir)
+ # Without an explicit workdir the default sandbox root applies, so a
+ # /workspace path is out of sandbox and keeps the habit-path hint.
+ assert "working directory is writable" in _missing_path_hint(root_output)
+
+
+def test_missing_path_hint_convention_scoped_to_failing_line():
+ # A convention prefix appearing only OUTSIDE the failing-path line (a traceback
+ # frame under /workspace, or the user's code printing /mnt/data) must not trigger
+ # the hint when the actual miss was a relative / in-workdir path.
+ frame_err = (
+ "Traceback (most recent call last):\n"
+ ' File "/workspace/proj/script.py", line 5, in \n'
+ " open('data.csv')\n"
+ "FileNotFoundError: [Errno 2] No such file or directory: 'data.csv'"
+ )
+ assert _missing_path_hint(frame_err) == ""
+ printed_err = (
+ "outputs go to /mnt/data normally\n"
+ "FileNotFoundError: [Errno 2] No such file or directory: 'notes.txt'"
+ )
+ assert _missing_path_hint(printed_err) == ""
+ # But a convention path ON the error line still earns the hint.
+ on_line = "FileNotFoundError: [Errno 2] No such file or directory: '/mnt/data/x.html'"
+ assert "'x.html', not '/mnt/data/x.html'" in _missing_path_hint(on_line)
+
+
+def test_code_tool_descriptions_mention_relative_paths():
+ for tool in (PYTHON_TOOL, TERMINAL_TOOL):
+ description = tool["function"]["description"]
+ assert "relative paths" in description
+ assert "/mnt/data" in description
+
+
+def test_python_exec_mnt_data_open_is_remapped_into_workdir():
+ # The shim remaps open()/os.makedirs() on /mnt/data into the sandbox CWD and
+ # prints a one-line stderr notice, identically with and without streaming.
+ fname = f"remap_{_uuid.uuid4().hex}.txt"
+ code = (
+ "import os\n"
+ "os.makedirs('/mnt/data', exist_ok=True)\n"
+ f"with open('/mnt/data/{fname}', 'w') as f:\n"
+ " f.write('hello remap')\n"
+ f"print(open('/mnt/data/{fname}').read())\n"
+ )
+ target = _os.path.join(get_sandbox_workdir(), fname)
+ try:
+ baseline = _python_exec(code, timeout = 60)
+ assert _os.path.isfile(target), baseline
+ with open(target) as f:
+ assert f.read() == "hello remap"
+ assert "hello remap" in baseline
+ assert "/mnt/data does not exist in this sandbox" in baseline
+ _os.remove(target)
+ streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
+ assert streamed == baseline
+ assert _os.path.isfile(target)
+ finally:
+ if _os.path.exists(target):
+ _os.remove(target)
+
+
+def test_python_exec_pathlib_write_text_is_remapped_into_workdir():
+ # pathlib.Path.open / write_text / read_text call io.open directly,
+ # bypassing the builtins.open patch, so the shim must remap io.open too.
+ fname = f"remap_{_uuid.uuid4().hex}.txt"
+ code = (
+ "from pathlib import Path\n"
+ f"p = Path('/mnt/data/{fname}')\n"
+ "p.write_text('pathlib remap')\n"
+ "print(p.read_text())\n"
+ )
+ target = _os.path.join(get_sandbox_workdir(), fname)
+ try:
+ baseline = _python_exec(code, timeout = 60)
+ assert _os.path.isfile(target), baseline
+ with open(target) as f:
+ assert f.read() == "pathlib remap"
+ assert "pathlib remap" in baseline
+ assert "/mnt/data does not exist in this sandbox" in baseline
+ _os.remove(target)
+ streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
+ assert streamed == baseline
+ assert _os.path.isfile(target)
+ finally:
+ if _os.path.exists(target):
+ _os.remove(target)
+
+
+def test_python_exec_hallucinated_absolute_write_is_remapped_into_workdir():
+ # The model invents an absolute path outside the enumerated prefixes and opens
+ # it for writing; the write-mode fallback redirects it to the basename in the
+ # sandbox workdir instead of dying with FileNotFoundError.
+ fname = f"remap_{_uuid.uuid4().hex}.html"
+ hallucinated = f"/nonexistent_root_xyz/Sandbox/{fname}"
+ # Read-back goes through the mapped basename: reads are never redirected, only
+ # the write is healed.
+ code = (
+ f"with open('{hallucinated}', 'w') as f:\n"
+ " f.write('hello fallback')\n"
+ f"print(open('{fname}').read())\n"
+ )
+ target = _os.path.join(get_sandbox_workdir(), fname)
+ try:
+ baseline = _python_exec(code, timeout = 60)
+ assert _os.path.isfile(target), baseline
+ with open(target) as f:
+ assert f.read() == "hello fallback"
+ assert "hello fallback" in baseline
+ assert "does not exist in this sandbox" in baseline
+ _os.remove(target)
+ streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
+ assert streamed == baseline
+ assert _os.path.isfile(target)
+ finally:
+ if _os.path.exists(target):
+ _os.remove(target)
+
+
+def test_python_exec_unremapped_mnt_data_failure_gets_hint():
+ # os.listdir is deliberately not remapped: the failure carries the retry hint
+ # instead, identically with and without streaming.
+ import re as _re
+
+ code = "import os\nos.listdir('/mnt/data/nonexistent_dir_xyz')\n"
+ baseline = _python_exec(code, timeout = 60)
+ assert "FileNotFoundError" in baseline
+ assert "working directory is writable" in baseline
+ streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
+
+ # Normalize each run's random temp filename (byte-identity is per-execution).
+ def normalize(text: str) -> str:
+ return _re.sub(r"studio_exec_\w+\.py", "studio_exec.py", text)
+
+ assert normalize(streamed) == normalize(baseline)
+
+
+def test_bash_exec_missing_path_hint():
+ baseline = _bash_exec("cat /mnt/data/definitely_missing.txt", timeout = 60)
+ assert "No such file or directory" in baseline
+ assert "working directory is writable" in baseline
+ streamed = _bash_exec(
+ "cat /mnt/data/definitely_missing.txt", timeout = 60, output_callback = lambda _t: None
+ )
+ assert streamed == baseline
+
+
+def test_bash_exec_local_failure_gets_no_hint():
+ result = _bash_exec("cat definitely_missing_local_file.txt", timeout = 60)
+ assert "No such file or directory" in result
+ assert "working directory is writable" not in result
+
+
+def test_producer_queue_is_bounded_under_tight_print_loop(monkeypatch):
+ # The consumer-side cap only bounds the concatenated stream; a fast worker can
+ # still enqueue unboundedly while the SSE consumer is backpressured. The producer
+ # boundary now discards callbacks past the cap so the queue cannot grow without
+ # limit (finding 12).
+ import queue as _queue
+
+ from core.inference import tool_stream_exec
+
+ observed = []
+
+ class _TrackingQueue(_queue.Queue):
+ def put(self, *args, **kwargs):
+ result = super().put(*args, **kwargs)
+ observed.append(self.qsize())
+ return result
+
+ monkeypatch.setattr(tool_stream_exec.queue, "Queue", _TrackingQueue)
+
+ def tool(callback):
+ for _ in range(200_000):
+ callback("x")
+ return "done"
+
+ events, result = _run_stream(tool, tool_name = "python")
+ assert result == "done"
+ # At most cap + 1 chars enter the queue, so 1-char items cannot exceed that
+ # regardless of consumer lag.
+ assert observed
+ assert max(observed) <= TOOL_OUTPUT_STREAM_MAX_CHARS + 2
+
+
+def test_continuous_over_cap_output_does_not_starve_heartbeats():
+ # Once the cap is tripped, a continuously producing tool must not spin the drain
+ # forever with no heartbeat: callbacks past the budget never enter the queue, so
+ # the idle heartbeat path resumes (finding 13).
+ release = threading.Event()
+
+ def tool(callback):
+ callback("x" * (TOOL_OUTPUT_STREAM_MAX_CHARS + 10)) # trip the cap
+ while not release.is_set():
+ callback("spam") # discarded at the producer boundary
+ return "done"
+
+ watchdog = threading.Timer(8.0, release.set)
+ watchdog.start()
+ gen = stream_tool_execution(
+ tool,
+ tool_name = "python",
+ heartbeat_interval_s = 0.04,
+ poll_interval_s = 0.02,
+ )
+ events = []
+ result = None
+ try:
+ while True:
+ event = next(gen)
+ events.append(event)
+ if len([e for e in events if e["type"] == "heartbeat"]) >= 2:
+ release.set()
+ except StopIteration as stop:
+ result = stop.value
+ finally:
+ release.set()
+ watchdog.cancel()
+ assert result == "done"
+ assert len([e for e in events if e["type"] == "heartbeat"]) >= 2
+
+
+def test_accepts_output_callback_signature_detection():
+ from core.inference.tool_stream_exec import accepts_output_callback
+
+ def legacy(
+ name,
+ arguments,
+ cancel_event = None,
+ timeout = None,
+ ):
+ return "ok"
+
+ def modern(
+ name,
+ arguments,
+ output_callback = None,
+ ):
+ return "ok"
+
+ def kwargs_only(name, arguments, **kw):
+ return "ok"
+
+ assert accepts_output_callback(legacy) is False
+ assert accepts_output_callback(modern) is True
+ assert accepts_output_callback(kwargs_only) is True
+ # Uninspectable callables (e.g. some builtins) fall back to not-supported.
+ assert accepts_output_callback(len) is False
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
+def test_bash_exec_nonstreaming_cancel_kills_grandchild_after_leader_exit(tmp_path):
+ # NON-streaming cancellation: the leader exits at once while a grandchild holds
+ # stdout. The cancel watcher loops on the leader's poll() and is gone, so before
+ # the fix communicate() blocked until the grandchild finished. The unified drain
+ # kills the captured group on cancel instead.
+ sentinel = tmp_path / "grandchild_ran"
+ command = f"( sleep 3; touch '{sentinel}' ) & echo parent-done"
+ cancel_event = threading.Event()
+ timer = threading.Timer(0.5, cancel_event.set)
+ timer.start()
+ started = time.monotonic()
+ try:
+ result = _bash_exec(command, cancel_event = cancel_event, timeout = 30)
+ finally:
+ timer.cancel()
+ assert time.monotonic() - started < 2.5
+ assert result == "Execution cancelled."
+ time.sleep(3.5)
+ assert not sentinel.exists(), "non-streaming cancel leaked a stdout-holding grandchild"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
+def test_python_exec_nonstreaming_cancel_kills_grandchild_after_leader_exit(tmp_path):
+ sentinel = tmp_path / "grandchild_ran"
+ code = (
+ "import subprocess\n"
+ f"subprocess.Popen(['bash', '-c', \"sleep 3; touch '{sentinel}'\"])\n"
+ "print('parent-done')\n"
+ )
+ cancel_event = threading.Event()
+ timer = threading.Timer(0.5, cancel_event.set)
+ timer.start()
+ started = time.monotonic()
+ try:
+ result = _python_exec(code, cancel_event = cancel_event, timeout = 30)
+ finally:
+ timer.cancel()
+ assert time.monotonic() - started < 2.5
+ assert result == "Execution cancelled."
+ time.sleep(3.5)
+ assert not sentinel.exists(), "non-streaming cancel leaked a stdout-holding grandchild"
diff --git a/studio/backend/tests/test_tool_stream_generator_drain.py b/studio/backend/tests/test_tool_stream_generator_drain.py
new file mode 100644
index 0000000000..e1d751ab09
--- /dev/null
+++ b/studio/backend/tests/test_tool_stream_generator_drain.py
@@ -0,0 +1,83 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Regression tests for generator-close cleanup in the tool-streaming routes.
+
+Tool streams run ``next(gen)`` in an ``asyncio.to_thread`` worker. Closing the
+generator while that worker is still inside ``next`` raises ``ValueError:
+generator already executing`` and skips the generator's ``finally`` (tool
+cleanup); the routes drain the pending task first (``_drain_pending_next_task``),
+which these tests exercise.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import threading
+
+import pytest
+
+from routes.inference import _drain_pending_next_task
+
+
+def test_drain_before_close_avoids_generator_already_executing():
+ cancel_event = threading.Event()
+ entered = threading.Event()
+ finally_ran = threading.Event()
+
+ def blocking_gen():
+ try:
+ entered.set()
+ # Blocking call inside next(gen) that respects the cancel flag.
+ cancel_event.wait()
+ yield "value"
+ finally:
+ finally_ran.set()
+
+ async def scenario():
+ gen = blocking_gen()
+ next_task = asyncio.create_task(asyncio.to_thread(next, gen, object()))
+ await asyncio.to_thread(entered.wait) # worker now inside next(gen)
+
+ # Closing mid-next races and raises, leaving the finally unrun.
+ with pytest.raises(ValueError):
+ gen.close()
+ assert not finally_ran.is_set()
+
+ # Draining sets the cancel flag so the worker returns; then close is
+ # clean and the generator's finally runs.
+ await _drain_pending_next_task(next_task, cancel_event)
+ gen.close()
+ return
+
+ asyncio.run(scenario())
+ assert finally_ran.is_set()
+
+
+def test_drain_pending_next_task_is_noop_without_task():
+ # None (task already consumed): draining is a no-op, cancel flag untouched.
+ cancel_event = threading.Event()
+
+ asyncio.run(_drain_pending_next_task(None, cancel_event))
+ assert not cancel_event.is_set()
+
+
+def test_drain_pending_next_task_returns_when_worker_finishes():
+ # A worker finishing on its own drains without error; the cancel flag stays
+ # set (the caller is tearing the stream down).
+ cancel_event = threading.Event()
+ release = threading.Event()
+
+ def gen():
+ release.wait()
+ yield "done"
+
+ async def scenario():
+ g = gen()
+ task = asyncio.create_task(asyncio.to_thread(next, g, object()))
+ release.set() # let the worker complete before draining
+ await _drain_pending_next_task(task, cancel_event)
+ assert task.done()
+
+ asyncio.run(scenario())
+ assert cancel_event.is_set()
diff --git a/studio/backend/tests/test_torchao_select.py b/studio/backend/tests/test_torchao_select.py
index 2d3dc5fbff..e4775a10a6 100644
--- a/studio/backend/tests/test_torchao_select.py
+++ b/studio/backend/tests/test_torchao_select.py
@@ -32,16 +32,23 @@ def _load_module(monkeypatch):
@pytest.mark.parametrize(
"torch_version, expected",
[
- # torch 2.10 (the reported bug: cu130 resolves 2.10.0) -> 0.16.0,
- # independent of the local +cuXXX/+rocm/+cpu suffix or patch level.
- ("2.10.0+cu130", "torchao==0.16.0"),
+ # torch 2.10 on CUDA <= 12 -> 0.16.0 (its cpp is built for torch 2.10.0 and
+ # loads against the CUDA-12 PyPI wheel). Independent of patch level.
+ ("2.10.0+cu128", "torchao==0.16.0"),
+ ("2.10.0+cu126", "torchao==0.16.0"),
("2.10.0+rocm6.4", "torchao==0.16.0"),
("2.10.0+cpu", "torchao==0.16.0"),
("2.10.1", "torchao==0.16.0"),
("2.10.0", "torchao==0.16.0"),
- # Pre-release / dev / rc builds: the minor is cleaned of non-digits.
+ # torch 2.10 on CUDA >= 13 (Blackwell / cu130): 0.16.0's CUDA-12 cpp can't
+ # load against a CUDA-13 torch (libcudart.so.12 error), so use 0.17.0.
+ ("2.10.0+cu130", "torchao==0.17.0"),
+ ("2.10.0+cu140", "torchao==0.17.0"),
+ # Pre-release / dev / rc builds: the minor is cleaned of non-digits; the
+ # CUDA tag still decides 0.16.0 vs 0.17.0.
("2.10.0rc1", "torchao==0.16.0"),
- ("2.10.0.dev20250804+cu130", "torchao==0.16.0"),
+ ("2.10.0.dev20250804+cu130", "torchao==0.17.0"),
+ ("2.10.0.dev20250804+cu128", "torchao==0.16.0"),
("2.10rc1", "torchao==0.16.0"),
# torch 2.11 (reachable via ROCm rocm7.2) and forward -> 0.17.0.
("2.11.0+cu130", "torchao==0.17.0"),
diff --git a/studio/backend/tests/test_torchao_stub_worker_parity.py b/studio/backend/tests/test_torchao_stub_worker_parity.py
new file mode 100644
index 0000000000..bb743385f1
--- /dev/null
+++ b/studio/backend/tests/test_torchao_stub_worker_parity.py
@@ -0,0 +1,130 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Invariant: the inference subprocess must install the torchao Windows-ROCm stub before it imports
+transformers.
+
+``core/_torchao_stub.py:install_torchao_windows_rocm_stub`` stubs torchao so transformers can import
+without an absent RCCL backend on Windows ROCm (no-op on every other runtime). If transformers imports
+first, a legacy Windows-ROCm venv that still carries a real torchao crashes on import (issue #6833).
+Three entrypoints already guard this (the training and export workers, and the main-process rag
+embedder); the inference worker -- the most-used path -- never had the call.
+
+CPU-only: parses source with ``ast``, no torch/transformers/GPU/weights needed.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+from core._torchao_stub import install_torchao_windows_rocm_stub
+
+_BACKEND = Path(__file__).resolve().parent.parent # studio/backend
+_CORE = _BACKEND / "core"
+_STUB = install_torchao_windows_rocm_stub.__name__ # a rename breaks the import loudly
+
+_ENTRYPOINTS = [
+ _CORE / "training" / "worker.py",
+ _CORE / "export" / "worker.py",
+ _CORE / "rag" / "embeddings.py",
+ _CORE / "inference" / "worker.py",
+]
+
+
+def _stub_call_linenos(node) -> list[int]:
+ """Line numbers of every ``install_torchao_windows_rocm_stub()`` call under ``node``."""
+ return [
+ c.lineno
+ for c in ast.walk(node)
+ if isinstance(c, ast.Call) and isinstance(c.func, ast.Name) and c.func.id == _STUB
+ ]
+
+
+def _func(tree, name):
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef) and node.name == name:
+ return node
+ return None
+
+
+def test_all_entrypoints_call_stub():
+ """Every entrypoint that imports transformers must call the stub at all -- this is the exact
+ gap that shipped (the inference worker never gained the call). This is a presence check (the call
+ exists in the file); ordering is asserted only for the inference worker below, the path this fix
+ hardened. The other three import transformers at structurally different sites."""
+ for path in _ENTRYPOINTS:
+ assert _stub_call_linenos(ast.parse(path.read_text(encoding = "utf-8"))), (
+ f"{path.relative_to(_BACKEND)} never calls {_STUB}() -- transformers would import "
+ "unguarded and crash on a legacy Windows-ROCm venv (issue #6833)."
+ )
+
+
+_INFERENCE_MOD = "core.inference.inference"
+
+
+def _imports_transformers(node) -> bool:
+ """A statement that imports transformers directly (``import transformers[.x]`` /
+ ``from transformers[.x] import ...``) or transitively at load: any absolute or relative import
+ form resolving to ``core.inference.inference`` (whose module imports transformers), so a style
+ refactor of the section-2 import can't slip past the anchor."""
+ if isinstance(node, ast.Import):
+ return any(
+ a.name.split(".")[0] == "transformers"
+ or a.name == _INFERENCE_MOD
+ or a.name.startswith(_INFERENCE_MOD + ".")
+ for a in node.names
+ )
+ if isinstance(node, ast.ImportFrom):
+ module = node.module or ""
+ if node.level == 0:
+ return (
+ module.split(".")[0] == "transformers"
+ or module == _INFERENCE_MOD
+ or module.startswith(_INFERENCE_MOD + ".")
+ or (module == "core.inference" and any(a.name == "inference" for a in node.names))
+ )
+ # Relative forms inside core/inference/worker.py: ``from .inference import X`` and
+ # ``from . import inference`` both resolve to core.inference.inference.
+ return module == "inference" or (
+ not module and any(a.name == "inference" for a in node.names)
+ )
+ return False
+
+
+def test_inference_worker_stubs_before_transformers():
+ """In ``run_inference_process`` the stub must precede every path that reaches transformers: the
+ section-2 imports (direct ``import transformers`` and the transitive ``core.inference.inference``
+ import), and -- the reason it sits at the top of the function -- the ``_resolve_base_model`` call,
+ which pulls transformers via ``utils.models`` for a local LoRA adapter with no recorded base.
+ Scoped to the function (mirrors ``test_ssm_runtime``) so a stub call elsewhere in the module can't
+ mask a drop from the function that actually runs the import. The ``_activate_transformers_version``
+ call inside the MLX branch is not an anchor: MLX is never Windows ROCm, so it needs no stub."""
+ tree = ast.parse((_CORE / "inference" / "worker.py").read_text(encoding = "utf-8"))
+ fn = _func(tree, "run_inference_process")
+ assert (
+ fn is not None
+ ), "run_inference_process not found in inference/worker.py -- renamed? update this test."
+
+ stub = _stub_call_linenos(fn)
+ assert stub, f"run_inference_process must call {_STUB}()"
+
+ dangers = []
+ for node in ast.walk(fn):
+ if _imports_transformers(node):
+ dangers.append(node.lineno)
+ elif (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_resolve_base_model"
+ ):
+ dangers.append(node.lineno)
+ assert dangers, (
+ "no transformers-reaching site found in run_inference_process -- the anchors are stale, update "
+ "them to the new import/resolution sites."
+ )
+
+ assert min(stub) < min(dangers), (
+ f"{_STUB}() at line {min(stub)} must run before the first transformers-reaching site at line "
+ f"{min(dangers)}; otherwise torchao imports unguarded on Windows ROCm (issue #6833)."
+ )
diff --git a/studio/backend/tests/test_training_preflight.py b/studio/backend/tests/test_training_preflight.py
index 54048a65dd..47c6669f8f 100644
--- a/studio/backend/tests/test_training_preflight.py
+++ b/studio/backend/tests/test_training_preflight.py
@@ -6,9 +6,15 @@ empty-chat-template crash) before train(). The real methods are bound onto a lig
fake self so the production logic runs against controlled batches."""
import importlib
+import json
+import os
+import queue
+import subprocess
import sys
+import threading
import types
import unittest
+from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -184,5 +190,231 @@ class TestChatTemplateRendersEmpty(unittest.TestCase):
self.assertFalse(s._chat_template_renders_empty())
+def _clear_trainer_module(package: str):
+ sys.modules.pop(f"{package}.trainer", None)
+ pkg = sys.modules.get(package)
+ if pkg is not None and hasattr(pkg, "trainer"):
+ delattr(pkg, "trainer")
+
+
+def _set_training_platform(monkeypatch, package: str, backend: str):
+ training_mod = importlib.import_module(f"{package}.training")
+ from utils.hardware import hardware as hw
+
+ monkeypatch.setattr(hw, "DEVICE", None)
+ monkeypatch.setattr(
+ training_mod.platform,
+ "system",
+ lambda: "Darwin" if backend == "mlx" else "Linux",
+ )
+ monkeypatch.setattr(
+ training_mod.platform,
+ "machine",
+ lambda: "arm64" if backend == "mlx" else "x86_64",
+ )
+
+
+def _load_trainer_module(
+ monkeypatch,
+ backend: str,
+ package: str = "core.training",
+):
+ _set_training_platform(monkeypatch, package, backend)
+ _clear_trainer_module(package)
+ if package in sys.modules:
+ importlib.reload(sys.modules[package])
+ trainer_mod = importlib.import_module(f"{package}.trainer")
+ training_mod = importlib.import_module(f"{package}.training")
+ monkeypatch.setattr(
+ training_mod._MLXTrainerAdapter,
+ "_activate_transformers_for_model",
+ lambda self, model_name, hf_token: None,
+ )
+ return trainer_mod
+
+
+class _ExitedProc:
+ def join(self, timeout = None):
+ return None
+
+ def is_alive(self):
+ return False
+
+
+class _TerminableProc:
+ def __init__(self):
+ self.terminated = False
+ self._done = threading.Event()
+
+ def join(self, timeout = None):
+ self._done.wait(timeout = timeout or 5)
+
+ def is_alive(self):
+ return not self.terminated
+
+ def terminate(self):
+ self.terminated = True
+ self._done.set()
+
+
+def test_unsloth_trainer_dispatches_for_mlx_and_torch(monkeypatch):
+ trainer_mod = _load_trainer_module(monkeypatch, "mlx")
+
+ mlx_trainer = trainer_mod.UnslothTrainer()
+
+ assert type(mlx_trainer).__module__ == "core.training.training"
+ assert mlx_trainer.get_training_progress().status_message == "Ready to train"
+
+ trainer_mod = _load_trainer_module(monkeypatch, "torch")
+
+ assert trainer_mod.UnslothTrainer().__class__ is trainer_mod.UnslothTrainer
+
+
+def test_cli_mlx_trainer_activates_before_importing_trainer():
+ repo_root = Path(__file__).resolve().parents[3]
+ script = """
+import json
+import sys
+import unsloth_cli.commands.train as train_cmd
+from studio.backend.core.training import training as training_mod
+from utils.hardware import hardware as hw
+
+training_mod.platform.system = lambda: "Darwin"
+training_mod.platform.machine = lambda: "arm64"
+hw.DEVICE = None
+events = []
+
+def fake_activate(model_name, hf_token):
+ events.append({
+ "model_name": model_name,
+ "trainer_loaded": "studio.backend.core.training.trainer" in sys.modules,
+ })
+
+train_cmd._activate_mlx_transformers = fake_activate
+trainer = train_cmd._create_cli_trainer("mlx-community/Qwen3-0.6B-4bit", None)
+print(json.dumps({
+ "trainer_module": type(trainer).__module__,
+ "events": events,
+}))
+"""
+ env = os.environ.copy()
+ env["PYTHONPATH"] = os.pathsep.join(
+ [str(repo_root), str(repo_root / "studio" / "backend"), env.get("PYTHONPATH", "")]
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ cwd = repo_root,
+ env = env,
+ text = True,
+ stdout = subprocess.PIPE,
+ stderr = subprocess.PIPE,
+ check = True,
+ )
+ payload = json.loads(result.stdout)
+
+ assert payload["trainer_module"] == "studio.backend.core.training.training"
+ assert payload["events"] == [
+ {"model_name": "mlx-community/Qwen3-0.6B-4bit", "trainer_loaded": False}
+ ]
+
+
+def test_mlx_adapter_builds_config_and_reports_completion(tmp_path, monkeypatch):
+ trainer_mod = _load_trainer_module(monkeypatch, "mlx")
+ captured = {}
+
+ def fake_run_worker(config, event_queue, stop_queue):
+ captured["config"] = config
+ event_queue.put({"type": "progress", "step": 1, "total_steps": 1, "loss": 0.25})
+ event_queue.put(
+ {"type": "complete", "status_message": "done", "output_dir": config["output_dir"]}
+ )
+
+ trainer = trainer_mod.UnslothTrainer()
+ monkeypatch.setattr(trainer, "_run_mlx_worker", fake_run_worker)
+
+ assert trainer.load_model("mlx-community/Qwen3-0.6B-4bit", max_seq_length = 1024)
+ assert trainer.prepare_model_for_training(use_lora = False)
+ dataset, eval_dataset = trainer.load_and_format_dataset("org/dataset")
+ output_dir = tmp_path / "mlx-out"
+
+ assert trainer.start_training(
+ dataset = dataset,
+ eval_dataset = eval_dataset,
+ output_dir = output_dir,
+ project_name = "Sales Assistant",
+ max_steps = 1,
+ learning_rate = 3e-4,
+ )
+ trainer.training_thread.join(timeout = 5)
+
+ progress = trainer.get_training_progress()
+ config = captured["config"]
+ assert progress.is_completed
+ assert progress.output_dir == str(output_dir.resolve())
+ progress.status_message = "mutated"
+ assert trainer.get_training_progress().status_message == "done"
+ assert config["model_name"] == "mlx-community/Qwen3-0.6B-4bit"
+ assert config["project_name"] == "Sales Assistant"
+ assert config["hf_dataset"] == "org/dataset"
+ assert config["training_type"] == "Full Finetuning"
+ assert config["load_in_4bit"] is False
+ assert config["max_seq_length"] == 1024
+ assert config["learning_rate"] == 3e-4
+ assert config["output_dir"] == str(output_dir.resolve())
+ assert config["allow_external_output_dir"] is True
+
+
+def test_mlx_worker_helpers_cover_cli_paths(tmp_path, monkeypatch):
+ _load_trainer_module(monkeypatch, "mlx")
+ from core.training.worker import (
+ _resolve_mlx_local_dataset_files,
+ _resolve_mlx_output_dir,
+ )
+
+ dataset = tmp_path / "train.jsonl"
+ dataset.write_text('{"text":"hello"}\n', encoding = "utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ assert _resolve_mlx_local_dataset_files(["train.jsonl"]) == [str(dataset)]
+ assert _resolve_mlx_output_dir(
+ {"output_dir": "cli-out", "allow_external_output_dir": True},
+ "mlx-community/Qwen3-0.6B-4bit",
+ ) == str((tmp_path / "cli-out").resolve())
+
+
+def test_run_mlx_training_process_applies_side_effects_before_hardware_detection(monkeypatch):
+ _load_trainer_module(monkeypatch, "mlx")
+ from core.training import worker
+ from utils.hardware import hardware as hw
+
+ order = []
+
+ def fake_activate(model_name, hf_token):
+ order.append(("activate", model_name, hf_token))
+
+ def fake_detect_hardware():
+ order.append("detect")
+ hw.DEVICE = hw.DeviceType.CPU
+ return hw.DEVICE
+
+ monkeypatch.delenv("HF_HUB_DISABLE_XET", raising = False)
+ monkeypatch.delenv("HF_HUB_ENABLE_HF_TRANSFER", raising = False)
+ monkeypatch.setattr(worker, "_activate_transformers_version_or_warn", fake_activate)
+ monkeypatch.setattr(hw, "detect_hardware", fake_detect_hardware)
+
+ event_queue = queue.Queue()
+ worker.run_mlx_training_process(
+ event_queue = event_queue,
+ stop_queue = queue.Queue(),
+ config = {"model_name": "mlx-community/Gemma-4-12B", "disable_xet": True},
+ )
+
+ event = event_queue.get_nowait()
+ assert order == [("activate", "mlx-community/Gemma-4-12B", None), "detect"]
+ assert os.environ["HF_HUB_DISABLE_XET"] == "1"
+ assert os.environ["HF_HUB_ENABLE_HF_TRANSFER"] == "0"
+ assert "MLX training requires Apple Silicon" in event["error"]
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/studio/backend/tests/test_training_stop_watchdog.py b/studio/backend/tests/test_training_stop_watchdog.py
new file mode 100644
index 0000000000..457dfc8ea2
--- /dev/null
+++ b/studio/backend/tests/test_training_stop_watchdog.py
@@ -0,0 +1,824 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Stop-watchdog escalation for a stuck training stop.
+
+A save-stop signals the worker and waits for it to save and exit. On some platforms the
+worker saves but then wedges in post-save GPU/driver teardown and never exits, leaving the
+run stuck in "Stopping..." forever. These tests pin the bounded recovery: the watchdog
+escalates to force_terminate() a short grace after "complete" (save done) or after an
+absolute timeout (hang during save), and never force-kills a worker that exits cleanly.
+Fakes only; no GPU, network, or subprocess.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import queue
+import sys
+import threading
+import time
+import types as _types
+from pathlib import Path
+
+_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
+if _BACKEND_DIR not in sys.path:
+ sys.path.insert(0, _BACKEND_DIR)
+
+# Stub the heavy module-level imports of core/training/training.py so it imports
+# under CPU-only/no-network, then restore them (see the restore loop below).
+_SAVED: dict = {}
+
+
+def _stub(name, mod):
+ _SAVED[name] = sys.modules.get(name)
+ sys.modules[name] = mod
+
+
+_lg = _types.ModuleType("loggers")
+_lg.get_logger = lambda name: logging.getLogger(name)
+_stub("loggers", _lg)
+_stub("structlog", _types.ModuleType("structlog"))
+_mpl = _types.ModuleType("matplotlib")
+_plt = _types.ModuleType("matplotlib.pyplot")
+_plt.Figure = type("Figure", (), {}) # referenced in a class-def annotation
+_mpl.pyplot = _plt
+_stub("matplotlib", _mpl)
+_stub("matplotlib.pyplot", _plt)
+_hw = _types.ModuleType("utils.hardware")
+_hw.prepare_gpu_selection = lambda *a, **k: (None, None)
+_stub("utils.hardware", _hw)
+_npl = _types.ModuleType("utils.native_path_leases")
+_npl.native_path_secret_removed_for_child_start = lambda: contextlib.nullcontext()
+_npl.run_without_native_path_secret = lambda fn: fn
+_stub("utils.native_path_leases", _npl)
+_pth = _types.ModuleType("utils.paths")
+_pth.outputs_root = lambda *a, **k: "/tmp/outputs"
+_stub("utils.paths", _pth)
+
+# Whether core.training.training was already imported before this file ran; only
+# evict it below if we were the one to create the (stub-bound) module instance.
+_TRAINING_PRE_IMPORTED = "core.training.training" in sys.modules
+
+from core.training.training import TrainingBackend
+
+# Restore every stubbed module so this file never pollutes the shared session.
+for _name in (
+ "loggers",
+ "structlog",
+ "matplotlib",
+ "matplotlib.pyplot",
+ "utils.hardware",
+ "utils.native_path_leases",
+ "utils.paths",
+):
+ _prev = _SAVED.get(_name)
+ if _prev is None:
+ sys.modules.pop(_name, None)
+ else:
+ sys.modules[_name] = _prev
+
+if not _TRAINING_PRE_IMPORTED:
+ sys.modules.pop("core.training.training", None)
+ sys.modules.pop("core.training", None)
+
+# The module globals hold the escalation timeouts and are the watchdog's own
+# namespace; patch them here so tests run in well under a second.
+_G = TrainingBackend._stop_watchdog_loop.__globals__
+
+
+class _FakeProc:
+ """A subprocess handle whose liveness and kill calls the test observes."""
+
+ def __init__(self, alive: bool = True):
+ self._alive = alive
+ self.pid = 4321
+ self.terminated = False
+ self.killed = False
+
+ def is_alive(self):
+ return self._alive
+
+ def terminate(self):
+ self.terminated = True
+
+ def kill(self):
+ self.killed = True
+
+ def join(self, timeout = None):
+ pass
+
+
+def _wait_until(predicate, timeout = 5.0):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if predicate():
+ return True
+ time.sleep(0.01)
+ return predicate()
+
+
+def _record_force_terminate(monkeypatch, b):
+ """Replace force_terminate + escalation finalize with recorders (no DB/OS)."""
+ calls: list = []
+ monkeypatch.setattr(b, "force_terminate", lambda target_proc = None: calls.append("force"))
+ monkeypatch.setattr(
+ b,
+ "_finalize_stopped_after_escalation",
+ lambda target_proc = None, watched_job_id = None: calls.append("final"),
+ )
+ return calls
+
+
+# ----------------------------------------------------------------------------
+# (a) Escalate a short grace after "complete" (save done) if still alive.
+# ----------------------------------------------------------------------------
+
+
+def test_watchdog_escalates_after_grace_once_complete_seen(monkeypatch):
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 0.05)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0) # ensure grace, not timeout, fires
+ b = TrainingBackend()
+ calls = _record_force_terminate(monkeypatch, b)
+
+ proc = _FakeProc(alive = True)
+ b._proc = proc
+ b._complete_seen.set() # worker reported "complete" -> save is done
+
+ b._start_stop_watchdog(cancel = False)
+ assert _wait_until(
+ lambda: calls == ["force", "final"]
+ ), "watchdog must force_terminate a worker still alive after the post-save grace"
+ b._stop_watchdog.join(timeout = 5)
+
+
+# ----------------------------------------------------------------------------
+# (b) The absolute cap is a last-resort backstop, not a save killer.
+# ----------------------------------------------------------------------------
+
+
+def test_watchdog_does_not_kill_save_still_saving_within_window(monkeypatch):
+ # save=True, no "complete" yet: a slow save in progress must not be force-killed
+ # inside the (long) absolute window.
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0)
+ b = TrainingBackend()
+ calls = _record_force_terminate(monkeypatch, b)
+
+ proc = _FakeProc(alive = True)
+ b._proc = proc
+ b._start_stop_watchdog(cancel = False)
+
+ time.sleep(0.3)
+ assert calls == [], "an in-progress save must not be killed within the absolute window"
+ assert b._stop_watchdog.is_alive()
+
+ proc._alive = False
+ b._stop_watchdog.join(timeout = 5)
+
+
+def test_watchdog_backstop_fires_for_save_after_absolute_timeout(monkeypatch):
+ # Past the long save=True cap with no completion: force-terminate as last resort.
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0) # never trips (no complete)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 0.05)
+ b = TrainingBackend()
+ calls = _record_force_terminate(monkeypatch, b)
+
+ b._proc = _FakeProc(alive = True)
+ b._start_stop_watchdog(cancel = False)
+ assert _wait_until(
+ lambda: calls == ["force", "final"]
+ ), "the absolute backstop must force_terminate a save that never completes"
+ b._stop_watchdog.join(timeout = 5)
+
+
+def test_cancel_uses_shorter_absolute_timeout(monkeypatch):
+ # A cancel has nothing to save, so it escalates on the shorter cancel cap even before
+ # the long save cap elapses.
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0) # save cap would not fire
+ monkeypatch.setitem(_G, "_CANCEL_TIMEOUT_S", 0.05)
+ b = TrainingBackend()
+ calls = _record_force_terminate(monkeypatch, b)
+
+ b._proc = _FakeProc(alive = True)
+ b._start_stop_watchdog(cancel = True)
+ assert _wait_until(
+ lambda: calls == ["force", "final"]
+ ), "a cancel must escalate on the shorter cancel timeout"
+ b._stop_watchdog.join(timeout = 5)
+
+
+# ----------------------------------------------------------------------------
+# (c) No force-kill when the worker exits cleanly and promptly.
+# ----------------------------------------------------------------------------
+
+
+def test_watchdog_no_op_on_clean_quick_exit(monkeypatch):
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 5.0)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 10.0)
+ b = TrainingBackend()
+ calls = _record_force_terminate(monkeypatch, b)
+
+ proc = _FakeProc(alive = True)
+ b._proc = proc
+ b._complete_seen.set() # save done; worker is about to exit on its own
+
+ b._start_stop_watchdog(cancel = False)
+ # Worker exits promptly, well before the grace period elapses.
+ time.sleep(0.1)
+ proc._alive = False
+
+ b._stop_watchdog.join(timeout = 5)
+ assert not b._stop_watchdog.is_alive()
+ assert calls == [], "a clean quick exit must not trigger force_terminate"
+
+
+def test_watchdog_no_op_when_worker_superseded(monkeypatch):
+ # A stale watchdog from a prior run must never kill a new run's worker: once
+ # self._proc is replaced, it exits silently.
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 0.05)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 0.05)
+ b = TrainingBackend()
+ calls = _record_force_terminate(monkeypatch, b)
+
+ old_proc = _FakeProc(alive = True)
+ b._proc = old_proc
+ b._complete_seen.set()
+ b._start_stop_watchdog(cancel = False)
+
+ # A new run takes over the handle before the grace elapses.
+ b._proc = _FakeProc(alive = True)
+
+ b._stop_watchdog.join(timeout = 5)
+ assert calls == [], "watchdog must not force_terminate a superseded worker"
+
+
+def test_new_run_gets_its_own_watchdog(monkeypatch):
+ # A stale watchdog sleeping on an old proc must not stop a new run's stop from
+ # creating its own watcher.
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0)
+ b = TrainingBackend()
+ _record_force_terminate(monkeypatch, b)
+
+ old_proc = _FakeProc(alive = True)
+ b._proc = old_proc
+ b._start_stop_watchdog(cancel = False)
+ first_wd = b._stop_watchdog
+
+ # New run: fresh worker replaces the handle; its stop must get a new watcher
+ # even though the old (superseded) watchdog is still alive.
+ new_proc = _FakeProc(alive = True)
+ b._proc = new_proc
+ b._start_stop_watchdog(cancel = False)
+ second_wd = b._stop_watchdog
+
+ try:
+ assert first_wd.is_alive()
+ assert second_wd is not first_wd, "a new run must get its own watchdog"
+ assert b._stop_watchdog_proc is new_proc
+ finally:
+ old_proc._alive = False
+ new_proc._alive = False
+ first_wd.join(timeout = 5)
+ second_wd.join(timeout = 5)
+
+
+def test_force_terminate_targets_only_captured_proc():
+ # Superseded: force_terminate(target) must not touch a different current worker.
+ b = TrainingBackend()
+ old_proc = _FakeProc(alive = True)
+ new_proc = _FakeProc(alive = True)
+ b._proc = new_proc
+ b.force_terminate(target_proc = old_proc)
+ assert new_proc.terminated is False, "must not terminate the new run's worker"
+ assert old_proc.terminated is False, "must not terminate a handle that is not current"
+
+ # Matching: the captured handle is the current worker, so it is terminated.
+ p = _FakeProc(alive = True)
+ b._proc = p
+ b.force_terminate(target_proc = p)
+ assert p.terminated is True
+
+
+# ----------------------------------------------------------------------------
+# Post-escalation finalize leaves the parent ready for a new run.
+# ----------------------------------------------------------------------------
+
+
+def test_finalize_runs_even_if_force_terminate_raises(monkeypatch):
+ # A wedged child can make force_terminate() raise; finalize must still run so the
+ # run does not stay stuck in "Stopping...".
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 0.05)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0)
+ b = TrainingBackend()
+
+ def _boom(target_proc = None):
+ raise RuntimeError("kill() failed on wedged child")
+
+ finalized: list = []
+ monkeypatch.setattr(b, "force_terminate", _boom)
+ monkeypatch.setattr(
+ b,
+ "_finalize_stopped_after_escalation",
+ lambda target_proc = None, watched_job_id = None: finalized.append(True),
+ )
+
+ b._proc = _FakeProc(alive = True)
+ b._complete_seen.set()
+ b._start_stop_watchdog(cancel = False)
+
+ assert _wait_until(
+ lambda: finalized == [True]
+ ), "finalize must run even when force_terminate raises"
+ b._stop_watchdog.join(timeout = 5)
+
+
+def test_finalize_after_escalation_clears_state(monkeypatch):
+ # Even if the OS never reaps the wedged worker, the parent must report the run
+ # stopped so the UI leaves "Stopping..." and a new run can start.
+ b = TrainingBackend()
+ finstop: list = []
+ monkeypatch.setattr(b, "_finish_stopped_run", lambda *a: finstop.append(a))
+
+ b._proc = _FakeProc(alive = True) # wedged: still reports alive
+ b._should_stop = True
+ b.current_job_id = "job_c"
+ b._db_run_created = True
+ b._progress.is_training = True
+
+ b._finalize_stopped_after_escalation(watched_job_id = "job_c")
+
+ assert b._proc is None, "the wedged handle must be dropped so is_training_active clears"
+ assert b._progress.is_training is False
+ assert b._progress.status_message == "Training stopped."
+ assert finstop and finstop[0][0] == "job_c", "the captured run must be finalized by id"
+ assert b.is_training_active() is False
+
+
+def test_finalize_after_escalation_preserves_output_dir(monkeypatch):
+ # A save-stop that already emitted "complete" has the checkpoint dir; run history
+ # must record it even if the watchdog wins the finalize race against the pump.
+ b = TrainingBackend()
+ finstop: list = []
+ monkeypatch.setattr(b, "_finish_stopped_run", lambda *a: finstop.append(a))
+
+ b._proc = _FakeProc(alive = True)
+ b._should_stop = True
+ b.current_job_id = "job_c"
+ b._db_run_created = True
+ b._output_dir = "/tmp/outputs/run-123"
+
+ b._finalize_stopped_after_escalation(watched_job_id = "job_c")
+
+ # _finish_stopped_run(run_id, output_dir, batch, final_step, final_loss, duration, loss_history)
+ assert finstop and finstop[0][0] == "job_c"
+ assert finstop[0][1] == "/tmp/outputs/run-123"
+
+
+def test_stop_training_starts_watchdog_only_when_worker_alive(monkeypatch):
+ # No worker -> nothing to escalate; the watchdog must not spawn.
+ b = TrainingBackend()
+ b._proc = None
+ assert b.stop_training(save = True) is True
+ assert b._stop_watchdog is None
+
+
+# ----------------------------------------------------------------------------
+# (d) A stale watchdog must never clobber a run that replaced its worker.
+# ----------------------------------------------------------------------------
+
+
+def test_finalize_after_escalation_no_ops_when_superseded(monkeypatch):
+ # A /start can slip in while the watchdog force-terminates the old worker
+ # (is_training_active() is False once _should_stop is set and the old proc is dead).
+ # The escalation finalize must then leave the NEW run untouched, not drop its handle.
+ b = TrainingBackend()
+ finstop: list = []
+ monkeypatch.setattr(b, "_finish_stopped_run", lambda *a: finstop.append(a))
+
+ old_proc = _FakeProc(alive = False) # force-terminated worker we were watching
+ new_proc = _FakeProc(alive = True) # a new run already took over
+ b._proc = new_proc
+ b.current_job_id = "job_new"
+ b._db_run_created = True
+ b._progress.is_training = True
+
+ b._finalize_stopped_after_escalation(target_proc = old_proc)
+
+ assert b._proc is new_proc, "must not drop the new run's handle"
+ assert b._progress.is_training is True, "must not mark the new run stopped"
+ assert finstop == [], "must not finalize the new run in the DB"
+
+
+def test_finalize_after_escalation_runs_for_its_own_worker(monkeypatch):
+ # Common case: the watched worker is still current, so finalize proceeds and
+ # finalizes the captured run by id.
+ b = TrainingBackend()
+ finstop: list = []
+ monkeypatch.setattr(b, "_finish_stopped_run", lambda *a: finstop.append(a))
+
+ proc = _FakeProc(alive = False)
+ b._proc = proc
+ b.current_job_id = "job_a"
+ b._db_run_created = True
+ b._progress.is_training = True
+
+ b._finalize_stopped_after_escalation(target_proc = proc, watched_job_id = "job_a")
+
+ assert b._proc is None
+ assert b._progress.is_training is False
+ assert finstop and finstop[0][0] == "job_a", "must finalize the captured run by id"
+
+
+def test_finalize_after_escalation_no_ops_on_job_change_during_startup(monkeypatch):
+ # start_training updates current_job_id BEFORE it installs the new _proc, so a stale
+ # watchdog can enter while _proc is still the old (dead) handle. The job-id guard must
+ # catch this even though the proc-only guard would not.
+ b = TrainingBackend()
+ finstop: list = []
+ monkeypatch.setattr(b, "_finish_stopped_run", lambda *a: finstop.append(a))
+
+ old_proc = _FakeProc(alive = False) # old worker, dead; new _proc not installed yet
+ b._proc = old_proc # still the old handle (== target), so proc guard would pass
+ b.current_job_id = "job_new" # but the new run already claimed the job id
+ b._db_run_created = True
+ b._progress.is_training = True
+
+ b._finalize_stopped_after_escalation(target_proc = old_proc, watched_job_id = "job_old")
+
+ assert b._proc is old_proc, "must not drop the handle during a new run's startup"
+ assert b._progress.is_training is True, "must not mark the starting run stopped"
+ assert finstop == [], "must not finalize while a new run is starting up"
+
+
+# ----------------------------------------------------------------------------
+# (e) A later cancel (save=False) tightens an in-flight save watchdog.
+# ----------------------------------------------------------------------------
+
+
+def test_later_cancel_tightens_watchdog_timeout(monkeypatch):
+ monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0) # never trips (no complete)
+ monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0) # save cap would not fire
+ monkeypatch.setitem(_G, "_CANCEL_TIMEOUT_S", 0.05)
+ b = TrainingBackend()
+ calls = _record_force_terminate(monkeypatch, b)
+
+ b._proc = _FakeProc(alive = True)
+ b._start_stop_watchdog(cancel = False) # started as a save-stop with the long cap
+ time.sleep(0.15)
+ assert calls == [], "a save-stop must not escalate on the short cancel cap yet"
+
+ # The user now cancels the in-flight stop: the watchdog must tighten its cap.
+ b._cancel_requested = True
+ assert _wait_until(
+ lambda: calls == ["force", "final"]
+ ), "a later cancel must tighten the watchdog to the shorter cancel cap"
+ b._stop_watchdog.join(timeout = 5)
+
+
+# ----------------------------------------------------------------------------
+# (f) DB finalize/flush are safe when the watchdog and pump race (see Item 4).
+# ----------------------------------------------------------------------------
+
+
+def _install_fake_db(monkeypatch):
+ """Stub storage.studio_db + utils.downsample so the real DB helpers run without
+ SQLite. Returns the recorder dict."""
+ recs = {"created": [], "finished": [], "inserted": [], "insert_ids": [], "progress_ids": []}
+ fake_storage = _types.ModuleType("storage")
+ fake_db = _types.ModuleType("storage.studio_db")
+ fake_db.create_run = lambda **kw: recs["created"].append(kw)
+ fake_db.finish_run = lambda **kw: recs["finished"].append(kw)
+ fake_db.insert_metrics_batch = lambda job_id, batch: (
+ recs["inserted"].extend(batch),
+ recs["insert_ids"].append(job_id),
+ )
+ fake_db.update_run_progress = lambda **kw: recs["progress_ids"].append(kw.get("id"))
+ fake_storage.studio_db = fake_db
+ monkeypatch.setitem(sys.modules, "storage", fake_storage)
+ monkeypatch.setitem(sys.modules, "storage.studio_db", fake_db)
+ fake_ds = _types.ModuleType("utils.downsample")
+ fake_ds.downsample = lambda seq, n: list(seq)[:n]
+ monkeypatch.setitem(sys.modules, "utils.downsample", fake_ds)
+ return recs
+
+
+def test_finalize_run_in_db_single_winner_under_concurrency(monkeypatch):
+ # The watchdog and pump can both finalize; only one call may reach finish_run.
+ recs = _install_fake_db(monkeypatch)
+ b = TrainingBackend()
+ b.current_job_id = "job_x"
+ b._db_run_created = True
+ b._run_finalized = False
+
+ start = threading.Barrier(8)
+
+ def worker():
+ start.wait()
+ b._finalize_run_in_db(status = "stopped")
+
+ threads = [threading.Thread(target = worker) for _ in range(8)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(timeout = 5)
+
+ assert len(recs["finished"]) == 1, f"finalize must run once, got {len(recs['finished'])}"
+ assert b._run_finalized is True
+
+
+def test_finalize_run_in_db_no_ops_on_job_mismatch(monkeypatch):
+ # A finalize captured for an old job must not finalize the run that replaced it.
+ recs = _install_fake_db(monkeypatch)
+ b = TrainingBackend()
+ b.current_job_id = "job_new"
+ b._db_run_created = True
+ b._run_finalized = False
+
+ b._finalize_run_in_db(status = "stopped", expected_job_id = "job_old")
+
+ assert recs["finished"] == [], "a superseded job id must not finalize the current run"
+ assert b._run_finalized is False
+
+
+def test_concurrent_flush_claims_each_metric_once(monkeypatch):
+ # Concurrent flushes (pump periodic flush vs watchdog finalize flush) must not
+ # double-remove or drop buffered metrics.
+ recs = _install_fake_db(monkeypatch)
+ b = TrainingBackend()
+ b.current_job_id = "job_y"
+ b._db_run_created = True
+ b._metric_buffer[:] = [{"step": i} for i in range(200)]
+
+ start = threading.Barrier(6)
+
+ def worker():
+ start.wait()
+ for _ in range(50):
+ b._flush_metrics_to_db()
+
+ threads = [threading.Thread(target = worker) for _ in range(6)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(timeout = 5)
+ b._flush_metrics_to_db() # drain any remainder
+
+ steps = sorted(m["step"] for m in recs["inserted"])
+ assert steps == list(range(200)), "each metric must be inserted exactly once"
+ assert b._metric_buffer == [], "the buffer must be fully drained"
+
+
+def test_flush_pins_to_passed_run_id(monkeypatch):
+ # A finalizer flushes to the run it captured, even if a new /start has already
+ # changed current_job_id.
+ recs = _install_fake_db(monkeypatch)
+ b = TrainingBackend()
+ b.current_job_id = "job_new" # a new run is already live
+ b._db_run_created = True
+ b._metric_buffer[:] = [{"step": 1}, {"step": 2}]
+
+ b._flush_metrics_to_db(run_id = "job_old")
+
+ assert recs["insert_ids"] == ["job_old"], "metrics must go to the captured run, not the new one"
+ assert recs["progress_ids"] == ["job_old"]
+
+
+def test_finalize_uses_snapshot_run_id_across_new_run(monkeypatch):
+ # If a new /start changes current_job_id after the finalize claim but before the DB
+ # writes, finish_run must still target the run captured under the lock.
+ recs = _install_fake_db(monkeypatch)
+ b = TrainingBackend()
+ b.current_job_id = "job_x"
+ b._db_run_created = True
+ b._run_finalized = False
+
+ def hijack(run_id = None):
+ # Simulate a new run taking over during the flush (after the finalize claim).
+ b.current_job_id = "job_y"
+
+ monkeypatch.setattr(b, "_flush_metrics_to_db", hijack)
+
+ b._finalize_run_in_db(status = "stopped", expected_job_id = "job_x")
+
+ assert [f["id"] for f in recs["finished"]] == [
+ "job_x"
+ ], "finish_run must target the captured run, not the run that replaced it"
+
+
+# ----------------------------------------------------------------------------
+# (g) DB row creation must not be published before the insert commits.
+# ----------------------------------------------------------------------------
+
+
+def test_ensure_db_run_created_publishes_only_after_insert(monkeypatch):
+ # _db_run_created must stay False while create_run is in flight, so a concurrent
+ # finalize can't run finish_run (an UPDATE) against a not-yet-inserted row.
+ b = TrainingBackend()
+ b.current_job_id = "job_z"
+ b._db_config = {"model_name": "m"}
+ observed: dict = {}
+
+ fake_storage = _types.ModuleType("storage")
+ fake_db = _types.ModuleType("storage.studio_db")
+
+ def _create(**kw):
+ observed["flag_during_create"] = b._db_run_created
+ observed["in_progress_during_create"] = b._db_create_in_progress
+
+ fake_db.create_run = _create
+ fake_storage.studio_db = fake_db
+ monkeypatch.setitem(sys.modules, "storage", fake_storage)
+ monkeypatch.setitem(sys.modules, "storage.studio_db", fake_db)
+
+ b._ensure_db_run_created()
+
+ assert observed["flag_during_create"] is False, "flag must not be published before insert"
+ assert observed["in_progress_during_create"] is True
+ assert b._db_run_created is True, "flag must be published after a successful insert"
+ assert b._db_create_in_progress is False
+
+
+def test_ensure_db_run_created_stays_unpublished_on_failure(monkeypatch):
+ # If create_run raises, neither flag stays set, so a later caller can retry.
+ b = TrainingBackend()
+ b.current_job_id = "job_z"
+ b._db_config = {"model_name": "m"}
+
+ fake_storage = _types.ModuleType("storage")
+ fake_db = _types.ModuleType("storage.studio_db")
+
+ def _boom_create(**kw):
+ raise RuntimeError("insert failed")
+
+ fake_db.create_run = _boom_create
+ fake_storage.studio_db = fake_db
+ monkeypatch.setitem(sys.modules, "storage", fake_storage)
+ monkeypatch.setitem(sys.modules, "storage.studio_db", fake_db)
+
+ b._ensure_db_run_created()
+
+ assert b._db_run_created is False, "a failed insert must not publish the row as created"
+ assert b._db_create_in_progress is False, "the in-progress flag must be cleared on failure"
+
+
+def test_ensure_db_run_created_does_not_publish_for_a_new_run(monkeypatch):
+ # A killed worker lets a new /start proceed while the watchdog is still creating the old
+ # run's row. The stale create must not publish the backend-wide flags against the new
+ # current_job_id, or the new run would skip inserting its own row.
+ b = TrainingBackend()
+ b.current_job_id = "job_old"
+ b._db_config = {"model_name": "m"}
+ b._db_run_created = False
+ b._db_create_in_progress = False
+
+ fake_storage = _types.ModuleType("storage")
+ fake_db = _types.ModuleType("storage.studio_db")
+
+ def _create(**kw):
+ b.current_job_id = "job_new" # a new run takes over during the slow create
+
+ fake_db.create_run = _create
+ fake_storage.studio_db = fake_db
+ monkeypatch.setitem(sys.modules, "storage", fake_storage)
+ monkeypatch.setitem(sys.modules, "storage.studio_db", fake_db)
+
+ b._ensure_db_run_created()
+
+ assert b._db_run_created is False, "must not publish the created flag against the new run"
+ # The stale claim is left for start_training to reset, not satisfied for the new run.
+ assert b._db_create_in_progress is True, "must not clear the claim once the run is not current"
+
+
+# ----------------------------------------------------------------------------
+# (h) The escalation finalizes the watched run by id (so it is never left running).
+# ----------------------------------------------------------------------------
+
+
+def test_escalation_finalizes_watched_run_by_id_end_to_end(monkeypatch):
+ # Exercise the real _finish_stopped_run against a fake DB. The watched run is finalized
+ # by its captured id with its buffered metrics, so a new run that starts in the gap
+ # after the backend goes idle can never leave the stopped run recorded running.
+ recs = _install_fake_db(monkeypatch)
+ b = TrainingBackend()
+ b.current_job_id = "job_old"
+ b._db_run_created = True
+ b._proc = _FakeProc(alive = False)
+ b._progress.is_training = True
+ b._progress.step = 42
+ b._metric_buffer[:] = [{"step": 41}, {"step": 42}]
+
+ b._finalize_stopped_after_escalation(target_proc = b._proc, watched_job_id = "job_old")
+
+ assert [f["id"] for f in recs["finished"]] == ["job_old"], "must finish the captured run by id"
+ assert recs["finished"][0]["status"] == "stopped"
+ assert recs["insert_ids"] == ["job_old"], "buffered metrics must land on the captured run"
+ assert b._metric_buffer == [], "the captured batch must be drained"
+
+
+def test_escalation_defers_when_row_cannot_be_created_here(monkeypatch):
+ # If the row does not exist and cannot be created here (no db_config, or the pump is
+ # mid-create), the escalation must not claim _run_finalized or call _finish_stopped_run,
+ # so the pump's create-then-finalize records the run. Parent state still clears.
+ b = TrainingBackend()
+ called: list = []
+ monkeypatch.setattr(b, "_finish_stopped_run", lambda *a: called.append(a))
+
+ b._proc = _FakeProc(alive = False)
+ b.current_job_id = "job_q"
+ b._db_run_created = False # row not created yet
+ b._db_config = None # ... and cannot be created here
+ b._run_finalized = False
+ b._progress.is_training = True
+
+ b._finalize_stopped_after_escalation(target_proc = b._proc, watched_job_id = "job_q")
+
+ assert called == [], "must not finalize when the row can't be established here"
+ assert b._run_finalized is False, "must not claim the finalize the pump still owes"
+ assert b._progress.is_training is False, "parent state must still clear so the UI unsticks"
+ assert b._proc is None
+
+
+def test_escalation_creates_row_then_finalizes_when_start_create_failed(monkeypatch):
+ # A wedged worker's pump can never finalize and would bail once _proc is dropped, so if
+ # the row was never created (start-time create failed) the escalation creates it and
+ # finalizes by id itself, recording the terminal state before dropping the handle.
+ recs = _install_fake_db(monkeypatch)
+ b = TrainingBackend()
+ b.current_job_id = "job_s"
+ b._db_config = {"model_name": "m"} # so _ensure_db_run_created can create the row
+ b._db_run_created = False # start-time create failed
+ b._proc = _FakeProc(alive = True) # wedged: still reports alive
+ b._should_stop = True
+ b._progress.is_training = True
+
+ b._finalize_stopped_after_escalation(target_proc = b._proc, watched_job_id = "job_s")
+
+ assert [c["id"] for c in recs["created"]] == ["job_s"], "must create the missing row"
+ assert [f["id"] for f in recs["finished"]] == ["job_s"], "must finish the created row by id"
+ assert b._proc is None, "handle dropped only after the terminal state is recorded"
+ assert b._db_run_created is True
+
+
+def test_escalation_does_not_drop_a_new_runs_handle(monkeypatch):
+ # If a run replaces the worker while the finalize DB write is in flight, the final _proc
+ # drop must leave the new run's handle intact (re-guarded on target_proc).
+ b = TrainingBackend()
+ b.current_job_id = "job_old"
+ b._db_run_created = True
+ old_proc = _FakeProc(alive = False)
+ new_proc = _FakeProc(alive = True)
+ b._proc = old_proc
+
+ def hijack(*a):
+ b._proc = new_proc # a new run takes over during the finalize
+
+ monkeypatch.setattr(b, "_finish_stopped_run", hijack)
+
+ b._finalize_stopped_after_escalation(target_proc = old_proc, watched_job_id = "job_old")
+
+ assert b._proc is new_proc, "must not drop the handle a new run installed during finalize"
+
+
+def _make_finish_raise(monkeypatch, calls):
+ fn = sys.modules["storage.studio_db"]
+
+ def _boom(**kw):
+ calls.append(kw)
+ raise RuntimeError("database is locked")
+
+ fn.finish_run = _boom
+
+
+def test_finish_stopped_run_retries_then_unclaims_on_db_error(monkeypatch):
+ # The watchdog is the sole finalizer once _proc is dropped, so a transient DB error is
+ # retried a few times; on final failure the finalize is unclaimed (run still current).
+ monkeypatch.setitem(_G, "_DB_FINALIZE_RETRY_S", 0.0)
+ _install_fake_db(monkeypatch)
+ tries: list = []
+ _make_finish_raise(monkeypatch, tries)
+ b = TrainingBackend()
+ b.current_job_id = "job_r"
+ b._run_finalized = True # the caller (escalation) already claimed
+
+ b._finish_stopped_run("job_r", None, [{"step": 1}], 1, None, None, [])
+
+ assert len(tries) == 3, "a transient DB error must be retried before giving up"
+ assert b._run_finalized is False, "a persistent DB error must unclaim the finalize"
+
+
+def test_finish_stopped_run_error_leaves_new_run_untouched(monkeypatch):
+ # If the watched run was superseded, a DB error must not unclaim the new run's finalize.
+ monkeypatch.setitem(_G, "_DB_FINALIZE_RETRY_S", 0.0)
+ _install_fake_db(monkeypatch)
+ _make_finish_raise(monkeypatch, [])
+ b = TrainingBackend()
+ b.current_job_id = "job_new" # a new run is live
+ b._run_finalized = True # the new run's flag
+
+ b._finish_stopped_run("job_old", None, [{"step": 1}], 1, None, None, [])
+
+ assert b._run_finalized is True, "must not unclaim the new run's finalize"
diff --git a/studio/backend/tests/test_training_worker_flash_attn.py b/studio/backend/tests/test_training_worker_flash_attn.py
index 3c5d6cd094..7e7fc1af48 100644
--- a/studio/backend/tests/test_training_worker_flash_attn.py
+++ b/studio/backend/tests/test_training_worker_flash_attn.py
@@ -59,7 +59,6 @@ def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
statuses: list[str] = []
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
- monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: False)
monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
monkeypatch.setattr(
worker,
@@ -88,7 +87,6 @@ def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
statuses: list[str] = []
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
- monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: False)
monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
monkeypatch.setattr(
worker,
@@ -141,27 +139,6 @@ def test_runtime_flash_attn_skip_env_avoids_all_install_work(monkeypatch):
worker._sp.run.assert_not_called()
-def test_runtime_flash_attn_skips_on_blackwell(monkeypatch):
- statuses: list[str] = []
- install_mock = mock.Mock()
-
- monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
- monkeypatch.setattr(worker, "_should_try_runtime_flash_attn_install", lambda max_seq: True)
- monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: True)
- monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
- monkeypatch.setattr(
- worker,
- "_send_status",
- lambda queue, message: statuses.append(message),
- )
-
- worker._ensure_flash_attn_for_long_context(event_queue = [], max_seq_length = 65536)
-
- install_mock.assert_not_called()
- assert len(statuses) == 1
- assert "Blackwell" in statuses[0]
-
-
def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch):
install_mock = mock.Mock(return_value = True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
diff --git a/studio/backend/tests/test_training_worker_import_discipline.py b/studio/backend/tests/test_training_worker_import_discipline.py
new file mode 100644
index 0000000000..a047c91704
--- /dev/null
+++ b/studio/backend/tests/test_training_worker_import_discipline.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Invariant: the training worker must not import ``transformers`` before it activates the
+transformers sidecar.
+
+``core/training/worker.py:run_training_process`` runs a preflight (Xet decision, logging, hardware
+detection) and only THEN calls ``_activate_transformers_version`` -> ``activate_transformers_for_subprocess``,
+which prepends the correct ``.venv_t5_*`` (5.x) sidecar to ``sys.path``. Because activation only edits
+``sys.path``, it is a no-op for any module already cached in ``sys.modules``. So if the preflight imports
+``transformers`` (directly or transitively via ``unsloth_zoo``), the default 4.57.x gets pinned before
+the sidecar is on the path -- and 5.x models (Qwen3.5, GLM-4.7, gemma-4) then fail to load their
+tokenizer/config ("Tokenizer class TokenizersBackend does not exist").
+
+This regression shipped once when ``utils/hf_xet_fallback.py`` eagerly imported ``unsloth_zoo`` (which
+imports ``transformers``) at module load; the worker imports that shim during preflight to decide the
+Xet env flip (see issue #6951). This test locks the invariant in a fresh interpreter. It is CPU-only,
+needs no network/GPU/weights/sidecars, so it runs in the standard ``studio-backend-ci`` matrix.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+_BACKEND_DIR = Path(__file__).resolve().parent.parent # studio/backend
+
+# Mirrors run_training_process's imports that run BEFORE _activate_transformers_version (worker.py);
+# keep in sync. torch-dependent imports are optional (a no-torch CI shard skips them) but must still
+# not drag in transformers.
+_PREFLIGHT_SNIPPET = r"""
+import sys
+
+# worker.py: from utils.hf_xet_fallback import child_should_disable_xet (+ call it)
+from utils.hf_xet_fallback import child_should_disable_xet
+child_should_disable_xet({})
+
+# worker.py: from loggers.config import LogConfig
+from loggers.config import LogConfig # noqa: F401
+
+# worker.py: from utils.hardware import hardware (imports torch, not transformers)
+try:
+ from utils.hardware import hardware as _hw # noqa: F401
+except Exception:
+ pass # torch may be absent in a no-torch shard; the invariant below still applies
+
+# worker.py: from .training import is_apple_silicon_training_platform, should_use_mlx_training_backend
+# (the MLX-dispatch preflight; must also stay clear of transformers). Guarded because it may pull
+# unsloth/trl, absent in a minimal shard -- but a partial import that leaked transformers would still
+# be caught by the assertion below.
+try:
+ from core.training.training import ( # noqa: F401
+ is_apple_silicon_training_platform as _is_apple,
+ should_use_mlx_training_backend as _use_mlx,
+ )
+except Exception:
+ pass
+
+leaked_tf = sorted(m for m in sys.modules if m == "transformers" or m.startswith("transformers."))
+leaked_zoo = sorted(m for m in sys.modules if m == "unsloth_zoo" or m.startswith("unsloth_zoo."))
+assert not leaked_tf, f"transformers imported during worker preflight (before sidecar activation): {leaked_tf}"
+assert not leaked_zoo, f"unsloth_zoo imported during worker preflight (before sidecar activation): {leaked_zoo}"
+print("PREFLIGHT_CLEAN")
+"""
+
+
+def test_worker_preflight_does_not_import_transformers():
+ """A fresh interpreter running the worker's pre-activation imports must leave ``transformers``
+ (and ``unsloth_zoo``) unimported, so the 5.x sidecar prepend is not defeated by a stale module."""
+ result = subprocess.run(
+ [sys.executable, "-c", _PREFLIGHT_SNIPPET],
+ cwd = str(_BACKEND_DIR),
+ capture_output = True,
+ text = True,
+ )
+ assert result.returncode == 0, (
+ "Worker preflight imported transformers before sidecar activation.\n"
+ f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
+ )
+ assert "PREFLIGHT_CLEAN" in result.stdout, result.stdout
diff --git a/studio/backend/tests/test_transformers_dtype.py b/studio/backend/tests/test_transformers_dtype.py
new file mode 100644
index 0000000000..28629e5620
--- /dev/null
+++ b/studio/backend/tests/test_transformers_dtype.py
@@ -0,0 +1,77 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Tests for the version-safe torch_dtype/dtype kwarg helper."""
+
+import sys
+import types
+
+import pytest
+
+from utils.transformers_dtype import _has_torch_dtype_kwarg, dtype_kwargs
+
+
+@pytest.fixture(autouse = True)
+def _clear_cache():
+ _has_torch_dtype_kwarg.cache_clear()
+ yield
+ _has_torch_dtype_kwarg.cache_clear()
+
+
+def _stub_transformers(monkeypatch, version):
+ stub = types.ModuleType("transformers")
+ stub.__version__ = version
+ monkeypatch.setitem(sys.modules, "transformers", stub)
+
+
+def test_old_transformers_uses_torch_dtype(monkeypatch):
+ _stub_transformers(monkeypatch, "4.51.3")
+ assert _has_torch_dtype_kwarg() is True
+ assert dtype_kwargs("float16") == {"torch_dtype": "float16"}
+
+
+def test_new_transformers_uses_dtype(monkeypatch):
+ _stub_transformers(monkeypatch, "4.57.6")
+ assert _has_torch_dtype_kwarg() is False
+ assert dtype_kwargs("float16") == {"dtype": "float16"}
+
+
+def test_rename_boundary_uses_dtype(monkeypatch):
+ _stub_transformers(monkeypatch, "4.56.0")
+ assert _has_torch_dtype_kwarg() is False
+
+
+def test_just_below_boundary_uses_torch_dtype(monkeypatch):
+ _stub_transformers(monkeypatch, "4.55.4")
+ assert _has_torch_dtype_kwarg() is True
+
+
+@pytest.mark.parametrize("version", ["4.56.0.dev0", "4.56.0rc1"])
+def test_rename_prerelease_uses_dtype(monkeypatch, version):
+ """A pre-release of the rename version sorts *below* ``4.56.0`` but already
+ accepts (and prefers) ``dtype``; the release-tuple check must not fall back to
+ the legacy name there, or it re-emits the deprecation warning it suppresses."""
+ _stub_transformers(monkeypatch, version)
+ assert _has_torch_dtype_kwarg() is False
+
+
+def test_malformed_version_prefers_modern_name(monkeypatch):
+ """A non-PEP440 __version__ raises InvalidVersion; the except branch must
+ swallow it and default to the modern name rather than crash the embedder warm-up."""
+ _stub_transformers(monkeypatch, "not-a-version")
+ assert _has_torch_dtype_kwarg() is False
+ assert dtype_kwargs("float16") == {"dtype": "float16"}
+
+
+def test_missing_transformers_prefers_modern_name(monkeypatch):
+ monkeypatch.delitem(sys.modules, "transformers", raising = False)
+ real_import = __import__
+
+ def _raise(name, *args, **kwargs):
+ if name == "transformers":
+ raise ImportError("no transformers")
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr("builtins.__import__", _raise)
+ assert _has_torch_dtype_kwarg() is False
+ assert dtype_kwargs("float16") == {"dtype": "float16"}
diff --git a/studio/backend/tests/test_transformers_latest.py b/studio/backend/tests/test_transformers_latest.py
new file mode 100644
index 0000000000..20616dccba
--- /dev/null
+++ b/studio/backend/tests/test_transformers_latest.py
@@ -0,0 +1,1099 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Tests for the latest-transformers support check and the consented sidecar install."""
+
+import ast
+import json
+import os
+import textwrap
+import time
+import pytest
+from pathlib import Path
+
+
+# The backend uses "from utils..." imports; ensure the backend dir is on sys.path.
+import sys
+
+_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
+if _BACKEND_DIR not in sys.path:
+ sys.path.insert(0, _BACKEND_DIR)
+
+# Stub the custom logger before importing the modules under test.
+import types as _types
+
+_loggers_stub = _types.ModuleType("loggers")
+_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
+sys.modules.setdefault("loggers", _loggers_stub)
+
+import utils.transformers_latest as tl
+import utils.transformers_version as tv
+from utils.transformers_latest import (
+ check_upgrade_for_model,
+ install_latest_transformers,
+ latest_transformers_supports,
+ _fetch_remote_model_types,
+ _model_types_from_config,
+)
+from utils.transformers_version import (
+ _config_mapping_cache,
+ _config_json_cache,
+ _higher_tier,
+ _is_valid_version_string,
+ _model_types_from_source,
+ _tier_from_config_mapping,
+ _venv_t5_latest_packages,
+ activate_transformers_for_subprocess,
+ ensure_latest_transformers_venv,
+ get_transformers_tier,
+ latest_venv_pinned_version,
+)
+
+
+# A CONFIG_MAPPING_NAMES source exercising every construct the AST extractor supports.
+_MAPPING_SOURCE = """
+from collections import OrderedDict
+CONFIG_MAPPING_NAMES = OrderedDict(
+ [
+ ("llama", "LlamaConfig"),
+ ("gemma4", "Gemma4Config"),
+ ],
+ **{"qwen3_moe": "Qwen3MoeConfig"},
+)
+CONFIG_MAPPING_NAMES.update({"brandnew_arch": "BrandNewConfig"})
+"""
+
+_MAIN_ONLY_SOURCE = """
+CONFIG_MAPPING_NAMES = {
+ "llama": "LlamaConfig",
+ "gemma4": "Gemma4Config",
+ "qwen3_moe": "Qwen3MoeConfig",
+ "brandnew_arch": "BrandNewConfig",
+ "dev_only_arch": "DevOnlyConfig",
+}
+"""
+
+
+class _FakeResponse:
+ def __init__(self, body: bytes):
+ self._body = body
+
+ def read(self):
+ return self._body
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+
+def _fake_urlopen_factory(counter: dict):
+ """urlopen stub serving the PyPI JSON and both refs' mapping sources."""
+
+ def _fake_urlopen(req, timeout = None):
+ url = req.full_url if hasattr(req, "full_url") else str(req)
+ counter[url] = counter.get(url, 0) + 1
+ counter["__total__"] = counter.get("__total__", 0) + 1
+ if url == tl._PYPI_JSON_URL:
+ return _FakeResponse(json.dumps({"info": {"version": "5.13.0"}}).encode())
+ if "/v5.13.0/" in url and url.endswith("auto_mappings.py"):
+ return _FakeResponse(_MAPPING_SOURCE.encode())
+ if "/v5.13.0/" in url and url.endswith("configuration_auto.py"):
+ return _FakeResponse(b"CONFIG_MAPPING_NAMES = {}\n")
+ if "/main/" in url and url.endswith("auto_mappings.py"):
+ return _FakeResponse(_MAIN_ONLY_SOURCE.encode())
+ if "/main/" in url and url.endswith("configuration_auto.py"):
+ return _FakeResponse(b"CONFIG_MAPPING_NAMES = {}\n")
+ raise AssertionError(f"unexpected URL fetched: {url}")
+
+ return _fake_urlopen
+
+
+@pytest.fixture(autouse = True)
+def _isolated_caches(tmp_path: Path, monkeypatch):
+ """Fresh in-memory + on-disk caches per test; no accidental real studio_root writes."""
+ tl.clear_caches()
+ monkeypatch.setattr(tl, "_cache_file", lambda: tmp_path / "transformers_latest_check.json")
+ # The sidecar swap reservation writes a lock file next to the venv dir;
+ # point it at tmp so tests never touch the real studio root.
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / "venv_t5_latest"))
+ monkeypatch.delenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", raising = False)
+ monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
+ monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
+ yield
+ tl.clear_caches()
+
+
+def _no_network(monkeypatch, exc = None):
+ """Fail every urlopen and return a counter; tests assert n == 0 to prove no fetch
+ happened (check_upgrade_for_model swallows exceptions, so a raising stub alone
+ cannot prove the negative)."""
+ calls = {"n": 0}
+
+ def _raise(*args, **kwargs):
+ calls["n"] += 1
+ raise (exc or OSError("network fetch attempted"))
+
+ monkeypatch.setattr("urllib.request.urlopen", _raise)
+ return calls
+
+
+# --- AST extraction shared with the static router ---
+
+
+class TestModelTypesFromSource:
+ def test_ordereddict_update_and_unpacking(self):
+ keys = _model_types_from_source(_MAPPING_SOURCE)
+ assert keys == {"llama", "gemma4", "qwen3_moe", "brandnew_arch"}
+
+ def test_plain_dict_literal(self):
+ keys = _model_types_from_source(_MAIN_ONLY_SOURCE)
+ assert "dev_only_arch" in keys and "llama" in keys
+
+ def test_syntax_error_raises_for_caller_to_handle(self):
+ with pytest.raises(SyntaxError):
+ _model_types_from_source("def broken(:\n")
+
+
+class TestFetchRemoteModelTypes:
+ def test_merges_both_auto_files(self, monkeypatch):
+ counter = {}
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory(counter))
+ keys = _fetch_remote_model_types("v5.13.0")
+ assert keys is not None and "brandnew_arch" in keys
+
+ def test_all_fetches_failing_returns_none(self, monkeypatch):
+ _no_network(monkeypatch, exc = OSError("no route"))
+ assert _fetch_remote_model_types("main") is None
+
+ def test_empty_mapping_treated_as_failure(self, monkeypatch):
+ monkeypatch.setattr(
+ "urllib.request.urlopen",
+ lambda req, timeout = None: _FakeResponse(b"CONFIG_MAPPING_NAMES = {}\n"),
+ )
+ assert _fetch_remote_model_types("main") is None
+
+ def test_transient_failure_of_one_file_fails_whole_lookup(self, monkeypatch):
+ # One file times out: the partial map must not be returned and cached.
+ def _fake(req, timeout = None):
+ url = req.full_url if hasattr(req, "full_url") else str(req)
+ if url.endswith("configuration_auto.py"):
+ return _FakeResponse(_MAPPING_SOURCE.encode())
+ raise OSError("timed out")
+
+ monkeypatch.setattr("urllib.request.urlopen", _fake)
+ assert _fetch_remote_model_types("main") is None
+
+ def test_missing_auto_mappings_404_still_succeeds(self, monkeypatch):
+ # Pre-5.10 tags have no auto_mappings.py; a 404 must not fail the lookup.
+ import urllib.error
+
+ def _fake(req, timeout = None):
+ url = req.full_url if hasattr(req, "full_url") else str(req)
+ if url.endswith("configuration_auto.py"):
+ return _FakeResponse(_MAPPING_SOURCE.encode())
+ raise urllib.error.HTTPError(url, 404, "Not Found", None, None)
+
+ monkeypatch.setattr("urllib.request.urlopen", _fake)
+ keys = _fetch_remote_model_types("v5.9.0")
+ assert keys is not None and "brandnew_arch" in keys
+
+ def test_unparseable_file_fails_whole_lookup(self, monkeypatch):
+ def _fake(req, timeout = None):
+ url = req.full_url if hasattr(req, "full_url") else str(req)
+ if url.endswith("configuration_auto.py"):
+ return _FakeResponse(_MAPPING_SOURCE.encode())
+ return _FakeResponse(b"def broken(:\n")
+
+ monkeypatch.setattr("urllib.request.urlopen", _fake)
+ assert _fetch_remote_model_types("main") is None
+
+
+# --- latest_transformers_supports: snapshot, cache, offline, kill switch ---
+
+
+class TestLatestTransformersSupports:
+ def test_supported_in_pypi(self, monkeypatch):
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ result = latest_transformers_supports("brandnew_arch")
+ assert result == {
+ "pypi_version": "5.13.0",
+ "supported_in_pypi": True,
+ "supported_in_main": True,
+ }
+
+ def test_dev_only_arch_reported_main_only(self, monkeypatch):
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ result = latest_transformers_supports("dev_only_arch")
+ assert result["supported_in_pypi"] is False
+ assert result["supported_in_main"] is True
+
+ def test_unknown_everywhere(self, monkeypatch):
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ result = latest_transformers_supports("no_such_arch")
+ assert result["supported_in_pypi"] is False and result["supported_in_main"] is False
+
+ def test_network_failure_returns_none(self, monkeypatch):
+ _no_network(monkeypatch, exc = OSError("down"))
+ assert latest_transformers_supports("brandnew_arch") is None
+
+ def test_offline_returns_none_without_fetch(self, monkeypatch):
+ monkeypatch.setenv("HF_HUB_OFFLINE", "1")
+ calls = _no_network(monkeypatch)
+ assert latest_transformers_supports("brandnew_arch") is None
+ assert calls["n"] == 0
+
+ def test_kill_switch_returns_none_without_fetch(self, monkeypatch):
+ monkeypatch.setenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", "1")
+ calls = _no_network(monkeypatch)
+ assert latest_transformers_supports("brandnew_arch") is None
+ assert calls["n"] == 0
+
+ def test_memory_cache_hit_avoids_refetch(self, monkeypatch):
+ counter = {}
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory(counter))
+ latest_transformers_supports("brandnew_arch")
+ first_total = counter["__total__"]
+ latest_transformers_supports("some_other_arch")
+ assert counter["__total__"] == first_total
+
+ def test_disk_cache_survives_restart(self, monkeypatch):
+ counter = {}
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory(counter))
+ latest_transformers_supports("brandnew_arch")
+ # Simulate a restart: memory gone, disk snapshot stays, network unavailable.
+ tl.clear_caches()
+ _no_network(monkeypatch)
+ result = latest_transformers_supports("brandnew_arch")
+ assert result is not None and result["supported_in_pypi"] is True
+
+ def test_expired_snapshot_refetches(self, monkeypatch):
+ counter = {}
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory(counter))
+ latest_transformers_supports("brandnew_arch")
+ stale = dict(tl._memory_snapshot, fetched_at = time.time() - tl._CACHE_TTL_SECONDS - 1)
+ tl.clear_caches()
+ tl._save_snapshot_file(stale)
+ first_total = counter["__total__"]
+ latest_transformers_supports("brandnew_arch")
+ assert counter["__total__"] > first_total
+
+ def test_corrupt_disk_cache_ignored(self, monkeypatch, tmp_path: Path):
+ counter = {}
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory(counter))
+ tl._cache_file().write_text("{not json", encoding = "utf-8")
+ result = latest_transformers_supports("brandnew_arch")
+ assert result is not None and counter["__total__"] > 0
+
+ def test_failure_backoff_skips_immediate_retry(self, monkeypatch):
+ calls = {"n": 0}
+
+ def _fail(*args, **kwargs):
+ calls["n"] += 1
+ raise OSError("down")
+
+ monkeypatch.setattr("urllib.request.urlopen", _fail)
+ assert latest_transformers_supports("brandnew_arch") is None
+ first = calls["n"]
+ assert latest_transformers_supports("brandnew_arch") is None
+ assert calls["n"] == first # backed off, no second network attempt
+
+
+# --- check_upgrade_for_model: the tier hook ---
+
+
+def _local_model(tmp_path: Path, model_type: str) -> str:
+ d = tmp_path / f"model_{model_type}"
+ d.mkdir()
+ (d / "config.json").write_text(json.dumps({"model_type": model_type}))
+ return str(d)
+
+
+_FAKE_OVERLAYS = {
+ "default": frozenset({"llama", "bert", "gpt2"}),
+ "530": frozenset({"qwen3_moe", "qwen3_next"}),
+ "550": frozenset({"gemma4"}),
+ "510": frozenset({"gemma4_unified"}),
+ "latest": frozenset(),
+}
+
+
+def _fake_overlays(monkeypatch, overlays = None):
+ overlays = overlays or _FAKE_OVERLAYS
+ fake = lambda tier: overlays.get(tier, frozenset())
+ monkeypatch.setattr(tv, "_config_model_types", fake)
+ monkeypatch.setattr(tl, "_config_model_types", fake)
+
+
+class TestCheckUpgradeForModel:
+ def test_unknown_type_supported_in_pypi_signals(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ result = check_upgrade_for_model(_local_model(tmp_path, "brandnew_arch"))
+ assert result == {
+ "model_type": "brandnew_arch",
+ "pypi_version": "5.13.0",
+ "supported_in_pypi": True,
+ "supported_in_main": True,
+ }
+
+ def test_dev_only_type_signals_main_only(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ result = check_upgrade_for_model(_local_model(tmp_path, "dev_only_arch"))
+ assert result["supported_in_pypi"] is False and result["supported_in_main"] is True
+
+ def test_unknown_everywhere_falls_through(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ assert check_upgrade_for_model(_local_model(tmp_path, "no_such_arch")) is None
+
+ def test_offline_falls_through_without_fetch(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ monkeypatch.setenv("TRANSFORMERS_OFFLINE", "1")
+ calls = _no_network(monkeypatch)
+ assert check_upgrade_for_model(_local_model(tmp_path, "brandnew_arch")) is None
+ assert calls["n"] == 0
+
+ def test_network_failure_falls_through(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ _no_network(monkeypatch, exc = OSError("down"))
+ assert check_upgrade_for_model(_local_model(tmp_path, "brandnew_arch")) is None
+
+ def test_known_default_type_never_fetches(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ calls = _no_network(monkeypatch)
+ assert check_upgrade_for_model(_local_model(tmp_path, "llama")) is None
+ assert calls["n"] == 0
+
+ def test_known_sidecar_type_never_fetches(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ calls = _no_network(monkeypatch)
+ assert check_upgrade_for_model(_local_model(tmp_path, "gemma4_unified")) is None
+ assert calls["n"] == 0
+
+ def test_hardcoded_tier_type_never_fetches_even_without_overlays(
+ self, tmp_path: Path, monkeypatch
+ ):
+ # Sidecar overlays unreadable, but the hardcoded tables route it.
+ _fake_overlays(
+ monkeypatch,
+ {"default": frozenset({"llama"})},
+ )
+ calls = _no_network(monkeypatch)
+ assert check_upgrade_for_model(_local_model(tmp_path, "qwen3_5_moe")) is None
+ assert calls["n"] == 0
+
+ def test_unreadable_default_overlay_bails_out(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch, {"default": frozenset()})
+ calls = _no_network(monkeypatch)
+ assert check_upgrade_for_model(_local_model(tmp_path, "brandnew_arch")) is None
+ assert calls["n"] == 0
+
+ def test_no_model_type_falls_through(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ _no_network(monkeypatch)
+ d = tmp_path / "no_type"
+ d.mkdir()
+ (d / "config.json").write_text(json.dumps({"architectures": ["Whatever"]}))
+ assert check_upgrade_for_model(str(d)) is None
+
+ def test_nested_model_type_is_used(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ d = tmp_path / "nested"
+ d.mkdir()
+ (d / "config.json").write_text(json.dumps({"text_config": {"model_type": "brandnew_arch"}}))
+ result = check_upgrade_for_model(str(d))
+ assert result is not None and result["model_type"] == "brandnew_arch"
+
+ def test_never_raises_on_internal_error(self, monkeypatch):
+ monkeypatch.setattr(
+ tl, "_load_config_json", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))
+ )
+ assert check_upgrade_for_model("some/model") is None
+
+
+class TestNestedModelTypeExtraction:
+ def test_top_level_wins(self):
+ assert _model_types_from_config(
+ {"model_type": "a", "text_config": {"model_type": "b"}}
+ ) == ["a", "b"]
+
+ def test_nested_fallback(self):
+ assert _model_types_from_config({"llm_config": {"model_type": "b"}}) == ["b"]
+
+ def test_missing_returns_none(self):
+ assert _model_types_from_config({}) == []
+
+
+# --- Routing parity: overlay-shipped model_types route as before, never remote-check ---
+
+
+class TestRoutingParity:
+ def test_all_overlay_types_route_identically_and_never_check(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ calls = _no_network(monkeypatch)
+ expected_tier = {
+ "llama": "default",
+ "bert": "default",
+ "gpt2": "default",
+ "qwen3_moe": "530",
+ "qwen3_next": "530",
+ "gemma4": "550",
+ "gemma4_unified": "510",
+ }
+ for model_type, tier in expected_tier.items():
+ cfg = {"model_type": model_type}
+ assert _tier_from_config_mapping(cfg) == tier, model_type
+ assert check_upgrade_for_model(_local_model(tmp_path, model_type)) is None
+ assert calls["n"] == 0
+
+ def test_real_installed_mappings_route_without_checker(self, monkeypatch, tmp_path: Path):
+ """Parity over the REAL installed overlays (base + any provisioned sidecar):
+ every shipped model_type resolves statically, so the remote checker never
+ fires and routing is byte-identical with the feature enabled."""
+ _no_network(monkeypatch)
+ seen = 0
+ for tier in ("default", "530", "550", "510"):
+ types = tv._config_model_types(tier)
+ if not types:
+ continue # overlay not provisioned in this environment
+ for model_type in types:
+ assert _tier_from_config_mapping({"model_type": model_type}) is not None
+ seen += 1
+ if seen == 0:
+ pytest.skip("no transformers overlay available in this environment")
+
+ def test_get_tier_unchanged_by_kill_switch(self, tmp_path: Path, monkeypatch):
+ _fake_overlays(monkeypatch)
+ _no_network(monkeypatch)
+ path = _local_model(tmp_path, "no_such_arch")
+ _config_json_cache.clear()
+ tier_default = get_transformers_tier(path, probe = False)
+ monkeypatch.setenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", "1")
+ _config_json_cache.clear()
+ assert get_transformers_tier(path, probe = False) == tier_default == "default"
+
+
+# --- .venv_t5_latest provisioning and routing participation ---
+
+
+class TestLatestVenvProvisioning:
+ def test_version_string_validation(self):
+ assert _is_valid_version_string("5.13.0")
+ assert _is_valid_version_string("5.14.0rc1")
+ assert not _is_valid_version_string("5.13.0; rm -rf /")
+ assert not _is_valid_version_string("git+https://evil")
+ assert not _is_valid_version_string("")
+
+ def test_packages_pin_exact_version(self):
+ pkgs = _venv_t5_latest_packages("5.13.0")
+ assert pkgs[0] == "transformers==5.13.0"
+ assert any(p.startswith("huggingface_hub==") for p in pkgs)
+
+ def test_ensure_latest_writes_pin_and_invalidates_cache(self, tmp_path: Path, monkeypatch):
+ venv_dir = tmp_path / ".venv_t5_latest"
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+ recorded = {}
+
+ def _fake_ensure(dir_, packages, label):
+ recorded["dir"] = dir_
+ recorded["packages"] = packages
+ Path(dir_).mkdir(parents = True, exist_ok = True)
+ return True
+
+ monkeypatch.setattr(tv, "_ensure_venv_dir", _fake_ensure)
+ _config_mapping_cache["latest"] = frozenset({"stale"})
+ assert ensure_latest_transformers_venv("5.13.0") is True
+ # Stage-and-swap: pip installs into staging, the live dir is the swap result.
+ assert recorded["dir"] == str(venv_dir) + ".staging"
+ assert "transformers==5.13.0" in recorded["packages"]
+ assert venv_dir.is_dir()
+ assert not Path(str(venv_dir) + ".staging").exists()
+ assert latest_venv_pinned_version() == "5.13.0"
+ assert "latest" not in _config_mapping_cache
+
+ def test_ensure_latest_upgrade_failure_keeps_old_sidecar(self, tmp_path: Path, monkeypatch):
+ venv_dir = tmp_path / ".venv_t5_latest"
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+ venv_dir.mkdir(parents = True)
+ (venv_dir / tv._LATEST_PIN_MARKER).write_text(
+ json.dumps({"version": "5.12.0", "packages": ["transformers==5.12.0"]})
+ )
+ (venv_dir / "transformers").mkdir()
+ monkeypatch.setattr(tv, "_venv_dir_is_valid", lambda *a, **k: True)
+ # Install fails mid-flight: the previous sidecar and pin survive.
+ monkeypatch.setattr(tv, "_ensure_venv_dir", lambda *a, **k: False)
+ assert ensure_latest_transformers_venv("5.13.0") is False
+ assert latest_venv_pinned_version() == "5.12.0"
+ assert (venv_dir / "transformers").is_dir()
+ assert not Path(str(venv_dir) + ".staging").exists()
+
+ def test_ensure_latest_rejects_bad_version(self, tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / ".venv_t5_latest"))
+ monkeypatch.setattr(
+ tv,
+ "_ensure_venv_dir",
+ lambda *a: (_ for _ in ()).throw(AssertionError("must not install")),
+ )
+ assert ensure_latest_transformers_venv("5.13.0 && curl evil") is False
+
+ def test_ensure_latest_offline_refuses(self, tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / ".venv_t5_latest"))
+ monkeypatch.setenv("HF_HUB_OFFLINE", "1")
+ monkeypatch.setattr(
+ tv,
+ "_ensure_venv_dir",
+ lambda *a: (_ for _ in ()).throw(AssertionError("must not install")),
+ )
+ assert ensure_latest_transformers_venv("5.13.0") is False
+
+ def test_unpinned_sidecar_never_installs(self, tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / ".venv_t5_latest"))
+ monkeypatch.setattr(
+ tv,
+ "_ensure_venv_dir",
+ lambda *a: (_ for _ in ()).throw(AssertionError("must not install")),
+ )
+ assert tv._ensure_venv_t5_latest_exists() is False
+
+ def test_pinned_sidecar_repairs_with_same_version(self, tmp_path: Path, monkeypatch):
+ venv_dir = tmp_path / ".venv_t5_latest"
+ venv_dir.mkdir()
+ (venv_dir / tv._LATEST_PIN_MARKER).write_text("5.13.0")
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+ monkeypatch.setattr(tv, "_venv_dir_is_valid", lambda *a: False)
+ recorded = {}
+
+ def _fake_ensure(dir_, packages, label):
+ recorded["dir"] = dir_
+ recorded["packages"] = packages
+ Path(dir_).mkdir(parents = True, exist_ok = True)
+ return True
+
+ monkeypatch.setattr(tv, "_ensure_venv_dir", _fake_ensure)
+ assert tv._ensure_venv_t5_latest_exists() is True
+ # Repair also stage-and-swaps, never installing into the live dir.
+ assert recorded["dir"] == str(venv_dir) + ".staging"
+ assert "transformers==5.13.0" in recorded["packages"]
+ assert latest_venv_pinned_version() == "5.13.0"
+
+
+class TestLatestTierRouting:
+ def test_latest_outranks_510(self):
+ assert _higher_tier("latest", "510") == "latest"
+ assert _higher_tier("510", "latest") == "latest"
+
+ def test_tier_from_mapping_prefers_lowest_but_reaches_latest(self, monkeypatch):
+ overlays = dict(_FAKE_OVERLAYS)
+ overlays["latest"] = frozenset({"brandnew_arch"})
+ _fake_overlays(monkeypatch, overlays)
+ assert _tier_from_config_mapping({"model_type": "brandnew_arch"}) == "latest"
+ # Anything a lower tier ships stays on the lower tier.
+ assert _tier_from_config_mapping({"model_type": "qwen3_moe"}) == "530"
+
+ def test_overlay_dir_for_latest(self, tmp_path: Path, monkeypatch):
+ venv_dir = tmp_path / ".venv_t5_latest"
+ (venv_dir / "transformers").mkdir(parents = True)
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+ # Unpinned dir is ignored: activation refuses an unpinned sidecar.
+ assert tv._overlay_transformers_dir("latest") is None
+ (venv_dir / tv._LATEST_PIN_MARKER).write_text("5.13.0")
+ assert tv._overlay_transformers_dir("latest") == str(venv_dir / "transformers")
+
+ def test_probe_order_excludes_unprovisioned_latest(self, tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / ".venv_t5_latest"))
+ assert tv._probe_tier_order() == tv._PROBE_TIER_ORDER
+
+ def test_probe_order_includes_provisioned_latest(self, tmp_path: Path, monkeypatch):
+ venv_dir = tmp_path / ".venv_t5_latest"
+ venv_dir.mkdir()
+ (venv_dir / tv._LATEST_PIN_MARKER).write_text("5.13.0")
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+ assert tv._probe_tier_order() == tv._PROBE_TIER_ORDER + ("latest",)
+
+ def test_activation_prepends_latest_dir(self, tmp_path: Path, monkeypatch):
+ venv_dir = tmp_path / ".venv_t5_latest"
+ venv_dir.mkdir()
+ (venv_dir / tv._LATEST_PIN_MARKER).write_text("5.13.0")
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+ monkeypatch.setattr(tv, "get_transformers_tier", lambda *a, **k: "latest")
+ monkeypatch.setattr(tv, "_ensure_venv_t5_latest_exists", lambda: True)
+ old_sys_path = list(sys.path)
+ old_pp = os.environ.get("PYTHONPATH")
+ try:
+ activate_transformers_for_subprocess("some/brand-new-model")
+ assert sys.path[0] == str(venv_dir)
+ assert os.environ["PYTHONPATH"].split(os.pathsep)[0] == str(venv_dir)
+ finally:
+ sys.path[:] = old_sys_path
+ if old_pp is None:
+ os.environ.pop("PYTHONPATH", None)
+ else:
+ os.environ["PYTHONPATH"] = old_pp
+
+ def test_activation_raises_when_latest_missing(self, tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / ".venv_t5_latest"))
+ monkeypatch.setattr(tv, "get_transformers_tier", lambda *a, **k: "latest")
+ with pytest.raises(RuntimeError, match = "venv_t5_latest"):
+ activate_transformers_for_subprocess("some/brand-new-model")
+
+
+# --- install_latest_transformers: the consent endpoint helper ---
+
+
+class TestInstallLatestTransformers:
+ def test_success_path(self, monkeypatch):
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ monkeypatch.setattr(tl, "compat_plan", lambda v: ((), []))
+ recorded = {}
+
+ def _fake_ensure(
+ version,
+ extra_packages = (),
+ before_swap = None,
+ ):
+ recorded["args"] = (version, extra_packages)
+ return True
+
+ monkeypatch.setattr(tl, "ensure_latest_transformers_venv", _fake_ensure)
+ monkeypatch.setattr(tl, "latest_venv_pinned_version", lambda: "5.13.0")
+ result = install_latest_transformers("5.13.0")
+ assert result["success"] is True and result["version"] == "5.13.0"
+ assert recorded["args"] == ("5.13.0", ())
+
+ def test_version_mismatch_rejected(self, monkeypatch):
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ monkeypatch.setattr(
+ tl,
+ "ensure_latest_transformers_venv",
+ lambda v, extra_packages = (): (_ for _ in ()).throw(AssertionError("must not install")),
+ )
+ result = install_latest_transformers("4.99.0")
+ assert result["success"] is False and "not the latest" in result["message"]
+
+ def test_offline_rejected(self, monkeypatch):
+ monkeypatch.setenv("HF_HUB_OFFLINE", "1")
+ _no_network(monkeypatch)
+ result = install_latest_transformers("5.13.0")
+ assert result["success"] is False and "offline" in result["message"].lower()
+
+ def test_kill_switch_rejected(self, monkeypatch):
+ monkeypatch.setenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", "1")
+ _no_network(monkeypatch)
+ result = install_latest_transformers("5.13.0")
+ assert result["success"] is False
+
+ def test_install_failure_reported(self, monkeypatch):
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ monkeypatch.setattr(tl, "compat_plan", lambda v: ((), []))
+ monkeypatch.setattr(
+ tl,
+ "ensure_latest_transformers_venv",
+ lambda v, extra_packages = (), before_swap = None: False,
+ )
+ result = install_latest_transformers("5.13.0")
+ assert result["success"] is False and "failed" in result["message"]
+
+ def test_blocked_by_incompatible_deps(self, monkeypatch):
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ monkeypatch.setattr(tl, "compat_plan", lambda v: ((), ["numpy>=99.0"]))
+ monkeypatch.setattr(
+ tl,
+ "ensure_latest_transformers_venv",
+ lambda v, extra_packages = (): (_ for _ in ()).throw(AssertionError("must not install")),
+ )
+ result = install_latest_transformers("5.13.0")
+ assert result["success"] is False and "numpy>=99.0" in result["message"]
+
+ def test_compat_shadows_passed_to_installer(self, monkeypatch):
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ monkeypatch.setattr(tl, "compat_plan", lambda v: (("tokenizers==0.23.0",), []))
+ recorded = {}
+
+ def _fake_ensure(
+ version,
+ extra_packages = (),
+ before_swap = None,
+ ):
+ recorded["extras"] = extra_packages
+ return True
+
+ monkeypatch.setattr(tl, "ensure_latest_transformers_venv", _fake_ensure)
+ monkeypatch.setattr(tl, "latest_venv_pinned_version", lambda: "5.13.0")
+ result = install_latest_transformers("5.13.0")
+ assert result["success"] is True
+ assert recorded["extras"] == ("tokenizers==0.23.0",)
+
+
+class TestCompatPlan:
+ def _patch_env(self, monkeypatch, requires, installed):
+ monkeypatch.setattr(tl, "_fetch_requires_dist", lambda v: requires)
+
+ def _ver(name):
+ from importlib.metadata import PackageNotFoundError
+
+ key = name.lower().replace("_", "-")
+ if key not in installed:
+ raise PackageNotFoundError(name)
+ return installed[key]
+
+ monkeypatch.setattr("importlib.metadata.version", _ver)
+
+ def test_satisfied_env_needs_nothing(self, monkeypatch):
+ self._patch_env(
+ monkeypatch,
+ ["tokenizers<=0.23.0,>=0.22.0", "safetensors>=0.8.0", "numpy>=1.17"],
+ {"tokenizers": "0.22.2", "safetensors": "0.8.0", "numpy": "2.4.4"},
+ )
+ extras, blockers = tl.compat_plan("5.13.0")
+ assert extras == () and blockers == []
+
+ def test_unsatisfied_shadowable_dep_pinned(self, monkeypatch):
+ self._patch_env(
+ monkeypatch,
+ ["tokenizers>=0.24.0"],
+ {"tokenizers": "0.22.2"},
+ )
+ monkeypatch.setattr(tl, "_resolve_exact_version", lambda name, spec: "0.24.1")
+ extras, blockers = tl.compat_plan("5.99.0")
+ assert extras == ("tokenizers==0.24.1",) and blockers == []
+
+ def test_unsatisfied_non_shadowable_dep_blocks(self, monkeypatch):
+ self._patch_env(monkeypatch, ["numpy>=99.0"], {"numpy": "2.4.4"})
+ extras, blockers = tl.compat_plan("5.99.0")
+ assert extras == () and blockers == ["numpy>=99.0"]
+
+ def test_cli_only_dep_ignored(self, monkeypatch):
+ self._patch_env(monkeypatch, ["typer"], {})
+ extras, blockers = tl.compat_plan("5.13.0")
+ assert extras == () and blockers == []
+
+ def test_sidecar_provided_hub_checked_against_recipe_pin(self, monkeypatch):
+ self._patch_env(monkeypatch, ["huggingface-hub<2.0,>=1.5.0"], {"huggingface-hub": "0.36.2"})
+ extras, blockers = tl.compat_plan("5.13.0")
+ assert extras == () and blockers == [] # 1.8.0 sidecar pin satisfies it
+
+ def test_sidecar_provided_hub_out_of_range_blocks(self, monkeypatch):
+ self._patch_env(monkeypatch, ["huggingface-hub>=2.1"], {"huggingface-hub": "0.36.2"})
+ extras, blockers = tl.compat_plan("5.99.0")
+ assert blockers == ["huggingface-hub>=2.1"]
+
+ def test_unfetchable_requires_dist_blocks_install(self, monkeypatch):
+ # Proceeding unverified could pin a sidecar whose imports crash workers.
+ monkeypatch.setattr(tl, "_fetch_requires_dist", lambda v: None)
+ extras, blockers = tl.compat_plan("5.13.0")
+ assert extras == () and len(blockers) == 1 and "retry" in blockers[0]
+
+ def test_extra_marker_requirements_skipped(self, monkeypatch):
+ self._patch_env(
+ monkeypatch,
+ ['torch>=99.0; extra == "torch"', 'pytest; python_version < "3.0"'],
+ {},
+ )
+ extras, blockers = tl.compat_plan("5.13.0")
+ assert extras == () and blockers == []
+
+
+def test_get_snapshot_dedupes_concurrent_fetch(monkeypatch):
+ """While one thread is fetching, other callers return None instead of stacking fetches."""
+ with tl._lock:
+ tl._is_fetching = True
+ calls = {"n": 0}
+
+ def boom():
+ calls["n"] += 1
+ raise AssertionError("must not fetch while another fetch is in flight")
+
+ monkeypatch.setattr(tl, "_refresh_snapshot", boom)
+ assert tl._get_snapshot() is None
+ assert calls["n"] == 0
+ tl.clear_caches()
+
+
+def test_install_serialized():
+ """A second install call while one is in progress gets a structured refusal."""
+ from utils.transformers_version import try_begin_sidecar_swap
+
+ assert try_begin_sidecar_swap() is True
+ out = tl.install_latest_transformers("5.13.0")
+ assert out["success"] is False
+ assert "already in progress" in out["message"]
+ tl.clear_caches()
+
+
+def test_install_in_progress_reflects_reservation():
+ """is_install_in_progress mirrors the shared sidecar swap reservation, so a
+ lazy repair (which takes the same reservation) also blocks worker starts."""
+ from utils.transformers_version import end_sidecar_swap, try_begin_sidecar_swap
+
+ assert tl.is_install_in_progress() is False
+ assert try_begin_sidecar_swap() is True
+ try:
+ assert tl.is_install_in_progress() is True
+ finally:
+ end_sidecar_swap()
+ assert tl.is_install_in_progress() is False
+
+
+def test_upgrade_check_sees_nested_model_types(monkeypatch):
+ """A supported wrapper with a brand-new nested backbone must still signal."""
+ cfg = {
+ "model_type": "llava", # in every installed overlay
+ "text_config": {"model_type": "zz_brand_new_llm"},
+ }
+ monkeypatch.setattr(tl, "_load_config_json", lambda *a, **k: cfg)
+ monkeypatch.setattr(
+ tl,
+ "latest_transformers_supports",
+ lambda mt: {
+ "pypi_version": "5.13.0",
+ "supported_in_pypi": mt == "zz_brand_new_llm",
+ "supported_in_main": mt == "zz_brand_new_llm",
+ },
+ )
+ out = tl.check_upgrade_for_model("some-org/wrapped-new-backbone")
+ assert out is not None
+ assert out["model_type"] == "zz_brand_new_llm"
+
+
+def test_upgrade_check_ignores_nested_known_types(monkeypatch):
+ """All nested types known to installed overlays -> no signal, no remote call."""
+ cfg = {
+ "model_type": "llava",
+ "text_config": {"model_type": "llama"},
+ "vision_config": {"model_type": "clip_vision_model"},
+ }
+ monkeypatch.setattr(tl, "_load_config_json", lambda *a, **k: cfg)
+ calls = []
+ monkeypatch.setattr(tl, "latest_transformers_supports", lambda mt: calls.append(mt) or None)
+ assert tl.check_upgrade_for_model("some-org/normal-vlm") is None
+ assert calls == []
+
+
+def test_upgrade_check_requires_primary_supported(monkeypatch):
+ """Latest supporting only a nested type must not prompt: routing still
+ cannot load the primary, so the install would not fix the model."""
+ cfg = {
+ "model_type": "zz_new_wrapper",
+ "text_config": {"model_type": "zz_new_llm"},
+ }
+ monkeypatch.setattr(tl, "_load_config_json", lambda *a, **k: cfg)
+ monkeypatch.setattr(
+ tl,
+ "latest_transformers_supports",
+ lambda mt: {
+ "pypi_version": "5.13.0",
+ "supported_in_pypi": mt == "zz_new_llm",
+ "supported_in_main": mt == "zz_new_llm",
+ },
+ )
+ assert tl.check_upgrade_for_model("some-org/half-supported") is None
+
+
+def test_upgrade_check_requires_every_missing_type(monkeypatch):
+ """Primary supported but a nested backbone missing from latest -> no prompt
+ (CONFIG_MAPPING would still fail on the sub-config); all supported -> signal
+ carries the primary type."""
+ cfg = {
+ "model_type": "zz_new_wrapper",
+ "text_config": {"model_type": "zz_new_llm"},
+ }
+ monkeypatch.setattr(tl, "_load_config_json", lambda *a, **k: cfg)
+ monkeypatch.setattr(
+ tl,
+ "latest_transformers_supports",
+ lambda mt: {
+ "pypi_version": "5.13.0",
+ "supported_in_pypi": mt == "zz_new_wrapper",
+ "supported_in_main": mt == "zz_new_wrapper",
+ },
+ )
+ assert tl.check_upgrade_for_model("some-org/half-supported") is None
+
+ monkeypatch.setattr(
+ tl,
+ "latest_transformers_supports",
+ lambda mt: {
+ "pypi_version": "5.13.0",
+ "supported_in_pypi": True,
+ "supported_in_main": True,
+ },
+ )
+ out = tl.check_upgrade_for_model("some-org/fully-supported")
+ assert out is not None and out["model_type"] == "zz_new_wrapper"
+
+
+def test_install_success_invalidates_capability_caches(monkeypatch):
+ """A successful install must drop tier probes, the latest mapping, and the
+ vision-detection cache so the new sidecar takes effect without a restart."""
+ from utils.models import model_config as mc
+
+ monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen_factory({}))
+ monkeypatch.setattr(tl, "compat_plan", lambda v: ((), []))
+ monkeypatch.setattr(
+ tl, "ensure_latest_transformers_venv", lambda v, extra_packages = (), before_swap = None: True
+ )
+ monkeypatch.setattr(tl, "latest_venv_pinned_version", lambda: "5.13.0")
+
+ tv._probe_tier_cache["stale/model"] = "default"
+ tv._config_mapping_cache["latest"] = frozenset({"stale_type"})
+ tv._config_mapping_cache["default"] = frozenset({"llama"})
+ mc._vision_detection_cache[("stale/model", None, False)] = False
+
+ result = install_latest_transformers("5.13.0")
+ assert result["success"] is True
+ assert tv._probe_tier_cache == {}
+ assert "latest" not in tv._config_mapping_cache
+ assert tv._config_mapping_cache.get("default") == frozenset({"llama"}) # untouched
+ assert mc._vision_detection_cache == {}
+
+ tv._probe_tier_cache.clear()
+ tv._config_mapping_cache.clear()
+ tl.clear_caches()
+
+
+def test_vision_subprocess_unions_sidecar_registry():
+ """The embedded vision-check script must extend the inlined parent sets with
+ the ACTIVE sidecar's registry so sidecar-only architectures classify."""
+ from utils.models import model_config as mc
+
+ script = mc._VISION_CHECK_SCRIPT
+ ast.parse(script)
+ stub_registry = {
+ "MODEL_FOR_IMAGE_TEXT_TO_TEXT_MAPPING_NAMES": {
+ "zz_sidecar_vlm": "ZzSidecarForConditionalGeneration"
+ },
+ }
+ ns = {}
+ # Exec only the registry-union block against a stubbed sidecar registry.
+ body = script.split("from transformers import AutoConfig", 1)[1]
+ body = body.split("kwargs = {", 1)[0]
+ helpers = script.split("sys.path.insert(0, backend_dir)", 1)[1]
+ helpers = helpers.split("try:", 1)[0]
+ exec(helpers, ns)
+
+ class _FakeMa:
+ MODEL_FOR_IMAGE_TEXT_TO_TEXT_MAPPING_NAMES = stub_registry[
+ "MODEL_FOR_IMAGE_TEXT_TO_TEXT_MAPPING_NAMES"
+ ]
+
+ import sys as _sys
+ import types as _types
+
+ fake_pkg = _types.ModuleType("transformers.models.auto")
+ fake_pkg.modeling_auto = _FakeMa
+ saved = {
+ k: _sys.modules.get(k)
+ for k in ("transformers.models.auto", "transformers.models.auto.modeling_auto")
+ }
+ _sys.modules["transformers.models.auto"] = fake_pkg
+ _sys.modules["transformers.models.auto.modeling_auto"] = _FakeMa
+ try:
+ exec(textwrap.dedent(body), ns)
+ finally:
+ for k, v in saved.items():
+ if v is None:
+ _sys.modules.pop(k, None)
+ else:
+ _sys.modules[k] = v
+
+ assert "zz_sidecar_vlm" in ns["_VLM_MODEL_TYPES"]
+ assert "ZzSidecarForConditionalGeneration" in ns["_VLM_CLASS_NAMES"]
+
+ class _Cfg:
+ architectures = ["ZzSidecarForConditionalGeneration"]
+ model_type = "zz_sidecar_vlm"
+
+ assert ns["_is_vlm"](_Cfg()) is True
+
+
+def test_upgrade_check_mixed_pypi_main_reports_dev_only(monkeypatch):
+ """Primary in the PyPI release but a nested type only on main: no install
+ may be offered (CONFIG_MAPPING would fail on the nested sub-config), so the
+ aggregate must read as main-only."""
+ cfg = {
+ "model_type": "zz_new_wrapper",
+ "text_config": {"model_type": "zz_new_llm"},
+ }
+ monkeypatch.setattr(tl, "_load_config_json", lambda *a, **k: cfg)
+ monkeypatch.setattr(
+ tl,
+ "latest_transformers_supports",
+ lambda mt: {
+ "pypi_version": "5.13.0",
+ "supported_in_pypi": mt == "zz_new_wrapper",
+ "supported_in_main": True,
+ },
+ )
+ out = tl.check_upgrade_for_model("some-org/mixed-support")
+ assert out is not None
+ assert out["model_type"] == "zz_new_wrapper"
+ assert out["supported_in_pypi"] is False # no install offered
+ assert out["supported_in_main"] is True
+
+
+def test_install_endpoint_not_mounted_on_v1():
+ """The consented pip-install endpoint is a Studio admin action; it must live
+ on studio_router (kept off the OpenAI-compatible /v1 mount), not router."""
+ from routes import inference as ri
+
+ path = "/install-latest-transformers"
+ assert path in [r.path for r in ri.studio_router.routes]
+ assert path not in [r.path for r in ri.router.routes]
+
+
+def test_kill_switch_removes_provisioned_latest_from_routing(tmp_path, monkeypatch):
+ """UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS must roll back a provisioned latest
+ sidecar: no overlay mapping, no probe participation, no file deletion needed."""
+ venv_dir = tmp_path / ".venv_t5_latest"
+ (venv_dir / "transformers").mkdir(parents = True)
+ (venv_dir / tv._LATEST_PIN_MARKER).write_text("5.13.0")
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+
+ assert tv._overlay_transformers_dir("latest") == str(venv_dir / "transformers")
+ assert tv._probe_tier_order() == tv._PROBE_TIER_ORDER + ("latest",)
+
+ monkeypatch.setenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", "1")
+ tv._config_mapping_cache.pop("latest", None)
+ assert tv._overlay_transformers_dir("latest") is None
+ assert tv._probe_tier_order() == tv._PROBE_TIER_ORDER
+ tv._config_mapping_cache.pop("latest", None)
+
+
+def test_repair_failure_preserves_pin_and_live_dir(tmp_path, monkeypatch):
+ """A failed lazy repair must not delete the incomplete-but-pinned live
+ sidecar: the pin survives so a later attempt can still repair it."""
+ venv_dir = tmp_path / ".venv_t5_latest"
+ venv_dir.mkdir()
+ (venv_dir / tv._LATEST_PIN_MARKER).write_text("5.13.0")
+ (venv_dir / "partial_file").write_text("x")
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+ monkeypatch.setattr(tv, "_venv_dir_is_valid", lambda *a: False)
+ monkeypatch.setattr(tv, "_ensure_venv_dir", lambda *a, **k: False)
+
+ from utils.transformers_version import latest_venv_pinned_version
+
+ assert tv._ensure_venv_t5_latest_exists() is False
+ assert venv_dir.is_dir()
+ assert (venv_dir / "partial_file").exists()
+ assert latest_venv_pinned_version() == "5.13.0"
+ assert not (tmp_path / ".venv_t5_latest.staging").exists()
+
+
+def test_failed_staging_install_removes_staging_dir(tmp_path, monkeypatch):
+ """A pip failure inside _ensure_venv_dir returns False without raising, so
+ the except cleanup never runs; the partial staging dir must still go."""
+ venv_dir = tmp_path / ".venv_t5_latest"
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(venv_dir))
+
+ def _fake_ensure(dir_, packages, label):
+ Path(dir_).mkdir(parents = True, exist_ok = True)
+ (Path(dir_) / "partial").write_text("x")
+ return False
+
+ monkeypatch.setattr(tv, "_ensure_venv_dir", _fake_ensure)
+ assert ensure_latest_transformers_venv("5.13.0") is False
+ assert not Path(str(venv_dir) + ".staging").exists()
diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py
index b9b5abb9e5..a6e6803a5c 100644
--- a/studio/backend/tests/test_transformers_version.py
+++ b/studio/backend/tests/test_transformers_version.py
@@ -2550,3 +2550,649 @@ class TestHfEndpointUnreachable:
t0 = time.time()
result = hf_endpoint_unreachable(timeout = 2)
assert result is True and (time.time() - t0) < 6.0
+
+
+class TestLatestTierActiveFor:
+ """latest_tier_active_for: the 16-bit guard for the consented latest sidecar."""
+
+ @staticmethod
+ def _pin(
+ monkeypatch,
+ tv,
+ version = "5.13.1",
+ ):
+ monkeypatch.setattr(tv, "latest_venv_pinned_version", lambda: version)
+ monkeypatch.setattr(tv, "_remote_lora_base", lambda name, hf_token = None: None)
+
+ def test_true_when_tier_latest(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ self._pin(monkeypatch, tv)
+ monkeypatch.setattr(tv, "get_transformers_tier", lambda *a, **k: "latest")
+ assert tv.latest_tier_active_for("Zyphra/ZAYA1-8B") is True
+
+ def test_false_for_fixed_tiers(self, monkeypatch):
+ import utils.transformers_version as tv
+ self._pin(monkeypatch, tv)
+ for tier in ("default", "530", "550", "510"):
+ monkeypatch.setattr(tv, "get_transformers_tier", lambda *a, _t = tier, **k: _t)
+ assert tv.latest_tier_active_for("some/model") is False
+
+ def test_false_without_pin_and_no_resolution(self, monkeypatch):
+ """No sidecar pin returns False before any tier or network resolution."""
+ import utils.transformers_version as tv
+
+ def _boom(*a, **k):
+ raise AssertionError("must not resolve without a pin")
+
+ monkeypatch.setattr(tv, "latest_venv_pinned_version", lambda: None)
+ monkeypatch.setattr(tv, "_remote_lora_base", _boom)
+ monkeypatch.setattr(tv, "get_transformers_tier", _boom)
+ assert tv.latest_tier_active_for("Zyphra/ZAYA1-8B") is False
+
+ def test_never_raises(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ def _boom(*a, **k):
+ raise RuntimeError("tier resolution exploded")
+
+ self._pin(monkeypatch, tv)
+ monkeypatch.setattr(tv, "get_transformers_tier", _boom)
+ assert tv.latest_tier_active_for("some/model") is False
+
+ def test_remote_lora_base_is_resolved(self, monkeypatch):
+ """A remote adapter is judged by its base model, like worker activation."""
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "latest_venv_pinned_version", lambda: "5.13.1")
+ monkeypatch.setattr(tv, "_remote_lora_base", lambda name, hf_token = None: "Zyphra/ZAYA1-8B")
+ tiers = {"Zyphra/ZAYA1-8B": "latest"}
+ monkeypatch.setattr(
+ tv, "get_transformers_tier", lambda name, *a, **k: tiers.get(name, "default")
+ )
+ assert tv.latest_tier_active_for("someuser/zaya-lora") is True
+
+ def test_local_checkpoint_config_upgrades(self, monkeypatch, tmp_path):
+ """An adapter dir with its own config.json merges tiers like activation does."""
+ import utils.transformers_version as tv
+
+ adapter = tmp_path / "ckpt"
+ adapter.mkdir()
+ (adapter / "adapter_config.json").write_text("{}")
+ (adapter / "adapter_model.safetensors").write_text("x")
+ (adapter / "config.json").write_text("{}")
+ self._pin(monkeypatch, tv)
+ monkeypatch.setattr(tv, "_resolve_base_model", lambda name: "base/model")
+ tiers = {"base/model": "default", str(adapter): "latest"}
+ monkeypatch.setattr(
+ tv, "get_transformers_tier", lambda name, *a, **k: tiers.get(name, "default")
+ )
+ assert tv.latest_tier_active_for(str(adapter)) is True
+
+
+class TestLatestTierForces16Bit:
+ """The inference worker and load route refuse bnb 4-bit on the latest sidecar."""
+
+ def _read(self, rel):
+ backend_dir = Path(__file__).resolve().parent.parent
+ return (backend_dir / rel).read_text()
+
+ def test_worker_guard_present(self):
+ src = self._read("core/inference/worker.py")
+ assert "latest_tier_active_for" in src, (
+ "core/inference/worker.py must force load_in_4bit=False when "
+ "latest_tier_active_for(model) is true: transformers' grouped-MoE "
+ "kernels crash on bnb-quantized expert weights for brand-new "
+ "architectures."
+ )
+
+ def test_route_guard_present(self):
+ src = self._read("routes/inference.py")
+ assert "latest_tier_active_for" in src, (
+ "routes/inference.py must size the VRAM guard with the same 16-bit "
+ "flip the worker applies for latest-sidecar models."
+ )
+
+ def test_validate_route_mirrors_16bit_flip(self):
+ # Without the same flip, /validate sizes 4-bit and /load then 409s.
+ src = self._read("routes/inference.py")
+ body = src.split("async def validate_model", 1)[1].split("\nasync def ", 1)[0]
+ assert "latest_tier_active_for" in body, (
+ "validate_model must apply the latest-sidecar 16-bit flip before "
+ "_guard_chat_load_against_training so /validate and /load agree."
+ )
+ # First-time loads have no pin yet, so an installable upgrade must also size 16-bit.
+ assert body.index("check_upgrade_for_model") < body.index(
+ "_guard_chat_load_against_training"
+ ), "the upgrade check must run before the training guard"
+ assert (
+ "supported_in_pypi" in body.split("_guard_chat_load_against_training")[0]
+ ), "an installable upgrade must force 16-bit sizing for the guard"
+
+ def test_validate_offered_upgrade_preserves_custom_code_4bit(self):
+ # A merely-offered (not installed) upgrade must NOT force 16-bit sizing when the
+ # model has a custom-code (auto_map) fallback: /load loads it 4-bit without the
+ # install, and the install route refuses during active training, so 16-bit sizing
+ # here would 409 the only viable 4-bit path.
+ src = self._read("routes/inference.py")
+ body = src.split("async def validate_model", 1)[1].split("\nasync def ", 1)[0]
+ flip = body.split("Mirror /load's latest-sidecar 16-bit flip", 1)[1].split(
+ "_guard_chat_load_against_training", 1
+ )[0]
+ assert "not requires_trust_remote_code" in flip, (
+ "the offered-upgrade 16-bit flip must be gated on the absence of a custom-code "
+ "fallback so /validate does not 409 a 4-bit load /load would allow"
+ )
+ # requires_trust_remote_code must be resolved before the flip consumes it.
+ assert body.index("requires_trust_remote_code = any(") < body.index(
+ "not requires_trust_remote_code"
+ )
+
+ def test_install_route_guards_active_latest_workers(self):
+ # Stage-and-swap replaces .venv_t5_latest in place, so a live worker on the
+ # old sidecar would lazy-import files from the new version.
+ src = self._read("routes/inference.py")
+ body = src.split("async def install_latest_transformers_route", 1)[1].split(
+ "\nasync def ", 1
+ )[0]
+ assert (
+ "is_training_active" in body
+ and "is_export_active" in body
+ and "inference_lifecycle_gate" in body
+ ), (
+ "install_latest_transformers_route must refuse while training or export "
+ "runs, and hold the lifecycle gate while unloading the chat model and "
+ "swapping the sidecar."
+ )
+ # The unload (via before_swap so failed installs keep the model), the export-worker
+ # teardown, and the install must all sit INSIDE the gate so no /load interleaves.
+ assert "unload_model(active)" in body
+ assert "cleanup_memory()" in body
+ # Export teardown precedes the chat unload so its failure aborts with the model still loaded.
+ assert body.index("cleanup_memory()") < body.index("unload_model(active)")
+ assert "install_latest_transformers(" in body and "_unload_before_swap" in body
+ # The gate must be owned by the shielded task, not the request coroutine: a cancelled
+ # POST unwinding an async-with would release the only guard /load honors mid-install.
+ gated_task = body.split("async def _gated_install", 1)[1]
+ assert "inference_lifecycle_gate():" in gated_task
+ assert "asyncio.to_thread(_run_install)" in gated_task
+ # The reservation must be taken BEFORE the (awaitable) gate wait, or a
+ # training/export start could slip in while this request queues on the gate.
+ assert body.index("try_begin_sidecar_swap()") < body.index(
+ "inference_lifecycle_gate():"
+ ), "the swap reservation must be raised before waiting on the lifecycle gate"
+ # A failed teardown must abort the swap (raise), not fall through to it.
+ assert body.count("raise RuntimeError") >= 3, (
+ "export, chat-unload, and idle-worker teardown failures must raise so "
+ "the staged install never swaps under a live worker"
+ )
+ # The installer thread owns (and releases) the reservation, shielded from
+ # request cancellation, so a cancelled POST cannot unlock a live swap.
+ assert "asyncio.shield" in body and "end_sidecar_swap()" in body
+ # In-flight generation streams predate the gate; the route refuses rather than kill them
+ # via the before_swap unload. The count is rechecked UNDER the gate, since a wait on a
+ # long /load outlasts the pre-gate fast path and streams take this same gate.
+ assert "other_inference_request_count" in body
+ gated_task = body.split("async def _gated_install", 1)[1]
+ assert "other_inference_request_count" in gated_task
+
+ def test_start_routes_refuse_during_install(self):
+ # A worker spawned mid-swap could activate a half-replaced sidecar.
+ training = self._read("routes/training.py")
+ start = training.split("async def start_training", 1)[1].split("\nasync def ", 1)[0]
+ assert (
+ "is_install_in_progress" in start
+ ), "training /start must refuse while a transformers install is in progress"
+ export = self._read("routes/export.py")
+ helper = export.split("def _ensure_export_supported", 1)[1].split("\ndef ", 1)[0]
+ assert (
+ "is_install_in_progress" in helper
+ ), "mutating export routes must refuse while a transformers install is in progress"
+
+ def test_spawn_sites_recheck_reservation(self):
+ # The route-level guards are one-shot; validation between them and the
+ # actual spawn can outlast an install's start, so the spawn itself rechecks.
+ training = self._read("core/training/training.py")
+ assert (
+ training.count("sidecar_swap_in_progress()") >= 2
+ ), "both training spawn sites must recheck the sidecar swap reservation"
+ export = self._read("core/export/orchestrator.py")
+ spawn = export.split("def _spawn_subprocess", 1)[1].split("\n def ", 1)[0]
+ assert (
+ "sidecar_swap_kind()" in spawn
+ ), "the export subprocess spawn must recheck the sidecar swap reservation"
+ # Training marks the spawn active BEFORE its recheck, so either side sees the other:
+ # is_training_active covers the window between proc.start() and the _proc assignment.
+ assert training.index("self._spawn_in_progress = True") < training.index(
+ "if sidecar_swap_in_progress():"
+ )
+ active = training.split("def is_training_active", 1)[1].split("\n def ", 1)[0]
+ assert "_spawn_in_progress" in active
+ # Export load-checkpoint refuses BEFORE tearing down the old worker, so a
+ # lost race against an install keeps the loaded checkpoint (no bare 500).
+ loadck = export.split("def load_checkpoint", 1)[1].split("\n def ", 1)[0]
+ assert loadck.index("sidecar_swap_in_progress()") < loadck.index("_shutdown_subprocess()")
+ # The training handshake precedes the VRAM-freeing before_spawn hook, so
+ # losing the race never tears down chat/export for a run that won't spawn.
+ assert training.index("self._spawn_in_progress = True") < training.index("before_spawn()")
+ # The spawn-time export check is op-aware for installs (the install side
+ # aborts on is_export_active) but always refuses for repairs, which have
+ # no such abort and can be rebuilding the sidecar right now.
+ assert (
+ '_swap_kind == "repair" or (_swap_kind is not None and not self._export_active)'
+ in spawn
+ )
+
+
+class TestSidecarSwapReservation:
+ """The lazy repair takes the same reservation the install route and worker starts use."""
+
+ def _repair_setup(self, monkeypatch, tmp_path):
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / "venv_t5_latest"))
+ monkeypatch.setattr(
+ tv,
+ "_latest_pin_data",
+ lambda: {
+ "version": "5.99.0",
+ "packages": ["transformers==5.99.0"],
+ },
+ )
+ monkeypatch.setattr(tv, "_venv_dir_is_valid", lambda d, p: False)
+ monkeypatch.setattr(tv, "_env_offline", lambda: False)
+ return tv
+
+ def test_repair_holds_reservation_during_swap(self, monkeypatch, tmp_path):
+ tv = self._repair_setup(monkeypatch, tmp_path)
+ seen = {}
+
+ def _fake_swap(
+ version,
+ packages,
+ before_swap = None,
+ ):
+ seen["active_during_swap"] = tv.sidecar_swap_in_progress()
+ return True
+
+ monkeypatch.setattr(tv, "_stage_and_swap_latest_venv", _fake_swap)
+ assert tv._ensure_venv_t5_latest_exists() is True
+ assert seen["active_during_swap"] is True
+ assert tv.sidecar_swap_in_progress() is False
+
+ def test_foreign_process_lock_file_visible(self, monkeypatch, tmp_path):
+ """A repair in a LIVE worker subprocess is seen (via the lock file) by this
+ process, and its lock is never broken while the owner is alive."""
+ import os
+ import time
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / "venv_t5_latest"))
+ lock = tv._swap_lock_path()
+ lock.parent.mkdir(parents = True, exist_ok = True)
+ # A live owner (this process): visible and never reclaimed, even once aged past
+ # the cutoff -- a slow but live pip install must keep its lock.
+ lock.write_text('{"pid": %d}' % os.getpid())
+ assert tv.sidecar_swap_in_progress() is True
+ assert tv.try_begin_sidecar_swap() is False
+ old_ts = time.time() - 3 * 60 * 60
+ os.utime(lock, (old_ts, old_ts))
+ assert tv.sidecar_swap_in_progress() is True
+ assert tv.try_begin_sidecar_swap() is False
+
+ def test_dead_owner_lock_reclaimed_promptly(self, monkeypatch, tmp_path):
+ """A fresh lock whose recorded owner is dead is reclaimed at once, not after the
+ long cutoff: a crash mid-install must not wedge loads/training/export for hours."""
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / "venv_t5_latest"))
+ lock = tv._swap_lock_path()
+ lock.parent.mkdir(parents = True, exist_ok = True)
+ # 999999 is not a live PID: a fresh dead-owner lock is immediately stale.
+ lock.write_text('{"pid": 999999, "kind": "install"}')
+ assert tv._pid_alive(999999) is False
+ assert tv.sidecar_swap_in_progress() is False
+ assert tv.try_begin_sidecar_swap() is True
+ try:
+ assert lock.is_file()
+ finally:
+ tv.end_sidecar_swap()
+ assert not lock.exists()
+
+ def test_unreadable_pid_lock_uses_age_cutoff(self, monkeypatch, tmp_path):
+ """A lock with no readable owner PID (mid create-before-write, or corrupt) is not
+ reclaimed while fresh -- only after the long cutoff -- so a lock a live owner just
+ created is not stolen before its PID lands."""
+ import os
+ import time
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / "venv_t5_latest"))
+ lock = tv._swap_lock_path()
+ lock.parent.mkdir(parents = True, exist_ok = True)
+ lock.write_text("") # created but metadata not yet written
+ assert tv.sidecar_swap_in_progress() is True
+ old_ts = time.time() - (tv._SWAP_LOCK_STALE_SECS + 60)
+ os.utime(lock, (old_ts, old_ts))
+ assert tv.sidecar_swap_in_progress() is False
+
+ def test_repair_refused_while_install_holds_reservation(self, monkeypatch, tmp_path):
+ tv = self._repair_setup(monkeypatch, tmp_path)
+
+ def _must_not_run(*a, **k):
+ raise AssertionError("repair must not swap while an install is in progress")
+
+ monkeypatch.setattr(tv, "_stage_and_swap_latest_venv", _must_not_run)
+ assert tv.try_begin_sidecar_swap() is True
+ try:
+ assert tv._ensure_venv_t5_latest_exists() is False
+ finally:
+ tv.end_sidecar_swap()
+
+
+class TestRecoverStrandedSidecar:
+ """A swap whose activation rename AND rollback both fail strands the previous sidecar
+ at .old with no live dir (its pin marker went with it). Reading the pin self-heals it,
+ but never while a swap legitimately holds the reservation."""
+
+ def _setup(self, monkeypatch, tmp_path):
+ import utils.transformers_version as tv
+
+ live = str(tmp_path / "venv_t5_latest")
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", live)
+ # Stranded state: live gone, previous sidecar (with its marker) sits at .old.
+ retired = Path(live + ".old")
+ retired.mkdir(parents = True)
+ (retired / tv._LATEST_PIN_MARKER).write_text(
+ '{"version": "5.99.0", "packages": ["transformers==5.99.0"]}'
+ )
+ return tv, Path(live), retired
+
+ def test_stranded_old_recovered_on_pin_read(self, monkeypatch, tmp_path):
+ tv, live, retired = self._setup(monkeypatch, tmp_path)
+ data = tv._latest_pin_data()
+ assert live.is_dir()
+ assert not retired.exists()
+ assert data is not None and data["version"] == "5.99.0"
+
+ def test_stranded_recovery_skipped_during_swap(self, monkeypatch, tmp_path):
+ tv, live, retired = self._setup(monkeypatch, tmp_path)
+ assert tv.try_begin_sidecar_swap() is True
+ try:
+ # A swap holds the reservation and may be mid-rename; do not race it.
+ assert tv._latest_pin_data() is None
+ assert not live.exists()
+ assert retired.is_dir()
+ finally:
+ tv.end_sidecar_swap()
+ # Once the swap is done, the next pin read recovers the stranded sidecar.
+ assert tv._latest_pin_data() is not None
+ assert live.is_dir()
+
+
+class TestCachedLatestMappingRevalidated:
+ """A cached 'latest' mapping is dropped and re-resolved when the sidecar since broke
+ in-process, so routing self-heals instead of trusting a mapping parsed from a sidecar
+ that no longer exists (which would keep routing latest-only models to a broken tier)."""
+
+ def test_broken_sidecar_drops_cached_latest_mapping(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "_config_mapping_cache", {"latest": frozenset({"brandnew"})})
+ monkeypatch.setattr(tv, "_latest_sidecar_intact", lambda: False)
+ seen = {"n": 0}
+
+ def _fake_overlay(tier):
+ seen["n"] += 1
+ return None # broken/unavailable -> empty, uncached
+
+ monkeypatch.setattr(tv, "_overlay_transformers_dir", _fake_overlay)
+ assert tv._config_model_types("latest") == frozenset()
+ assert seen["n"] == 1 # re-resolved, not served from the stale cache
+ assert "latest" not in tv._config_mapping_cache
+
+ def test_intact_sidecar_serves_cached_latest_mapping(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "_config_mapping_cache", {"latest": frozenset({"brandnew"})})
+ monkeypatch.setattr(tv, "_latest_sidecar_intact", lambda: True)
+ monkeypatch.setattr(
+ tv,
+ "_overlay_transformers_dir",
+ lambda tier: pytest.fail("intact sidecar must serve the cache without re-resolving"),
+ )
+ assert tv._config_model_types("latest") == frozenset({"brandnew"})
+
+ def test_non_latest_cache_not_revalidated(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "_config_mapping_cache", {"530": frozenset({"gemma3"})})
+ monkeypatch.setattr(
+ tv,
+ "_latest_sidecar_intact",
+ lambda: pytest.fail("non-latest tiers must not pay the sidecar-intact check"),
+ )
+ assert tv._config_model_types("530") == frozenset({"gemma3"})
+
+ def test_deleted_pin_drops_cached_latest_mapping(self, monkeypatch, tmp_path):
+ # A pin marker deleted after the mapping was cached makes _latest_pin_data None;
+ # the cache must be dropped (not trusted), so routing re-resolves to no latest tier
+ # rather than routing to a latest tier that then fails worker activation.
+ import utils.transformers_version as tv
+
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(tmp_path / "venv_t5_latest"))
+ monkeypatch.setattr(tv, "_latest_tier_disabled", lambda: False)
+ monkeypatch.setattr(tv, "_config_mapping_cache", {"latest": frozenset({"brandnew"})})
+ # No pin marker on disk -> _latest_pin_data() is None -> not intact.
+ assert tv._latest_sidecar_intact() is False
+ assert tv._config_model_types("latest") == frozenset()
+ assert "latest" not in tv._config_mapping_cache
+
+
+class TestOverlayRepairsIncompleteSidecar:
+ """Routing self-heals a pinned latest sidecar that is present but incomplete,
+ not only one whose transformers/ dir vanished: workers refuse parent-only
+ repairs, so a sidecar missing a pinned package would fail every load."""
+
+ def _setup(self, monkeypatch, tmp_path, valid):
+ import utils.transformers_version as tv
+
+ live = tmp_path / "venv_t5_latest"
+ (live / "transformers").mkdir(parents = True)
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(live))
+ monkeypatch.setattr(tv, "_latest_tier_disabled", lambda: False)
+ monkeypatch.setattr(tv, "latest_venv_pinned_version", lambda: "5.99.0")
+ monkeypatch.setattr(
+ tv,
+ "_latest_pin_data",
+ lambda: {"version": "5.99.0", "packages": ["transformers==5.99.0", "tiktoken"]},
+ )
+ monkeypatch.setattr(tv, "_venv_dir_is_valid", lambda d, p: valid)
+ monkeypatch.setattr(tv, "_latest_repair_failed_at", 0.0)
+ return tv
+
+ def test_incomplete_sidecar_triggers_repair(self, monkeypatch, tmp_path):
+ tv = self._setup(monkeypatch, tmp_path, valid = False)
+ called = {"n": 0}
+
+ def _fake_repair():
+ called["n"] += 1
+ return True
+
+ monkeypatch.setattr(tv, "_ensure_venv_t5_latest_exists", _fake_repair)
+ src = tv._overlay_transformers_dir("latest")
+ assert called["n"] == 1
+ assert src == str(tmp_path / "venv_t5_latest" / "transformers")
+
+ def test_intact_sidecar_skips_repair(self, monkeypatch, tmp_path):
+ tv = self._setup(monkeypatch, tmp_path, valid = True)
+
+ def _must_not_run():
+ raise AssertionError("intact sidecar must not trigger a repair")
+
+ monkeypatch.setattr(tv, "_ensure_venv_t5_latest_exists", _must_not_run)
+ assert tv._overlay_transformers_dir("latest") == str(
+ tmp_path / "venv_t5_latest" / "transformers"
+ )
+
+ def test_failed_repair_backs_off(self, monkeypatch, tmp_path):
+ tv = self._setup(monkeypatch, tmp_path, valid = False)
+ called = {"n": 0}
+
+ def _fake_repair():
+ called["n"] += 1
+ return False
+
+ monkeypatch.setattr(tv, "_ensure_venv_t5_latest_exists", _fake_repair)
+ # A failed repair must not route through the broken sidecar, neither on
+ # the failing attempt nor while the backoff suppresses the next attempt.
+ assert tv._overlay_transformers_dir("latest") is None
+ assert tv._overlay_transformers_dir("latest") is None
+ assert called["n"] == 1
+
+
+class TestStageAndSwapBeforeSwap:
+ """before_swap fires only when the staged install succeeded and the swap is next."""
+
+ def _setup(self, monkeypatch, tmp_path, build_ok):
+ import utils.transformers_version as tv
+
+ live = tmp_path / "venv_latest"
+ monkeypatch.setattr(tv, "_VENV_T5_LATEST_DIR", str(live))
+
+ def _fake_build(target, packages, label):
+ if build_ok:
+ Path(target).mkdir(parents = True, exist_ok = True)
+ return build_ok
+
+ monkeypatch.setattr(tv, "_ensure_venv_dir", _fake_build)
+ return tv, live
+
+ def test_called_after_successful_staging(self, monkeypatch, tmp_path):
+ tv, live = self._setup(monkeypatch, tmp_path, build_ok = True)
+ calls = []
+ assert tv._stage_and_swap_latest_venv(
+ "5.99.0", ("transformers==5.99.0",), before_swap = lambda: calls.append(1)
+ )
+ assert calls == [1] and live.is_dir()
+
+ def test_not_called_when_staging_fails(self, monkeypatch, tmp_path):
+ tv, live = self._setup(monkeypatch, tmp_path, build_ok = False)
+ calls = []
+ assert not tv._stage_and_swap_latest_venv(
+ "5.99.0", ("transformers==5.99.0",), before_swap = lambda: calls.append(1)
+ )
+ assert calls == [] and not live.exists()
+
+ def test_failure_in_before_swap_keeps_previous_sidecar(self, monkeypatch, tmp_path):
+ tv, live = self._setup(monkeypatch, tmp_path, build_ok = True)
+ live.mkdir()
+ (live / "sentinel").write_text("old")
+
+ def _boom():
+ raise RuntimeError("worker teardown failed")
+
+ assert not tv._stage_and_swap_latest_venv(
+ "5.99.0", ("transformers==5.99.0",), before_swap = _boom
+ )
+ assert (live / "sentinel").read_text() == "old"
+
+
+class TestKillSwitchBeatsMappingCache:
+ def test_cached_latest_probe_ignored_when_disabled(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ key = tv._probe_cache_key("some/model")
+ monkeypatch.setitem(tv._probe_tier_cache, key, "latest")
+ monkeypatch.setenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", "1")
+ # With the switch set, the cached latest entry must not short-circuit;
+ # the probe re-resolves against the non-latest order (stub it to 530).
+ monkeypatch.setattr(tv, "_probe_tier_venvs", lambda: {})
+ monkeypatch.setattr(tv, "_probe_tier_order", lambda: ())
+ assert tv._probe_tier("some/model", None, "test") != "latest"
+ # Cached non-latest entries and the unset switch still short-circuit.
+ monkeypatch.delenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS")
+ assert tv._probe_tier("some/model", None, "test") == "latest"
+
+ def test_cached_latest_mapping_ignored_when_disabled(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ monkeypatch.setitem(tv._config_mapping_cache, "latest", frozenset({"brandnew"}))
+ # The cache is trusted only when the sidecar is intact; hold it intact so this
+ # test isolates the kill switch, not the sidecar-revalidation path.
+ monkeypatch.setattr(tv, "_latest_sidecar_intact", lambda: True)
+ monkeypatch.setenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", "1")
+ assert tv._config_model_types("latest") == frozenset()
+ monkeypatch.delenv("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS")
+ assert tv._config_model_types("latest") == frozenset({"brandnew"})
+
+
+class TestRaiseTierForNested:
+ """_raise_tier_for_nested: a wrapper's nested model_type can raise a fast-path tier."""
+
+ def _patch_types(self, monkeypatch, per_tier):
+ import utils.transformers_version as tv
+ monkeypatch.setattr(
+ tv, "_config_model_types", lambda tier: frozenset(per_tier.get(tier, ()))
+ )
+
+ def test_nested_latest_only_type_raises(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ self._patch_types(monkeypatch, {"550": {"gemma4"}, "latest": {"gemma4", "brandnew_arch"}})
+ cfg = {"model_type": "gemma4", "text_config": {"model_type": "brandnew_arch"}}
+ assert tv._raise_tier_for_nested(cfg, "550") == "latest"
+
+ def test_never_lowers_a_fast_path_tier(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ # Mapping alone would say 530, but the fast path (e.g. a name override) said 550.
+ self._patch_types(monkeypatch, {"530": {"qwen3_5"}, "550": {"qwen3_5"}})
+ assert tv._raise_tier_for_nested({"model_type": "qwen3_5"}, "550") == "550"
+
+ def test_no_config_keeps_tier(self):
+ import utils.transformers_version as tv
+ assert tv._raise_tier_for_nested(None, "550") == "550"
+
+ def test_unknown_nested_type_never_vetoes(self, monkeypatch):
+ import utils.transformers_version as tv
+
+ # A nested type unknown everywhere (not even latest) keeps the fast path.
+ self._patch_types(monkeypatch, {"550": {"gemma4"}, "latest": {"gemma4"}})
+ cfg = {"model_type": "gemma4", "text_config": {"model_type": "unreleased"}}
+ assert tv._raise_tier_for_nested(cfg, "550") == "550"
+
+ def test_name_fast_path_folds_when_latest_pinned(self, monkeypatch):
+ """A fixed-tier name match with a latest-only model_type routes to latest
+ once the sidecar is pinned; without a pin the name tier stands (no I/O)."""
+ import utils.transformers_version as tv
+
+ self._patch_types(monkeypatch, {"550": {"gemma4"}, "latest": {"brandnew_arch"}})
+ monkeypatch.setattr(tv, "_tier_from_name", lambda name: ("550", "gemma-4"))
+ monkeypatch.setattr(
+ tv, "_load_config_json", lambda name, tok = None: {"model_type": "brandnew_arch"}
+ )
+ monkeypatch.setattr(tv, "latest_venv_pinned_version", lambda: "5.99.0")
+ assert tv.get_transformers_tier("org/gemma-4-new", probe = False) == "latest"
+ monkeypatch.setattr(tv, "latest_venv_pinned_version", lambda: None)
+ monkeypatch.setattr(
+ tv,
+ "_load_config_json",
+ lambda name, tok = None: (_ for _ in ()).throw(AssertionError("no I/O without a pin")),
+ )
+ assert tv.get_transformers_tier("org/gemma-4-new", probe = False) == "550"
+
+ def test_fast_path_folds_nested_tier(self, monkeypatch, tmp_path):
+ """End to end: a local wrapper config on a fixed fast path routes to latest
+ when its nested type only exists in the installed latest sidecar."""
+ import utils.transformers_version as tv
+
+ ckpt = tmp_path / "wrapper"
+ ckpt.mkdir()
+ (ckpt / "config.json").write_text(
+ json.dumps({"model_type": "gemma4", "text_config": {"model_type": "brandnew_arch"}})
+ )
+ self._patch_types(monkeypatch, {"550": {"gemma4"}, "latest": {"gemma4", "brandnew_arch"}})
+ monkeypatch.setattr(tv, "_config_needs_510", lambda cfg: False)
+ monkeypatch.setattr(tv, "_config_needs_550", lambda cfg: True)
+ assert tv.get_transformers_tier(str(ckpt), probe = False) == "latest"
diff --git a/studio/backend/tests/test_web_fetch_binary_guard.py b/studio/backend/tests/test_web_fetch_binary_guard.py
new file mode 100644
index 0000000000..3041ed5c34
--- /dev/null
+++ b/studio/backend/tests/test_web_fetch_binary_guard.py
@@ -0,0 +1,270 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Regression tests for binary bodies poisoning web_search model context (#7084)."""
+
+from __future__ import annotations
+
+import codecs
+import sys
+from email.message import Message
+from pathlib import Path
+
+import pytest
+
+_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
+if _BACKEND_DIR not in sys.path:
+ sys.path.insert(0, _BACKEND_DIR)
+
+from core.inference import tools
+
+
+class _FakeResp:
+ def __init__(self, body: bytes, content_type: str | None):
+ self._body = body
+ self._pos = 0
+ self.headers = Message()
+ if content_type is not None:
+ self.headers["Content-Type"] = content_type
+
+ def read(self, n: int | None = None) -> bytes:
+ # Advance a cursor like a real stream so the chunked reader reaches EOF.
+ chunk = self._body[self._pos :] if n is None else self._body[self._pos : self._pos + n]
+ self._pos += len(chunk)
+ return chunk
+
+
+class _FakeOpener:
+ def __init__(self, resp):
+ self._resp = resp
+
+ def open(
+ self,
+ req,
+ timeout = None,
+ ):
+ return self._resp
+
+
+def _fetch_with(monkeypatch, body: bytes, content_type: str | None) -> str:
+ # Pass SSRF validation and skip real DNS/network.
+ monkeypatch.setattr(
+ tools, "_validate_and_resolve_host", lambda host, port: (True, "", "93.184.216.34")
+ )
+ monkeypatch.setattr(
+ tools.urllib.request,
+ "build_opener",
+ lambda *a, **k: _FakeOpener(_FakeResp(body, content_type)),
+ )
+ return tools._fetch_page_text("https://example.com/thing", timeout = 5)
+
+
+@pytest.mark.parametrize(
+ "content_type,expected",
+ [
+ ("text/html", True),
+ ("text/plain; charset=utf-8", True),
+ ("application/json", True),
+ ("application/json; charset=utf-8", True),
+ ("application/xml", True),
+ ("application/xhtml+xml", True),
+ ("application/ld+json", True),
+ ("application/yaml", True),
+ ("application/x-yaml", True),
+ ("application/x-ndjson", True),
+ ("application/ndjson", True),
+ ("application/sql", True),
+ ("application/x-www-form-urlencoded", True),
+ ("application/pdf", False),
+ ("image/png", False),
+ ("image/svg+xml", False),
+ ("application/octet-stream", True),
+ ("application/zip", False),
+ ("application/vnd.ms-excel", True),
+ ("application/vnd.openxmlformats-officedocument.wordprocessingml.document", True),
+ ("", True),
+ (None, True),
+ ],
+)
+def test_is_text_candidate_content_type(content_type, expected):
+ assert tools._is_text_candidate_content_type(content_type) is expected
+
+
+def test_pdf_rejected_by_content_type(monkeypatch):
+ out = _fetch_with(monkeypatch, b"%PDF-1.7\n\xff\xd8\xff\x00\x89PNG" * 200, "application/pdf")
+ assert "�" not in out
+ assert "non-text content" in out and "application/pdf" in out
+
+
+def test_text_octet_stream_kept_after_sniffing(monkeypatch):
+ body = b"level=info\nmessage=plain text artifact\n" * 100
+ out = _fetch_with(monkeypatch, body, "application/octet-stream")
+ assert "plain text artifact" in out
+ assert "non-text content" not in out and "binary content" not in out
+
+
+@pytest.mark.parametrize(
+ "content_type",
+ ["application/octet-stream", "application/x-custom-binary", "text/plain", None],
+)
+def test_binary_candidates_rejected_after_sniffing(monkeypatch, content_type):
+ out = _fetch_with(monkeypatch, bytes(range(256)) * 20, content_type)
+ assert "�" not in out
+ assert "binary content" in out
+
+
+@pytest.mark.parametrize("content_type", ["application/sql", "application/x-www-form-urlencoded"])
+def test_unknown_application_text_kept_after_sniffing(monkeypatch, content_type):
+ out = _fetch_with(monkeypatch, b"select readable_text from artifacts;\n" * 100, content_type)
+ assert "readable_text" in out
+ assert "non-text content" not in out and "binary content" not in out
+
+
+def test_excel_labeled_csv_kept_after_sniffing(monkeypatch):
+ body = b"name,value\nreadable,42\n" * 100
+ out = _fetch_with(monkeypatch, body, "application/vnd.ms-excel")
+ assert "readable" in out
+ assert "binary content" not in out
+
+
+@pytest.mark.parametrize(
+ "bom,encoding",
+ [
+ (codecs.BOM_UTF16_LE, "utf-16-le"),
+ (codecs.BOM_UTF16_BE, "utf-16-be"),
+ (codecs.BOM_UTF32_LE, "utf-32-le"),
+ (codecs.BOM_UTF32_BE, "utf-32-be"),
+ ],
+)
+@pytest.mark.parametrize("content_type", ["text/plain", "application/vnd.ms-excel"])
+def test_bom_unicode_text_without_charset_kept(monkeypatch, bom, encoding, content_type):
+ body = bom + ("name,value\nreadable,42\n" * 100).encode(encoding)
+ out = _fetch_with(monkeypatch, body, content_type)
+ assert "readable" in out
+ assert "binary content" not in out
+
+
+def test_valid_utf8_binary_caught_by_control_chars(monkeypatch):
+ # These controls are valid UTF-8 and therefore produce no replacement chars.
+ body = bytes([0, 1, 2, 3, 4, 5, 6, 7]) * 400
+ out = _fetch_with(monkeypatch, body, "text/plain")
+ assert "binary content" in out
+
+
+@pytest.mark.parametrize(
+ "magic",
+ [
+ b"%PDF-",
+ b"PK\x03\x04",
+ b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",
+ b"\x1f\x8b",
+ b"BZh",
+ b"\xfd7zXZ\x00",
+ b"\x28\xb5\x2f\xfd",
+ ],
+)
+def test_text_labeled_binary_caught_by_magic(monkeypatch, magic):
+ out = _fetch_with(monkeypatch, magic + b" printable text-heavy body" * 100, "text/plain")
+ assert "binary content" in out
+
+
+@pytest.mark.parametrize(
+ "prefix",
+ [
+ codecs.BOM_UTF8,
+ codecs.BOM_UTF16_LE,
+ codecs.BOM_UTF16_BE,
+ codecs.BOM_UTF32_LE,
+ codecs.BOM_UTF32_BE,
+ b" \r\n",
+ b"\t\xef\xbb\xbf ",
+ ],
+)
+def test_pdf_magic_after_harmless_prefix(monkeypatch, prefix):
+ body = prefix + b"%PDF-1.7\n" + b"1 0 obj<>endobj\n" * 100
+ out = _fetch_with(monkeypatch, body, "text/plain")
+ assert "binary content" in out
+
+
+@pytest.mark.parametrize(
+ "content_type,magic",
+ [
+ ("application/vnd.ms-excel", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"),
+ (
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ b"PK\x03\x04",
+ ),
+ ],
+)
+def test_office_labeled_binary_caught_by_magic(monkeypatch, content_type, magic):
+ out = _fetch_with(monkeypatch, magic + b" printable text-heavy body" * 100, content_type)
+ assert "binary content" in out
+
+
+def test_latin1_text_without_charset_kept(monkeypatch):
+ # The cp1252 retry should rescue accent-heavy text with ASCII structure.
+ body = (
+ "Muller lauft uber die Strasse: schoene, groesse. MARKERWORD ".replace("ue", "ü")
+ + "äöüß éèà "
+ ) * 30
+ out = _fetch_with(monkeypatch, body.encode("cp1252"), "text/plain")
+ assert "binary content" not in out
+ assert "MARKERWORD" in out
+
+
+@pytest.mark.parametrize("charset", ["iso-8859-1", "latin-1", "latin1"])
+def test_declared_latin1_cp1252_punctuation_kept(monkeypatch, charset):
+ body = ("“quoted” " * 100).encode("cp1252")
+ out = _fetch_with(monkeypatch, body, f"text/plain; charset={charset}")
+ assert "quoted" in out
+ assert "binary content" not in out
+
+
+def test_high_byte_binary_not_rescued_as_cp1252(monkeypatch):
+ # cp1252 maps these bytes to printable characters, but they lack ASCII structure.
+ body = bytes(range(0xA0, 0x100)) * 40
+ out = _fetch_with(monkeypatch, body, "text/plain")
+ assert "binary content" in out
+
+
+def test_ansi_colored_text_log_kept(monkeypatch):
+ # ESC is excluded from the binary set so ANSI logs remain readable.
+ line = "".join(f"\x1b[32m+{i}\x1b[0m\n" for i in range(300)).encode()
+ out = _fetch_with(monkeypatch, line, "text/plain")
+ assert "binary content" not in out
+
+
+def test_html_page_unaffected(monkeypatch):
+ html = b"Hello Real text content here.
"
+ out = _fetch_with(monkeypatch, html, "text/html; charset=utf-8")
+ assert "Hello" in out
+ assert "non-text content" not in out and "binary content" not in out
+
+
+def test_content_type_sanitized_in_message(monkeypatch):
+ # Do not echo obs-folded header content into the model response.
+ out = _fetch_with(monkeypatch, b"\x00\x01\x02" * 500, "application/pdf\r\n data: injected")
+ assert "\n" not in out and "\r" not in out
+ assert "injected" not in out
+ assert "application/pdf" in out
+
+
+@pytest.mark.parametrize(
+ "n_bad,n_total,expect_binary",
+ [
+ (120, 1000, False),
+ (130, 1000, True),
+ ],
+)
+def test_binary_char_ratio_boundary(monkeypatch, n_bad, n_total, expect_binary):
+ body = b"\x00" * n_bad + b"a" * (n_total - n_bad)
+ out = _fetch_with(monkeypatch, body, "text/plain")
+ assert ("binary content" in out) is expect_binary
+
+
+def test_text_with_a_few_stray_replacement_chars_kept(monkeypatch):
+ # Minor encoding glitches below the floor should not drop a real page.
+ body = ("Real article text. " * 200).encode() + b"\xff\xfe\xff"
+ out = _fetch_with(monkeypatch, body, "text/html")
+ assert "Real article text." in out
+ assert "binary content" not in out
diff --git a/studio/backend/tests/test_web_fetch_extraction.py b/studio/backend/tests/test_web_fetch_extraction.py
new file mode 100644
index 0000000000..b794ee3e81
--- /dev/null
+++ b/studio/backend/tests/test_web_fetch_extraction.py
@@ -0,0 +1,1196 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Main-content extraction and boilerplate stripping for the web fetch tool.
+
+The HTML fixtures below snapshot the relevant fragments of a real GitHub repo
+page (github.com/unslothai/unsloth, fetched 2026-07): the ``hidden``
+client-side error placeholders ("Uh oh! There was an error while loading."),
+the skip-link / nav / footer furniture, and the README rendered inside
+````. No network access is required.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
+if _BACKEND_DIR not in sys.path:
+ sys.path.insert(0, _BACKEND_DIR)
+
+from core.inference._html_to_md import html_to_markdown
+from core.inference.tools import (
+ _fetch_page_text,
+ _fetch_url_raw,
+ _github_repo_readme_api_url,
+ _looks_like_html,
+)
+
+
+# ── Fixtures: snapshot of GitHub repo page fragments ─────────────
+
+# GitHub ships client-side error placeholders behind the `hidden` attribute (JS
+# reveals them on a failed fetch); a text converter must not surface them.
+_GITHUB_HIDDEN_ERROR_BLOCK = """
+
+"""
+
+_GITHUB_PAGE = f"""
+
+unslothai/unsloth
+
+Skip to content
+
+
+
+ You signed in with another tab or window. Reload to refresh your session.
+ You signed out in another tab or window. Reload to refresh your session.
+ You switched accounts on another tab or window. Reload to refresh your session.
+ Dismiss alert
+
+{{{{ message }}}}
+{_GITHUB_HIDDEN_ERROR_BLOCK}
+
+ {_GITHUB_HIDDEN_ERROR_BLOCK}
+
+
+
+ Name Last commit message
+ unsloth
+
+
+ Unsloth Studio
+ Unsloth Studio lets you run and train models locally. Fine-tune and
+ run LLMs on Windows, Linux and macOS with a single install command,
+ then export to GGUF, Ollama, vLLM or Hugging Face when you are done.
+ Install
+ curl -fsSL https://unsloth.ai/install.sh | sh
+ See the documentation for
+ quickstarts, notebooks, and fine-tuning guides for every major model
+ family including Llama, Gemma, Qwen and DeepSeek.
+
+
+
+
+
+You can't perform that action at this time.
+
+
+"""
+
+
+# ── html_to_markdown: hidden elements ────────────────────────────
+
+
+def test_hidden_attribute_subtree_is_dropped():
+ html = "visible
after
"
+ out = html_to_markdown(html)
+ assert "visible" in out
+ assert "after" in out
+ assert "secret error text" not in out
+
+
+def test_aria_hidden_true_subtree_is_dropped():
+ html = 'keep
decoration '
+ out = html_to_markdown(html)
+ assert "keep" in out
+ assert "decoration" not in out
+
+
+def test_aria_hidden_false_subtree_is_kept():
+ html = 'still here '
+ assert "still here" in html_to_markdown(html)
+
+
+def test_inline_style_display_none_subtree_is_dropped():
+ # Error/loading blocks are often hidden with inline CSS rather than the
+ # ``hidden`` attribute; browsers do not render them, so they must not leak.
+ html = (
+ "visible
"
+ 'secret loading block
'
+ "after
"
+ )
+ out = html_to_markdown(html)
+ assert "visible" in out
+ assert "after" in out
+ assert "secret loading block" not in out
+
+
+def test_inline_style_visibility_hidden_subtree_is_dropped():
+ html = 'keep
ghost '
+ out = html_to_markdown(html)
+ assert "keep" in out
+ assert "ghost" not in out
+
+
+def test_inline_style_display_none_important_is_dropped():
+ # The !important flag must not defeat the display:none detection.
+ html = 'keep
gone
'
+ out = html_to_markdown(html)
+ assert "keep" in out
+ assert "gone" not in out
+
+
+def test_inline_style_display_none_among_other_declarations():
+ html = (
+ "keep
" 'gone
'
+ )
+ out = html_to_markdown(html)
+ assert "keep" in out
+ assert "gone" not in out
+
+
+def test_inline_style_visible_display_is_kept():
+ # Over-strip guard: display:block / visibility:visible render, and a value or
+ # URL merely containing the substring "none" must not trigger the hidden path.
+ html = (
+ ""
+ 'block kept
'
+ 'visible kept
'
+ 'link kept '
+ ""
+ )
+ out = html_to_markdown(html)
+ assert "block kept" in out
+ assert "visible kept" in out
+ assert "link kept" in out
+
+
+def test_hidden_recovers_from_omitted_close_tags():
+ # is never closed; the parent must still end the hidden region.
+ html = "
kept
"
+ out = html_to_markdown(html)
+ assert "gone" not in out
+ assert "kept" in out
+
+
+def test_nested_hidden_regions():
+ html = "ok
"
+ out = html_to_markdown(html)
+ assert "inner" not in out
+ assert "outer" not in out
+ assert "ok" in out
+
+
+def test_hidden_false_is_still_hidden():
+ # ``hidden`` is enumerated: the spec maps invalid/empty values to the Hidden
+ # state, so hidden="false" is NOT rendered and must not reach the Markdown.
+ html = 'keep
not rendered
'
+ out = html_to_markdown(html)
+ assert "keep" in out
+ assert "not rendered" not in out
+
+
+def test_hidden_paragraph_omitted_close_does_not_swallow_siblings():
+ # HTML5 optional end tags: a sibling start tag implicitly closes an open
+ #
, so the hidden region ends there instead of swallowing siblings.
+ html = (
+ "
secret"
+ "
visible one
visible two
after
"
+ )
+ out = html_to_markdown(html)
+ assert "secret" not in out
+ assert "visible one" in out
+ assert "visible two" in out
+ assert "after" in out
+
+
+def test_hidden_list_item_omitted_close_keeps_following_items():
+ # without is implicitly closed by the next .
+ html = ""
+ out = html_to_markdown(html)
+ assert "secret" not in out
+ assert "shown A" in out
+ assert "shown B" in out
+
+
+def test_hr_implicitly_closes_hidden_paragraph():
+ # Void elements also imply closes: ends an open .
+ html = "
secret
kept text"
+ out = html_to_markdown(html)
+ assert "secret" not in out
+ assert "kept text" in out
+
+
+def test_skipped_tag_implicitly_closes_hidden_paragraph():
+ # A skipped block (/) also closes an open . The optional-close
+ # bookkeeping must run before the skip, or the never-closed
keeps its
+ # hidden mark and swallows every following sibling.
+ for skipped in ("nav", "footer"):
+ html = f"
secret<{skipped}>chrome{skipped}>VISIBLE"
+ out = html_to_markdown(html)
+ assert "secret" not in out
+ assert "chrome" not in out
+ assert "VISIBLE" in out
+
+
+def test_hidden_void_element_is_suppressed():
+ # A hidden void element (
/ ) never joins the open-element stack, so it
+ # must be suppressed inline rather than emitting its markup.
+ html = 'before
after
'
+ out = html_to_markdown(html)
+ assert "before" in out
+ assert "after" in out
+ assert "---" not in out
+
+
+def test_hidden_void_br_emits_no_break():
+ html = "one two
"
+ out = html_to_markdown(html)
+ assert "one" in out
+ assert "two" in out
+ # The hidden must not inject a newline between the two runs.
+ assert "one\ntwo" not in out
+
+
+def test_visible_void_hr_still_renders():
+ # Guard: the suppression must not affect non-hidden void elements.
+ html = "a
b
"
+ out = html_to_markdown(html)
+ assert "---" in out
+
+
+# ── html_to_markdown: main-content scoping ───────────────────────
+
+
+def test_github_page_main_content_keeps_readme_only():
+ out = html_to_markdown(_GITHUB_PAGE, main_content = True)
+ # README content survives.
+ assert "Unsloth Studio" in out
+ assert "install.sh" in out
+ assert "documentation" in out
+ # Client-side error placeholders and page furniture are gone.
+ assert "Uh oh!" not in out
+ assert "There was an error while loading" not in out
+ assert "Please reload this page" not in out
+ assert "You can't perform that action at this time" not in out
+ assert "Skip to content" not in out
+ assert "Sign in" not in out
+ assert "Reload to refresh your session" not in out
+ assert "JavaScript 89.3%" not in out
+ assert "Languages" not in out
+ assert "Last commit message" not in out
+
+
+def test_main_scope_used_when_no_article():
+ html = """
+
+
+ Doc title %s
+
+
+ """ % ("Body text. " * 40)
+ out = html_to_markdown(html, main_content = True)
+ assert "Doc title" in out
+ assert "Body text." in out
+ assert "Sign in" not in out
+ assert "footer junk" not in out
+
+
+def test_main_content_falls_back_to_full_document():
+ # No article/main and a tiny body: the unscoped conversion is returned.
+ html = "Tiny Just a short page.
"
+ out = html_to_markdown(html, main_content = True)
+ assert "Tiny" in out
+ assert "Just a short page." in out
+
+
+def test_tiny_article_stub_does_not_hijack_scope():
+ # An with negligible text must not swallow the real content.
+ body_text = "Real content paragraph. " * 30
+ html = f"ad {body_text}
"
+ out = html_to_markdown(html, main_content = True)
+ assert "Real content paragraph." in out
+
+
+def test_sibling_articles_do_not_leak_after_main_selected():
+ # The size gate picks the largest single and renders only that
+ # subtree: sibling articles (related-post cards, comment threads) must not leak
+ # in just because the real article cleared the threshold.
+ real = "Main article body content for selection. " * 20
+ card = "Unrelated related-post card teaser blurb. " * 3
+ cards = "".join(f"{card}
" for _ in range(5))
+ html = f"Real {real}
{cards}"
+ out = html_to_markdown(html, main_content = True)
+ assert "Main article body content" in out
+ assert "Unrelated related-post" not in out
+
+
+def test_default_conversion_unscoped_and_unstripped():
+ # Without main_content the whole document converts (backwards compatible),
+ # boilerplate included; only hidden subtrees are dropped.
+ html = "Skip to content
gone
hello
"
+ out = html_to_markdown(html)
+ assert "Skip to content" in out
+ assert "hello" in out
+ assert "gone" not in out
+
+
+def test_boilerplate_filter_preserves_phrase_inside_real_prose():
+ # The furniture filter once matched by substring, deleting a real sentence that
+ # merely CONTAINS a fragment ("we use cookies"). It must drop only lines COMPOSED
+ # of furniture, keeping real prose that quotes one.
+ body = (
+ "Authentication "
+ "We use cookies to authenticate API requests and keep sessions safe.
"
+ "%s
"
+ ) % ("Additional documentation content to select the article. " * 8)
+ out = html_to_markdown(f"{body}", main_content = True)
+ assert "We use cookies to authenticate API requests" in out
+
+
+def test_boilerplate_filter_still_drops_standalone_and_stacked_furniture():
+ # A line that is purely furniture is dropped, as is one stacking several
+ # furniture phrases (as GitHub renders them).
+ body = (
+ ""
+ "Skip to content
"
+ "You signed in with another tab or window. Reload to refresh your session.
"
+ "Real README body. %s
"
+ " "
+ ) % ("Genuine documentation text. " * 8)
+ out = html_to_markdown(f"{body}", main_content = True)
+ assert "Real README body." in out
+ assert "Skip to content" not in out
+ assert "Reload to refresh your session" not in out
+
+
+def test_boilerplate_not_stripped_inside_code_fences():
+ html = (
+ "%s
"
+ "assert 'There was an error while loading' in page "
+ " " % ("Prose. " * 40)
+ )
+ out = html_to_markdown(html, main_content = True)
+ assert "There was an error while loading" in out
+
+
+def test_aside_callout_inside_article_is_kept():
+ # Docs render notes/warnings as callouts. An aside inside the selected
+ # article/main scope is real content and must survive; dropping it unconditionally
+ # loses page text.
+ body = (
+ "Guide "
+ "%s
"
+ "Warning: "
+ "This operation is destructive and cannot be undone. "
+ "Trailing paragraph.
"
+ ) % ("Documentation body text to select the article scope. " * 6)
+ out = html_to_markdown(f"{body}", main_content = True)
+ assert "This operation is destructive and cannot be undone." in out
+ assert "Warning:" in out
+ # Also kept in the unscoped (backwards-compatible) conversion.
+ out_full = html_to_markdown(f"{body}")
+ assert "This operation is destructive and cannot be undone." in out_full
+
+
+# ── GitHub README rewrite ────────────────────────────────────────
+
+
+def test_github_repo_url_maps_to_readme_api():
+ assert (
+ _github_repo_readme_api_url("https://github.com/unslothai/unsloth")
+ == "https://api.github.com/repos/unslothai/unsloth/readme"
+ )
+ assert (
+ _github_repo_readme_api_url("https://github.com/unslothai/unsloth/")
+ == "https://api.github.com/repos/unslothai/unsloth/readme"
+ )
+ assert (
+ _github_repo_readme_api_url("http://www.github.com/owner/repo.git")
+ == "https://api.github.com/repos/owner/repo/readme"
+ )
+
+
+def test_github_non_repo_urls_are_not_rewritten():
+ for url in (
+ "https://github.com/unslothai/unsloth/tree/main/studio",
+ "https://github.com/unslothai/unsloth/issues/123",
+ "https://github.com/topics/llm",
+ "https://github.com/orgs/unslothai/repositories",
+ "https://github.com/login/oauth",
+ "https://github.com/unslothai",
+ "https://example.com/owner/repo",
+ "https://raw.githubusercontent.com/owner/repo/main/README.md",
+ ):
+ assert _github_repo_readme_api_url(url) is None, url
+
+
+def test_fetch_page_text_prefers_github_readme(monkeypatch):
+ calls = []
+
+ def fake_fetch(
+ url,
+ timeout = 30,
+ extra_headers = None,
+ deadline = None,
+ cancel_event = None,
+ ):
+ calls.append((url, extra_headers))
+ assert url == "https://api.github.com/repos/unslothai/unsloth/readme"
+ return None, "# Unsloth\n\nFine-tune LLMs faster.", "text/plain"
+
+ monkeypatch.setattr("core.inference.tools._fetch_url_raw", fake_fetch)
+ out = _fetch_page_text("https://github.com/unslothai/unsloth")
+ assert "Fine-tune LLMs faster." in out
+ assert "README of https://github.com/unslothai/unsloth" in out
+ assert len(calls) == 1
+ assert calls[0][1]["Accept"] == "application/vnd.github.raw+json"
+
+
+def test_fetch_page_text_keeps_html_readme_from_api(monkeypatch):
+ # A repo whose README is HTML returns HTML from the README API with a 200. That
+ # success is authoritative: convert to Markdown and keep it, never discard it in
+ # favour of the repo root page's UI chrome.
+ html_readme = (
+ ""
+ "Project Title "
+ "Install with the one-line script and read the docs.
"
+ ""
+ )
+ calls = []
+
+ def fake_fetch(
+ url,
+ timeout = 30,
+ extra_headers = None,
+ deadline = None,
+ cancel_event = None,
+ ):
+ calls.append(url)
+ assert url == "https://api.github.com/repos/unslothai/unsloth/readme"
+ return None, html_readme, "text/html"
+
+ monkeypatch.setattr("core.inference.tools._fetch_url_raw", fake_fetch)
+ out = _fetch_page_text("https://github.com/unslothai/unsloth")
+ # The successful README is converted and returned; no fallback fetch fires.
+ assert "README of https://github.com/unslothai/unsloth" in out
+ assert "Project Title" in out
+ assert "Install with the one-line script" in out
+ assert "")
+ assert _looks_like_html("\n ")
+ assert not _looks_like_html("# Markdown README\n\nembedded html later ")
+ assert not _looks_like_html("plain text")
+
+
+def test_looks_like_html_markdown_with_leading_fenced_example_stays_markdown():
+ # A Markdown README OPENING with a fenced HTML example must not be sniffed as
+ # HTML just because a doctype/tag appears in the first 256 chars; html_to_markdown
+ # would corrupt the fences and prose.
+ fenced = (
+ "```html\n\nhi
\n```\n\n# Real README\n"
+ )
+ assert not _looks_like_html(fenced)
+ # Prose that mentions a tag inline, and a centered-logo README that opens
+ # with /
/
, also stay Markdown.
+ assert not _looks_like_html("Use the element to start a page.")
+ assert not _looks_like_html('
\n\n# Project\n')
+ assert not _looks_like_html('
\n\n# Project\n\n
\n')
+ assert not _looks_like_html('
Project \n\nMarkdown body.\n')
+ # An autolink is not a tag opener.
+ assert not _looks_like_html("
is the homepage")
+
+
+def test_looks_like_html_detects_bare_fragments():
+ # A body that is a bare HTML fragment (no /doctype) must still be
+ # recognized so it is converted to Markdown.
+ assert _looks_like_html("hello
")
+ assert _looks_like_html("\nTitle Body
")
+ assert _looks_like_html("")
+
+
+def test_looks_like_html_leading_table_stays_markdown():
+ # Markdown READMEs routinely open with a raw HTML badge row or logo
+ # layout, then continue in Markdown. Sniffing that as HTML would collapse the
+ # Markdown body, so a leading (and its row/cell children) must stay
+ # Markdown, like the excluded /
layout headers.
+ assert not _looks_like_html("
")
+ assert not _looks_like_html(
+ '
\n\n# Project\n'
+ )
+ assert not _looks_like_html("
cell ")
+
+
+def test_fetch_page_text_keeps_markdown_readme_with_html_example(monkeypatch):
+ # A Markdown README opening with a fenced HTML snippet must be served verbatim,
+ # never run through html_to_markdown (which would drop the fences/tags).
+ md_readme = (
+ "```html\n"
+ "\n"
+ "
Demo \n"
+ "```\n\n"
+ "# My Project\n\nInstall and run.\n"
+ )
+
+ def fake_fetch(
+ url,
+ timeout = 30,
+ extra_headers = None,
+ deadline = None,
+ cancel_event = None,
+ ):
+ assert url == "https://api.github.com/repos/unslothai/unsloth/readme"
+ return None, md_readme, "text/plain"
+
+ monkeypatch.setattr("core.inference.tools._fetch_url_raw", fake_fetch)
+ out = _fetch_page_text("https://github.com/unslothai/unsloth")
+ assert "README of https://github.com/unslothai/unsloth" in out
+ # Markdown preserved verbatim: the fence and literal tags survive.
+ assert "```html" in out
+ assert "" in out
+ assert "# My Project" in out
+
+
+def test_fetch_page_text_keeps_markdown_readme_with_leading_table(monkeypatch):
+ # A README opening with a raw HTML
badge/layout row then continuing in
+ # Markdown must be served verbatim, never run through html_to_markdown (which
+ # would collapse the list/fence/heading body onto one line).
+ md_readme = (
+ '\n'
+ 'Badges \n'
+ "
\n\n"
+ "# My Project\n\n"
+ "- feature one\n"
+ "- feature two\n\n"
+ "```python\nprint('hi')\n```\n"
+ )
+
+ def fake_fetch(
+ url,
+ timeout = 30,
+ extra_headers = None,
+ deadline = None,
+ cancel_event = None,
+ ):
+ assert url == "https://api.github.com/repos/unslothai/unsloth/readme"
+ return None, md_readme, "text/plain"
+
+ monkeypatch.setattr("core.inference.tools._fetch_url_raw", fake_fetch)
+ out = _fetch_page_text("https://github.com/unslothai/unsloth")
+ assert "README of https://github.com/unslothai/unsloth" in out
+ # Markdown body verbatim: list, fence and heading survive on their own lines.
+ assert "- feature one\n- feature two" in out
+ assert "```python" in out
+ assert "# My Project" in out
+
+
+def test_fetch_url_raw_missing_content_type_reported_empty(monkeypatch):
+ # Message.get_content_type() falls back to the RFC 2045 "text/plain" default
+ # when the header is absent; _fetch_url_raw must report "" instead so the HTML
+ # sniffing fallback can fire.
+ import email
+ import urllib.request
+
+ class _FakeResp:
+ headers = email.message_from_string("")
+
+ def __init__(self):
+ self._body = b"hello"
+
+ def read(self, n = -1):
+ # Hand back the body once, then EOF, so the chunked reader terminates.
+ body, self._body = self._body, b""
+ return body
+
+ class _FakeOpener:
+ def open(
+ self,
+ req,
+ timeout = None,
+ ):
+ return _FakeResp()
+
+ monkeypatch.setattr(
+ "core.inference.tools._validate_and_resolve_host",
+ lambda host, port: (True, "", "203.0.113.7"),
+ )
+ monkeypatch.setattr(urllib.request, "build_opener", lambda *handlers: _FakeOpener())
+ err, body, content_type = _fetch_url_raw("https://example.com/")
+ assert err is None
+ assert "hello" in body
+ assert content_type == ""
+
+
+def test_fetch_page_text_missing_content_type_html_sniffed(monkeypatch):
+ # A header-less server returning an HTML body must still be converted.
+ def fake_fetch(
+ url,
+ timeout = 30,
+ extra_headers = None,
+ deadline = None,
+ cancel_event = None,
+ ):
+ return None, _GITHUB_PAGE, ""
+
+ monkeypatch.setattr("core.inference.tools._fetch_url_raw", fake_fetch)
+ out = _fetch_page_text("https://example.com/no-content-type")
+ assert "Unsloth Studio" in out
+ assert "/doctype) must
+ # still be sniffed as HTML and converted, not served as raw markup.
+ fragment = "Doc Title Readable fragment body.
"
+
+ def fake_fetch(
+ url,
+ timeout = 30,
+ extra_headers = None,
+ deadline = None,
+ cancel_event = None,
+ ):
+ return None, fragment, ""
+
+ monkeypatch.setattr("core.inference.tools._fetch_url_raw", fake_fetch)
+ out = _fetch_page_text("https://example.com/fragment")
+ assert "Doc Title" in out
+ assert "Readable fragment body." in out
+ assert " when a arrives, even with an unclosed
+ #
on top of it. The hidden region must end there, not swallow the
+ # following visible blocks.
+ html = "secretvisible div
visible paragraph"
+ out = html_to_markdown(html)
+ assert "secret" not in out
+ assert "visible div" in out
+ assert "visible paragraph" in out
+
+
+def test_hidden_list_item_with_inline_child_closed_by_next_item():
+ html = "
after
"
+ out = html_to_markdown(html)
+ assert "secret" not in out
+ assert "visible item" in out
+ assert "after" in out
+
+
+# ── nested hidden list/table contents must stay suppressed ──
+
+
+def test_nested_hidden_list_does_not_leak_child_items():
+ # The nested re-scopes the item, so the inner is a DESCENDANT of the
+ # hidden outer , not an optional-close sibling. Optional-end-tag recovery
+ # must not cross the intervening , or the outer li's hidden mark is popped
+ # and the nested text leaks.
+ html = (
+ ""
+ "parent "
+ "visible sibling "
+ " "
+ )
+ out = html_to_markdown(html)
+ assert "parent" not in out
+ assert "secret child" not in out
+ assert "visible sibling" in out
+
+
+def test_nested_hidden_list_with_omitted_closes_stays_suppressed():
+ # Same leak, doubly nested with omitted / . Every hidden descendant
+ # stays gone; the following visible sibling (which implicitly closes the hidden
+ # outer ) still renders.
+ html = (
+ ""
+ "parent"
+ " visible sibling"
+ " "
+ )
+ out = html_to_markdown(html)
+ assert "parent" not in out
+ assert "secret child" not in out
+ assert "deeper secret" not in out
+ assert "visible sibling" in out
+
+
+def test_nested_hidden_table_does_not_leak_inner_cells():
+ # A nested re-scopes /: an inner must not be an
+ # optional-close sibling of a hidden outer across the nested table.
+ html = (
+ ""
+ "outer "
+ "visible cell "
+ "
"
+ )
+ out = html_to_markdown(html)
+ assert "secret cell" not in out
+ assert "visible cell" in out
+
+
+# ── aggregate tiny cards must not displace (finding 15) ──
+
+
+def test_many_tiny_articles_do_not_displace_substantial_main():
+ cards = "".join(
+ f"Teaser {i} Advertisement card blurb.
" for i in range(12)
+ )
+ main_body = "Authoritative main documentation content. " * 30
+ html = f"{cards}Real page {main_body}
"
+ out = html_to_markdown(html, main_content = True)
+ assert "Authoritative main documentation content." in out
+ assert "Advertisement card blurb." not in out
+
+
+def test_single_substantial_article_still_preferred_over_main():
+ # GitHub-README case: one substantial inside must still win
+ # over sibling furniture.
+ article_body = "Real README documentation body text. " * 20
+ html = (
+ ""
+ f"Guide {article_body}
"
+ "Languages JavaScript 89.3%
"
+ " "
+ )
+ out = html_to_markdown(html, main_content = True)
+ assert "Real README documentation body text." in out
+ assert "JavaScript 89.3%" not in out
+
+
+# ── truncated (unclosed) main-content scopes must still be scored ──
+
+
+def test_truncated_open_article_scope_is_scored_and_preferred():
+ # _fetch_url_raw caps large pages, so the download can end before the closing
+ # . The scope is still the main content and must be preferred over the
+ # whole document (which re-leaks the page chrome).
+ chrome = "Skip to content Repository file tree and page chrome.
"
+ article_body = "Real README documentation body text. " * 20
+ # No closing / -- the fetch cap truncated the page.
+ html = f"{chrome}Guide {article_body}
"
+ out = html_to_markdown(html, main_content = True)
+ assert "Real README documentation body text." in out
+ assert "Repository file tree and page chrome." not in out
+
+
+def test_truncated_open_main_scope_is_scored_and_preferred():
+ chrome = "Skip to content Repository file tree and page chrome.
"
+ main_body = "Authoritative main documentation content. " * 30
+ html = f"{chrome}Doc {main_body}
"
+ out = html_to_markdown(html, main_content = True)
+ assert "Authoritative main documentation content." in out
+ assert "Repository file tree and page chrome." not in out
+
+
+# ── overall fetch deadline + cancellation (no per-hop timeout blowup) ──
+
+
+def test_fetch_url_raw_overall_deadline_aborts_across_redirects(monkeypatch):
+ # Each hop advances a fake clock by 5s; an 8s overall budget is exhausted on the
+ # third hop even though every hop stays within its own socket timeout. Without
+ # the deadline this would redirect until the 5-hop cap, so the "timed out" error
+ # proves the overall budget aborted it, not the hop cap.
+ import urllib.request
+ from urllib.error import HTTPError
+
+ import core.inference.tools as tools_mod
+
+ clock = {"t": 1000.0}
+ monkeypatch.setattr(tools_mod.time, "monotonic", lambda: clock["t"])
+
+ hops = {"n": 0}
+
+ class _RedirectingOpener:
+ def open(
+ self,
+ req,
+ timeout = None,
+ ):
+ clock["t"] += 5.0
+ hops["n"] += 1
+ raise HTTPError(
+ req.full_url,
+ 302,
+ "Found",
+ {"Location": "https://example.com/next"},
+ None,
+ )
+
+ monkeypatch.setattr(
+ tools_mod,
+ "_validate_and_resolve_host",
+ lambda host, port: (True, "", "203.0.113.7"),
+ )
+ monkeypatch.setattr(urllib.request, "build_opener", lambda *handlers: _RedirectingOpener())
+
+ err, body, content_type = tools_mod._fetch_url_raw(
+ "https://example.com/start",
+ timeout = 30,
+ deadline = clock["t"] + 8.0,
+ )
+ assert err == "Failed to fetch URL: timed out."
+ assert body == ""
+ assert hops["n"] < 5
+
+
+def test_fetch_url_raw_cancel_event_aborts_before_network(monkeypatch):
+ # A set cancel_event (client disconnected) stops the fetch before it opens any
+ # socket, so a dropped stream cannot leave a tool blocking on the wire.
+ import threading
+ import urllib.request
+
+ import core.inference.tools as tools_mod
+
+ ev = threading.Event()
+ ev.set()
+ opened = {"n": 0}
+
+ class _Opener:
+ def open(
+ self,
+ req,
+ timeout = None,
+ ):
+ opened["n"] += 1
+ raise AssertionError("network must not be touched after cancel")
+
+ monkeypatch.setattr(
+ tools_mod,
+ "_validate_and_resolve_host",
+ lambda host, port: (True, "", "203.0.113.7"),
+ )
+ monkeypatch.setattr(urllib.request, "build_opener", lambda *handlers: _Opener())
+
+ err, body, content_type = tools_mod._fetch_url_raw(
+ "https://example.com/",
+ cancel_event = ev,
+ )
+ assert err == "Failed to fetch URL: cancelled."
+ assert opened["n"] == 0
+
+
+def test_fetch_page_text_shares_one_deadline_across_readme_and_fallback(monkeypatch):
+ # The README API attempt and its HTML fallback must draw from ONE budget: a
+ # failed API call cannot hand the fallback a fresh full timeout.
+ seen_deadlines = []
+
+ def fake_fetch(
+ url,
+ timeout = 30,
+ extra_headers = None,
+ deadline = None,
+ cancel_event = None,
+ ):
+ seen_deadlines.append(deadline)
+ # Fail the README API so the HTML fallback also runs.
+ return "Failed to fetch URL: HTTP 429 rate limited", "", ""
+
+ monkeypatch.setattr("core.inference.tools._fetch_url_raw", fake_fetch)
+ out = _fetch_page_text("https://github.com/unslothai/unsloth", timeout = 30)
+ assert out == "Failed to fetch URL: HTTP 429 rate limited"
+ # Both attempts ran and shared the same, single deadline value.
+ assert len(seen_deadlines) == 2
+ assert seen_deadlines[0] is not None
+ assert seen_deadlines[0] == seen_deadlines[1]
+
+
+# -- overall deadline reaches the body read, the resolver, and the query path --
+
+
+def test_fetch_url_raw_deadline_aborts_slow_body(monkeypatch):
+ # A server dribbling the body must not stretch the read past the overall
+ # deadline: the body is read in chunks with the budget re-checked between them,
+ # so a single slow resp.read cannot outlast the fetch budget.
+ import email
+ import urllib.request
+
+ import core.inference.tools as tools_mod
+
+ clock = {"t": 1000.0}
+ monkeypatch.setattr(tools_mod.time, "monotonic", lambda: clock["t"])
+
+ class _DrippingResp:
+ headers = email.message_from_string("")
+
+ def read(self, n = -1):
+ # One chunk, then jump the clock past the deadline so the next
+ # between-chunk budget check aborts instead of reading forever.
+ clock["t"] += 10.0
+ return b"x" * 16
+
+ def close(self):
+ pass
+
+ class _Opener:
+ def open(
+ self,
+ req,
+ timeout = None,
+ ):
+ return _DrippingResp()
+
+ monkeypatch.setattr(
+ tools_mod,
+ "_validate_and_resolve_host",
+ lambda host, port: (True, "", "203.0.113.7"),
+ )
+ monkeypatch.setattr(urllib.request, "build_opener", lambda *handlers: _Opener())
+
+ err, body, content_type = tools_mod._fetch_url_raw(
+ "https://example.com/",
+ timeout = 30,
+ deadline = clock["t"] + 5.0,
+ )
+ assert err == "Failed to fetch URL: timed out."
+ assert body == ""
+
+
+def test_resolve_with_budget_aborts_on_slow_resolver(monkeypatch):
+ # getaddrinfo has no deadline of its own; a resolver slower than the budget must
+ # abort on time instead of blocking the whole fetch.
+ import threading
+
+ import core.inference.tools as tools_mod
+
+ clock = {"t": 1000.0}
+ monkeypatch.setattr(tools_mod.time, "monotonic", lambda: clock["t"])
+
+ release = threading.Event()
+
+ def slow_resolve(host, port):
+ release.wait(5.0) # block until released; the budget should abort first
+ return True, "", "203.0.113.7"
+
+ monkeypatch.setattr(tools_mod, "_validate_and_resolve_host", slow_resolve)
+
+ def advance_past_deadline():
+ import time as _t
+ _t.sleep(0.1)
+ clock["t"] += 100.0
+
+ t = threading.Thread(target = advance_past_deadline, daemon = True)
+ t.start()
+ try:
+ ok, reason, ip = tools_mod._resolve_with_budget(
+ "example.com",
+ 443,
+ 1005.0,
+ None,
+ )
+ finally:
+ release.set()
+ assert ok is False
+ assert reason == "Failed to fetch URL: timed out."
+
+
+def test_web_search_query_cancelled_skips_search(monkeypatch):
+ # A pre-set cancel_event (client disconnected) skips the blocking DDGS query,
+ # matching the direct-URL path's cancellation.
+ import sys
+ import threading
+ import types
+
+ import core.inference.tools as tools_mod
+
+ ev = threading.Event()
+ ev.set()
+ called = {"n": 0}
+
+ class _DDGS:
+ def __init__(self, *a, **k):
+ called["n"] += 1
+
+ def text(self, *a, **k):
+ called["n"] += 1
+ return []
+
+ fake_mod = types.ModuleType("ddgs")
+ fake_mod.DDGS = _DDGS
+ monkeypatch.setitem(sys.modules, "ddgs", fake_mod)
+
+ out = tools_mod._web_search("some query", cancel_event = ev)
+ assert out == "Search cancelled."
+ assert called["n"] == 0
+
+
+def test_fetch_page_text_markdown_readme_with_leading_block_tag_stays_markdown(monkeypatch):
+ # A raw-Markdown README that OPENS with an HTML block tag (, , ...) must not be run through html_to_markdown, which would collapse its
+ # headings/list/fence. Only a real HTML document (doctype / ) is converted.
+ md_readme = (
+ "Note: pre-release. \n\n"
+ "# My Project\n\n"
+ "Install:\n\n"
+ "- step one\n"
+ "- step two\n\n"
+ "```bash\npip install myproject\n```\n"
+ )
+
+ def fake_fetch(
+ url,
+ timeout = 30,
+ extra_headers = None,
+ deadline = None,
+ cancel_event = None,
+ ):
+ assert url == "https://api.github.com/repos/unslothai/unsloth/readme"
+ return None, md_readme, "text/plain"
+
+ monkeypatch.setattr("core.inference.tools._fetch_url_raw", fake_fetch)
+ out = _fetch_page_text("https://github.com/unslothai/unsloth")
+ assert "README of https://github.com/unslothai/unsloth" in out
+ # Markdown structure survives verbatim (heading, list, fenced code).
+ assert "# My Project" in out
+ assert "- step one" in out
+ assert "```bash" in out
+
+
+def test_looks_like_html_document_only_matches_real_documents():
+ from core.inference.tools import _looks_like_html_document
+
+ assert _looks_like_html_document("x")
+ assert _looks_like_html_document("\n ")
+ assert _looks_like_html_document("x ")
+ # Block tags a Markdown README can open with are NOT full documents.
+ for frag in (
+ "q ",
+ " ",
+ "x ",
+ "x ",
+ ):
+ assert not _looks_like_html_document(frag), frag
diff --git a/studio/backend/tests/test_windows_external_drive_paths.py b/studio/backend/tests/test_windows_external_drive_paths.py
new file mode 100644
index 0000000000..9686d45c9f
--- /dev/null
+++ b/studio/backend/tests/test_windows_external_drive_paths.py
@@ -0,0 +1,354 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+from __future__ import annotations
+
+import ast
+import os
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Optional
+
+from utils.paths import external_media
+
+
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+
+
+class _HTTPException(Exception):
+ def __init__(self, status_code: int, detail: str):
+ super().__init__(detail)
+ self.status_code = status_code
+ self.detail = detail
+
+
+def _extract_routes_function(name: str, ns_extra: Optional[dict] = None) -> dict:
+ """Exec one top-level function from routes/models.py without importing the module (which pulls in FastAPI)."""
+ tree = ast.parse((_BACKEND_ROOT / "routes" / "models.py").read_text(encoding = "utf-8"))
+ fn = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name)
+ module = ast.Module(body = [fn], type_ignores = [])
+ ast.fix_missing_locations(module)
+ ns = {"os": os, "Path": Path, "Optional": Optional}
+ if ns_extra:
+ ns.update(ns_extra)
+ exec(compile(module, "", "exec"), ns)
+ return ns
+
+
+def _stub_windows(monkeypatch, existing_drives):
+ """Simulate Windows exposing only *existing_drives* (e.g. {"C", "D"}) as readable roots, independent of the host FS.
+
+ Overriding _active_windows_drive_bitmask keeps it deterministic even on a
+ real Windows host, where live GetLogicalDrives would return the actual layout."""
+ monkeypatch.setattr(external_media.platform, "system", lambda: "Windows")
+ mask = sum(1 << (ord(d.upper()) - ord("A")) for d in existing_drives)
+ monkeypatch.setattr(external_media, "_active_windows_drive_bitmask", lambda: mask)
+ present = {f"{d.upper()}:\\" for d in existing_drives}
+ monkeypatch.setattr(external_media.os.path, "isdir", lambda p: str(p) in present)
+ monkeypatch.setattr(external_media.os, "access", lambda p, _mode: str(p) in present)
+
+
+def test_windows_drive_roots_empty_off_windows(monkeypatch):
+ # Regression guard: the helper is a no-op on Linux/macOS so it can't change the allowlist on the platforms CI runs on.
+ monkeypatch.setattr(external_media.platform, "system", lambda: "Linux")
+ assert external_media.windows_drive_roots() == []
+ monkeypatch.setattr(external_media.platform, "system", lambda: "Darwin")
+ assert external_media.windows_drive_roots() == []
+
+
+def test_windows_drive_roots_lists_readable_drives(monkeypatch):
+ _stub_windows(monkeypatch, {"C", "D", "E"})
+
+ roots = external_media.windows_drive_roots(drive_letters = "CDEF")
+
+ # F is absent, so it is skipped; the rest are exposed in order.
+ assert roots == [Path("C:\\"), Path("D:\\"), Path("E:\\")]
+
+
+def test_windows_drive_roots_skips_absent_and_unreadable(monkeypatch):
+ _stub_windows(monkeypatch, {"C"})
+
+ roots = external_media.windows_drive_roots(drive_letters = "CDE")
+
+ assert roots == [Path("C:\\")]
+
+
+def test_windows_drive_roots_ignores_bad_letters_and_dedupes(monkeypatch):
+ _stub_windows(monkeypatch, {"C", "D"})
+
+ roots = external_media.windows_drive_roots(
+ drive_letters = ["c:", "C", "D", "1", "AB", "", " d "],
+ )
+
+ assert roots == [Path("C:\\"), Path("D:\\")]
+
+
+def test_readable_dir_within_times_out(monkeypatch):
+ # A probe that outlives the timeout is reported not-readable, so a hung
+ # (disconnected mapped network) drive is skipped instead of blocking.
+ import time
+
+ monkeypatch.setattr(external_media.os.path, "isdir", lambda p: time.sleep(5) or True)
+ monkeypatch.setattr(external_media.os, "access", lambda p, _mode: True)
+ start = time.monotonic()
+ ok = external_media._readable_dir_within("Z:\\", timeout = 0.2)
+ elapsed = time.monotonic() - start
+ assert ok is False
+ assert elapsed < 3.0 # returned on the timeout, did not wait out the 5s stall
+
+
+def test_readable_dir_within_reports_fast_probe(monkeypatch):
+ monkeypatch.setattr(external_media.os.path, "isdir", lambda p: True)
+ monkeypatch.setattr(external_media.os, "access", lambda p, _mode: True)
+ assert external_media._readable_dir_within("C:\\", timeout = 2.0) is True
+
+
+def test_windows_drive_roots_skips_hung_drive(monkeypatch):
+ # A disconnected mapped drive stays set in the bitmask and its os.path.isdir
+ # stalls; it must be skipped without stalling enumeration. C answers, D hangs,
+ # so only C is listed, bounded by the per-drive timeout, not the stall.
+ import time
+
+ monkeypatch.setattr(external_media.platform, "system", lambda: "Windows")
+ monkeypatch.setattr(
+ external_media,
+ "_active_windows_drive_bitmask",
+ lambda: sum(1 << (ord(d) - ord("A")) for d in "CD"),
+ )
+ monkeypatch.setattr(external_media, "_DRIVE_PROBE_TIMEOUT_S", 0.2)
+
+ def _isdir(p):
+ if str(p) == "D:\\":
+ time.sleep(5) # simulate the reconnect stall
+ return True
+ return str(p) == "C:\\"
+
+ monkeypatch.setattr(external_media.os.path, "isdir", _isdir)
+ monkeypatch.setattr(external_media.os, "access", lambda p, _mode: True)
+
+ start = time.monotonic()
+ roots = external_media.windows_drive_roots(drive_letters = "CD")
+ elapsed = time.monotonic() - start
+
+ assert roots == [Path("C:\\")]
+ assert elapsed < 3.0 # bounded by the per-drive timeout, not the 5s stall
+
+
+def test_windows_drive_roots_probes_hung_drives_in_parallel(monkeypatch):
+ # Several disconnected mapped drives must add ~one timeout total, not one
+ # per drive: C answers fast, D/E/F stall. The concurrent probe stays bounded
+ # by a single deadline where serial probing would cost ~4x the timeout.
+ import time
+
+ monkeypatch.setattr(external_media.platform, "system", lambda: "Windows")
+ monkeypatch.setattr(
+ external_media,
+ "_active_windows_drive_bitmask",
+ lambda: sum(1 << (ord(d) - ord("A")) for d in "CDEF"),
+ )
+ timeout = 0.2
+ monkeypatch.setattr(external_media, "_DRIVE_PROBE_TIMEOUT_S", timeout)
+
+ def _isdir(p):
+ if str(p) == "C:\\":
+ return True
+ time.sleep(5) # every other drive simulates a reconnect stall
+ return True
+
+ monkeypatch.setattr(external_media.os.path, "isdir", _isdir)
+ monkeypatch.setattr(external_media.os, "access", lambda p, _mode: True)
+
+ start = time.monotonic()
+ roots = external_media.windows_drive_roots(drive_letters = "CDEF")
+ elapsed = time.monotonic() - start
+
+ assert roots == [Path("C:\\")]
+ # 3 stalled drives probed in parallel finish within ~1 timeout, well under the ~3*timeout a serial probe would take.
+ assert elapsed < 3 * timeout
+
+
+def test_browse_allowlist_includes_windows_drive_roots(monkeypatch, tmp_path):
+ # End-to-end wiring: windows_drive_roots() output flows into the browse
+ # allowlist built by routes/models.py, mirroring the Linux media-mounts test.
+ tree = ast.parse((_BACKEND_ROOT / "routes" / "models.py").read_text(encoding = "utf-8"))
+ function_names = {
+ "_build_browse_allowlist",
+ "_browse_relative_parts",
+ "_is_path_inside_allowlist",
+ "_match_browse_child",
+ "_normalize_browse_request_path",
+ "_resolve_browse_target",
+ }
+ functions = [
+ node
+ for node in tree.body
+ if isinstance(node, ast.FunctionDef) and node.name in function_names
+ ]
+ module = ast.Module(body = functions, type_ignores = [])
+ ast.fix_missing_locations(module)
+
+ home = tmp_path / "home"
+ drive_root = tmp_path / "D_drive"
+ model_dir = drive_root / "modelsAI" / "gguf"
+ home.mkdir()
+ model_dir.mkdir(parents = True)
+
+ fake_paths = SimpleNamespace(
+ hf_default_cache_dir = lambda: tmp_path / "missing-default-hf",
+ legacy_hf_cache_dir = lambda: tmp_path / "missing-legacy-hf",
+ well_known_model_dirs = lambda: [],
+ studio_root = lambda: tmp_path / "missing-studio",
+ outputs_root = lambda: tmp_path / "missing-outputs",
+ exports_root = lambda: tmp_path / "missing-exports",
+ )
+ fake_external_media = SimpleNamespace(
+ linux_run_media_mount_roots = lambda: [],
+ windows_drive_roots = lambda: [drive_root],
+ )
+ fake_studio_db = SimpleNamespace(
+ list_scan_folders = lambda: [],
+ contains_sensitive_path_component = lambda _p: False,
+ # The simulated D:\ root maps to a tmp_path dir, not a denied system path.
+ is_denied_system_path = lambda _p: False,
+ )
+ monkeypatch.setitem(sys.modules, "utils.paths", fake_paths)
+ monkeypatch.setitem(sys.modules, "utils.paths.external_media", fake_external_media)
+ monkeypatch.setitem(sys.modules, "storage.studio_db", fake_studio_db)
+
+ ns = {
+ "HTTPException": _HTTPException,
+ "os": os,
+ "Path": Path,
+ "Optional": Optional,
+ "_safe_is_dir": lambda p: Path(p).is_dir(),
+ "_resolve_hf_cache_dir": lambda: tmp_path / "missing-hf",
+ "logger": SimpleNamespace(debug = lambda *_args, **_kwargs: None),
+ }
+ exec(compile(module, "", "exec"), ns)
+
+ allowlist = ns["_build_browse_allowlist"]()
+
+ # The simulated Windows drive root is now browsable, and a model dir on it resolves.
+ assert drive_root.resolve() in allowlist
+ assert ns["_resolve_browse_target"](str(model_dir), allowlist) == model_dir.resolve()
+
+
+def test_build_browse_allowlist_reuses_passed_roots(monkeypatch, tmp_path):
+ # Double-probe fix: a browse request probes the drive/media roots once and
+ # passes them in, so _build_browse_allowlist must NOT scan
+ # windows_drive_roots() again (a disconnected drive would double the stall).
+ tree = ast.parse((_BACKEND_ROOT / "routes" / "models.py").read_text(encoding = "utf-8"))
+ functions = [
+ node
+ for node in tree.body
+ if isinstance(node, ast.FunctionDef) and node.name == "_build_browse_allowlist"
+ ]
+ module = ast.Module(body = functions, type_ignores = [])
+ ast.fix_missing_locations(module)
+
+ drive_root = tmp_path / "D_drive"
+ drive_root.mkdir()
+
+ calls = {"drive": 0, "media": 0}
+
+ def _drive_roots():
+ calls["drive"] += 1
+ return [drive_root]
+
+ def _media_roots():
+ calls["media"] += 1
+ return []
+
+ fake_paths = SimpleNamespace(
+ hf_default_cache_dir = lambda: tmp_path / "missing-default-hf",
+ legacy_hf_cache_dir = lambda: tmp_path / "missing-legacy-hf",
+ well_known_model_dirs = lambda: [],
+ studio_root = lambda: tmp_path / "missing-studio",
+ outputs_root = lambda: tmp_path / "missing-outputs",
+ exports_root = lambda: tmp_path / "missing-exports",
+ )
+ fake_external_media = SimpleNamespace(
+ linux_run_media_mount_roots = _media_roots,
+ windows_drive_roots = _drive_roots,
+ )
+ fake_studio_db = SimpleNamespace(list_scan_folders = lambda: [])
+ monkeypatch.setitem(sys.modules, "utils.paths", fake_paths)
+ monkeypatch.setitem(sys.modules, "utils.paths.external_media", fake_external_media)
+ monkeypatch.setitem(sys.modules, "storage.studio_db", fake_studio_db)
+
+ ns = {
+ "os": os,
+ "Path": Path,
+ "Optional": Optional,
+ "_safe_is_dir": lambda p: Path(p).is_dir(),
+ "_resolve_hf_cache_dir": lambda: tmp_path / "missing-hf",
+ "logger": SimpleNamespace(debug = lambda *_args, **_kwargs: None),
+ }
+ exec(compile(module, "", "exec"), ns)
+ build = ns["_build_browse_allowlist"]
+
+ # Roots passed in -> neither helper is probed, but the roots still flow in.
+ allowlist = build([], [drive_root])
+ assert calls == {"drive": 0, "media": 0}
+ assert drive_root.resolve() in allowlist
+
+ # No args -> each helper is probed exactly once.
+ build()
+ assert calls == {"drive": 1, "media": 1}
+
+
+def test_is_path_inside_allowlist_real_descendants_and_siblings(tmp_path):
+ # Component-wise containment (commonpath): a genuine descendant is allowed,
+ # but a sibling sharing only a string prefix ("models_root_evil" vs
+ # "models_root") is not, which the old startswith check could miss.
+ ns = _extract_routes_function("_is_path_inside_allowlist")
+ root = tmp_path / "models_root"
+ child = root / "gguf" / "qwen"
+ sibling = tmp_path / "models_root_evil"
+ child.mkdir(parents = True)
+ sibling.mkdir()
+
+ is_inside = ns["_is_path_inside_allowlist"]
+ assert is_inside(root, [root]) is True # the root itself
+ assert is_inside(child, [root]) is True # a genuine descendant
+ assert is_inside(sibling, [root]) is False # prefix-collision sibling
+
+
+def test_is_path_inside_allowlist_posix_root_does_not_authorize_descendants(monkeypatch):
+ # Regression for the reported POSIX "/" unlock: a bare filesystem root may
+ # match itself but must NOT authorize arbitrary descendants such as /etc.
+ ns = _extract_routes_function("_is_path_inside_allowlist")
+ monkeypatch.setattr(os.path, "realpath", lambda p: str(p)) # keep "/" intact
+
+ is_inside = ns["_is_path_inside_allowlist"]
+ assert is_inside("/", ["/"]) is True # the root itself
+ assert is_inside("/etc", ["/"]) is False # not a licensed descendant
+ assert is_inside("/root/models", ["/"]) is False
+
+
+def test_is_path_inside_allowlist_windows_drive_root_descendants():
+ # Exercise the Windows drive-root branch on a POSIX host by backing os.path
+ # with ntpath and an identity realpath (the simulated drives don't exist
+ # here). A drive root authorizes its descendants; a different drive does not.
+ import ntpath
+
+ win_os = SimpleNamespace(
+ sep = "\\",
+ path = SimpleNamespace(
+ normcase = ntpath.normcase,
+ realpath = lambda p: str(p),
+ splitdrive = ntpath.splitdrive,
+ dirname = ntpath.dirname,
+ commonpath = ntpath.commonpath,
+ ),
+ )
+ ns = _extract_routes_function("_is_path_inside_allowlist", {"os": win_os})
+ is_inside = ns["_is_path_inside_allowlist"]
+
+ assert is_inside("D:\\", ["D:\\"]) is True # drive root itself
+ assert is_inside("D:\\models", ["D:\\"]) is True # descendant on the drive
+ assert is_inside("D:\\models\\gguf", ["D:\\"]) is True # deeper descendant
+ assert is_inside("d:\\models", ["D:\\"]) is True # case-insensitive drive letter
+ assert is_inside("C:\\Users", ["D:\\"]) is False # different drive
+ assert is_inside("D:\\models", ["E:\\"]) is False
diff --git a/studio/backend/tests/test_worker_activates_correct_transformers.py b/studio/backend/tests/test_worker_activates_correct_transformers.py
new file mode 100644
index 0000000000..fe7b8dd25a
--- /dev/null
+++ b/studio/backend/tests/test_worker_activates_correct_transformers.py
@@ -0,0 +1,155 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Invariant: after the training worker runs its preflight and then activates the transformers
+sidecar, the in-process ``transformers`` must be the sidecar version the model requires -- not the
+default 4.57.x that the base environment ships.
+
+The CPU-only "does it choose the correct transformers version" guard, stronger than the pure
+import-order check in ``test_training_worker_import_discipline.py``: it runs the REAL tier detection
+(``get_transformers_tier``) and REAL activation (``activate_transformers_for_subprocess``) for a
+transformers-5.x model (Qwen3.5, tier 530) and asserts the version actually switched. It catches the
+whole failure family at once:
+
+ * a stale pre-activation ``transformers`` import (the #6951 / ``TokenizersBackend`` regression: an
+ already-cached 4.57.x defeats the sidecar's ``sys.path`` prepend),
+ * a wrong tier selected for a 5.x model, and
+ * activation not actually swapping the resident module.
+
+Why the CUDA spoof matters (verified): ``unsloth_zoo``'s eager ``import transformers`` only happens on
+its full, GPU-present init path. On a GPU-less runner it silently degrades and never preloads
+transformers -- which would MASK the stale-import bug (the check would falsely pass). Spoofing
+``torch.cuda`` so ``unsloth_zoo`` believes a GPU is present forces the real init path, exposing the
+regression on CPU CI. The spoof mirrors ``tests/_zoo_aggressive_cuda_spoof.py`` but is inlined so the
+test is self-contained in the ``studio-backend-ci`` matrix (whose conftest does not apply the shared
+spoof). No GPU/network/weights/real sidecar needed: a one-line stub sidecar stands in for the 5.x venv,
+so we only assert activation lands on it.
+
+Proven: passes on the fixed tree (active == 5.3.0) and fails on the buggy tree (active == 4.57.x) on
+a simulated GPU-less runner.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+_BACKEND_DIR = Path(__file__).resolve().parent.parent # studio/backend
+# Canonical CUDA spoof at the repo root (studio/backend -> studio -> repo root). Loaded by the
+# subprocess when present (matches the consolidated CI); absent in a standalone studio checkout, where
+# the subprocess falls back to a minimal inline spoof.
+_SPOOF_PATH = _BACKEND_DIR.parent.parent / "tests" / "_zoo_aggressive_cuda_spoof.py"
+
+# Runs in a fresh interpreter with cwd == studio/backend so ``utils.*`` resolves like the worker.
+# STUB_HOME (a pytest tmp dir) holds a throwaway ``.venv_t5_530`` sidecar exporting transformers 5.3.0.
+_SNIPPET = r"""
+import os, sys
+sys.path.insert(0, os.getcwd())
+
+# CUDA spoof so unsloth_zoo takes its full, transformers-importing init path on a GPU-less runner.
+# Without it unsloth_zoo degrades and never preloads transformers, which would MASK the stale-import
+# regression under test (verified). Prefer the repo's canonical spoof (single source of truth, and the
+# one the consolidated CI already relies on); fall back to a minimal inline spoof so this also works in
+# a standalone studio checkout. If torch is absent the fixed tree still passes below; the bug just
+# would not be exposable in that shard.
+try:
+ import torch # noqa: F401
+ _sp = os.environ.get("SPOOF_PATH")
+ if _sp and os.path.exists(_sp):
+ import importlib.util
+ _spec = importlib.util.spec_from_file_location("_zoo_aggressive_cuda_spoof", _sp)
+ _mod = importlib.util.module_from_spec(_spec)
+ _spec.loader.exec_module(_mod)
+ _mod.apply()
+ else:
+ torch.cuda.is_available = lambda: True
+ torch.cuda.device_count = lambda: 1
+ torch.cuda.current_device = lambda: 0
+ torch.cuda.get_device_capability = lambda *a, **k: (8, 0)
+ torch.cuda.get_device_name = lambda *a, **k: "NVIDIA A100-SPOOFED"
+ torch.cuda.is_bf16_supported = lambda *a, **k: True
+ class _Props:
+ name = "NVIDIA A100-SPOOFED"
+ major = 8
+ minor = 0
+ total_memory = 80 * 1024**3
+ multi_processor_count = 108
+ torch.cuda.get_device_properties = lambda *a, **k: _Props()
+ torch.cuda.mem_get_info = lambda *a, **k: (0, 80 * 1024**3)
+except Exception:
+ pass
+os.environ["UNSLOTH_IS_PRESENT"] = "1"
+
+# Stub 5.x sidecar: activation only edits sys.path, so a package that merely exports __version__ is
+# enough to prove the resident transformers switched to it.
+home = os.environ["STUB_HOME"]
+pkg = os.path.join(home, ".venv_t5_530", "transformers")
+os.makedirs(pkg, exist_ok = True)
+with open(os.path.join(pkg, "__init__.py"), "w") as f:
+ f.write('__version__ = "5.3.0"\n')
+os.environ["UNSLOTH_STUDIO_HOME"] = home
+
+# Faithful worker preflight (worker.py: from utils.hf_xet_fallback import child_should_disable_xet).
+# This is the exact stale-import trigger: on the buggy tree it pulls unsloth_zoo -> transformers 4.57.x
+# into sys.modules BEFORE activation.
+from utils.hf_xet_fallback import child_should_disable_xet
+child_should_disable_xet({})
+_tf = sys.modules.get("transformers")
+preload = _tf.__version__ if _tf is not None else None
+
+# Real tier detection + real activation, with the 530 sidecar pointed at the stub above.
+import utils.transformers_version as tv
+tv._VENV_T5_530_DIR = os.path.join(home, ".venv_t5_530")
+tv._ensure_venv_t5_530_exists = lambda: True
+tier = tv.get_transformers_tier("Qwen/Qwen3.5-9B", None)
+tv.activate_transformers_for_subprocess("Qwen/Qwen3.5-9B", None)
+
+import transformers
+print(f"RESULT tier={tier} preload={preload} active={transformers.__version__}")
+"""
+
+
+def _parse(stdout: str) -> dict[str, str]:
+ for line in stdout.splitlines():
+ if line.startswith("RESULT "):
+ return dict(kv.split("=", 1) for kv in line.split()[1:])
+ return {}
+
+
+def test_worker_activates_correct_transformers_version(tmp_path):
+ """The worker's real preflight + activation for a transformers-5.x model (Qwen3.5, tier 530) must
+ leave the in-process ``transformers`` on the 5.x sidecar. A stale pre-activation import leaves the
+ default 4.57.x pinned and fails this assertion -- exactly the #6951 ``TokenizersBackend`` regression."""
+ result = subprocess.run(
+ [sys.executable, "-c", _SNIPPET],
+ cwd = str(_BACKEND_DIR),
+ env = {
+ **__import__("os").environ,
+ "STUB_HOME": str(tmp_path),
+ **({"SPOOF_PATH": str(_SPOOF_PATH)} if _SPOOF_PATH.exists() else {}),
+ },
+ capture_output = True,
+ text = True,
+ )
+ assert result.returncode == 0, (
+ "Worker preflight + activation harness crashed.\n"
+ f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
+ )
+ parsed = _parse(result.stdout)
+ assert parsed, f"No RESULT line.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
+
+ # Correct tier chosen for a transformers-5.x model (pure, deterministic; no network/GPU).
+ assert parsed["tier"] == "530", (
+ f"Wrong transformers tier for Qwen3.5 (expected 530, got {parsed['tier']}). "
+ "Tier detection regressed."
+ )
+
+ # Activation must actually swap the resident transformers to the sidecar version. If a preflight
+ # import cached 4.57.x first, the sidecar prepend is a no-op and this stays 4.57.x -- the bug.
+ assert parsed["active"] == "5.3.0", (
+ "Sidecar activation did NOT switch the in-process transformers to the model's 5.x version "
+ f"(active={parsed['active']}, preloaded-before-activation={parsed['preload']}). A pre-activation "
+ "transformers import (directly or via unsloth_zoo) defeated the sidecar; 5.x models (Qwen3.5, "
+ "GLM-4.7, gemma-4) then fail with 'Tokenizer class TokenizersBackend does not exist'. See #6951."
+ )
diff --git a/studio/backend/utils/coding_agents.py b/studio/backend/utils/coding_agents.py
new file mode 100644
index 0000000000..f7dd2f8357
--- /dev/null
+++ b/studio/backend/utils/coding_agents.py
@@ -0,0 +1,39 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Detect which `unsloth start ` coding-agent CLIs are on PATH.
+
+The web UI only ever shows the user the "claude" flavor of the `unsloth start`
+command (see agent-command.ts), leaving anyone using Codex, OpenCode, and the
+other supported agents to manually edit the copied command. This module gives
+the frontend a way to ask which of those CLIs are actually installed so it can
+default to one the user can run immediately.
+"""
+
+import shutil
+
+# Keep in sync with the `unsloth start ` subcommands defined in
+# unsloth_cli/commands/start.py. Each entry is the exact executable name that
+# subcommand launches, so a hit here means `unsloth start ` can find the
+# binary on PATH without the user installing anything first.
+CODING_AGENTS: tuple[str, ...] = ("claude", "codex", "openclaw", "opencode", "hermes", "pi")
+
+
+def _is_on_path(agent: str) -> bool:
+ # shutil.which is documented to return None on a miss, but PATH lookups can
+ # still raise (e.g. a permission error while probing a directory entry);
+ # this is an advisory check, so a lookup failure should read as "not
+ # installed" instead of breaking the settings endpoint.
+ try:
+ return shutil.which(agent) is not None
+ except OSError:
+ return False
+
+
+def detect_installed_coding_agents() -> list[str]:
+ """Return the subset of CODING_AGENTS whose CLI binary is on PATH.
+
+ Order follows CODING_AGENTS, not discovery order, so callers can treat the
+ first entry as the preferred default among the installed agents.
+ """
+ return [agent for agent in CODING_AGENTS if _is_on_path(agent)]
diff --git a/studio/backend/utils/datasets/completion_masking.py b/studio/backend/utils/datasets/completion_masking.py
new file mode 100644
index 0000000000..c7c4a474e3
--- /dev/null
+++ b/studio/backend/utils/datasets/completion_masking.py
@@ -0,0 +1,144 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Completion-only masking policy shared by the CUDA and MLX training paths.
+
+Decides how train_on_responses_only is applied for a model: chat template
+auto-detection first, manual TEMPLATE_TO_RESPONSES_MAPPER markers as the
+fallback. gpt-oss included: its quantized checkpoints ship a different
+chat template, so only detection from the actual template is reliable.
+"""
+
+from .model_mappings import (
+ MODEL_TO_TEMPLATE_MAPPER,
+ TEMPLATE_TO_RESPONSES_MAPPER,
+ is_gpt_oss_model_name,
+)
+
+
+def lookup_manual_markers(model_name):
+ """Return (template_name, instruction_part, response_part) from the
+ manual template table, with None parts when the model or template is
+ not mapped."""
+ template = MODEL_TO_TEMPLATE_MAPPER.get((model_name or "").lower())
+ markers = TEMPLATE_TO_RESPONSES_MAPPER.get(template) if template else None
+ if markers:
+ return template, markers["instruction"], markers["response"]
+ return template, None, None
+
+
+def apply_completion_masking(
+ trainer,
+ model_name,
+ train_fn,
+ num_proc = None,
+ notify = None,
+ detect_fn = None,
+):
+ """Apply completion-only masking with auto-detection first and the manual
+ template table as fallback.
+
+ Args:
+ trainer: The platform trainer (SFTTrainer or MLXTrainer).
+ model_name: Model repo id used for table lookup and the gpt-oss
+ renamed-checkpoint fallback.
+ train_fn: The platform train_on_responses_only callable.
+ num_proc: Forwarded to train_fn when not None (CUDA path only).
+ notify: Optional callback notify(level, message) with level "info" or
+ "warning" for user-visible progress and warnings.
+ detect_fn: Marker detector (tokenizer/processor) -> (instruction_part,
+ response_part). Defaults to unsloth_zoo's get_chat_template_parts,
+ which raises loudly when the template cannot be parsed. Test seam.
+
+ Returns:
+ (trainer, applied): the possibly wrapped trainer and whether masking
+ was applied. When applied is False the trainer is unchanged and
+ training runs on full sequences.
+
+ Only marker DETECTION failures trigger the table fallback. Exceptions
+ raised while applying the masking (dataset map, tokenization) propagate
+ to the caller in both the auto and manual paths, so a real failure stops
+ the run instead of silently changing the training objective.
+ """
+ if notify is None:
+ notify = lambda level, message: None
+ kwargs = {}
+ if num_proc is not None:
+ kwargs["num_proc"] = num_proc
+
+ template, instruction_part, response_part = lookup_manual_markers(model_name)
+
+ # gpt-oss goes auto-first: quantized/BF16 checkpoints ship a channel-less
+ # template, so the manual markers match nothing (zero tokens trained). Auto
+ # derives markers from whichever template ships, and per the harmony format
+ # only the final terminator carries stop supervision. Renamed checkpoints
+ # miss the exact-name table, so give the fallback the gpt-oss markers.
+ if is_gpt_oss_model_name(model_name) and not (instruction_part and response_part):
+ markers = TEMPLATE_TO_RESPONSES_MAPPER.get("gpt-oss")
+ if markers:
+ template = "gpt-oss"
+ instruction_part = markers["instruction"]
+ response_part = markers["response"]
+ processor = getattr(trainer, "processing_class", None) or getattr(trainer, "tokenizer", None)
+ # mlx-lm TokenizerWrapper hides underscore attrs, so preset _unsloth_*
+ # markers are invisible through it. Unwrap to the real tokenizer (as
+ # zoo's MLX resolver does) before the preset check and detection.
+ if type(processor).__name__ == "TokenizerWrapper":
+ wrapped = getattr(processor, "_tokenizer", None)
+ if wrapped is not None:
+ processor = wrapped
+ inner = getattr(processor, "tokenizer", processor)
+ if hasattr(inner, "_unsloth_input_part") and hasattr(inner, "_unsloth_output_part"):
+ # Markers preset on the tokenizer; zoo reuses them on a bare call.
+ trainer = train_fn(trainer, **kwargs)
+ notify(
+ "info",
+ "Train on responses only configured via tokenizer preset markers",
+ )
+ return trainer, True
+ auto_instruction = auto_response = None
+ try:
+ if detect_fn is None:
+ # Torch-backed import is fine: the MLX train_fn itself requires
+ # unsloth_zoo.dataset_utils, so a torch-free host cannot mask either way.
+ from unsloth_zoo.dataset_utils import get_chat_template_parts as detect_fn
+ auto_instruction, auto_response = detect_fn(processor)
+ except Exception as e:
+ notify(
+ "warning",
+ f"Auto-detection of instruction/response markers failed ({e}); "
+ f"falling back to the template table",
+ )
+ if auto_instruction and auto_response:
+ trainer = train_fn(
+ trainer,
+ instruction_part = auto_instruction,
+ response_part = auto_response,
+ **kwargs,
+ )
+ notify(
+ "info",
+ "Train on responses only configured via chat template auto-detection",
+ )
+ return trainer, True
+
+ if instruction_part and response_part:
+ trainer = train_fn(
+ trainer,
+ instruction_part = instruction_part,
+ response_part = response_part,
+ **kwargs,
+ )
+ notify(
+ "info",
+ f"Train on responses only configured with template table markers ({template})",
+ )
+ return trainer, True
+
+ notify(
+ "warning",
+ f"'Train on completions' could not be applied for {model_name}: no "
+ f"auto-detected or mapped instruction/response markers. Training "
+ f"will run on full sequences (prompts included).",
+ )
+ return trainer, False
diff --git a/studio/backend/utils/datasets/model_mappings.py b/studio/backend/utils/datasets/model_mappings.py
index 9d2c983aed..65ba4b4688 100644
--- a/studio/backend/utils/datasets/model_mappings.py
+++ b/studio/backend/utils/datasets/model_mappings.py
@@ -485,9 +485,11 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
+ # No "" suffix: Qwen3-Thinking-2507 strips it from non-final turns
+ # and QwQ renders none, so a marker holding it masks those responses.
"qwen3-thinking": {
"instruction": "<|im_start|>user\n",
- "response": "<|im_start|>assistant\n",
+ "response": "<|im_start|>assistant\n",
},
"qwen3": {
"instruction": "<|im_start|>user\n",
@@ -525,29 +527,39 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
"instruction": "<|im_start|>user<|im_sep|>",
"response": "<|im_start|>assistant<|im_sep|>",
},
+ # No surrounding spaces: in Mistral v0.3 they fold into neighbouring text
+ # tokens ("[INST]"/"[/INST]" are single special tokens), so padded strings
+ # never match and everything masks. Same for Llama-2's SentencePiece.
"mistral": {
- "instruction": "[INST] ",
- "response": " [/INST]",
+ "instruction": "[INST]",
+ "response": "[/INST]",
},
"llama": {
- "instruction": "[INST] ",
- "response": " [/INST]",
+ # -anchored: llama-2 tokenizes [INST] after as bare "[" on
+ # transformers 5.x (standalone gives space-prefixed "▁["), so an
+ # unanchored marker misses every turn boundary there.
+ "instruction": "[INST]",
+ "response": "[/INST]",
},
"chatml": {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
+ # Leading newline required: Zephyr's role tags are plain text, and
+ # SentencePiece tokenizes "<|assistant|>" differently at text start than
+ # after " \n". Without the "\n" anchor the markers never match real
+ # turns, so every assistant token masks.
"zephyr": {
- "instruction": "<|user|>\n",
- "response": "<|assistant|>\n",
+ "instruction": "\n<|user|>\n",
+ "response": "\n<|assistant|>\n",
},
"unsloth": {
- "instruction": ">>> User: ",
- "response": ">>> Assistant: ",
+ "instruction": ">>> User:",
+ "response": ">>> Assistant:",
},
"vicuna": {
- "instruction": "USER: ",
- "response": "ASSISTANT: ",
+ "instruction": "USER:",
+ "response": "ASSISTANT:",
},
"alpaca": {
"instruction": "### Instruction:\n",
@@ -573,16 +585,21 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
+ # No trailing space: SentencePiece folds it into the next content token
+ # ("▁Hello"), so the padded marker never matches and masks everything.
"starling": {
- "instruction": "GPT4 Correct User: ",
- "response": "GPT4 Correct Assistant: ",
+ "instruction": "GPT4 Correct User:",
+ "response": "GPT4 Correct Assistant:",
},
"yi-chat": {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
+ # "[gMASK]" appears once at text start, so a marker holding it matches
+ # no later user turn; "" is scaffolding GLM-4.x renders as a lone
+ # " " on non-final turns, so "<|assistant|>" never matches.
"glm": {
- "instruction": "[gMASK]<|user|>",
- "response": "<|assistant|>",
+ "instruction": "<|user|>",
+ "response": "<|assistant|>",
},
}
diff --git a/studio/backend/utils/hf_xet_fallback.py b/studio/backend/utils/hf_xet_fallback.py
index 2dd2247396..9bc4a60fad 100644
--- a/studio/backend/utils/hf_xet_fallback.py
+++ b/studio/backend/utils/hf_xet_fallback.py
@@ -6,6 +6,16 @@
Re-exports the shared API and injects Studio's marker-aware cache purge
(``prepare_cache_for_transport``) so the download manager keeps its ``.transport``
marker semantics on the HTTP retry.
+
+Import discipline: ``unsloth_zoo``'s ``__init__`` eagerly imports ``transformers``. The workers
+import this shim at startup (to decide the per-worker Xet env flip) *before* activating the model's
+``transformers`` sidecar. Activation only prepends the sidecar to ``sys.path``, so a ``transformers``
+already cached in ``sys.modules`` (via an eager ``unsloth_zoo`` import here) wins -- pinning the
+default 4.57.x and regressing Qwen3.5 / GLM-4.7 / gemma-4 training with
+``Tokenizer class TokenizersBackend does not exist``. So the shared backend is loaded **lazily**
+(``_load_shared``), only on first use of a heavy download helper, i.e. after the sidecar is active.
+``child_should_disable_xet`` and the ``DEFAULT_*`` constants are defined locally so importing them
+never triggers the heavy load.
"""
from __future__ import annotations
@@ -13,161 +23,230 @@ from __future__ import annotations
import threading
from typing import Any, Callable, Optional
-_shared_import_error = None
-try:
- import unsloth_zoo.hf_xet_fallback as _shared
- _shared_available = True
-except Exception as _exc: # noqa: BLE001 - any import failure must degrade, not crash
- # unsloth_zoo's __init__ runs torch/GPU detection, which raises on a torch-less/GPU-less Studio
- # host. The download helper needs none of it, so retry via the light UNSLOTH_ZOO_DISABLE_GPU_INIT
- # path before giving up.
- _shared_import_error = _exc
- import os as _os
+# Defaults mirror unsloth_zoo.hf_xet_fallback; plain literals so they resolve (including as
+# default args below) without importing unsloth_zoo/transformers.
+DEFAULT_GRACE_PERIOD = 10.0
+DEFAULT_HEARTBEAT_INTERVAL = 30.0
+DEFAULT_STALL_TIMEOUT = 180.0
- _prev_gpu_init = _os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT")
- _os.environ["UNSLOTH_ZOO_DISABLE_GPU_INIT"] = "1"
- try:
- import unsloth_zoo.hf_xet_fallback as _shared
- _shared_available = True
- _shared_import_error = None
- except Exception as _exc2: # noqa: BLE001 - degrade so Studio still boots with plain HF downloads
- _shared_import_error = _exc2
- _shared_available = False
- finally:
- if _prev_gpu_init is None:
- _os.environ.pop("UNSLOTH_ZOO_DISABLE_GPU_INIT", None)
- else:
- _os.environ["UNSLOTH_ZOO_DISABLE_GPU_INIT"] = _prev_gpu_init
+# --- lazy shared-backend loader ----------------------------------------------------------------
+_shared: Any = None
+_shared_available: Optional[bool] = None # None = not yet attempted
+_shared_import_error: Optional[BaseException] = None
+_load_lock = threading.Lock()
-if _shared_available:
- # Bind by assignment so each public name shares one module-level binding with the degraded branch.
- DEFAULT_GRACE_PERIOD = _shared.DEFAULT_GRACE_PERIOD
- DEFAULT_HEARTBEAT_INTERVAL = _shared.DEFAULT_HEARTBEAT_INTERVAL
- DEFAULT_STALL_TIMEOUT = _shared.DEFAULT_STALL_TIMEOUT
- DownloadStallError = _shared.DownloadStallError
- child_should_disable_xet = _shared.child_should_disable_xet
- get_hf_download_state = _shared.get_hf_download_state
- start_watchdog = _shared.start_watchdog
- _shared_hf_hub_download_with_xet_fallback = _shared.hf_hub_download_with_xet_fallback
- _shared_snapshot_download_with_xet_fallback = _shared.snapshot_download_with_xet_fallback
-else:
- # Degrade instead of crashing Studio: plain HF downloads, stall watchdog disabled. Thin stubs,
- # not a second copy of the orchestration; recovery returns once unsloth_zoo is upgraded.
- import logging as _logging
- _logging.getLogger(__name__).warning(
- "unsloth_zoo.hf_xet_fallback unavailable (%s); the Xet stall watchdog is "
- "disabled. Install/upgrade unsloth_zoo (and its torch dependency) to "
- "re-enable automatic Xet -> HTTP download recovery.",
- _shared_import_error,
- )
+def _load_shared() -> bool:
+ """Import ``unsloth_zoo.hf_xet_fallback`` on demand; return True if available. Deferred so
+ importing this module at worker startup does not pull transformers in before the sidecar is
+ activated. Degrades (returns False) rather than crashing when unsloth_zoo is unavailable."""
+ global _shared, _shared_available, _shared_import_error
+ if _shared_available is not None:
+ return _shared_available
+ with _load_lock:
+ if _shared_available is not None:
+ return _shared_available
+ try:
+ import unsloth_zoo.hf_xet_fallback as shared
- DEFAULT_HEARTBEAT_INTERVAL = 30.0
- DEFAULT_STALL_TIMEOUT = 180.0
- DEFAULT_GRACE_PERIOD = 10.0
+ _shared = shared
+ _shared_available = True
+ _shared_import_error = None
+ return True
+ except Exception as exc: # noqa: BLE001 - any import failure must degrade, not crash
+ # unsloth_zoo's __init__ runs torch/GPU detection, which raises on a torch-less/GPU-less
+ # host. The download helper needs none of it, so retry via UNSLOTH_ZOO_DISABLE_GPU_INIT.
+ _shared_import_error = exc
+ import os as _os
- class DownloadStallError(RuntimeError):
- """Stub mirror so callers' ``except`` clauses resolve; never raised in degraded mode."""
+ _prev_gpu_init = _os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT")
+ _os.environ["UNSLOTH_ZOO_DISABLE_GPU_INIT"] = "1"
+ try:
+ import unsloth_zoo.hf_xet_fallback as shared
- def child_should_disable_xet(config: dict) -> bool:
- return bool(config.get("disable_xet"))
+ _shared = shared
+ _shared_available = True
+ _shared_import_error = None
+ return True
+ except Exception as exc2: # noqa: BLE001 - degrade so Studio still boots with plain HF
+ _shared_import_error = exc2
+ _shared_available = False
+ import logging as _logging
- def get_hf_download_state(*args: Any, **kwargs: Any) -> None:
- return None # unmeasurable -> the (absent) watchdog never fires
+ _logging.getLogger(__name__).warning(
+ "unsloth_zoo.hf_xet_fallback unavailable (%s); the Xet stall watchdog is "
+ "disabled. Install/upgrade unsloth_zoo (and its torch dependency) to "
+ "re-enable automatic Xet -> HTTP download recovery.",
+ _shared_import_error,
+ )
+ return False
+ finally:
+ if _prev_gpu_init is None:
+ _os.environ.pop("UNSLOTH_ZOO_DISABLE_GPU_INIT", None)
+ else:
+ _os.environ["UNSLOTH_ZOO_DISABLE_GPU_INIT"] = _prev_gpu_init
- def start_watchdog(
- *,
- on_heartbeat: "Optional[Callable[[str], None]]" = None,
- interval: float = DEFAULT_HEARTBEAT_INTERVAL,
- xet_disabled: bool = False,
- **kwargs: Any,
- ) -> "threading.Event":
- # No stall detection, but keep emitting heartbeats so the orchestrator's inactivity deadline
- # is not tripped during a long download.
- stop = threading.Event()
- if on_heartbeat is None:
- return stop
- transport = "https" if xet_disabled else "xet"
- def _beat() -> None:
- while not stop.wait(interval):
- try:
- on_heartbeat(f"Downloading ({transport} transport)...")
- except Exception:
- pass
+def child_should_disable_xet(config: dict) -> bool:
+ """Single source of truth for the per-worker Xet env flip (mirrors
+ ``unsloth_zoo.hf_xet_fallback.child_should_disable_xet``). Deliberately lightweight: importing or
+ calling it must NOT pull in unsloth_zoo/transformers, so the worker can decide before activating
+ the transformers sidecar (see the module docstring)."""
+ return bool(config.get("disable_xet"))
- threading.Thread(
- target = _beat,
- daemon = True,
- name = "hf-xet-degraded-heartbeat",
- ).start()
+
+# --- degraded stubs (used only when unsloth_zoo is unavailable) -------------------------------
+class _DegradedDownloadStallError(RuntimeError):
+ """Stub mirror so callers' ``except`` clauses resolve; never raised in degraded mode."""
+
+
+def _degraded_get_hf_download_state(*args: Any, **kwargs: Any) -> None:
+ return None # unmeasurable -> the (absent) watchdog never fires
+
+
+def _degraded_start_watchdog(
+ *,
+ on_heartbeat: "Optional[Callable[[str], None]]" = None,
+ interval: float = DEFAULT_HEARTBEAT_INTERVAL,
+ xet_disabled: bool = False,
+ **kwargs: Any,
+) -> "threading.Event":
+ # No stall detection, but keep emitting heartbeats so the orchestrator's inactivity deadline
+ # is not tripped during a long download.
+ stop = threading.Event()
+ if on_heartbeat is None:
return stop
+ transport = "https" if xet_disabled else "xet"
- def _degraded_cancelled(cancel_event: "Optional[threading.Event]") -> bool:
- return cancel_event is not None and cancel_event.is_set()
+ def _beat() -> None:
+ while not stop.wait(interval):
+ try:
+ on_heartbeat(f"Downloading ({transport} transport)...")
+ except Exception:
+ pass
- def _shared_hf_hub_download_with_xet_fallback(
- repo_id: str,
- filename: str,
- token: Optional[str],
- *,
- repo_type: str = "model",
- revision: Optional[str] = None,
- cache_dir: Optional[str] = None,
- force_download: bool = False,
- cancel_event: "Optional[threading.Event]" = None,
- **_ignored: Any,
- ) -> str:
- # Keep the cancellation contract: do not start or return a download once cancelled.
- if _degraded_cancelled(cancel_event):
- raise RuntimeError("Cancelled")
+ threading.Thread(
+ target = _beat,
+ daemon = True,
+ name = "hf-xet-degraded-heartbeat",
+ ).start()
+ return stop
- from huggingface_hub import hf_hub_download
- path = hf_hub_download(
- repo_id = repo_id,
- filename = filename,
- token = token,
- repo_type = repo_type,
- revision = revision,
- cache_dir = cache_dir,
- force_download = force_download,
- )
- if _degraded_cancelled(cancel_event):
- raise RuntimeError("Cancelled")
- return path
+def _degraded_cancelled(cancel_event: "Optional[threading.Event]") -> bool:
+ return cancel_event is not None and cancel_event.is_set()
- def _shared_snapshot_download_with_xet_fallback(
- repo_id: str,
- *,
- revision: Optional[str] = None,
- token: Optional[str] = None,
- repo_type: str = "model",
- cache_dir: Optional[str] = None,
- allow_patterns: Optional[Any] = None,
- ignore_patterns: Optional[Any] = None,
- force_download: bool = False,
- cancel_event: "Optional[threading.Event]" = None,
- **_ignored: Any,
- ) -> str:
- if _degraded_cancelled(cancel_event):
- raise RuntimeError("Cancelled")
- from huggingface_hub import snapshot_download
+def _degraded_hf_hub_download_with_xet_fallback(
+ repo_id: str,
+ filename: str,
+ token: Optional[str],
+ *,
+ repo_type: str = "model",
+ revision: Optional[str] = None,
+ cache_dir: Optional[str] = None,
+ force_download: bool = False,
+ cancel_event: "Optional[threading.Event]" = None,
+ **_ignored: Any,
+) -> str:
+ # Keep the cancellation contract: do not start or return a download once cancelled.
+ if _degraded_cancelled(cancel_event):
+ raise RuntimeError("Cancelled")
- path = snapshot_download(
- repo_id = repo_id,
- repo_type = repo_type,
- revision = revision,
- token = token,
- cache_dir = cache_dir,
- allow_patterns = allow_patterns,
- ignore_patterns = ignore_patterns,
- force_download = force_download,
- )
- if _degraded_cancelled(cancel_event):
- raise RuntimeError("Cancelled")
- return path
+ from huggingface_hub import hf_hub_download
+
+ path = hf_hub_download(
+ repo_id = repo_id,
+ filename = filename,
+ token = token,
+ repo_type = repo_type,
+ revision = revision,
+ cache_dir = cache_dir,
+ force_download = force_download,
+ )
+ if _degraded_cancelled(cancel_event):
+ raise RuntimeError("Cancelled")
+ return path
+
+
+def _degraded_snapshot_download_with_xet_fallback(
+ repo_id: str,
+ *,
+ revision: Optional[str] = None,
+ token: Optional[str] = None,
+ repo_type: str = "model",
+ cache_dir: Optional[str] = None,
+ allow_patterns: Optional[Any] = None,
+ ignore_patterns: Optional[Any] = None,
+ force_download: bool = False,
+ cancel_event: "Optional[threading.Event]" = None,
+ **_ignored: Any,
+) -> str:
+ if _degraded_cancelled(cancel_event):
+ raise RuntimeError("Cancelled")
+
+ from huggingface_hub import snapshot_download
+
+ path = snapshot_download(
+ repo_id = repo_id,
+ repo_type = repo_type,
+ revision = revision,
+ token = token,
+ cache_dir = cache_dir,
+ allow_patterns = allow_patterns,
+ ignore_patterns = ignore_patterns,
+ force_download = force_download,
+ )
+ if _degraded_cancelled(cancel_event):
+ raise RuntimeError("Cancelled")
+ return path
+
+
+# --- lazy attribute access for the heavy shared API -------------------------------------------
+# ``DownloadStallError`` (class identity matters for ``except``), ``start_watchdog`` and
+# ``get_hf_download_state`` come from the shared backend when available, else the degraded stubs.
+# Resolved via PEP 562 ``__getattr__`` so ``from utils.hf_xet_fallback import X`` triggers the load
+# only for these heavy names, not for ``child_should_disable_xet`` / ``DEFAULT_*``.
+_DEGRADED_ATTRS = {
+ "DownloadStallError": _DegradedDownloadStallError,
+ "start_watchdog": _degraded_start_watchdog,
+ "get_hf_download_state": _degraded_get_hf_download_state,
+}
+
+# Annotation-only declarations for the three names above: they bind NO value, so lookup still misses
+# and PEP 562 ``__getattr__`` resolves them lazily -- but ruff/pyflakes see them as defined, so listing
+# them in ``__all__`` does not trip F822 (while F822 still catches a real typo elsewhere in the list).
+DownloadStallError: type
+start_watchdog: Any
+get_hf_download_state: Any
+
+
+def __getattr__(name: str) -> Any:
+ if name in _DEGRADED_ATTRS:
+ if _load_shared():
+ return getattr(_shared, name)
+ return _DEGRADED_ATTRS[name]
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
+# Indirection seam the public wrappers call (and tests monkeypatch): lazy-load the shared backend,
+# then dispatch to it or the degraded stub. The ``_shared_*`` names preserve the pre-refactor contract.
+def _shared_hf_hub_download_with_xet_fallback(*args: Any, **kwargs: Any) -> str:
+ impl = (
+ _shared.hf_hub_download_with_xet_fallback
+ if _load_shared()
+ else _degraded_hf_hub_download_with_xet_fallback
+ )
+ return impl(*args, **kwargs)
+
+
+def _shared_snapshot_download_with_xet_fallback(*args: Any, **kwargs: Any) -> str:
+ impl = (
+ _shared.snapshot_download_with_xet_fallback
+ if _load_shared()
+ else _degraded_snapshot_download_with_xet_fallback
+ )
+ return impl(*args, **kwargs)
__all__ = [
diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py
index c16ae91467..f6d3635301 100644
--- a/studio/backend/utils/llama_cpp_update.py
+++ b/studio/backend/utils/llama_cpp_update.py
@@ -473,9 +473,18 @@ def _rocm_install_args(asset: Optional[str]) -> list[str]:
return ["--has-rocm"]
-def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path) -> None:
+def _run_update(
+ install_dir: Path,
+ repo: str,
+ asset: Optional[str],
+ script: Path,
+ pin_release_tag: Optional[str] = None,
+) -> None:
"""Worker: put the backend into a maintenance state, run the installer for
- the latest prebuilt, then refresh caches so the next load uses the new build."""
+ the latest prebuilt, then refresh caches so the next load uses the new build.
+
+ pin_release_tag pins the installer to that exact published release instead
+ of letting it re-resolve "latest" itself (see start_update for why)."""
backend = None
model_was_active = False
try:
@@ -510,10 +519,18 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
"--published-repo",
repo,
]
+ if pin_release_tag:
+ cmd.extend(["--published-release-tag", pin_release_tag])
cmd.extend(_rocm_install_args(asset))
logger.info("llama update: installing", cmd = " ".join(cmd))
# Stream progress lines into job["progress"].
env = dict(os.environ, UNSLOTH_PROGRESS_PERCENT_STEP = "5")
+ # Preserve a Vulkan install across updates: detect_host on a CUDA/ROCm
+ # box would otherwise re-route and silently replace the Vulkan build.
+ # Re-assert it via the same env flag setup uses (mirrors
+ # _rocm_install_args).
+ if asset and "vulkan" in asset.lower():
+ env["UNSLOTH_FORCE_VULKAN"] = "1"
proc = subprocess.Popen(
cmd,
stdout = subprocess.PIPE,
@@ -563,6 +580,16 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
new_marker = read_install_marker(_find_binary())
new_tag = (new_marker or {}).get("release_tag") or (new_marker or {}).get("tag")
+ # Pinned install must land on that exact release; a same-repo mismatch
+ # means the pin was ignored (Vulkan/Intel reroute to another repo is fine).
+ if (
+ pin_release_tag
+ and new_tag
+ and (new_marker or {}).get("published_repo") == repo
+ and new_tag != pin_release_tag
+ ):
+ raise RuntimeError(f"pinned release {pin_release_tag} but installer produced {new_tag}")
+
with _job_lock:
_job.update(
state = _JOB_SUCCESS,
@@ -644,6 +671,13 @@ def start_update() -> dict:
repo = marker.get("published_repo") or DEFAULT_PUBLISHED_REPO
from_tag = marker.get("tag") or marker.get("release_tag")
asset = marker.get("asset")
+ # Install exactly the release the banner offered: the installer's own
+ # "latest" is commit-date ordered and can lag the published_at pick
+ # above, reinstalling the current build in a loop (the #6219 class).
+ # Not on macOS, which needs the older-release walk-back a pin disables
+ # (skipping too-new prebuilts); elsewhere an unusable latest now fails
+ # the job loudly (retryable) instead of walking back.
+ pin_release_tag = None if sys.platform == "darwin" else status.get("latest_tag")
else:
# Source build / custom path: only proceed when the same detection logic
# would offer the update (prebuilt exists, install is behind, root is
@@ -671,6 +705,9 @@ def start_update() -> dict:
repo = (res or {}).get("repo") or DEFAULT_PUBLISHED_REPO
from_tag = None
asset = (res or {}).get("asset")
+ # No pin: source-build detection resolves via --resolve-prebuilt latest,
+ # the same resolver the unpinned apply uses, so the two already agree.
+ pin_release_tag = None
if install_dir is None:
return {
@@ -698,7 +735,7 @@ def start_update() -> dict:
thread = threading.Thread(
target = _run_update,
- args = (install_dir, repo, asset, script),
+ args = (install_dir, repo, asset, script, pin_release_tag),
name = "llama-cpp-update",
daemon = True,
)
diff --git a/studio/backend/utils/models/checkpoints.py b/studio/backend/utils/models/checkpoints.py
index 90e26d45d0..b6b080b1c4 100644
--- a/studio/backend/utils/models/checkpoints.py
+++ b/studio/backend/utils/models/checkpoints.py
@@ -248,7 +248,7 @@ def scan_checkpoints(
# Sort by modification time (newest first)
models.sort(key = lambda x: Path(x[1][0][1]).stat().st_mtime, reverse = True)
- logger.info(f"Found {len(models)} training runs in {outputs_dir}")
+ logger.debug(f"Found {len(models)} training runs in {outputs_dir}")
return models
except Exception as e:
diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py
index 5d8458e5f0..284bbb5745 100644
--- a/studio/backend/utils/models/model_config.py
+++ b/studio/backend/utils/models/model_config.py
@@ -698,6 +698,25 @@ if backend_dir not in sys.path:
try:
from transformers import AutoConfig
+ # Union the ACTIVE sidecar's registry into the inlined parent-process sets
+ # so architectures only the sidecar knows still classify correctly.
+ try:
+ from transformers.models.auto import modeling_auto as _ma
+ for _attr in ("MODEL_FOR_IMAGE_TEXT_TO_TEXT_MAPPING_NAMES",
+ "MODEL_FOR_VISION_2_SEQ_MAPPING_NAMES"):
+ _d = dict(getattr(_ma, _attr, None) or {})
+ _VLM_MODEL_TYPES |= set(_d)
+ _VLM_CLASS_NAMES |= set(_d.values())
+ for _attr in ("MODEL_FOR_CTC_MAPPING_NAMES",
+ "MODEL_FOR_SPEECH_SEQ_2_SEQ_MAPPING_NAMES",
+ "MODEL_FOR_AUDIO_CLASSIFICATION_MAPPING_NAMES",
+ "MODEL_FOR_TEXT_TO_WAVEFORM_MAPPING_NAMES",
+ "MODEL_FOR_TEXT_TO_SPECTROGRAM_MAPPING_NAMES",
+ "MODEL_FOR_AUDIO_XVECTOR_MAPPING_NAMES"):
+ _AUDIO_ONLY_MODEL_TYPES |= set(dict(getattr(_ma, _attr, None) or {}))
+ except Exception:
+ pass
+
# Capability detection never executes model repo code.
kwargs = {"trust_remote_code": False}
if token:
@@ -727,13 +746,23 @@ def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None)
"""
token_arg = hf_token or ""
+ # Latest-only architectures need the latest sidecar for AutoConfig;
+ # other tiers keep the 5.5 sidecar.
+ sidecar_dir = _VENV_T5_DIR
+ try:
+ from utils.transformers_version import _VENV_T5_LATEST_DIR, get_transformers_tier
+ if get_transformers_tier(model_name, hf_token, probe = False) == "latest":
+ sidecar_dir = _VENV_T5_LATEST_DIR
+ except Exception:
+ pass
+
try:
result = subprocess.run(
[
sys.executable,
"-c",
_VISION_CHECK_SCRIPT,
- _VENV_T5_DIR,
+ sidecar_dir,
_BACKEND_DIR,
model_name,
token_arg,
@@ -876,6 +905,17 @@ def _is_vision_model_uncached(
model_name, hf_token = hf_token, local_files_only = local_files_only
)
if raw is not None:
+ if raw is False and not local_files_only:
+ # Raw heuristics predate latest-only architectures; on the latest tier,
+ # trust that sidecar's AutoConfig probe over the heuristic False. An
+ # inconclusive probe (sidecar mid-repair, timeout) is transient: return
+ # None so the heuristic False is not cached and the model is re-probed.
+ try:
+ from utils.transformers_version import get_transformers_tier
+ if get_transformers_tier(model_name, hf_token, probe = False) == "latest":
+ return _is_vision_model_subprocess(model_name, hf_token = hf_token)
+ except Exception:
+ pass
return raw
# Raw read failed transiently: fall back to AutoConfig (remote code DISABLED), via a
@@ -1617,36 +1657,60 @@ def _iter_hf_cache_snapshots(repo_id: str):
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
- repo_dir: Optional[Path] = None
+ repo_dirs: list[Path] = []
try:
if not cache_dir.is_dir():
return
for entry in cache_dir.iterdir():
if entry.is_dir() and entry.name.lower() == target:
- repo_dir = entry
- break
+ repo_dirs.append(entry)
except OSError:
return
- if repo_dir is None:
+ if not repo_dirs:
return
- snapshots = repo_dir / "snapshots"
- try:
- if not snapshots.is_dir():
- return
- snap_dirs = [s for s in snapshots.iterdir() if s.is_dir()]
- except OSError:
+ snap_dirs: list[Path] = []
+ for repo_dir in repo_dirs:
+ snapshots = repo_dir / "snapshots"
+ try:
+ if snapshots.is_dir():
+ for snap_dir in snapshots.iterdir():
+ try:
+ if snap_dir.is_dir():
+ snap_dirs.append(snap_dir)
+ except OSError:
+ continue
+ except OSError:
+ continue
+ if not snap_dirs:
return
- snap_dirs.sort(key = lambda s: s.stat().st_mtime, reverse = True)
- yield from snap_dirs
+ snap_dirs_with_mtime = []
+ for snap_dir in snap_dirs:
+ try:
+ snap_dirs_with_mtime.append((snap_dir.stat().st_mtime, snap_dir))
+ except OSError:
+ continue
+ snap_dirs_with_mtime.sort(key = lambda item: item[0], reverse = True)
+ yield from (snap_dir for _, snap_dir in snap_dirs_with_mtime)
def _list_gguf_variants_from_hf_cache(repo_id: str) -> Optional[tuple[list[GgufVariantInfo], bool]]:
- """Variants from the local HF cache snapshot, or None if not cached."""
+ """Variants from the local HF cache snapshot, or None if not cached.
+
+ A newer snapshot can hold only a companion file (for example a vision
+ projector fetched on demand) while the quant files live in an older
+ snapshot. Returning the first snapshot that merely reports a vision flag
+ would shadow those real variants, so keep scanning older snapshots for
+ actual variants and carry the vision flag across snapshots.
+ """
+ any_vision = False
for snap in _iter_hf_cache_snapshots(repo_id):
variants, has_vision = list_local_gguf_variants(str(snap))
- if variants or has_vision:
- return variants, has_vision
+ any_vision = any_vision or has_vision
+ if variants:
+ return variants, any_vision
+ if any_vision:
+ return [], True
return None
diff --git a/studio/backend/utils/paths/external_media.py b/studio/backend/utils/paths/external_media.py
index 1f1754664f..0ea0477cc7 100644
--- a/studio/backend/utils/paths/external_media.py
+++ b/studio/backend/utils/paths/external_media.py
@@ -8,6 +8,10 @@ from __future__ import annotations
import getpass
import os
import platform
+import string
+import threading
+import time
+from collections.abc import Iterable
from pathlib import Path
from utils.paths.sensitive import (
@@ -16,6 +20,33 @@ from utils.paths.sensitive import (
)
+def is_local_filesystem_root(path: str, *, _pathmod = os.path) -> bool:
+ """True for a bare local filesystem root -- POSIX ``/``, a drive root ``C:\\``,
+ or a device-namespace volume root like ``\\\\?\\C:\\`` or
+ ``\\\\?\\Volume{GUID}\\`` -- which sit above denied system dirs, but NOT a UNC
+ share root (``\\\\server\\share`` or its ``\\\\?\\UNC\\...`` form), which has
+ none under it and was registerable before this guard. ``splitdrive`` is empty
+ on POSIX servers, so this reduces to the plain ``dirname == self`` test there.
+ ``_pathmod`` lets tests drive ``ntpath`` semantics on a POSIX CI.
+ """
+ # Resolve the Windows device / extended-length namespace, where \\?\C:\,
+ # \\.\C:\ and \\?\Volume{GUID}\ are all bare LOCAL volume roots (rejected)
+ # while only \\?\UNC\server\share is a UNC share (handled like \\server\share).
+ if path[:4].lower() in ("\\\\?\\", "\\\\.\\"):
+ rest = path[4:]
+ if rest[:4].lower() == "unc\\":
+ path = "\\\\" + rest[4:]
+ else:
+ # A device volume root is just the volume specifier (C:, Volume{GUID})
+ # with no further component; a deeper path is an ordinary folder.
+ core = rest.rstrip("\\/")
+ return "\\" not in core and "/" not in core
+ if _pathmod.dirname(path) != path:
+ return False
+ drive, _ = _pathmod.splitdrive(path)
+ return drive[:2] not in ("\\\\", "//")
+
+
def _is_linux_media_mount_path(path: str, media_root: Path | str) -> bool:
normalized = os.path.normpath(os.path.realpath(os.path.expanduser(path)))
root = os.path.normpath(os.path.realpath(os.path.expanduser(str(media_root))))
@@ -98,3 +129,101 @@ def linux_run_media_mount_roots(
seen.add(key)
roots.append(resolved)
return roots
+
+
+def _active_windows_drive_bitmask() -> int:
+ """Active-logical-drive bitmask from ``GetLogicalDrives`` (bit 0 = ``A:``), or ``0`` when unavailable.
+
+ A fast non-blocking call that lets :func:`windows_drive_roots` skip the
+ ``os.path.isdir`` probe on unmapped letters. A disconnected network mapping
+ stays set here, so it does not guard the reconnect stall on its own;
+ :func:`windows_drive_roots` bounds each surviving probe too. Returns ``0``
+ (probe every letter) when ctypes/``windll`` is missing.
+ """
+ try:
+ import ctypes
+ return int(ctypes.windll.kernel32.GetLogicalDrives())
+ except Exception: # noqa: BLE001 -- best-effort; fall back to probing all letters
+ return 0
+
+
+# A disconnected mapped drive stays set in the GetLogicalDrives bitmask, so
+# ``os.path.isdir`` on it can block for tens of seconds. Bound each drive probe
+# so one stale mapping cannot stall a whole folder-browser request.
+_DRIVE_PROBE_TIMEOUT_S = 2.0
+
+
+def _readable_dirs_within(paths: Iterable[str], timeout: float) -> set[str]:
+ """Which of *paths* are readable directories, probed concurrently under one overall *timeout* (seconds).
+
+ Each path is checked (``os.path.isdir`` + ``os.access(R_OK)``) in its own
+ daemon thread and the call waits at most *timeout* total, not per path, so N
+ stalled network drives add ~timeout instead of N*timeout. A path not
+ answering ``True`` by the deadline is treated as unreadable. The daemon
+ threads are never joined past the deadline, so a stuck OS call cannot delay
+ interpreter exit or block the caller (``os.path.isdir`` releases the GIL).
+ """
+ paths = list(paths) # fixed input we can iterate twice; one probe per path
+ results: dict[str, bool] = {}
+
+ def _probe(path: str) -> None:
+ try:
+ results[path] = os.path.isdir(path) and os.access(path, os.R_OK)
+ except OSError:
+ results[path] = False
+
+ threads: list[threading.Thread] = []
+ for path in paths:
+ thread = threading.Thread(target = _probe, args = (path,), daemon = True)
+ thread.start()
+ threads.append(thread)
+
+ deadline = time.monotonic() + timeout
+ for thread in threads:
+ thread.join(max(0.0, deadline - time.monotonic()))
+
+ # Iterate the fixed input, not results.items(): a probe that timed out is
+ # still alive and may insert its key here, which would raise "dictionary
+ # changed size during iteration". results.get() is an atomic read.
+ return {path for path in paths if results.get(path)}
+
+
+def _readable_dir_within(path: str, timeout: float) -> bool:
+ """``os.path.isdir(path) and os.access(path, R_OK)``, bounded by *timeout* seconds; single-path wrapper over :func:`_readable_dirs_within`."""
+ return path in _readable_dirs_within((path,), timeout)
+
+
+def windows_drive_roots(drive_letters: Iterable[str] = string.ascii_uppercase) -> list[Path]:
+ """Readable logical drive roots (``C:\\``, ``D:\\`` ...) for the folder browser; the Windows analog of :func:`linux_run_media_mount_roots`.
+
+ Without it the allowlist and chips only reach the home drive, so a user
+ cannot navigate from ``C:`` to ``D:``/``E:``. ``GetLogicalDrives`` drops
+ unmapped letters; the rest are probed concurrently under a single timeout
+ and kept only if readable in time. A disconnected mapped drive stays active
+ in the bitmask and its ``os.path.isdir`` can hang for tens of seconds, so
+ parallel probing bounds the added delay at ~one timeout rather than one per
+ drive. Returns ``[]`` off Windows.
+ """
+ if platform.system() != "Windows":
+ return []
+
+ active_mask = _active_windows_drive_bitmask()
+ candidates: list[str] = []
+ seen: set[str] = set()
+ for letter in drive_letters:
+ letter = letter.strip().rstrip(":").upper()
+ if len(letter) != 1 or letter not in string.ascii_uppercase:
+ continue
+ if active_mask and not active_mask & (1 << (ord(letter) - ord("A"))):
+ continue
+ root_text = f"{letter}:\\"
+ key = os.path.normcase(root_text)
+ if key in seen:
+ continue
+ seen.add(key)
+ candidates.append(root_text)
+
+ # Bounded concurrent probe: an active bitmask bit can still be a
+ # disconnected mapping whose os.path.isdir blocks, so probe all at once.
+ readable = _readable_dirs_within(candidates, _DRIVE_PROBE_TIMEOUT_S)
+ return [Path(root_text) for root_text in candidates if root_text in readable]
diff --git a/studio/backend/utils/transformers_dtype.py b/studio/backend/utils/transformers_dtype.py
new file mode 100644
index 0000000000..daeb6e2452
--- /dev/null
+++ b/studio/backend/utils/transformers_dtype.py
@@ -0,0 +1,51 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Version-safe fp-dtype kwarg for transformers/sentence-transformers loads.
+
+transformers renamed the ``torch_dtype`` kwarg to ``dtype`` in 4.56.0, and emits
+``torch_dtype is deprecated! Use dtype instead!`` when the old name is passed. But our floor (``transformers>=4.51.3``) predates ``dtype`` and only
+accepts ``torch_dtype``, so a bare rename would ``TypeError`` on the floor. Pick
+the name the installed version accepts instead.
+
+Answers the same question as ``unsloth_zoo.hf_utils.HAS_TORCH_DTYPE`` but derives
+it independently, for two reasons. It uses a ``packaging.version`` check rather
+than that constant's ``"torch_dtype" in PretrainedConfig.__doc__`` sniffing, which
+raises ``TypeError`` under ``python -OO`` / ``PYTHONOPTIMIZE=2`` (docstrings are
+stripped to ``None``, and ``"torch_dtype" in None`` is a type error). And it avoids
+importing the constant at all: the RAG
+embedder warms here at startup in the lean main process, and reading it would run
+``unsloth_zoo``'s package ``__init__`` (torch import, GPU/Pytorch checks, the
+patching banner) as a side effect. The embedder is deliberately torch-optional (it
+degrades to the ``llama-server`` GGUF backend), so it must not drag in that
+heavyweight import just to read one bool.
+"""
+
+from functools import lru_cache
+
+
+@lru_cache(maxsize = 1)
+def _has_torch_dtype_kwarg() -> bool:
+ """True if the installed transformers still expects the legacy ``torch_dtype``
+ name (i.e. predates the ``dtype`` rename). False when ``dtype`` is the accepted
+ name, or when transformers is missing/broken (prefer the modern name)."""
+ try:
+ import transformers
+ from packaging.version import Version
+
+ # Compare on the release tuple so a pre-release of the rename version
+ # (``4.56.0.dev0``/``rc1``, which sort *below* ``4.56.0``) still counts as
+ # new and picks ``dtype`` -- those builds already accept it, and picking
+ # ``torch_dtype`` there would re-emit the very warning this suppresses.
+ return Version(transformers.__version__).release < (4, 56, 0)
+ except Exception:
+ return False
+
+
+def dtype_kwargs(value) -> dict:
+ """``{"torch_dtype": value}`` on old transformers, ``{"dtype": value}`` on new.
+
+ Splat into a load call (``pipeline(..., **dtype_kwargs(torch.float16))``) or use
+ directly as ``model_kwargs`` (``model_kwargs = dtype_kwargs("float16")``).
+ """
+ return {"torch_dtype" if _has_torch_dtype_kwarg() else "dtype": value}
diff --git a/studio/backend/utils/transformers_latest.py b/studio/backend/utils/transformers_latest.py
new file mode 100644
index 0000000000..40c8f729a5
--- /dev/null
+++ b/studio/backend/utils/transformers_latest.py
@@ -0,0 +1,607 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Latest-transformers support check for brand-new model architectures.
+
+When a model's ``model_type`` is absent from every installed transformers overlay
+(base 4.57.x plus the .venv_t5_530/550/510 sidecars and, if provisioned, .venv_t5_latest),
+Studio cannot load it today. This module answers, without authentication, code execution,
+or trust_remote_code:
+
+ 1. Does the LATEST transformers release on PyPI ship this ``model_type``?
+ 2. Does transformers ``main`` on GitHub ship it (dev-only, not yet installable)?
+
+Sources (all unauthenticated; raw.githubusercontent.com is not API rate-limited and
+api.github.com is deliberately never used):
+ - https://pypi.org/pypi/transformers/json -> latest release version
+ - https://raw.githubusercontent.com/huggingface/transformers/{ref}/src/transformers/
+ models/auto/configuration_auto.py + auto_mappings.py -> CONFIG_MAPPING_NAMES
+
+The fetched sources are parsed with the same AST extractor the static router uses
+(:func:`utils.transformers_version._model_types_from_source`), so the remote answer is
+computed exactly like the local overlay answer.
+
+Results are cached in memory and in a small JSON snapshot under ``studio_root()/cache``
+(ttl ~1 day) so repeated tier resolutions never re-fetch; failures are backed off in
+memory. Every fetch is bounded (<=5s, one retry), so a hung network cannot block model
+loading. Fully offline-safe: offline env vars or the kill switch
+``UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS=1`` make every check return None (current
+behavior preserved).
+
+The consented install path (:func:`install_latest_transformers`) provisions the
+persistent ``.venv_t5_latest`` sidecar via
+:func:`utils.transformers_version.ensure_latest_transformers_venv`.
+"""
+
+import json
+import os
+import threading
+import time
+from pathlib import Path
+
+from loggers import get_logger
+from utils.paths.storage_roots import studio_root as _studio_root
+from utils.transformers_version import (
+ _env_offline,
+ _load_config_json,
+ _model_types_from_source,
+ _tier_from_config_mapping,
+ _config_model_types,
+ _NESTED_CONFIG_KEYS,
+ _TIER_RANK,
+ _model_types_from_config,
+ _TRANSFORMERS_510_MODEL_TYPES,
+ _TRANSFORMERS_530_MODEL_TYPES,
+ _TRANSFORMERS_550_MODEL_TYPES,
+ ensure_latest_transformers_venv,
+ latest_venv_pinned_version,
+)
+
+logger = get_logger(__name__)
+
+_PYPI_JSON_URL = "https://pypi.org/pypi/transformers/json"
+_RAW_URL = (
+ "https://raw.githubusercontent.com/huggingface/transformers/{ref}"
+ "/src/transformers/models/auto/{name}"
+)
+_AUTO_FILES = ("configuration_auto.py", "auto_mappings.py")
+
+_FETCH_TIMEOUT_SECONDS = 5.0
+_FETCH_RETRIES = 1
+_CACHE_TTL_SECONDS = 24 * 60 * 60
+_FAILURE_BACKOFF_SECONDS = 300
+
+_CACHE_FILE_NAME = "transformers_latest_check.json"
+_SNAPSHOT_SCHEMA = 1
+
+# Snapshot: {"schema", "fetched_at", "pypi_version", "pypi_model_types", "main_model_types"}.
+# Install-in-progress state lives in utils.transformers_version (the sidecar swap reservation).
+_lock = threading.Lock()
+_memory_snapshot: dict | None = None
+_last_failure_at: float = 0.0
+_is_fetching: bool = False
+
+_TRUE_VALUES = {"1", "true", "yes", "on"}
+
+
+def _disabled() -> bool:
+ """True if the operator disabled the latest-transformers check entirely."""
+ return (
+ os.environ.get("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", "").strip().lower() in _TRUE_VALUES
+ )
+
+
+def _cache_file() -> Path:
+ return _studio_root() / "cache" / _CACHE_FILE_NAME
+
+
+# Sentinel for HTTP 404 (absent at ref), distinct from transient failures.
+_FETCH_MISSING = "__unsloth_fetch_missing__"
+
+
+def _fetch_text(url: str) -> str | None:
+ """GET *url* with a bounded timeout and one retry; None on any failure.
+
+ Returns ``_FETCH_MISSING`` (without retrying) on HTTP 404 so callers can tell
+ "absent at this ref" apart from "network flaked".
+ """
+ import urllib.error
+ import urllib.request
+
+ for attempt in range(1 + _FETCH_RETRIES):
+ try:
+ req = urllib.request.Request(url, headers = {"User-Agent": "unsloth-studio"})
+ with urllib.request.urlopen(req, timeout = _FETCH_TIMEOUT_SECONDS) as resp:
+ return resp.read().decode("utf-8", "replace")
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ return _FETCH_MISSING
+ logger.debug("Fetch failed (attempt %d) for %s: %s", attempt + 1, url, exc)
+ except Exception as exc:
+ logger.debug("Fetch failed (attempt %d) for %s: %s", attempt + 1, url, exc)
+ return None
+
+
+def _fetch_latest_pypi_version() -> str | None:
+ """Latest transformers release version from PyPI's unauthenticated JSON API."""
+ body = _fetch_text(_PYPI_JSON_URL)
+ if body is None or body == _FETCH_MISSING:
+ return None
+ try:
+ version = json.loads(body).get("info", {}).get("version")
+ except Exception as exc:
+ logger.debug("Could not parse PyPI JSON: %s", exc)
+ return None
+ return version if isinstance(version, str) and version else None
+
+
+def _fetch_remote_model_types(ref: str) -> frozenset[str] | None:
+ """CONFIG_MAPPING_NAMES keys at *ref* (a release tag like ``v5.12.0`` or ``main``).
+
+ Fetches configuration_auto.py plus auto_mappings.py (the 5.10+ split) from
+ raw.githubusercontent.com and parses them with the shared AST extractor. A file
+ that 404s (auto_mappings.py on pre-5.10 tags) is skipped, but a transient fetch
+ or parse failure of EITHER file fails the whole lookup: most model types live in
+ auto_mappings.py on current releases, so a partial map cached for the TTL would
+ make /validate skip the upgrade prompt for architectures the release does ship.
+ An empty result is likewise a failure so it is never cached as "supports nothing".
+ """
+ keys: set[str] = set()
+ fetched_any = False
+ for name in _AUTO_FILES:
+ source = _fetch_text(_RAW_URL.format(ref = ref, name = name))
+ if source is None:
+ return None
+ if source == _FETCH_MISSING:
+ continue
+ fetched_any = True
+ try:
+ keys |= _model_types_from_source(source)
+ except Exception as exc:
+ logger.debug("Could not parse %s at %s: %s", name, ref, exc)
+ return None
+ if not fetched_any or not keys:
+ return None
+ return frozenset(keys)
+
+
+def _load_snapshot_file() -> dict | None:
+ """Persisted snapshot from disk, or None (missing/corrupt/old schema)."""
+ try:
+ with open(_cache_file(), encoding = "utf-8") as f:
+ data = json.load(f)
+ except Exception:
+ return None
+ if not isinstance(data, dict) or data.get("schema") != _SNAPSHOT_SCHEMA:
+ return None
+ if not isinstance(data.get("fetched_at"), (int, float)):
+ return None
+ if not isinstance(data.get("pypi_version"), str):
+ return None
+ for key in ("pypi_model_types", "main_model_types"):
+ value = data.get(key)
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
+ return None
+ return data
+
+
+def _save_snapshot_file(snapshot: dict) -> None:
+ """Atomic best-effort write (tmp + os.replace, Windows-safe); failures only log."""
+ path = _cache_file()
+ tmp = path.with_name(path.name + ".tmp")
+ try:
+ path.parent.mkdir(parents = True, exist_ok = True)
+ tmp.write_text(json.dumps(snapshot), encoding = "utf-8")
+ os.replace(tmp, path)
+ except Exception as exc:
+ logger.debug("Could not persist %s: %s", path, exc)
+ try:
+ tmp.unlink(missing_ok = True)
+ except Exception:
+ pass
+
+
+def _snapshot_is_fresh(snapshot: dict | None) -> bool:
+ return (
+ snapshot is not None
+ and (time.time() - float(snapshot.get("fetched_at", 0))) < _CACHE_TTL_SECONDS
+ )
+
+
+def _refresh_snapshot() -> dict | None:
+ """Fetch a fresh snapshot from PyPI + raw.githubusercontent.com; None on failure.
+
+ The PyPI version and its tagged mapping are required; the ``main`` mapping is
+ best-effort (recorded as an empty list plus ``main_checked=False`` when unavailable,
+ so a dev-only architecture is reported as "unknown" rather than "unsupported").
+ """
+ version = _fetch_latest_pypi_version()
+ if version is None:
+ return None
+ pypi_types = _fetch_remote_model_types(f"v{version}")
+ if pypi_types is None:
+ return None
+ main_types = _fetch_remote_model_types("main")
+ return {
+ "schema": _SNAPSHOT_SCHEMA,
+ "fetched_at": time.time(),
+ "pypi_version": version,
+ "pypi_model_types": sorted(pypi_types),
+ "main_model_types": sorted(main_types) if main_types is not None else [],
+ "main_checked": main_types is not None,
+ }
+
+
+def _get_snapshot() -> dict | None:
+ """Current support snapshot: memory -> disk -> network, with TTL and failure backoff.
+
+ The network refresh runs outside the lock so a slow fetch cannot stall other
+ threads in the ASGI pool; _is_fetching deduplicates concurrent refreshes
+ (losers return None, the graceful fallthrough, rather than waiting).
+ """
+ global _memory_snapshot, _last_failure_at, _is_fetching
+ with _lock:
+ if _snapshot_is_fresh(_memory_snapshot):
+ return _memory_snapshot
+ disk = _load_snapshot_file()
+ if _snapshot_is_fresh(disk):
+ _memory_snapshot = disk
+ return disk
+ if _disabled() or _env_offline():
+ return None
+ if time.time() - _last_failure_at < _FAILURE_BACKOFF_SECONDS:
+ return None
+ if _is_fetching:
+ return None
+ _is_fetching = True
+ fresh = None
+ try:
+ fresh = _refresh_snapshot()
+ finally:
+ with _lock:
+ _is_fetching = False
+ if fresh is None:
+ _last_failure_at = time.time()
+ else:
+ _memory_snapshot = fresh
+ if fresh is None:
+ # A stale positive could offer a version PyPI no longer serves; be strict.
+ return None
+ _save_snapshot_file(fresh)
+ return fresh
+
+
+def clear_caches() -> None:
+ """Test helper: drop the in-memory snapshot, failure backoff, and busy flags."""
+ global _memory_snapshot, _last_failure_at, _is_fetching
+ with _lock:
+ _memory_snapshot = None
+ _last_failure_at = 0.0
+ _is_fetching = False
+ from utils.transformers_version import end_sidecar_swap
+
+ end_sidecar_swap()
+
+
+def latest_transformers_supports(model_type: str) -> dict | None:
+ """Whether the newest transformers (PyPI release and/or GitHub main) ships *model_type*.
+
+ Returns ``{"pypi_version": str, "supported_in_pypi": bool, "supported_in_main": bool}``
+ or None when the answer is unavailable (offline, kill switch, network failure) — the
+ caller must then fall through to current behavior. Cached (memory + JSON snapshot on
+ disk, ttl ~1 day) so repeated tier resolutions never re-fetch.
+ """
+ if not isinstance(model_type, str) or not model_type:
+ return None
+ if _disabled() or _env_offline():
+ return None
+ snapshot = _get_snapshot()
+ if snapshot is None:
+ return None
+ return {
+ "pypi_version": snapshot["pypi_version"],
+ "supported_in_pypi": model_type in set(snapshot["pypi_model_types"]),
+ "supported_in_main": model_type in set(snapshot["main_model_types"]),
+ }
+
+
+# model_types the hardcoded tier tables already route; never remote-check these.
+def _hardcoded_model_types() -> frozenset[str]:
+ return frozenset(
+ _TRANSFORMERS_530_MODEL_TYPES
+ | _TRANSFORMERS_550_MODEL_TYPES
+ | _TRANSFORMERS_510_MODEL_TYPES
+ )
+
+
+def check_upgrade_for_model(model_name: str, hf_token: str | None = None) -> dict | None:
+ """Upgrade signal for *model_name*, or None when current routing already handles it.
+
+ The tier hook for the pre-load ``/validate`` path: fires ONLY when the model's
+ ``model_type`` is absent from every installed overlay (and from the hardcoded tier
+ tables), i.e. exactly when today's load would fail with an unrecognized-architecture
+ error. Returns ``{"model_type", "pypi_version", "supported_in_pypi",
+ "supported_in_main"}`` when the newest transformers knows the type, else None.
+
+ Never raises; every network touch is bounded and cached. Offline or with the
+ ``UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS`` kill switch it returns None immediately.
+ """
+ try:
+ if _disabled() or _env_offline():
+ return None
+ cfg = _load_config_json(model_name, hf_token)
+ if not isinstance(cfg, dict):
+ return None
+ candidates = _model_types_from_config(cfg)
+ if not candidates:
+ return None
+ # Without a readable base mapping every type looks brand new; bail out.
+ if not _config_model_types("default"):
+ return None
+ hardcoded = _hardcoded_model_types()
+ missing = [
+ candidate
+ for candidate in candidates
+ if candidate not in hardcoded
+ and not any(candidate in _config_model_types(tier) for tier in _TIER_RANK)
+ ]
+ if not missing:
+ return None
+ # Latest must load EVERY missing type (wrappers build nested sub-configs
+ # through CONFIG_MAPPING) or the load still fails.
+ supports = [latest_transformers_supports(candidate) for candidate in missing]
+ if any(
+ s is None or not (s["supported_in_pypi"] or s["supported_in_main"]) for s in supports
+ ):
+ return None
+ # Offer the PyPI install only if the release ships every missing type; a
+ # main-only type in the mix surfaces as dev-only.
+ model_type = missing[0]
+ supported_in_pypi = all(s["supported_in_pypi"] for s in supports)
+ supported_in_main = all(s["supported_in_pypi"] or s["supported_in_main"] for s in supports)
+ logger.info(
+ "Model %s has model_type=%s unknown to every installed transformers "
+ "(latest PyPI %s: %s, main: %s)",
+ model_name,
+ model_type,
+ supports[0]["pypi_version"],
+ "supported" if supported_in_pypi else "unsupported",
+ "supported" if supported_in_main else "unsupported",
+ )
+ return {
+ "model_type": model_type,
+ "pypi_version": supports[0]["pypi_version"],
+ "supported_in_pypi": supported_in_pypi,
+ "supported_in_main": supported_in_main,
+ }
+ except Exception as exc:
+ logger.debug("Latest-transformers check failed for '%s': %s", model_name, exc)
+ return None
+
+
+# --- Dependency compatibility preflight ------------------------------------------------------
+# Sidecars install transformers --no-deps atop the base env. Before installing, compare
+# requires_dist: unsatisfied shadowable deps become exact --target pins, anything else blocks.
+
+# Safe to shadow inside the sidecar dir (pure wheels, no torch coupling).
+_SHADOWABLE_DEPS = frozenset({"tokenizers", "safetensors"})
+# Provided by the sidecar recipe; checked against its pin, not the base env.
+_SIDECAR_PROVIDED = {"huggingface-hub": "1.8.0", "hf-xet": "1.4.2"}
+# CLI-only; never imported at runtime in Studio's workers.
+_IGNORED_DEPS = frozenset({"typer"})
+
+
+def _canonical_dep_name(name: str) -> str:
+ return name.lower().replace("_", "-")
+
+
+def _fetch_requires_dist(version: str) -> list[str] | None:
+ """Core (marker-free, non-extra) requires_dist of transformers *version* from PyPI."""
+ body = _fetch_text(f"https://pypi.org/pypi/transformers/{version}/json")
+ if body is None or body == _FETCH_MISSING:
+ return None
+ try:
+ reqs = json.loads(body).get("info", {}).get("requires_dist")
+ except Exception:
+ return None
+ if not isinstance(reqs, list):
+ return None
+ return [r for r in reqs if isinstance(r, str)]
+
+
+def _resolve_exact_version(name: str, specifier) -> str | None:
+ """Newest PyPI release of *name* satisfying *specifier* (exact pin for the shadow)."""
+ body = _fetch_text(f"https://pypi.org/pypi/{name}/json")
+ if body is None or body == _FETCH_MISSING:
+ return None
+ try:
+ from packaging.version import InvalidVersion, Version
+
+ releases = json.loads(body).get("releases", {})
+ best = None
+ for candidate in releases:
+ try:
+ parsed = Version(candidate)
+ except InvalidVersion:
+ continue
+ if parsed.is_prerelease or not specifier.contains(candidate):
+ continue
+ if best is None or parsed > Version(best):
+ best = candidate
+ return best
+ except Exception as exc:
+ logger.debug("Could not resolve an exact %s version: %s", name, exc)
+ return None
+
+
+def compat_plan(version: str) -> tuple[tuple[str, ...], list[str]]:
+ """(extra exact pins to shadow-install, blocking requirement strings) for *version*.
+
+ Compares the release's core requires_dist against the running base env (the env the
+ workers overlay the sidecar onto). A requirement the base env satisfies needs nothing;
+ an unsatisfied shadowable dep becomes an exact pin inside the sidecar; any other
+ unsatisfied requirement is a blocker. An unavailable requires_dist BLOCKS the
+ install: proceeding unverified could pin a sidecar whose imports then crash the
+ workers, and the caller just reached PyPI for the version check so a retry is cheap.
+ """
+ reqs = _fetch_requires_dist(version)
+ if reqs is None:
+ return (), ["dependency metadata for this release (could not be fetched from PyPI; retry)"]
+ try:
+ from importlib.metadata import PackageNotFoundError
+ from importlib.metadata import version as _installed_version
+ from packaging.requirements import InvalidRequirement, Requirement
+ except Exception:
+ return (), []
+ extras: list[str] = []
+ blockers: list[str] = []
+ for raw in reqs:
+ try:
+ req = Requirement(raw)
+ except InvalidRequirement:
+ continue
+ if req.extras or (req.marker is not None and not req.marker.evaluate()):
+ continue
+ name = _canonical_dep_name(req.name)
+ if name in _IGNORED_DEPS:
+ continue
+ if name in _SIDECAR_PROVIDED:
+ if not req.specifier.contains(_SIDECAR_PROVIDED[name], prereleases = True):
+ blockers.append(raw)
+ continue
+ try:
+ installed = _installed_version(req.name)
+ except PackageNotFoundError:
+ installed = None
+ if installed is not None and req.specifier.contains(installed, prereleases = True):
+ continue
+ if name in _SHADOWABLE_DEPS:
+ exact = _resolve_exact_version(name, req.specifier)
+ if exact is None:
+ blockers.append(raw)
+ else:
+ extras.append(f"{name}=={exact}")
+ else:
+ blockers.append(raw)
+ return tuple(extras), blockers
+
+
+def is_install_in_progress() -> bool:
+ """True while a latest-transformers install or lazy repair holds the sidecar swap
+ reservation. Training and export starts check this so a fresh worker never
+ activates the sidecar mid-swap."""
+ from utils.transformers_version import sidecar_swap_in_progress
+ return sidecar_swap_in_progress()
+
+
+def install_latest_transformers(
+ version: str,
+ before_swap = None,
+ reserved: bool = False,
+) -> dict:
+ """Consented install of the latest transformers sidecar; returns a structured result.
+
+ Guards: the requested *version* must match the current PyPI latest from the (cached)
+ snapshot, so a client cannot pin an arbitrary package version through this endpoint.
+ On success ``.venv_t5_latest`` is provisioned and pinned; routing then resolves the
+ new tier automatically on this and every future start. *before_swap* is forwarded
+ to the stage-and-swap: it runs only after the staged install succeeded, right
+ before the live sidecar is replaced. *reserved* means the caller already holds the
+ sidecar swap reservation (the install route takes it before waiting on the
+ inference lifecycle gate, so worker starts see it for the whole window).
+ """
+ from utils.transformers_version import end_sidecar_swap, try_begin_sidecar_swap
+
+ if not reserved and not try_begin_sidecar_swap():
+ return {
+ "success": False,
+ "version": version,
+ "message": "A transformers installation is already in progress.",
+ }
+ try:
+ return _install_latest_transformers_locked(version, before_swap = before_swap)
+ finally:
+ if not reserved:
+ end_sidecar_swap()
+
+
+def _install_latest_transformers_locked(version: str, before_swap = None) -> dict:
+ """Body of install_latest_transformers; runs with the in-progress flag held."""
+ if _disabled():
+ return {
+ "success": False,
+ "version": version,
+ "message": "Latest-transformers installs are disabled "
+ "(UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS).",
+ }
+ if _env_offline():
+ return {
+ "success": False,
+ "version": version,
+ "message": "Cannot install: Studio is in offline mode.",
+ }
+ # Re-verify against a LIVE snapshot (a release may land inside the cache TTL);
+ # fall back to the cached one on fetch failure.
+ global _memory_snapshot
+ snapshot = _refresh_snapshot()
+ if snapshot is not None:
+ with _lock:
+ _memory_snapshot = snapshot
+ _save_snapshot_file(snapshot)
+ else:
+ snapshot = _get_snapshot()
+ if snapshot is None:
+ return {
+ "success": False,
+ "version": version,
+ "message": "Could not verify the latest transformers release on PyPI.",
+ }
+ if version != snapshot["pypi_version"]:
+ return {
+ "success": False,
+ "version": version,
+ "message": f"Requested version {version!r} is not the latest transformers "
+ f"release ({snapshot['pypi_version']}).",
+ # Lets the consent dialog retry with the release that superseded the
+ # one /validate saw, instead of re-sending the stale version forever.
+ "latest_version": snapshot["pypi_version"],
+ }
+ extra_packages, blockers = compat_plan(version)
+ if blockers:
+ return {
+ "success": False,
+ "version": version,
+ "message": "Cannot install transformers "
+ f"{version}: this environment does not satisfy {', '.join(blockers)}. "
+ "A Studio update is required first.",
+ }
+ if not ensure_latest_transformers_venv(version, extra_packages, before_swap = before_swap):
+ return {
+ "success": False,
+ "version": version,
+ "message": f"Installing transformers {version} failed; see the Studio logs.",
+ }
+ _invalidate_capability_caches()
+ return {
+ "success": True,
+ "version": version,
+ "message": f"Installed transformers {version} into the latest sidecar "
+ f"(pinned: {latest_venv_pinned_version()}).",
+ }
+
+
+def _invalidate_capability_caches():
+ """Drop caches computed before the new sidecar existed: tier probes and the
+ latest tier's model_type mapping (stale on upgrade) plus vision detection
+ (a raw-heuristic False may now defer to the sidecar AutoConfig probe)."""
+ try:
+ from utils import transformers_version as tv
+ tv._probe_tier_cache.clear()
+ tv._config_mapping_cache.pop("latest", None)
+ except Exception:
+ pass
+ try:
+ from utils.models import model_config as mc
+ mc._vision_detection_cache.clear()
+ except Exception:
+ pass
diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py
index 2a63caa5b2..9f9f8aa3de 100644
--- a/studio/backend/utils/transformers_version.py
+++ b/studio/backend/utils/transformers_version.py
@@ -28,14 +28,19 @@ Strategy:
sys.path swap using the same directories pre-installed by setup.sh.
"""
+import ast
import importlib
+import importlib.util
import json
import structlog
from loggers import get_logger
import os
+import re
import shutil
import subprocess
import sys
+import threading
+import time
from pathlib import Path
from utils.native_path_leases import child_env_without_native_path_secret
@@ -173,6 +178,7 @@ _TRANSFORMERS_530_ARCHITECTURES: set[str] = {
"Qwen3MoeForCausalLM",
"Qwen3NextForCausalLM",
"Glm4MoeLiteForCausalLM",
+ "Lfm2MoeForCausalLM",
"Lfm2VlForConditionalGeneration",
}
_TRANSFORMERS_530_MODEL_TYPES: set[str] = {
@@ -183,6 +189,7 @@ _TRANSFORMERS_530_MODEL_TYPES: set[str] = {
"qwen3_moe",
"qwen3_next",
"glm4_moe_lite",
+ "lfm2_moe",
"lfm2_vl",
}
@@ -231,8 +238,12 @@ _VENV_T5_DIR = _VENV_T5_550_DIR
# reuses the workspace torch (torch-agnostic).
_VENV_LLMCOMPRESSOR_DIR = str(_studio_root() / ".venv_llmcompressor")
-# Tier precedence: higher rank wins in _higher_tier.
-_TIER_RANK = {"default": 0, "530": 1, "550": 2, "510": 3}
+# User-consented "latest transformers" sidecar (utils/transformers_latest.py); pinned version in a marker file.
+_VENV_T5_LATEST_DIR = str(_studio_root() / ".venv_t5_latest")
+_LATEST_PIN_MARKER = ".unsloth_pinned_transformers"
+
+# Tier precedence: higher rank wins in _higher_tier. "latest" outranks every fixed tier.
+_TIER_RANK = {"default": 0, "530": 1, "550": 2, "510": 3, "latest": 4}
def _higher_tier(a: str, b: str) -> str:
@@ -250,20 +261,40 @@ def activate_transformers_for_subprocess(model_name: str, hf_token: str | None =
``hf_token`` is forwarded to tier detection so a gated/private model whose only 5.x
signal is an authenticated config/tokenizer reaches the right sidecar, not the default.
"""
- # Pre-resolve only LoRA adapters; full checkpoints go to get_transformers_tier so their
- # local config.json drives the tier (a full checkpoint with a private/offline
- # _name_or_path must not resolve to an unreachable HF id and skip its own config).
+ # Pre-resolve LoRA adapters (local dir or remote adapter repo); full checkpoints
+ # go to get_transformers_tier so their local config.json drives the tier (a full
+ # checkpoint with a private/offline _name_or_path must not resolve to an
+ # unreachable HF id and skip its own config). Remote adapters activate for their
+ # BASE model, matching latest_tier_active_for and the inference worker.
if _is_lora_adapter_dir(Path(model_name)):
resolved = _resolve_base_model(model_name)
else:
- resolved = model_name
+ resolved = _remote_lora_base(model_name, hf_token = hf_token) or model_name
tier = get_transformers_tier(resolved, hf_token)
if model_name != resolved and _safe_is_file(Path(model_name) / "config.json"):
# Gate on a real local config.json: a checkpoint carries config the base may not
# surface, but path names alone must not upgrade a plain adapter.
tier = _higher_tier(tier, get_transformers_tier(model_name, hf_token))
- if tier == "510":
+ if tier == "latest":
+ pinned = latest_venv_pinned_version()
+ if pinned is None or not _ensure_venv_t5_latest_exists():
+ raise RuntimeError(
+ f"Cannot activate the latest-transformers sidecar: "
+ f".venv_t5_latest missing or unpinned at {_VENV_T5_LATEST_DIR}"
+ )
+ if _VENV_T5_LATEST_DIR not in sys.path:
+ sys.path.insert(0, _VENV_T5_LATEST_DIR)
+ logger.info(
+ "Prepended transformers %s venv to sys.path from %s "
+ "(path only; the loaded version is confirmed later by "
+ "'Subprocess loaded transformers ...' on first import)",
+ pinned,
+ _VENV_T5_LATEST_DIR,
+ )
+ _pp = os.environ.get("PYTHONPATH", "")
+ os.environ["PYTHONPATH"] = _VENV_T5_LATEST_DIR + (os.pathsep + _pp if _pp else "")
+ elif tier == "510":
if not _ensure_venv_t5_510_exists():
raise RuntimeError(
f"Cannot activate transformers {TRANSFORMERS_510_VERSION}: "
@@ -318,6 +349,34 @@ def activate_transformers_for_subprocess(model_name: str, hf_token: str | None =
logger.info("Using default transformers (4.57.x) for %s", model_name)
+def latest_tier_active_for(model_name: str, hf_token: str | None = None) -> bool:
+ """True when *model_name* routes to the consented latest-transformers sidecar.
+
+ Mirrors the inference worker's pre-activation resolution (local adapter dir,
+ then a remote adapter's Hub adapter_config.json). ``latest`` only wins when
+ the sidecar exists with a valid pin, i.e. exactly the loads that will import
+ the newest release. Never raises: any resolution failure returns False so
+ callers treat the model as a known tier.
+ """
+ try:
+ # No consented sidecar pin means nothing routes to latest; return before
+ # any resolution so the common case costs no config or network reads.
+ if latest_venv_pinned_version() is None:
+ return False
+ if _is_lora_adapter_dir(Path(model_name)):
+ resolved = _resolve_base_model(model_name)
+ else:
+ # A remote LoRA activates the sidecar for its BASE model; sizing and the
+ # worker's 4-bit guard must see that base too, not the adapter repo.
+ resolved = _remote_lora_base(model_name, hf_token = hf_token) or model_name
+ tier = get_transformers_tier(resolved, hf_token)
+ if model_name != resolved and _safe_is_file(Path(model_name) / "config.json"):
+ tier = _higher_tier(tier, get_transformers_tier(model_name, hf_token))
+ return tier == "latest"
+ except Exception:
+ return False
+
+
def _has_adapter_weights(path: Path) -> bool:
"""True if *path* holds LoRA adapter weight files (``adapter_model.*``)."""
try:
@@ -870,6 +929,255 @@ def _cached_config_json(model_name: str, hf_token: str | None) -> dict | None:
return _config_json_cache.get(_token_cache_key(model_name, hf_token))
+# --- Static tier from CONFIG_MAPPING_NAMES (AST only: no import/network/exec) ---
+# A model_type absent from an overlay's mapping can't load there. Parse each sidecar's
+# config map from source and pick the lowest tier that ships it, so a new arch routes
+# correctly with no per-model table edit. Only ever upgrades default, never lowers.
+_config_mapping_cache: dict[str, frozenset[str]] = {}
+
+
+def _latest_tier_disabled() -> bool:
+ """Kill switch shared with utils.transformers_latest: lets operators roll
+ back a provisioned latest sidecar without deleting files."""
+ return os.environ.get("UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS", "").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ )
+
+
+# Failed lazy repairs back off so a broken sidecar can't turn every routing
+# call into a pip install attempt.
+_latest_repair_failed_at: float = 0.0
+_LATEST_REPAIR_BACKOFF_SECS = 5 * 60
+
+
+def _latest_sidecar_intact() -> bool:
+ """The pinned latest sidecar exists with its transformers dir and every pinned
+ package. False when the pin itself is gone: a cached 'latest' mapping must then be
+ dropped (routing re-resolves to no latest tier), not trusted, and a sidecar that kept
+ transformers/ but lost a pinned package must self-heal rather than route models to a
+ latest tier that fails activation in workers, which refuse parent-only repairs.
+
+ (_overlay_transformers_dir only calls this after gating on a present pin, so the
+ pin-missing case here is the cache-revalidation caller whose pin was deleted after
+ the mapping was first cached.)"""
+ pin = _latest_pin_data()
+ if pin is None:
+ return False
+ return _venv_dir_is_valid(_VENV_T5_LATEST_DIR, tuple(pin["packages"]))
+
+
+def _overlay_transformers_dir(tier: str) -> str | None:
+ """transformers source dir for a tier, located without importing it."""
+ global _latest_repair_failed_at
+ if tier != "default":
+ # latest requires a valid pin and the kill switch off.
+ if tier == "latest" and (_latest_tier_disabled() or latest_venv_pinned_version() is None):
+ return None
+ root = {
+ "530": _VENV_T5_530_DIR,
+ "550": _VENV_T5_550_DIR,
+ "510": _VENV_T5_510_DIR,
+ "latest": _VENV_T5_LATEST_DIR,
+ }.get(tier)
+ src = os.path.join(root, "transformers") if root else None
+ if src and tier == "latest" and not _latest_sidecar_intact():
+ # A valid pin whose sidecar vanished or lost a pinned package (partial
+ # deletion, disk issue, interrupted external edits) must self-heal, or
+ # latest-only models either silently route to older tiers or reach a
+ # worker that cannot repair, failing every load until a manual
+ # reinstall. Repair under the swap reservation; back off after a
+ # failure so routing calls don't hammer pip.
+ repaired = False
+ if time.time() - _latest_repair_failed_at >= _LATEST_REPAIR_BACKOFF_SECS:
+ if _ensure_venv_t5_latest_exists():
+ _latest_repair_failed_at = 0.0
+ repaired = True
+ else:
+ _latest_repair_failed_at = time.time()
+ if not repaired:
+ # Still broken: treat the overlay as unavailable rather than route
+ # models to a tier whose worker activation is known to fail. Models
+ # an older tier supports keep loading there until a repair succeeds,
+ # matching the behavior when the sidecar dir is missing entirely.
+ return None
+ return src if src and _safe_is_dir(Path(src)) else None
+ # default: the base 4.x transformers. find_spec resolves to a 5.x sidecar if one
+ # is already on sys.path, so skip any .venv_t5_* / llmcompressor overlay dir.
+ sidecars = tuple(
+ os.path.abspath(d) + os.sep
+ for d in (
+ _VENV_T5_530_DIR,
+ _VENV_T5_550_DIR,
+ _VENV_T5_510_DIR,
+ _VENV_T5_LATEST_DIR,
+ _VENV_LLMCOMPRESSOR_DIR,
+ )
+ )
+ candidates = []
+ try:
+ spec = importlib.util.find_spec("transformers")
+ if spec and spec.origin:
+ candidates.append(os.path.dirname(spec.origin))
+ except Exception:
+ pass
+ candidates += [os.path.join(e, "transformers") for e in sys.path if e]
+ for c in candidates:
+ if _safe_is_dir(Path(c)) and not os.path.abspath(c).startswith(sidecars):
+ return c
+ return None
+
+
+def _mapping_first_keys(value: ast.AST) -> set[str]:
+ """First keys of a dict literal, or of an OrderedDict(...)/dict(...)/.update(...)
+ built from 2-tuple lists and **{...} unpacking."""
+
+ def keys_of(node):
+ if isinstance(node, ast.Dict):
+ return list(node.keys)
+ if isinstance(node, (ast.List, ast.Tuple)):
+ return [
+ el.elts[0] for el in node.elts if isinstance(el, (ast.Tuple, ast.List)) and el.elts
+ ]
+ return []
+
+ nodes = keys_of(value)
+ if isinstance(value, ast.Call):
+ for a in value.args:
+ nodes += keys_of(a)
+ for kw in value.keywords: # **{...} unpacking has kw.arg is None
+ if kw.arg is None:
+ nodes += keys_of(kw.value)
+ return {n.value for n in nodes if isinstance(n, ast.Constant) and isinstance(n.value, str)}
+
+
+def _model_types_from_source(source: str) -> set[str]:
+ """model_type keys of CONFIG_MAPPING_NAMES in *source* (AST only, no execution).
+
+ Handles the direct ``CONFIG_MAPPING_NAMES = ...`` binding (dict literal or
+ OrderedDict/dict call over 2-tuple lists and **{...} unpacking) and any
+ ``CONFIG_MAPPING_NAMES.update({...})`` mutation. Shared by the on-disk overlay
+ reader below and the remote latest-release checker (utils/transformers_latest.py).
+ """
+ keys: set[str] = set()
+ tree = ast.parse(source)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Assign) and any(
+ isinstance(t, ast.Name) and t.id == "CONFIG_MAPPING_NAMES" for t in node.targets
+ ):
+ keys |= _mapping_first_keys(node.value)
+ elif isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
+ fn = node.value.func
+ if (
+ isinstance(fn, ast.Attribute)
+ and fn.attr == "update"
+ and isinstance(fn.value, ast.Name)
+ and fn.value.id == "CONFIG_MAPPING_NAMES"
+ ):
+ keys |= _mapping_first_keys(node.value)
+ return keys
+
+
+def _config_model_types(tier: str) -> frozenset[str]:
+ """model_type keys in a tier's CONFIG_MAPPING_NAMES (5.10 moved it to auto_mappings.py)."""
+ # Kill switch beats the cache: a stale mapping must not keep routing latest-only models until restart.
+ if tier == "latest" and _latest_tier_disabled():
+ return frozenset()
+ cached = _config_mapping_cache.get(tier)
+ if cached is not None:
+ # A cached 'latest' mapping can outlive the sidecar it was parsed from: if the
+ # pinned sidecar was since deleted or lost a package in this process, drop the
+ # cache so routing re-resolves through _overlay_transformers_dir (which self-heals)
+ # instead of routing latest-only models to a broken tier until restart.
+ if tier != "latest" or _latest_sidecar_intact():
+ return cached
+ _config_mapping_cache.pop("latest", None)
+ tdir = _overlay_transformers_dir(tier)
+ if tdir is None:
+ return frozenset() # overlay not provisioned yet; do not cache so a later call re-reads
+ keys: set[str] = set()
+ for rel in ("models/auto/configuration_auto.py", "models/auto/auto_mappings.py"):
+ path = Path(tdir) / rel
+ if not _safe_is_file(path):
+ continue
+ try:
+ keys |= _model_types_from_source(path.read_text(encoding = "utf-8"))
+ except Exception:
+ continue
+ result = frozenset(keys)
+ _config_mapping_cache[tier] = result
+ return result
+
+
+def _model_types_from_config(cfg: dict) -> list[str]:
+ """All model_types in the config: the primary (top-level, else first nested)
+ first, then every other nested sub-config. Wrappers instantiate sub-configs
+ through CONFIG_MAPPING, so nested types matter for routing too."""
+ seen: list[str] = []
+
+ def add(value):
+ if isinstance(value, str) and value and value not in seen:
+ seen.append(value)
+
+ add(cfg.get("model_type"))
+ for key in _NESTED_CONFIG_KEYS:
+ sub = cfg.get(key)
+ if isinstance(sub, dict):
+ add(sub.get("model_type"))
+ for value in cfg.values():
+ if isinstance(value, dict):
+ add(value.get("model_type"))
+ return seen
+
+
+def _lowest_tier_for(model_type: str) -> str | None:
+ for tier in sorted(_TIER_RANK, key = _TIER_RANK.get):
+ if model_type in _config_model_types(tier):
+ return tier
+ return None
+
+
+def _tier_from_config_mapping(cfg: dict) -> str | None:
+ """Lowest tier able to load every model_type in cfg, or None when the
+ primary type is unknown everywhere. A nested type can raise the tier (its
+ sub-config is built through CONFIG_MAPPING); an unknown nested type never
+ vetoes, since no installed tier could load it either way (the latest
+ checker handles surfacing the install prompt for it)."""
+ types = _model_types_from_config(cfg)
+ if not types:
+ return None
+ best = _lowest_tier_for(types[0])
+ if best is None:
+ return None
+ for model_type in types[1:]:
+ tier = _lowest_tier_for(model_type)
+ if tier is not None and _TIER_RANK[tier] > _TIER_RANK[best]:
+ best = tier
+ return best
+
+
+def _raise_tier_for_nested(cfg: dict | None, tier: str) -> str:
+ """Raise *tier* when the mapping resolver needs a higher one for *cfg*.
+
+ A wrapper's top-level model_type can match a hardcoded fast path while a
+ nested text/vision config's type only exists in a newer sidecar (e.g. the
+ installed latest); its sub-config is built through CONFIG_MAPPING, so the
+ fast-path tier would fail to load it. Raise-only: never lowers a fast-path
+ match, so name overrides (Qwen3.6) keep their tier. Never raises an
+ exception: a resolution failure keeps the fast-path tier."""
+ if not isinstance(cfg, dict):
+ return tier
+ try:
+ mapped = _tier_from_config_mapping(cfg)
+ if mapped is not None and _TIER_RANK.get(mapped, 0) > _TIER_RANK.get(tier, 0):
+ return mapped
+ except Exception:
+ pass
+ return tier
+
+
# --- AutoConfig probe: general tier resolution for ambiguous models ----------
# When the cheap signals only say "needs some 5.x", parse config.json with the built-in
# parser in each candidate sidecar (lowest first) instead of guessing. Generalizes beyond
@@ -925,9 +1233,19 @@ def _probe_tier_venvs():
"530": (_VENV_T5_530_DIR, _ensure_venv_t5_530_exists),
"550": (_VENV_T5_550_DIR, _ensure_venv_t5_550_exists),
"510": (_VENV_T5_510_DIR, _ensure_venv_t5_510_exists),
+ "latest": (_VENV_T5_LATEST_DIR, _ensure_venv_t5_latest_exists),
}
+def _probe_tier_order() -> tuple[str, ...]:
+ """Sidecar probe order. The consented "latest" sidecar joins only once it is
+ provisioned (pin marker present): an absent optional tier must not flip the probe's
+ skipped-tier bookkeeping, keeping pre-latest behavior byte-identical."""
+ if not _latest_tier_disabled() and latest_venv_pinned_version() is not None:
+ return _PROBE_TIER_ORDER + ("latest",)
+ return _PROBE_TIER_ORDER
+
+
def _probe_autoconfig(target_dir: str, model_name: str, hf_token: str | None) -> bool | None:
"""Parse config.json with the built-in parser inside *target_dir*'s sidecar.
True = parses, False = parse/version failure (escalate), None = transient
@@ -1005,7 +1323,7 @@ def _probe_tier(
stays on the default. Cached per _probe_cache_key (process lifetime). No Hub sha is
resolved: that would import huggingface_hub before the sidecar is on sys.path.
"""
- if os.environ.get("UNSLOTH_DISABLE_TIER_PROBE", "").lower() in ("1", "true", "yes"):
+ if os.environ.get("UNSLOTH_DISABLE_TIER_PROBE", "").lower() in ("1", "true", "yes", "on"):
return floor
key = _probe_cache_key(model_name)
# Key by probe mode: the default-first path can return 'default', which must not be
@@ -1013,7 +1331,10 @@ def _probe_tier(
if include_default or floor != "530":
key = f"{key}\0floor={floor}:def={int(include_default)}"
if key in _probe_tier_cache:
- return _probe_tier_cache[key]
+ cached = _probe_tier_cache[key]
+ # Kill switch beats the cache (like _config_model_types): a stale 'latest' probe must not keep activating it.
+ if cached != "latest" or not _latest_tier_disabled():
+ return cached
def _cache(tier: str, *, skipped: bool) -> str:
# Do not pin a result that depended on a skipped lower tier: once that sidecar is
@@ -1023,7 +1344,8 @@ def _probe_tier(
return tier
venvs = _probe_tier_venvs()
- order = (("default",) + _PROBE_TIER_ORDER) if include_default else _PROBE_TIER_ORDER
+ sidecar_order = _probe_tier_order()
+ order = (("default",) + sidecar_order) if include_default else sidecar_order
probed_count = 0
skipped_any = False
for tier in order:
@@ -1150,17 +1472,21 @@ def get_transformers_tier(
cfg = _load_config_json(model_name, hf_token)
if cfg is not None:
if _config_needs_510(cfg):
+ tier = _raise_tier_for_nested(cfg, "510")
logger.info(
- "Transformers tier 510 selected for %s (local config.json check)",
+ "Transformers tier %s selected for %s (local config.json check)",
+ tier,
model_name,
)
- return "510"
+ return tier
if _config_needs_550(cfg):
+ tier = _raise_tier_for_nested(cfg, "550")
logger.info(
- "Transformers tier 550 selected for %s (local config.json check)",
+ "Transformers tier %s selected for %s (local config.json check)",
+ tier,
model_name,
)
- return "550"
+ return tier
if _config_needs_530(cfg):
# Qwen3.6 reuses Qwen3.5 config ids but needs 5.5 by name. Only a real
# Hub id (or the folder basename) may override 530, so a stale local
@@ -1173,17 +1499,20 @@ def get_transformers_tier(
)
override = _higher_tier_name_override(hint_src)
if override is not None:
+ override = _raise_tier_for_nested(cfg, override)
logger.info(
"Transformers tier %s selected for %s (name overrides 530 config)",
override,
model_name,
)
return override
+ tier = _raise_tier_for_nested(cfg, "530")
logger.info(
- "Transformers tier 530 selected for %s (local config.json check)",
+ "Transformers tier %s selected for %s (local config.json check)",
+ tier,
model_name,
)
- return "530"
+ return tier
# Unknown arch: resolve the base id from config. A resolved local dir
# recurses (config check); a Hub id uses name rules only (no network).
resolved = _resolve_base_model(model_name)
@@ -1210,6 +1539,14 @@ def get_transformers_tier(
match,
)
return tier
+ static = _tier_from_config_mapping(cfg)
+ if static is not None and static != "default":
+ logger.info(
+ "Transformers tier %s selected for %s (config mapping: model_type absent below)",
+ static,
+ model_name,
+ )
+ return static
local_tc = Path(model_name) / "tokenizer_config.json"
if _safe_is_file(local_tc) and _check_tokenizer_config_needs_v5(model_name, hf_token):
if not probe:
@@ -1237,6 +1574,13 @@ def get_transformers_tier(
result = _tier_from_name(model_name)
if result is not None:
tier, match = result
+ # With a consented latest sidecar pinned, a name that matches a fixed
+ # tier can still carry a latest-only model_type (e.g. a newer variant
+ # reusing a family name); consult the config so an accepted upgrade
+ # actually routes to the sidecar it installed. Costs a config read only
+ # in the pinned case, keeping the pre-latest path I/O-free.
+ if latest_venv_pinned_version() is not None:
+ tier = _raise_tier_for_nested(_load_config_json(model_name, hf_token), tier)
logger.info(
"Transformers tier %s selected for %s (substring match: %s)",
tier,
@@ -1247,11 +1591,13 @@ def get_transformers_tier(
# --- Slow config fallbacks (network for HF IDs; authenticated with hf_token) --------
if _check_config_needs_510(model_name, hf_token):
- logger.info("Transformers tier 510 selected for %s (config.json check)", model_name)
- return "510"
+ tier = _raise_tier_for_nested(_load_config_json(model_name, hf_token), "510")
+ logger.info("Transformers tier %s selected for %s (config.json check)", tier, model_name)
+ return tier
if _check_config_needs_550(model_name, hf_token):
- logger.info("Transformers tier 550 selected for %s (config.json check)", model_name)
- return "550"
+ tier = _raise_tier_for_nested(_load_config_json(model_name, hf_token), "550")
+ logger.info("Transformers tier %s selected for %s (config.json check)", tier, model_name)
+ return tier
if _check_config_needs_530(model_name, hf_token):
# Qwen3.6 reuses Qwen3.5 config ids but needs 5.5 by name; honor a real Hub-id name
# hint from _name_or_path before selecting 530.
@@ -1261,14 +1607,28 @@ def get_transformers_tier(
base if isinstance(base, str) and base != model_name else None
)
if override is not None:
+ override = _raise_tier_for_nested(remote_cfg, override)
logger.info(
"Transformers tier %s selected for %s (name overrides 530 config)",
override,
model_name,
)
return override
- logger.info("Transformers tier 530 selected for %s (config.json check)", model_name)
- return "530"
+ tier = _raise_tier_for_nested(remote_cfg, "530")
+ logger.info("Transformers tier %s selected for %s (config.json check)", tier, model_name)
+ return tier
+ # _load_config_json (not the cache-only reader) so a config served from the hub
+ # cache during a transient outage still feeds the mapping resolver.
+ remote_cfg = _load_config_json(model_name, hf_token)
+ if remote_cfg is not None:
+ static = _tier_from_config_mapping(remote_cfg)
+ if static is not None and static != "default":
+ logger.info(
+ "Transformers tier %s selected for %s (config mapping: model_type absent below)",
+ static,
+ model_name,
+ )
+ return static
if _check_tokenizer_config_needs_v5(model_name, hf_token):
if not probe:
return "530"
@@ -1523,6 +1883,471 @@ def _ensure_venv_t5_exists() -> bool:
return _ensure_venv_t5_550_exists()
+# --- User-consented "latest transformers" sidecar (.venv_t5_latest) --------------------------
+# Provisioned via ensure_latest_transformers_venv() after the user confirms the upgrade popup
+# (utils/transformers_latest.py); pinned in a marker file so restarts revalidate and routing auto-picks it.
+
+# PEP 440-ish release strings only (guards the pip install spec against injection).
+_LATEST_VERSION_RE = r"[0-9]+(\.[0-9]+)*((a|b|rc)[0-9]+)?(\.post[0-9]+)?(\.dev[0-9]+)?"
+
+
+def _is_valid_version_string(version: str) -> bool:
+ import re
+ return isinstance(version, str) and re.fullmatch(_LATEST_VERSION_RE, version) is not None
+
+
+# Only the sidecar recipe's own packages, as plain (optionally ==pinned) specs, may
+# come from the on-disk pin marker; anything else (URLs, extras, options) is rebuilt.
+_PIN_SPEC_RE = re.compile(r"^[A-Za-z0-9_.-]+(==[A-Za-z0-9_.+-]+)?$")
+_PIN_ALLOWED_NAMES = frozenset(
+ {
+ "transformers",
+ "huggingface_hub",
+ "huggingface-hub",
+ "hf_xet",
+ "hf-xet",
+ "tiktoken",
+ "tokenizers",
+ "safetensors",
+ }
+)
+
+
+def _is_safe_pin_spec(spec: str) -> bool:
+ if not _PIN_SPEC_RE.match(spec):
+ return False
+ name = spec.split("==", 1)[0].lower().replace("_", "-")
+ return name in {n.replace("_", "-") for n in _PIN_ALLOWED_NAMES}
+
+
+def _recover_stranded_latest_sidecar() -> None:
+ """Restore a sidecar stranded at ``.old`` by a swap whose activation rename AND its
+ rollback both failed (e.g. a lingering worker file handle on Windows blocked both).
+
+ That double failure leaves no live dir and the pin marker gone with it, so the
+ sidecar reads as unprovisioned and never self-heals. Recover only when no live dir
+ exists and no swap is in flight: the reservation is held throughout the swap, so the
+ transient live-absent window of a legitimate swap never triggers a restore."""
+ live = Path(_VENV_T5_LATEST_DIR)
+ retired = Path(_VENV_T5_LATEST_DIR + ".old")
+ try:
+ if live.exists() or not retired.is_dir() or sidecar_swap_in_progress():
+ return
+ os.rename(retired, live)
+ logger.info("Recovered .venv_t5_latest from a stranded .old after a failed swap")
+ except OSError:
+ pass
+
+
+def _latest_pin_data() -> dict | None:
+ """Parsed pin marker: {"version": str, "packages": [specs...]}, or None.
+
+ The marker is JSON; a plain version string (older/simpler writers) is tolerated and
+ expanded with the default package set.
+ """
+ _recover_stranded_latest_sidecar()
+ marker = Path(_VENV_T5_LATEST_DIR) / _LATEST_PIN_MARKER
+ try:
+ if not marker.is_file():
+ return None
+ raw = marker.read_text(encoding = "utf-8").strip()
+ except Exception:
+ return None
+ try:
+ data = json.loads(raw)
+ except ValueError:
+ data = raw
+ if isinstance(data, str):
+ if not _is_valid_version_string(data):
+ return None
+ return {"version": data, "packages": list(_venv_t5_latest_packages(data))}
+ if not isinstance(data, dict):
+ return None
+ version = data.get("version")
+ if not _is_valid_version_string(version):
+ return None
+ packages = data.get("packages")
+ if not (
+ isinstance(packages, list)
+ and packages
+ and all(isinstance(p, str) and _is_safe_pin_spec(p) for p in packages)
+ ):
+ # Malformed or unexpected specs (the pin is user-writable on disk) never
+ # reach pip: rebuild the canonical set for the pinned version instead.
+ packages = list(_venv_t5_latest_packages(version))
+ return {"version": version, "packages": packages}
+
+
+def latest_venv_pinned_version() -> str | None:
+ """Exact transformers version pinned in .venv_t5_latest's marker, or None if the
+ sidecar was never provisioned (or the marker is unreadable/invalid)."""
+ data = _latest_pin_data()
+ return data["version"] if data else None
+
+
+def _venv_t5_latest_packages(version: str, extra_packages: tuple[str, ...] = ()) -> tuple[str, ...]:
+ """Package set for the latest sidecar; mirrors the fixed .venv_t5_* sidecars.
+ *extra_packages* carries dep-compat shadows (e.g. a newer tokenizers) computed by
+ utils.transformers_latest before install."""
+ return (
+ f"transformers=={version}",
+ "huggingface_hub==1.8.0",
+ "hf_xet==1.4.2",
+ "tiktoken",
+ ) + tuple(extra_packages)
+
+
+# Single reservation for ANY .venv_t5_latest replacement (consented install or lazy repair),
+# checked by training/export starts so no worker spawns mid-swap. Backed by a lock FILE (not just
+# this flag) so a lazy repair running in a worker subprocess stays visible to the parent's route
+# checks; the in-process flag marks ownership (only the owner unlinks the file).
+_sidecar_swap_lock = threading.Lock()
+_sidecar_swap_active = False
+_sidecar_swap_token: str | None = None
+_sidecar_swap_kind: str | None = None
+# An install is minutes; a lock this old is a crashed owner, not a live swap.
+_SWAP_LOCK_STALE_SECS = 2 * 60 * 60
+
+
+def _swap_lock_path() -> Path:
+ return Path(_VENV_T5_LATEST_DIR + ".swaplock")
+
+
+def _pid_alive(pid) -> bool:
+ if not isinstance(pid, int) or pid <= 0:
+ return False
+ try:
+ import psutil
+ return psutil.pid_exists(pid)
+ except Exception:
+ pass
+ if os.name == "nt":
+ # os.kill(pid, 0) is NOT a POSIX signal-0 liveness probe on Windows: signal 0
+ # is CTRL_C_EVENT, so CPython routes it through GenerateConsoleCtrlEvent (a real
+ # Ctrl+C to that console group) rather than a harmless check. Probe via OpenProcess.
+ try:
+ import ctypes
+ from ctypes import wintypes
+
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error = True)
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
+ kernel32.OpenProcess.restype = wintypes.HANDLE
+ # PROCESS_QUERY_LIMITED_INFORMATION: minimal right, granted across integrity levels.
+ handle = kernel32.OpenProcess(0x1000, False, pid)
+ if handle:
+ kernel32.CloseHandle(handle)
+ return True
+ # ERROR_ACCESS_DENIED means the process exists but we may not query it.
+ return ctypes.get_last_error() == 5
+ except Exception:
+ return False
+ try:
+ os.kill(pid, 0)
+ return True
+ except OSError:
+ return False
+ except Exception:
+ return False
+
+
+def _swap_lock_is_stale(path: Path) -> bool:
+ """Stale when the recorded owner is provably dead: a crashed installer is reclaimed
+ at once, not after the long cutoff, so `/load`, training, export, and repair are not
+ wedged for hours after a crash. A live but slow pip install keeps its lock (its PID
+ is alive), so breaking it and racing two swaps on the same staging dirs stays
+ impossible. Only a lock whose PID can't be read (mid-write or corrupt) falls back to
+ the age cutoff, so the create-before-metadata-write window is never mistaken for dead."""
+ try:
+ age = time.time() - path.stat().st_mtime
+ except OSError:
+ return False
+ data = _read_swap_lock(path) or {}
+ pid = data.get("pid")
+ if not isinstance(pid, int) or pid <= 0:
+ return age > _SWAP_LOCK_STALE_SECS
+ return not _pid_alive(pid)
+
+
+class SidecarSwapInProgress(RuntimeError):
+ """A worker start lost the race to a .venv_t5_latest install/repair; retryable."""
+
+
+def _read_swap_lock(path: Path) -> dict | None:
+ try:
+ data = json.loads(path.read_text(encoding = "utf-8"))
+ return data if isinstance(data, dict) else {}
+ except FileNotFoundError:
+ return None
+ except OSError:
+ return {}
+ except Exception:
+ return {}
+
+
+def try_begin_sidecar_swap(kind: str = "install") -> bool:
+ """Reserve the sidecar swap window; False when one is already reserved
+ (in this process or, via the lock file, in any worker subprocess).
+ *kind* is "install" (consented route) or "repair" (lazy venv repair)."""
+ global _sidecar_swap_active, _sidecar_swap_token, _sidecar_swap_kind
+ with _sidecar_swap_lock:
+ if _sidecar_swap_active:
+ return False
+ token = f"{os.getpid()}-{time.time_ns()}"
+ path = _swap_lock_path()
+ try:
+ path.parent.mkdir(parents = True, exist_ok = True)
+ except OSError:
+ pass
+ for attempt in range(2):
+ try:
+ fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
+ break
+ except FileExistsError:
+ if attempt or not _swap_lock_is_stale(path):
+ return False
+ try:
+ path.unlink()
+ except OSError:
+ return False
+ except OSError:
+ # Lock file not creatable (odd filesystem): fall back to the process-local reservation.
+ fd = None
+ break
+ if fd is not None:
+ try:
+ with os.fdopen(fd, "w") as f:
+ f.write(
+ json.dumps(
+ {"pid": os.getpid(), "at": time.time(), "token": token, "kind": kind}
+ )
+ )
+ except OSError:
+ pass
+ _sidecar_swap_active = True
+ _sidecar_swap_token = token
+ _sidecar_swap_kind = kind
+ return True
+
+
+def end_sidecar_swap() -> None:
+ """Release the reservation taken by :func:`try_begin_sidecar_swap`."""
+ global _sidecar_swap_active, _sidecar_swap_token, _sidecar_swap_kind
+ with _sidecar_swap_lock:
+ if _sidecar_swap_active:
+ # Only the file WE wrote is removed: if this reservation was declared
+ # stale and superseded, unlinking blindly would drop the new owner's
+ # live lock and unguard its in-flight swap.
+ path = _swap_lock_path()
+ data = _read_swap_lock(path)
+ if data is not None and data.get("token", _sidecar_swap_token) == _sidecar_swap_token:
+ try:
+ path.unlink()
+ except OSError:
+ pass
+ _sidecar_swap_active = False
+ _sidecar_swap_token = None
+ _sidecar_swap_kind = None
+
+
+def sidecar_swap_in_progress() -> bool:
+ """True while a .venv_t5_latest install or repair holds the reservation,
+ in this process or any other Studio process (lock file)."""
+ return sidecar_swap_kind() is not None
+
+
+def sidecar_swap_kind() -> str | None:
+ """The active reservation's kind ("install" / "repair"), or None when idle.
+ Lets guards that rely on the install route's own abort-on-active-worker
+ checks keep refusing for repairs, which have no such checks."""
+ with _sidecar_swap_lock:
+ if _sidecar_swap_active:
+ return _sidecar_swap_kind or "install"
+ path = _swap_lock_path()
+ try:
+ if not path.is_file() or _swap_lock_is_stale(path):
+ return None
+ except OSError:
+ return None
+ data = _read_swap_lock(path) or {}
+ kind = data.get("kind")
+ return kind if kind in ("install", "repair") else "install"
+
+
+def _stage_and_swap_latest_venv(
+ version: str,
+ packages: tuple[str, ...],
+ before_swap = None,
+) -> bool:
+ """Stage-and-swap: build the new sidecar next to the live one and swap only
+ once complete, so a failed install or marker write never destroys a
+ previously working .venv_t5_latest or its pin. Shared by the consented
+ install and the lazy repair path. *before_swap* (optional callable) runs
+ after the staging build succeeds and immediately before the live dir is
+ replaced, so callers can tear down workers only when the swap is certain;
+ if it raises, the previous sidecar is left untouched."""
+ staging = _VENV_T5_LATEST_DIR + ".staging"
+ retired = _VENV_T5_LATEST_DIR + ".old"
+ shutil.rmtree(staging, ignore_errors = True)
+ try:
+ if not _ensure_venv_dir(staging, packages, f"transformers {version} (latest)"):
+ # No exception, so the except cleanup below never runs; drop the partial dir.
+ shutil.rmtree(staging, ignore_errors = True)
+ return False
+ (Path(staging) / _LATEST_PIN_MARKER).write_text(
+ json.dumps({"version": version, "packages": list(packages)}), encoding = "utf-8"
+ )
+ if before_swap is not None:
+ before_swap()
+ shutil.rmtree(retired, ignore_errors = True)
+ if os.path.isdir(_VENV_T5_LATEST_DIR):
+ os.rename(_VENV_T5_LATEST_DIR, retired)
+ try:
+ os.rename(staging, _VENV_T5_LATEST_DIR)
+ except OSError:
+ # Restore the previous sidecar if the final swap fails.
+ if not os.path.isdir(_VENV_T5_LATEST_DIR) and os.path.isdir(retired):
+ os.rename(retired, _VENV_T5_LATEST_DIR)
+ raise
+ except Exception as exc:
+ logger.error("Could not provision transformers %s into .venv_t5_latest: %s", version, exc)
+ shutil.rmtree(staging, ignore_errors = True)
+ return False
+ shutil.rmtree(retired, ignore_errors = True)
+ # CONFIG_MAPPING_NAMES may have changed: drop the cached key set.
+ _config_mapping_cache.pop("latest", None)
+ logger.info("Provisioned .venv_t5_latest with transformers %s", version)
+ return True
+
+
+def _workers_active_for_repair() -> bool:
+ """Best-effort: any parent-visible chat/training/export worker alive. Never
+ raises; unavailable backends (worker subprocess, early startup) count idle."""
+ try:
+ from core.training import get_training_backend
+ if get_training_backend().is_training_active():
+ return True
+ except Exception:
+ pass
+ try:
+ from core.export import get_export_backend
+
+ _export = get_export_backend()
+ if _export.is_export_active():
+ return True
+ _alive = getattr(_export, "is_worker_alive", None)
+ if callable(_alive) and _alive():
+ return True
+ except Exception:
+ pass
+ try:
+ from core.inference import get_inference_backend
+
+ backend = get_inference_backend()
+ if getattr(backend, "active_model_name", None):
+ return True
+ # An in-flight load counts too: its worker spawns moments later.
+ if getattr(backend, "loading_models", None):
+ return True
+ _alive = getattr(backend, "is_worker_alive", None)
+ if callable(_alive) and _alive():
+ return True
+ except Exception:
+ pass
+ return False
+
+
+def _ensure_venv_t5_latest_exists() -> bool:
+ """Ensure .venv_t5_latest/ holds its pinned transformers version.
+
+ Never installs without a pin: an unprovisioned sidecar (no marker) returns False so
+ routing and probing behave exactly as before the feature existed. With a pin present
+ it repairs a broken dir the same way the fixed sidecars do.
+ """
+ pin = _latest_pin_data()
+ if pin is None:
+ return False
+ version = pin["version"]
+ packages = tuple(pin["packages"])
+ if _venv_dir_is_valid(_VENV_T5_LATEST_DIR, packages):
+ return True
+ if _env_offline():
+ logger.warning(
+ ".venv_t5_latest (transformers %s) is incomplete and offline mode is set; "
+ "cannot repair it.",
+ version,
+ )
+ return False
+ # Repairs are a parent-process action: a worker child's backend singletons are
+ # empty, so it cannot see live siblings that may still lazy-import from the
+ # sidecar. Fail activation in the child instead; the parent's routing
+ # self-heal (guarded below) performs the actual repair.
+ try:
+ import multiprocessing as _mp
+ if _mp.parent_process() is not None:
+ logger.warning(
+ ".venv_t5_latest is incomplete; repairs run in the parent process. "
+ "Retry after the parent repairs the sidecar."
+ )
+ return False
+ except Exception:
+ pass
+ # Same stage-and-swap as the install, under the same reservation so training/export starts
+ # (which check sidecar_swap_in_progress) wait out a lazy repair; a failed repair keeps the pin.
+ if not try_begin_sidecar_swap(kind = "repair"):
+ logger.warning(
+ "Cannot repair .venv_t5_latest: another sidecar install or repair is in progress."
+ )
+ return False
+ try:
+ # Worker check UNDER the reservation (the install route quiesces workers;
+ # a repair has none): worker starts set their active markers BEFORE
+ # rechecking the reservation, so either this check sees them and aborts,
+ # or their recheck sees this reservation and aborts -- no interleaving
+ # lets a worker spawn against a mid-swap sidecar.
+ if _workers_active_for_repair():
+ logger.warning(
+ "Cannot repair .venv_t5_latest: active chat/training/export workers "
+ "may be importing from it. Retry when they are idle."
+ )
+ return False
+ return _stage_and_swap_latest_venv(version, packages)
+ finally:
+ end_sidecar_swap()
+
+
+def ensure_latest_transformers_venv(
+ version: str,
+ extra_packages: tuple[str, ...] = (),
+ before_swap = None,
+) -> bool:
+ """Provision .venv_t5_latest/ pinned to *version* (user-consented install path).
+
+ Reuses the same --target/--no-deps installer as the fixed sidecars, then writes the pin
+ marker (version + full package set) so the venv persists across restarts and
+ :func:`latest_venv_pinned_version` / routing pick it up automatically.
+ *extra_packages* carries dep-compat shadows (see utils.transformers_latest).
+ Returns True on success.
+ """
+ if not _is_valid_version_string(version):
+ logger.error("Refusing to install invalid transformers version %r", version)
+ return False
+ if _env_offline():
+ logger.warning(
+ "Cannot install transformers %s: HF/transformers offline mode is set.", version
+ )
+ return False
+ packages = _venv_t5_latest_packages(version, extra_packages)
+ pin = _latest_pin_data()
+ if (
+ pin is not None
+ and pin["version"] == version
+ and tuple(pin["packages"]) == packages
+ and _venv_dir_is_valid(_VENV_T5_LATEST_DIR, packages)
+ ):
+ return True
+ return _stage_and_swap_latest_venv(version, packages, before_swap = before_swap)
+
+
# --- llm-compressor-main shadow (FP8/FP4 export of newer-transformers models) ---------------------
# Exact, reproducible pins (bump deliberately in review). Full 40-char SHA validated to FP8-quantize
# Qwen3.5 / Gemma-4 / Llama.
@@ -1685,7 +2510,7 @@ def _activate_venv(venv_dir: str, label: str) -> None:
def _deactivate_5x() -> None:
"""Remove all .venv_t5_*/ dirs from sys.path, purge stale modules, reimport."""
- for d in (_VENV_T5_530_DIR, _VENV_T5_550_DIR, _VENV_T5_510_DIR):
+ for d in (_VENV_T5_530_DIR, _VENV_T5_550_DIR, _VENV_T5_510_DIR, _VENV_T5_LATEST_DIR):
while d in sys.path:
sys.path.remove(d)
logger.info("Removed venv_t5 dirs from sys.path")
@@ -1719,14 +2544,25 @@ def ensure_transformers_version(model_name: str) -> None:
if _is_lora_adapter_dir(Path(model_name)):
resolved = _resolve_base_model(model_name)
else:
- resolved = model_name
+ # A remote adapter's tier is its BASE model's (see activation above).
+ resolved = _remote_lora_base(model_name) or model_name
tier = get_transformers_tier(resolved)
if model_name != resolved and _safe_is_file(Path(model_name) / "config.json"):
# Gate on a real local config.json: a checkpoint carries config the base may not
# surface, but path names alone must not upgrade a plain adapter.
tier = _higher_tier(tier, get_transformers_tier(model_name))
- if tier == "510":
+ if tier == "latest":
+ pinned = latest_venv_pinned_version()
+ if pinned is None:
+ raise RuntimeError(
+ f"Cannot activate the latest-transformers sidecar: "
+ f"no pin marker at {_VENV_T5_LATEST_DIR}"
+ )
+ target_version = pinned
+ venv_dir = _VENV_T5_LATEST_DIR
+ ensure_fn = _ensure_venv_t5_latest_exists
+ elif tier == "510":
target_version = TRANSFORMERS_510_VERSION
venv_dir = _VENV_T5_510_DIR
ensure_fn = _ensure_venv_t5_510_exists
diff --git a/studio/backend/utils/wheel_utils.py b/studio/backend/utils/wheel_utils.py
index 98697df83c..1b5926fd49 100644
--- a/studio/backend/utils/wheel_utils.py
+++ b/studio/backend/utils/wheel_utils.py
@@ -26,11 +26,14 @@ FLASH_ATTN_RELEASE_BASE_URL = "https://github.com/Dao-AILab/flash-attention/rele
def has_blackwell_gpu() -> bool:
"""Return True if any visible NVIDIA GPU has compute capability >= 10.0 (Blackwell).
- Dao-AILab ships no flash-attention wheels for these archs and older-arch wheels
- fail to load, so callers use this to skip the flash-attn install path. Cached
- for the process lifetime; tests mocking nvidia-smi must call
+ Cached for the process lifetime; tests mocking nvidia-smi must call
``has_blackwell_gpu.cache_clear()`` first.
"""
+ # Detection disabled for now: Dao-AILab ships Blackwell (sm_100+) flash-attn
+ # wheels and url_exists() already gates resolution, so we no longer skip
+ # flash-attn on Blackwell. The nvidia-smi probe below is kept for possible
+ # future arch-based gating; drop this early return to re-enable it.
+ return False
exe = shutil.which("nvidia-smi")
if not exe:
return False
@@ -117,6 +120,19 @@ def probe_torch_wheel_env(*, timeout: int | None = None) -> dict[str, str] | Non
return env
+# torch 2.11 has no native prebuilt wheels for flash-attn / causal-conv1d / mamba
+# yet, but their torch 2.10 CUDA wheels load and pass the projects' own test suites
+# on torch 2.11 (verified on B200: FA2 fwd/bwd, causal-conv1d, and mamba selective
+# scan all match reference). Reuse the torch 2.10 wheels on torch 2.11 so a 2.11
+# install still gets these prebuilt accelerators instead of building from source.
+_PREBUILT_WHEEL_TORCH_MM = {"2.11": "2.10"}
+
+
+def prebuilt_wheel_torch_mm(torch_mm: str) -> str:
+ """Map a torch major.minor to the one whose prebuilt accelerator wheels to use."""
+ return _PREBUILT_WHEEL_TORCH_MM.get(torch_mm, torch_mm)
+
+
def direct_wheel_url(
*,
filename_prefix: str,
@@ -130,7 +146,7 @@ def direct_wheel_url(
filename = (
f"{filename_prefix}-{package_version}"
- f"+cu{env['cuda_major']}torch{env['torch_mm']}"
+ f"+cu{env['cuda_major']}torch{prebuilt_wheel_torch_mm(env['torch_mm'])}"
f"cxx11abi{env['cxx11abi']}-{env['python_tag']}-{env['python_tag']}"
f"-{env['platform_tag']}.whl"
)
@@ -152,7 +168,7 @@ def flash_attn_package_version(torch_mm: str) -> str | None:
def flash_attn_wheel_url(env: dict[str, str] | None) -> str | None:
if env is None:
return None
- package_version = flash_attn_package_version(env["torch_mm"])
+ package_version = flash_attn_package_version(prebuilt_wheel_torch_mm(env["torch_mm"]))
if package_version is None:
return None
return direct_wheel_url(
diff --git a/studio/frontend/index.html b/studio/frontend/index.html
index 0fbb4eaeeb..6a7eadc04e 100644
--- a/studio/frontend/index.html
+++ b/studio/frontend/index.html
@@ -8,6 +8,9 @@
Unsloth Studio
+
+
diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json
index 80db64553a..1d5c09ba72 100644
--- a/studio/frontend/package-lock.json
+++ b/studio/frontend/package-lock.json
@@ -53,7 +53,6 @@
"lucide-react": "^1.7.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
- "next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
@@ -1704,7 +1703,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1725,7 +1723,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1746,7 +1743,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1767,7 +1763,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1788,7 +1783,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1809,7 +1803,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1830,7 +1823,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1851,7 +1843,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1872,7 +1863,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1893,7 +1883,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -1914,7 +1903,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 10"
},
@@ -12570,16 +12558,6 @@
"node": ">= 0.6"
}
},
- "node_modules/next-themes": {
- "version": "0.4.6",
- "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
- "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
- "license": "MIT",
- "peerDependencies": {
- "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
- }
- },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
diff --git a/studio/frontend/package.json b/studio/frontend/package.json
index a2eddecda3..fc6911c4be 100644
--- a/studio/frontend/package.json
+++ b/studio/frontend/package.json
@@ -62,7 +62,6 @@
"lucide-react": "^1.7.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
- "next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
diff --git a/studio/frontend/public/theme-boot.js b/studio/frontend/public/theme-boot.js
new file mode 100644
index 0000000000..70bd93e0e1
--- /dev/null
+++ b/studio/frontend/public/theme-boot.js
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+// Apply the stored theme and palette before the bundle loads so the first
+// paint is never the wrong mode. Loaded as an external classic script (it
+// blocks parsing, like an inline script) because the backend CSP only
+// allows script-src 'self'.
+try {
+ // Storage reads get their own guards so a blocked localStorage (private
+ // browsing) still resolves a mode from the OS preference.
+ var theme = "system";
+ var palette = null;
+ try {
+ theme = localStorage.getItem("theme") || "system";
+ palette = localStorage.getItem("palette");
+ } catch (e) {}
+ var dark =
+ theme === "dark" ||
+ (theme !== "light" && matchMedia("(prefers-color-scheme: dark)").matches);
+ var root = document.documentElement;
+ root.classList.toggle("dark", dark);
+ root.classList.toggle("light", !dark);
+ root.style.colorScheme = dark ? "dark" : "light";
+ if (palette === "classic" || palette === "minimal") {
+ root.setAttribute("data-palette", palette);
+ }
+} catch (e) {}
diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx
index 176665769d..c35706e50a 100644
--- a/studio/frontend/src/app/provider.tsx
+++ b/studio/frontend/src/app/provider.tsx
@@ -1,33 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+import { LlamaUpdateBanner } from "@/components/llama-update-banner";
import { StartupScreen } from "@/components/tauri/startup-screen";
import { UpdateBanner } from "@/components/tauri/update-banner";
import { UpdateScreen } from "@/components/tauri/update-screen";
import {
WindowTitlebar,
- shouldUseNativeMacWindowTitlebar,
shouldUseCustomWindowTitlebar,
+ shouldUseNativeMacWindowTitlebar,
} from "@/components/tauri/window-titlebar";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WebUpdateBanner } from "@/components/web/update-banner";
-import { LlamaUpdateBanner } from "@/components/llama-update-banner";
-import { DownloadManagerPanel } from "@/features/hub/download-manager";
+import { fetchDeviceType } from "@/config/env";
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
+import { DownloadManagerPanel } from "@/features/hub/download-manager";
import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain";
-import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend";
+import {
+ applyCustomizationToDocument,
+ useAppearanceCustomStore,
+ useTheme,
+} from "@/features/settings";
+import { type BackendStatus, useTauriBackend } from "@/hooks/use-tauri-backend";
import { useTauriUpdate } from "@/hooks/use-tauri-update";
import { isTauri } from "@/lib/api-base";
-import { fetchDeviceType } from "@/config/env";
import { useRouterState } from "@tanstack/react-router";
-import { ThemeProvider } from "next-themes";
+import { MotionConfig } from "motion/react";
import {
+ type CSSProperties,
+ type ReactNode,
useEffect,
useRef,
useState,
- type CSSProperties,
- type ReactNode,
} from "react";
interface AppProviderProps {
@@ -43,7 +48,9 @@ const SETUP_WINDOW_WIDTH = 760;
const SETUP_WINDOW_HEIGHT = 560;
async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise {
- const { getCurrentWindow, LogicalSize } = await import("@tauri-apps/api/window");
+ const { getCurrentWindow, LogicalSize } = await import(
+ "@tauri-apps/api/window"
+ );
if (!isCurrent()) return;
const win = getCurrentWindow();
@@ -57,7 +64,9 @@ async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise {
}
async function enforceMinimumWindowSize(
- win: Awaited>,
+ win: Awaited<
+ ReturnType
+ >,
LogicalSize: typeof import("@tauri-apps/api/window")["LogicalSize"],
isCurrent: WindowLayoutGuard,
): Promise {
@@ -76,10 +85,16 @@ async function enforceMinimumWindowSize(
}
}
-async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise {
- const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window");
+async function applyAppWindowLayout(
+ isCurrent: WindowLayoutGuard,
+): Promise {
+ const { getCurrentWindow, currentMonitor, LogicalSize } = await import(
+ "@tauri-apps/api/window"
+ );
const { invoke } = await import("@tauri-apps/api/core");
- const { restoreStateCurrent, StateFlags } = await import("@tauri-apps/plugin-window-state");
+ const { restoreStateCurrent, StateFlags } = await import(
+ "@tauri-apps/plugin-window-state"
+ );
if (!isCurrent()) return;
const win = getCurrentWindow();
@@ -123,7 +138,10 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise
if (!isCurrent()) return;
// Apply constraints after restore/show: doing so before plugin restore can emit
// a Resized event and overwrite the plugin's cached saved size.
- await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT });
+ await win.setSizeConstraints({
+ minWidth: MIN_WINDOW_WIDTH,
+ minHeight: MIN_WINDOW_HEIGHT,
+ });
if (!isCurrent()) return;
await enforceMinimumWindowSize(win, LogicalSize, isCurrent);
}
@@ -252,9 +270,18 @@ const CUSTOM_CHROME_STYLE = {
function TauriWrapper({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const {
- status, logs, error, isExternalServer,
- currentStepIndex, progressDetail, elevationPackages,
- startInstall, retry, retryInstall, approveElevation, copyDiagnostics,
+ status,
+ logs,
+ error,
+ isExternalServer,
+ currentStepIndex,
+ progressDetail,
+ elevationPackages,
+ startInstall,
+ retry,
+ retryInstall,
+ approveElevation,
+ copyDiagnostics,
} = useTauriBackend();
const appliedWindowModeRef = useRef(null);
@@ -288,14 +315,18 @@ function TauriWrapper({ children }: { children: ReactNode }) {
const layoutGeneration = windowLayoutGenerationRef.current + 1;
windowLayoutGenerationRef.current = layoutGeneration;
- const isCurrent = () => windowLayoutGenerationRef.current === layoutGeneration;
- const applyWindowMode = nextMode === "setup" ? showSetupWindow : applyAppWindowLayout;
+ const isCurrent = () =>
+ windowLayoutGenerationRef.current === layoutGeneration;
+ const applyWindowMode =
+ nextMode === "setup" ? showSetupWindow : applyAppWindowLayout;
applyWindowMode(isCurrent).catch(async () => {
if (!isCurrent()) return;
// On failure, at minimum make the window visible and resizable so user can fix manually.
try {
await showWindowFallback();
- } catch { /* swallow — window may still be functional */ }
+ } catch {
+ /* swallow; window may still be functional */
+ }
});
}, [status]);
@@ -325,7 +356,9 @@ function TauriWrapper({ children }: { children: ReactNode }) {
}
});
- return () => { disposed = true; };
+ return () => {
+ disposed = true;
+ };
}, [status, desktopAuthRetry]);
useEffect(() => {
@@ -370,7 +403,9 @@ function TauriWrapper({ children }: { children: ReactNode }) {
positioned={false}
enabled={showInteractiveApp && !hidesTitlebarSidebar}
/>
- {showInteractiveApp ? : null}
+ {showInteractiveApp ? (
+
+ ) : null}
{showInteractiveApp ? : null}
{showInteractiveApp ? children : null}
@@ -378,7 +413,10 @@ function TauriWrapper({ children }: { children: ReactNode }) {
Preparing Studio
-
The local backend is ready. Signing in to your desktop session before loading chats.
+
+ The local backend is ready. Signing in to your desktop session
+ before loading chats.
+
Signing in to desktop session...
@@ -416,9 +454,9 @@ function TauriWrapper({ children }: { children: ReactNode }) {
}
style={MAC_NATIVE_CHROME_STYLE}
>
- {(!showApp || hidesTitlebarSidebar) ? (
+ {!showApp || hidesTitlebarSidebar ? (
@@ -428,13 +466,10 @@ function TauriWrapper({ children }: { children: ReactNode }) {
);
}
- return (
- <>{content}>
- );
+ return <>{content}>;
}
- const showSidebarSurface =
- showApp && !hidesTitlebarSidebar;
+ const showSidebarSurface = showApp && !hidesTitlebarSidebar;
return (
-
- {content}
-
+
{content}
);
}
+/**
+ * Mirrors the appearance customization store onto (inline CSS vars,
+ * classes, attributes). Colors are per resolved light/dark mode, so re-apply
+ * whenever either the customization or the resolved theme changes.
+ */
+function AppearanceCustomizationEffect() {
+ const { resolved } = useTheme();
+ const customization = useAppearanceCustomStore((s) => s.customization);
+ useEffect(() => {
+ applyCustomizationToDocument(customization, resolved);
+ }, [customization, resolved]);
+ return null;
+}
+
+const REDUCED_MOTION_MAP = {
+ system: "user",
+ on: "always",
+ off: "never",
+} as const;
+
export function AppProvider({ children }: AppProviderProps) {
+ const reduceMotion = useAppearanceCustomStore(
+ (s) => s.customization.reduceMotion,
+ );
return (
-
+
-
- {children}
-
+
+ {children}
-
+
);
}
diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx
index 8c6ddd197a..ba56ce7525 100644
--- a/studio/frontend/src/app/routes/__root.tsx
+++ b/studio/frontend/src/app/routes/__root.tsx
@@ -16,6 +16,7 @@ import {
type ChatSearch,
} from "@/features/chat";
import { RemoteCodeConsentDialog } from "@/features/security";
+import { TransformersUpgradeDialog } from "@/features/transformers-upgrade";
import { useTrainingUnloadGuard } from "@/features/training";
import { useExportRuntimeLifecycle } from "@/features/export";
import { hasAuthToken } from "@/features/auth";
@@ -44,6 +45,7 @@ declare module "@tanstack/react-router" {
interface StaticDataRouteOption {
title?: string;
titleKey?: TranslationKey;
+ isAuthFlow?: boolean;
}
}
@@ -103,6 +105,9 @@ function RootLayout() {
const t = useT();
const pathname = useRouterState({ select: (s) => s.location.pathname });
const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname);
+ const isAuthFlowRoute = useMatches({
+ select: (matches) => matches.some((match) => match.staticData.isAuthFlow),
+ });
// Exact match: a prefix would treat /chatty as chat, hiding its not-found UI.
const isChatRoute = pathname === "/chat";
const { pinned, setPinned, togglePinned } = useSidebarPin();
@@ -161,7 +166,8 @@ function RootLayout() {
});
const settingsDialogOpen = useSettingsDialogStore((s) => s.open);
- const documentTitle = settingsDialogOpen ? t("settings.title") : matchedTitle;
+ const documentTitle =
+ settingsDialogOpen && !isAuthFlowRoute ? t("settings.title") : matchedTitle;
useLayoutEffect(() => {
document.title = documentTitle
@@ -170,9 +176,13 @@ function RootLayout() {
}, [documentTitle]);
useEffect(() => {
+ if (isAuthFlowRoute) {
+ useSettingsDialogStore.getState().closeDialog();
+ }
const handler = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
+ if (isAuthFlowRoute) return;
e.preventDefault();
useSettingsDialogStore.getState().openDialog();
return;
@@ -196,7 +206,7 @@ function RootLayout() {
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
- }, [navigate]);
+ }, [isAuthFlowRoute, navigate]);
useEffect(() => {
if (isChatRoute) return;
@@ -219,8 +229,9 @@ function RootLayout() {
return (
-
+ {!isAuthFlowRoute && }
+
{hideNavbar ? (
}>
diff --git a/studio/frontend/src/app/routes/change-password.tsx b/studio/frontend/src/app/routes/change-password.tsx
index 55c8ceaa9c..beaf7ef60b 100644
--- a/studio/frontend/src/app/routes/change-password.tsx
+++ b/studio/frontend/src/app/routes/change-password.tsx
@@ -15,7 +15,7 @@ const ChangePasswordPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/change-password",
- staticData: { title: "Change Password" },
+ staticData: { title: "Change Password", isAuthFlow: true },
beforeLoad: () => requirePasswordChangeFlow(),
component: ChangePasswordPage,
});
diff --git a/studio/frontend/src/app/routes/login.tsx b/studio/frontend/src/app/routes/login.tsx
index bfd1b82132..756e826a83 100644
--- a/studio/frontend/src/app/routes/login.tsx
+++ b/studio/frontend/src/app/routes/login.tsx
@@ -13,7 +13,7 @@ const LoginPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/login",
- staticData: { title: "Login" },
+ staticData: { title: "Login", isAuthFlow: true },
beforeLoad: () => requireGuest(),
component: LoginPage,
});
diff --git a/studio/frontend/src/app/routes/onboarding.tsx b/studio/frontend/src/app/routes/onboarding.tsx
index 6c31d794ba..bdac4162b0 100644
--- a/studio/frontend/src/app/routes/onboarding.tsx
+++ b/studio/frontend/src/app/routes/onboarding.tsx
@@ -17,7 +17,7 @@ const WizardLayout = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/onboarding",
- staticData: { title: "Onboarding" },
+ staticData: { title: "Onboarding", isAuthFlow: true },
beforeLoad: () => requireAuth(),
validateSearch: (search: Record): OnboardingSearch => ({
redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined,
diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx
index f59a952b3a..25d88aeeea 100644
--- a/studio/frontend/src/components/app-sidebar.tsx
+++ b/studio/frontend/src/components/app-sidebar.tsx
@@ -56,6 +56,8 @@ import {
ArrowRight02Icon,
BadgeInfoIcon,
ChefHatIcon,
+ CloudIcon,
+ CpuIcon,
CursorInfo02Icon,
DashboardCircleIcon,
Delete02Icon,
@@ -68,7 +70,9 @@ import {
Globe02Icon,
HelpCircleIcon,
Logout05Icon,
+ Message01Icon,
MoreVerticalIcon,
+ PaintBrush02Icon,
Search01Icon,
PinIcon,
PinOffIcon,
@@ -79,9 +83,9 @@ import {
Settings02Icon,
Sun03Icon,
TestTube01Icon,
+ UserIcon,
ZapIcon,
} from "@hugeicons/core-free-icons";
-import { listStoredChatThreads } from "@/features/chat/utils/chat-history-storage";
import {
Tooltip,
TooltipContent,
@@ -97,6 +101,7 @@ import {
createChatProject,
deleteChatProject,
deleteChatItem,
+ listStoredChatThreads,
moveChatItemToProject,
renameChatItem,
renameChatProject,
@@ -109,7 +114,10 @@ import {
type ProjectRecord,
type SidebarItem,
} from "@/features/chat";
-import { useSettingsDialogStore } from "@/features/settings";
+import {
+ useAppearanceCustomStore,
+ useSettingsDialogStore,
+} from "@/features/settings";
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { clearAuthTokens, logout } from "@/features/auth";
@@ -162,6 +170,19 @@ function getTourId(pathname: string): string | null {
return null;
}
+// Optional user-menu shortcuts that jump straight to a settings tab; the id
+// doubles as the settings dialog tab id.
+const SETTINGS_TAB_MENU_ITEMS: Record<
+ "profile" | "appearance" | "resources" | "chat" | "connections",
+ { icon: typeof ZapIcon; labelKey: TranslationKey }
+> = {
+ profile: { icon: UserIcon, labelKey: "settings.tabs.profile" },
+ appearance: { icon: PaintBrush02Icon, labelKey: "settings.tabs.appearance" },
+ resources: { icon: CpuIcon, labelKey: "settings.tabs.resources" },
+ chat: { icon: Message01Icon, labelKey: "settings.tabs.chat" },
+ connections: { icon: CloudIcon, labelKey: "settings.tabs.connections" },
+};
+
// TestTube01Icon's last 2 paths are interior bubbles; slice to the first
// 3 (outline + cap + liquid line) to drop them. Original export untouched.
const TestTubeOutlineIcon = TestTube01Icon.slice(
@@ -290,6 +311,9 @@ function NavItem({
export function AppSidebar() {
const t = useT();
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
+ const sidebarMenu = useAppearanceCustomStore(
+ (s) => s.customization.sidebarMenu,
+ );
const [usesCustomTitlebar] = useState(shouldUseCustomWindowTitlebar);
const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar);
const { pathname, search } = useRouterState({
@@ -582,7 +606,14 @@ export function AppSidebar() {
useEffect(() => {
if (!pendingRename) return;
const match = allChatItems.find((i) => i.id === pendingRename.id);
- if (match && match.title === pendingRename.title) setPendingRename(null);
+ if (!match || match.title !== pendingRename.title) return;
+ queueMicrotask(() => {
+ setPendingRename((current) =>
+ current?.id === pendingRename.id && current.title === pendingRename.title
+ ? null
+ : current,
+ );
+ });
}, [allChatItems, pendingRename]);
const [creatingProject, setCreatingProject] = useState(false);
const [projectNameDraft, setProjectNameDraft] = useState("");
@@ -680,12 +711,6 @@ export function AppSidebar() {
useState(null);
const [deleteProjectFiles, setDeleteProjectFiles] = useState(false);
- useEffect(() => {
- if (confirmingDelete?.kind !== "project") {
- setDeleteProjectFiles(false);
- }
- }, [confirmingDelete]);
-
async function commitDelete() {
const target = confirmingDelete;
if (!target) return;
@@ -1067,7 +1092,7 @@ export function AppSidebar() {
@@ -1090,7 +1115,7 @@ export function AppSidebar() {
@@ -1485,7 +1510,7 @@ export function AppSidebar() {
.openDialog("about", { scrollTarget: "about-updates" });
closeMobileIfOpen();
}}
- className="flex h-[44px] w-full items-center gap-[9px] rounded-[14px] border border-border/60 bg-transparent px-2 py-[3px] text-left transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:h-[34px] group-data-[collapsible=icon]:w-[34px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:rounded-full group-data-[collapsible=icon]:p-0"
+ className="flex h-[44px] w-full items-center gap-[9px] rounded-[14px] border border-border/60 bg-transparent px-2 py-[3px] text-left transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:h-[34px] group-data-[collapsible=icon]:w-[34px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:rounded-full group-data-[collapsible=icon]:p-0"
>
{t("shell.navigation.settings")}
⌘,
- useSettingsDialogStore.getState().openDialog("api-keys")}
- >
-
- {t("shell.navigation.api")}
-
- {t("common.new")}
-
-
- }
- onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
- >
- {isDark ? : }
-
- {isDark
- ? t("shell.navigation.lightMode")
- : t("shell.navigation.darkMode")}
-
-
- {getTourId(pathname) && (
- {
- const tourId = getTourId(pathname);
- if (!tourId) return;
- window.dispatchEvent(
- new CustomEvent(TOUR_OPEN_EVENT, {
- detail: { id: tourId },
- }),
- );
- }}
- >
-
- {t("shell.navigation.guidedTour")}
-
- )}
+ {/* Optional items follow the order and visibility set in
+ Appearance settings; Settings above and the block after
+ the separator are pinned. */}
+ {sidebarMenu.map((item) => {
+ if (!item.visible) return null;
+ if (item.id === "api") {
+ return (
+ useSettingsDialogStore.getState().openDialog("api-keys")}
+ >
+
+ {t("shell.navigation.api")}
+
+ );
+ }
+ if (item.id === "darkMode") {
+ return (
+ }
+ onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
+ >
+ {isDark ? : }
+
+ {isDark
+ ? t("shell.navigation.lightMode")
+ : t("shell.navigation.darkMode")}
+
+
+ );
+ }
+ if (item.id === "guidedTour") {
+ if (!getTourId(pathname)) return null;
+ return (
+ {
+ const tourId = getTourId(pathname);
+ if (!tourId) return;
+ window.dispatchEvent(
+ new CustomEvent(TOUR_OPEN_EVENT, {
+ detail: { id: tourId },
+ }),
+ );
+ }}
+ >
+
+ {t("shell.navigation.guidedTour")}
+
+ );
+ }
+ // Remaining ids are settings tabs shown by their tab name.
+ const settingsTabId = item.id;
+ const tab = SETTINGS_TAB_MENU_ITEMS[settingsTabId];
+ return (
+ useSettingsDialogStore.getState().openDialog(settingsTabId)}
+ >
+
+ {t(tab.labelKey)}
+
+ );
+ })}
= ({ children }) => {
);
};
+const AUDIO_ATTACHMENT_RE = /\.(wav|mp3|m4a|ogg|oga|flac|webm|mp4|aac)$/i;
+
+const isAudioAttachment = (name: string | undefined, contentType: string) =>
+ /^audio\//i.test(contentType) || AUDIO_ATTACHMENT_RE.test(name ?? "");
+
const AttachmentThumb: FC = () => {
const src = useAttachmentSrc();
const name = useAuiState(({ attachment }) => attachment.name);
+ const contentType = useAuiState(
+ ({ attachment }) =>
+ (attachment as { file?: File }).file?.type ??
+ (attachment as { contentType?: string }).contentType ??
+ "",
+ );
if (src) {
return (
@@ -137,7 +148,7 @@ const AttachmentThumb: FC = () => {
return (
@@ -159,7 +170,12 @@ const AttachmentUI: FC = () => {
case "document":
return "Document";
case "file":
- return "File";
+ return isAudioAttachment(
+ attachment.name,
+ (attachment as { file?: File }).file?.type ?? "",
+ )
+ ? "Audio"
+ : "File";
default:
throw new Error(`Unknown attachment type: ${type as string}`);
}
diff --git a/studio/frontend/src/components/assistant-ui/message-response-details-sheet.tsx b/studio/frontend/src/components/assistant-ui/message-response-details-sheet.tsx
index 823696693a..cca61b766a 100644
--- a/studio/frontend/src/components/assistant-ui/message-response-details-sheet.tsx
+++ b/studio/frontend/src/components/assistant-ui/message-response-details-sheet.tsx
@@ -18,7 +18,7 @@ import {
useExternalProvidersStore,
} from "@/features/chat";
import { cn } from "@/lib/utils";
-import { FileDatabaseIcon } from "@hugeicons/core-free-icons";
+import { HelpCircleIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMessage, useMessageTiming } from "@assistant-ui/react";
import type { FC, ReactNode } from "react";
@@ -288,7 +288,7 @@ export const MessageResponseModelBadge: FC<{ className?: string }> = ({
return (
diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx
index 1fddf077ba..6bfd1276ac 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector.tsx
+++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx
@@ -168,9 +168,9 @@ function ModelSelectorTrigger({
// Suppress the pill's hover background while the eject hit area is
// hovered, so only the dot's own circle reacts.
variant === "outline" &&
- "rounded-full border border-border/60 hover:bg-[#ececec] has-[[data-eject-hit]:hover]:!bg-transparent dark:hover:bg-[#2d2e32]",
+ "rounded-full border border-border/60 hover:bg-accent has-[[data-eject-hit]:hover]:!bg-transparent",
variant === "ghost" &&
- "rounded-full hover:bg-[#ececec] has-[[data-eject-hit]:hover]:!bg-transparent dark:hover:bg-[#2d2e32]",
+ "rounded-full hover:bg-accent has-[[data-eject-hit]:hover]:!bg-transparent",
variant === "muted" &&
"rounded-full bg-muted hover:bg-muted/80 has-[[data-eject-hit]:hover]:!bg-muted",
// More left padding than right; the chevron is pulled close to the
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx b/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx
index 6311e15bfe..16cc8a1956 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx
+++ b/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx
@@ -18,8 +18,9 @@ import {
type BrowseFoldersResponse,
browseFolders,
} from "@/features/chat/api/chat-api";
+import { ChevronUpStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
-import { ArrowUp02Icon, Folder02Icon } from "@hugeicons/core-free-icons";
+import { Folder02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -230,7 +231,7 @@ export function FolderBrowser({
className="flex w-full items-center gap-2 px-6 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
..
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx
index 9890c5f574..8e06181585 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx
+++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx
@@ -393,6 +393,7 @@ function ModelRow({
vramEst,
gpuGb,
tooltipText,
+ hubUrl,
optionProps,
onArrowDownIntoChildren,
capabilities,
@@ -409,6 +410,10 @@ function ModelRow({
vramEst?: number;
gpuGb?: number;
tooltipText?: ReactNode;
+ /** Hugging Face address (e.g. "huggingface.co/owner/name") for online/Hub
+ * rows; surfaced on hover so their repo id / URL is discoverable the same
+ * way local rows show an on-disk path. Omit to show no address line. */
+ hubUrl?: string;
optionProps?: ModelRowOptionProps;
onArrowDownIntoChildren?: () => boolean;
/** Capability override (HF rows have tags); falls back to name detection. */
@@ -454,7 +459,7 @@ function ModelRow({
}}
onClick={onClick}
className={cn(
- "flex w-full items-center gap-2 rounded-full px-2 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
+ "flex w-full items-center gap-2 rounded-full px-2 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
selected && "bg-[#ececec] dark:bg-[var(--sidebar-accent)]",
className,
)}
@@ -546,30 +551,41 @@ function ModelRow({
);
- if (vramTooltipText) {
- return (
-
- {content}
-
- {label}
- {vramTooltipText}
-
-
- );
- }
+ // Optional Hugging Face address line for online/Hub rows, rendered under
+ // whichever tooltip shows so the repo id / URL is always visible on hover.
+ const hubUrlLine = hubUrl ? (
+
+ {hubUrl}
+
+ ) : null;
- if (tooltipText) {
+ const tooltipBody = vramTooltipText ? (
+ <>
+ {label}
+ {vramTooltipText}
+ {hubUrlLine}
+ >
+ ) : tooltipText ? (
+ <>
+ {tooltipText}
+ {hubUrlLine}
+ >
+ ) : hubUrl ? (
+ <>
+ {label}
+ {hubUrlLine}
+ >
+ ) : null;
+
+ if (tooltipBody) {
return (
-
+
{content}
- {tooltipText}
+ {tooltipBody}
);
@@ -919,7 +935,7 @@ function GgufVariantExpander({
handleVariantClick(v.quant, v.downloaded, expectedBytes)
}
className={cn(
- "flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-2 py-1 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
+ "flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-2 py-1 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
)}
>
@@ -1193,6 +1209,13 @@ function localPathTooltip(name: string, path: string): ReactNode {
);
}
+/** Hugging Face address for an online/Hub row, or undefined when the repo id is
+ * missing so the row shows no (empty) address line on hover. */
+function hubRepoUrl(id: string | null | undefined): string | undefined {
+ const trimmed = id?.trim();
+ return trimmed ? `huggingface.co/${trimmed}` : undefined;
+}
+
/** Whether a local model is an MLX build (name hint). MLX runs on Mac only, so
* callers gate visibility on the host being a Mac. */
function localModelIsMlx(m: LocalModelInfo): boolean {
@@ -2462,6 +2485,7 @@ export function HubModelPicker({
-
-
-
-
+
{isOpen && !isReasoningStreaming && (
)}
diff --git a/studio/frontend/src/components/assistant-ui/sources.tsx b/studio/frontend/src/components/assistant-ui/sources.tsx
index 7e5b52f7d0..18c62fc87f 100644
--- a/studio/frontend/src/components/assistant-ui/sources.tsx
+++ b/studio/frontend/src/components/assistant-ui/sources.tsx
@@ -104,7 +104,7 @@ function Source({
variant={variant}
size={size}
className={cn(
- "rounded-full cursor-pointer outline-none hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover! focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ "rounded-full cursor-pointer outline-none hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover! focus-visible:border-ring",
className,
)}
>
diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index 09551cd413..57213f07cb 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -79,6 +79,7 @@ import { McpComposerButton } from "@/features/chat/mcp-composer-button";
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
import { useRagToolDisabled } from "@/features/chat/hooks/use-rag-tool-disabled";
import { BypassPermissionsMenuItem } from "@/features/chat/bypass-permissions-menu-item";
+import { PermissionModeComposerPill } from "@/features/chat/permission-mode-select";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
import { PROMPT_QUEUE_STOP_EVENT } from "@/features/chat/utils/prompt-queue-boundary";
@@ -96,9 +97,11 @@ import { ThreadDocumentsBar } from "@/features/rag/components/thread-documents-b
import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge-base-composer-button";
import { DocumentPreviewMount } from "@/features/rag/components/document-preview-mount";
import { useUserProfileStore } from "@/features/profile/stores/user-profile-store";
+import { useVoiceSettingsStore } from "@/features/settings/stores/voice-settings-store";
import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
+import { MicIcon } from "@/lib/mic-icon";
import { toast } from "@/lib/toast";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
@@ -127,10 +130,10 @@ import {
FileDatabaseIcon,
Folder01Icon,
FolderAddIcon,
+ HelpCircleIcon,
Image03Icon,
McpServerIcon,
PencilRulerIcon,
- ShieldBanIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
@@ -149,6 +152,8 @@ import {
RefreshCwIcon,
SquareIcon,
TerminalIcon,
+ Volume2Icon,
+ VolumeXIcon,
XIcon,
} from "lucide-react";
import {
@@ -1216,7 +1221,7 @@ const ThreadComposerDock: FC<{
s.incognito);
const displayName = useUserProfileStore((s) => s.displayName);
const nickname = useUserProfileStore((s) => s.nickname);
+ const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
const [welcome, setWelcome] = useState
(DEFAULT_WELCOME);
useEffect(() => {
@@ -1332,10 +1338,12 @@ const ThreadWelcome: FC<{
{/* Center the greeting (sloth + title) over the composer. */}
-
+ {showGreetingSloth && (
+
+ )}
{incognito ? "Temporary chat" : welcome.text}
@@ -1424,11 +1432,13 @@ const Composer: FC<{
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
- const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
- // More than 4 pills: collapse to icons only. Search and Code always show;
+ const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
+ // More than 4 pills: collapse to icons only. Search and Code always show; the
+ // permission pill shows in every mode except "off" (it renders null there);
// Images, RAG, Canvas and MCP are conditional.
const pillsCompact =
2 +
+ (permissionMode !== "off" ? 1 : 0) +
(ragEnabled ? 1 : 0) +
(supportsBuiltinImageGeneration ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
@@ -1546,8 +1556,7 @@ const Composer: FC<{
}, [composerText, draftKey]);
// Two-row layout shows once the input wraps or a tool is on. Tools can
// pre-select before a model loads, so an active toggle expands it either way.
- // Bypass permissions counts too: turning it on should drop the composer into
- // the two-row layout immediately, same as Search/Code.
+ // Keep the composer expanded whenever the permission pill is visible.
const composerExpanded =
isMultiline ||
hasAttachments ||
@@ -1558,7 +1567,7 @@ const Composer: FC<{
ragEnabled ||
artifactsEnabled ||
mcpEnabledForChat ||
- bypassPermissions;
+ permissionMode !== "off";
// react-textarea-autosize re-measures only on value change or window resize,
// not on the width swap from expanding, so it keeps the taller height and
// leaves a stray blank row. Nudge a resize whenever input width changes.
@@ -1852,9 +1861,9 @@ const Composer: FC<{
data-pill-compact={pillsCompact ? "true" : undefined}
>
- {/* Active-mode badge: always visible when bypass is on, even while
- the pill row is collapsed (returns null when off). */}
-
+ {/* Permission-level pill: always visible, even while the pill row
+ is collapsed; opens the permission level dropdown. */}
+
{composerExpanded ? (
<>
@@ -2111,19 +2120,6 @@ function useImeComposerInputHandlers({
};
}
-// Phosphor microphone. Inlined to avoid a new icon dependency.
-const MicIcon: FC<{ className?: string }> = ({ className }) => (
-
-
-
-);
-
// HugeIcons arrow-down-01 (stroke-standard): straight-line chevron.
const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => (
= ({
type="button"
disabled={disabled}
className="unsloth-thinking-pill"
+ data-pill-label="Thinking settings"
data-active={activeLook ? "true" : "false"}
aria-label={thinkEffortAriaLabel({
modelLoaded,
@@ -2283,9 +2280,11 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({
>
{activeLook ? (
- {isEffort ? `Thinking · ${effortLabel}` : "Thinking"}
+
+ {isEffort ? `Thinking · ${effortLabel}` : "Thinking"}
+
) : null}
-
+
= ({
)}
{effectiveReasoningEffortLevels
- .filter((level) => level !== "none")
+ // 'none' is a real template level for models like Inkling
+ // (effort 0 = thinking off); show it as a pick unless the
+ // dedicated off item above already covers it.
+ .filter(
+ (level) =>
+ level !== "none" || !effectiveSupportsReasoningOff,
+ )
.map((level) => (
= ({
}
}}
className="unsloth-thinking-pill"
+ data-pill-label="Thinking"
data-active={activeLook ? "true" : "false"}
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
@@ -2436,7 +2442,9 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({
- {activeLook ? Thinking : null}
+ {activeLook ? (
+ Thinking
+ ) : null}
);
};
@@ -2616,36 +2624,6 @@ const ArtifactsToggle: FC = () => {
);
};
-// Claude gold pill shown while Bypass permissions is on; click to turn it off.
-// Mirror of shared-composer's badge so both composers surface the state.
-const BypassPermissionsToggle: FC = () => {
- const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
- const setBypassPermissions = useChatRuntimeStore(
- (s) => s.setBypassPermissions,
- );
- if (!bypassPermissions) return null;
- return (
- setBypassPermissions(false)}
- className="composer-pill-btn"
- data-active="true"
- data-variant="danger"
- aria-label="Disable Bypass permissions"
- title="Bypass permissions is on (no confirmation, no sandbox). Click to turn off."
- >
-
-
-
- Bypass permissions
-
- );
-};
-
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@@ -3282,7 +3260,7 @@ const PromptQueueStack: FC<{ queueThreadIds: string[] }> = ({
cancelEditing();
}
}}
- className="max-h-20 min-h-8 min-w-0 resize-none rounded-md border border-border/45 bg-transparent px-2 py-1.5 text-sm leading-5 text-foreground outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35"
+ className="max-h-20 min-h-8 min-w-0 resize-none rounded-md border border-border/45 bg-transparent px-2 py-1.5 text-sm leading-5 text-foreground outline-none transition-colors focus-visible:border-ring"
aria-label={`Edit queued prompt ${visiblePosition}`}
/>
{
return (
-
-
+
+
+ {/* Recovery path for interrupted/failed turns: regenerate in place. */}
+
+
+
+ Retry
+
+
);
@@ -3568,9 +3556,6 @@ const AssistantMessage: FC = () => {
const aui = useAui();
const messageId = useAuiState(({ message }) => message.id);
const messageContent = useAuiState(({ message }) => message.content);
- const hasReasoningParts = useAuiState(({ message }) =>
- message.parts.some((part) => part.type === "reasoning"),
- );
const incognito = useChatRuntimeStore((s) => s.incognito);
// Use global store for editing state to ensure a single source of truth
@@ -3636,7 +3621,7 @@ const AssistantMessage: FC = () => {
) : (
<>
- {!hasReasoningParts ? (
-
-
-
- ) : null}
+
+
+
@@ -3820,8 +3803,33 @@ const DeleteMessageButton: FC = () => {
const isRunning = useAuiState(({ thread }) => thread.isRunning);
const handleDelete = async () => {
- const remoteId = aui.threadListItem().getState().remoteId;
const thread = aui.thread();
+ // Deleting a message, and for a user prompt its cascaded assistant replies,
+ // unmounts their only Stop reading control. Stop read-aloud first when the
+ // spoken message is among those removed. Read speech state at click time and
+ // guard the call, which throws if playback already ended.
+ const speakingId = thread.getState().speech?.messageId;
+ if (speakingId) {
+ const { messages } = thread.export();
+ const target = messages.find(({ message }) => message.id === messageId);
+ const removed = new Set
([messageId]);
+ if (target?.message.role === "user") {
+ for (const { parentId, message } of messages) {
+ if (parentId === messageId && message.role === "assistant") {
+ removed.add(message.id);
+ }
+ }
+ }
+ if (removed.has(speakingId)) {
+ try {
+ thread.stopSpeaking();
+ } catch {
+ // Playback ended between reading the state and stopping it.
+ }
+ }
+ }
+
+ const remoteId = aui.threadListItem().getState().remoteId;
try {
await deleteThreadMessage({
thread: {
@@ -3906,11 +3914,15 @@ const EditAssistantMessageButton: FC = () => {
const AssistantActionBar: FC = () => {
const { forkMessage, forkDisabled } = useForkMessageAction();
const [detailsOpen, setDetailsOpen] = useState(false);
+ const ttsEnabled = useVoiceSettingsStore((s) => s.ttsEnabled);
+ // hideWhenRunning is thread-level, so a new run would hide this bar and its
+ // only Stop reading control while read-aloud keeps playing; keep it shown.
+ const speaking = useAuiState(({ message }) => message.speech != null);
return (
<>
@@ -3922,6 +3934,28 @@ const AssistantActionBar: FC = () => {
+ {ttsEnabled && (
+
+
+
+
+
+
+
+ )}
+ {/* Not gated on ttsEnabled: turning the setting off while a message
+ is being read aloud must not remove the only stop control. */}
+
+
+
+
+
+
+
{
strokeWidth={1.75}
className="size-icon"
/>
- Export as Markdown
+ Export as markdown
{
className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
>
@@ -4005,7 +4039,7 @@ const UserMessage: FC = () => {
-
+
diff --git a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx
index 20a22c3a4a..d0bc12706e 100644
--- a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx
+++ b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx
@@ -260,6 +260,30 @@ function ToolFallbackArgs({
);
}
+interface McpImageResult {
+ text: string;
+ images: { data: string; mimeType: string }[];
+}
+
+function isMcpImageResult(val: unknown): val is McpImageResult {
+ if (typeof val !== "object" || val === null) {
+ return false;
+ }
+ const v = val as { text?: unknown; images?: unknown };
+ return (
+ typeof v.text === "string" &&
+ Array.isArray(v.images) &&
+ v.images.length > 0 &&
+ v.images.every(
+ (img: unknown) =>
+ typeof img === "object" &&
+ img !== null &&
+ typeof (img as { data?: unknown }).data === "string" &&
+ typeof (img as { mimeType?: unknown }).mimeType === "string",
+ )
+ );
+}
+
function ToolFallbackResult({
result,
className,
@@ -271,6 +295,8 @@ function ToolFallbackResult({
return null;
}
+ const imageResult = isMcpImageResult(result) ? result : null;
+
return (
Result:
-
- {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
-
+ {imageResult ? (
+ <>
+ {imageResult.text && (
+
+ {imageResult.text}
+
+ )}
+
+ {imageResult.images.map((img, i) => (
+
+ ))}
+
+ >
+ ) : (
+
+ {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
+
+ )}
);
}
diff --git a/studio/frontend/src/components/assistant-ui/tool-group.tsx b/studio/frontend/src/components/assistant-ui/tool-group.tsx
index 74b5e02381..469c449a70 100644
--- a/studio/frontend/src/components/assistant-ui/tool-group.tsx
+++ b/studio/frontend/src/components/assistant-ui/tool-group.tsx
@@ -10,6 +10,7 @@ import {
} from "react";
import { useAuiState } from "@assistant-ui/react";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
+import { toolOutputKey, useToolPaneScope } from "@/features/chat";
import { ChevronDownIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -235,13 +236,29 @@ const ToolGroupImpl: FC<
const messageRunning = useAuiState(
({ message }) => message.status?.type === "running",
);
- // Keep the group open once a confirmation forced it open, so answering an
- // allow/deny doesn't snap it shut between sequential tool calls. It reverts
- // to the default collapsed state once the turn finishes.
+ // Force the group open when any call is receiving tool_output events.
+ const toolLiveOutput = useChatRuntimeStore((s) => s.toolLiveOutput);
+ const paneScope = useToolPaneScope();
+ const hasLiveOutput = useAuiState(({ message }) =>
+ message.parts
+ .slice(startIndex, endIndex + 1)
+ .some(
+ (part) =>
+ part.type === "tool-call" &&
+ Object.prototype.hasOwnProperty.call(
+ toolLiveOutput,
+ toolOutputKey(paneScope, part.toolCallId),
+ ),
+ ),
+ );
+ // Keep the group open once a confirmation or live output forced it (so an
+ // allow/deny doesn't snap it shut between calls); reverts once the turn ends.
const forcedOpenRef = useRef(false);
- if (hasPendingConfirmation) forcedOpenRef.current = true;
+ if (hasPendingConfirmation || hasLiveOutput) forcedOpenRef.current = true;
const forceOpen =
- hasPendingConfirmation || (forcedOpenRef.current && messageRunning);
+ hasPendingConfirmation ||
+ (hasLiveOutput && messageRunning) ||
+ (forcedOpenRef.current && messageRunning);
// Render single tool calls and canvases directly so cards never hide in a
// collapsed group.
diff --git a/studio/frontend/src/components/assistant-ui/tool-live-output.tsx b/studio/frontend/src/components/assistant-ui/tool-live-output.tsx
new file mode 100644
index 0000000000..3434783f0a
--- /dev/null
+++ b/studio/frontend/src/components/assistant-ui/tool-live-output.tsx
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"use client";
+
+import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
+import { toolOutputKey, useToolPaneScope } from "@/features/chat";
+import { useEffect, useMemo, useRef } from "react";
+import { tailText } from "./tool-result-output";
+
+/**
+ * Live-scrolling stdout/stderr pane for a running server-side tool, backed by
+ * the transient `toolLiveOutput` map fed by `tool_output` SSE events. Renders
+ * nothing until the first chunk, then follows the tail. Mounted only while
+ * running; the finished card shows the persisted result instead.
+ */
+export function ToolLiveOutput({ toolCallId }: { toolCallId: string }) {
+ const paneScope = useToolPaneScope();
+ const output = useChatRuntimeStore(
+ (s) => s.toolLiveOutput[toolOutputKey(paneScope, toolCallId)] ?? "",
+ );
+ const scrollRef = useRef
(null);
+ // Pinned to the bottom until the user scrolls up (handler below), so
+ // streaming chunks no longer yank them down.
+ const pinnedToBottom = useRef(true);
+
+ // The stream can reach hundreds of KB; render only the tail while live.
+ const visible = useMemo(() => tailText(output).visible, [output]);
+
+ const handleScroll = () => {
+ const el = scrollRef.current;
+ if (!el) {
+ return;
+ }
+ // Within 40px of the bottom counts as pinned (tolerates small nudges).
+ pinnedToBottom.current =
+ el.scrollHeight - el.scrollTop - el.clientHeight < 40;
+ };
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (el && pinnedToBottom.current) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }, [visible]);
+
+ if (!output) {
+ return null;
+ }
+
+ return (
+
+
output
+
+ {visible}
+
+
+ );
+}
diff --git a/studio/frontend/src/components/assistant-ui/tool-result-output.tsx b/studio/frontend/src/components/assistant-ui/tool-result-output.tsx
new file mode 100644
index 0000000000..45f72ed96c
--- /dev/null
+++ b/studio/frontend/src/components/assistant-ui/tool-result-output.tsx
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"use client";
+
+import { useMemo, useState } from "react";
+
+/** Tail-line cap so a huge output never mounts a megabyte block. */
+const TAIL_LINES = 2000;
+/** Char backstop for pathological single-line outputs. */
+const TAIL_CHARS = 200_000;
+
+interface Tail {
+ visible: string;
+ hiddenLines: number;
+ hiddenChars: number;
+}
+
+export function tailText(text: string): Tail {
+ let visible = text;
+ let hiddenLines = 0;
+ let hiddenChars = 0;
+ const lines = visible.split("\n");
+ if (lines.length > TAIL_LINES) {
+ hiddenLines = lines.length - TAIL_LINES;
+ visible = lines.slice(hiddenLines).join("\n");
+ }
+ if (visible.length > TAIL_CHARS) {
+ hiddenChars = visible.length - TAIL_CHARS;
+ visible = visible.slice(hiddenChars);
+ }
+ return { visible, hiddenLines, hiddenChars };
+}
+
+/**
+ * Finished-tool output pane: renders the tail (~2000 lines) with a "Show all"
+ * toggle so a large output stays scrollable without janking the DOM. Copy
+ * buttons still copy the FULL text (owned by the caller), not the tail.
+ */
+export function ToolResultOutput({ text }: { text: string }) {
+ const [showAll, setShowAll] = useState(false);
+ const tail = useMemo(() => tailText(text), [text]);
+ const truncated = !showAll && (tail.hiddenLines > 0 || tail.hiddenChars > 0);
+
+ return (
+ <>
+ {truncated && (
+ setShowAll(true)}
+ className="mt-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ >
+ {tail.hiddenLines > 0
+ ? `Show all (${tail.hiddenLines.toLocaleString()} earlier lines hidden)`
+ : `Show all (${tail.hiddenChars.toLocaleString()} earlier chars hidden)`}
+
+ )}
+
+ {showAll ? text : tail.visible}
+
+ >
+ );
+}
diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx
index edce6200d5..5f11cb85a8 100644
--- a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx
+++ b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx
@@ -326,7 +326,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
setExpandedCaptionPrompt((value) =>
value === captionPrompt ? null : captionPrompt,
diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx
index 1afffaed7d..1b3dd22000 100644
--- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx
+++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx
@@ -6,6 +6,7 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { getAuthToken } from "@/features/auth/session";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
+import { useToolArgsStatus } from "@assistant-ui/react";
import { code as codePlugin } from "@streamdown/code";
import { CodeIcon, CopyIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
@@ -18,6 +19,14 @@ import {
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
+import { ToolLiveOutput } from "./tool-live-output";
+import { ToolResultOutput } from "./tool-result-output";
+import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
+import {
+ preferFullToolOutput,
+ toolOutputKey,
+ useToolPaneScope,
+} from "@/features/chat";
interface StructuredResult {
text: string;
@@ -105,6 +114,7 @@ function isStructuredResult(val: unknown): val is StructuredResult {
}
const PythonToolUIImpl: ToolCallMessagePartComponent = ({
+ toolCallId,
args,
result,
status,
@@ -112,6 +122,9 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
const code = (args as { code?: string })?.code ?? "";
const firstLine = code.split("\n")[0]?.slice(0, 60) ?? "";
const isRunning = status?.type === "running";
+ // Args still streaming = the model is WRITING the code, not running it yet.
+ const { propStatus } = useToolArgsStatus();
+ const isWritingCode = isRunning && propStatus.code === "streaming";
let output: string;
let images: string[] = [];
@@ -129,10 +142,19 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
output = "";
}
+ // Show the fuller live stream over a truncated result, keeping its exit
+ // status. Session-transient: after a reload only the result remains.
+ const paneScope = useToolPaneScope();
+ const fullOutput = useChatRuntimeStore(
+ (s) => s.toolFullOutput[toolOutputKey(paneScope, toolCallId)] ?? "",
+ );
+ const displayOutput = preferFullToolOutput(fullOutput, output);
+
const authToken = getAuthToken();
return (
-
+ // Open when mounted mid-run so live output shows; collapsed from history.
+
-
- Running…
-
- ) : output ? (
+ <>
+
+
+ {isWritingCode ? "Writing code…" : "Running…"}
+
+ {/* Live stdout streamed via tool_output SSE events. */}
+
+ >
+ ) : displayOutput ? (
output
-
+
-
- {truncate(output)}
-
+
) : null}
diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx
index c088d5cf98..17ae26d388 100644
--- a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx
+++ b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx
@@ -5,6 +5,7 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
+import { useToolArgsStatus } from "@assistant-ui/react";
import { CopyIcon, TerminalIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -15,16 +16,17 @@ import {
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
+import { ToolLiveOutput } from "./tool-live-output";
+import { ToolResultOutput } from "./tool-result-output";
+import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
+import {
+ preferFullToolOutput,
+ toolOutputKey,
+ useToolPaneScope,
+} from "@/features/chat";
-const MAX_DISPLAY = 10_000;
const COPY_RESET_MS = 2000;
-function truncate(text: string): string {
- return text.length <= MAX_DISPLAY
- ? text
- : `${text.slice(0, MAX_DISPLAY)}\n... (truncated)`;
-}
-
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef
| null>(null);
@@ -65,12 +67,16 @@ function CopyBtn({ text }: { text: string }) {
}
const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
+ toolCallId,
args,
result,
status,
}) => {
const command = (args as { command?: string })?.command ?? "";
const isRunning = status?.type === "running";
+ // Args still streaming = the model is WRITING the command, not running it yet.
+ const { propStatus } = useToolArgsStatus();
+ const isWritingCommand = isRunning && propStatus.command === "streaming";
const output =
typeof result === "string"
? result
@@ -78,8 +84,17 @@ const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
? JSON.stringify(result, null, 2)
: "";
+ // Show the fuller live stream over a truncated result, keeping its exit
+ // status. Session-transient: after a reload only the result remains.
+ const paneScope = useToolPaneScope();
+ const fullOutput = useChatRuntimeStore(
+ (s) => s.toolFullOutput[toolOutputKey(paneScope, toolCallId)] ?? "",
+ );
+ const displayOutput = preferFullToolOutput(fullOutput, output);
+
return (
-
+ // Open when mounted mid-run so live output shows; collapsed from history.
+
{isRunning ? (
-
-
- Running…
-
- ) : output ? (
+ <>
+
+
+ {isWritingCommand ? "Writing command…" : "Running…"}
+
+ {/* Live stdout streamed via tool_output SSE events. */}
+
+ >
+ ) : displayOutput ? (
output
-
+
-
- {truncate(output)}
-
+
) : null}
diff --git a/studio/frontend/src/components/floating-monitor.tsx b/studio/frontend/src/components/floating-monitor.tsx
index bce4bf2831..1272a577e9 100644
--- a/studio/frontend/src/components/floating-monitor.tsx
+++ b/studio/frontend/src/components/floating-monitor.tsx
@@ -3,27 +3,35 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
-import { useMonitorOverlayStore } from "@/features/settings/stores/monitor-overlay-store";
+import { useMonitorOverlayStore } from "@/features/settings";
import { useSystemInfo } from "@/hooks/use-system";
import { useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { CpuIcon, GripVerticalIcon, XIcon } from "lucide-react";
-import { motion } from "motion/react";
-import { useRef } from "react";
+import { AnimatePresence, motion, useDragControls } from "motion/react";
+import { type PointerEvent, useMemo, useState } from "react";
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function usageIndicatorClass(percent: number): string {
- if (percent >= 90) return "bg-destructive";
- if (percent >= 70) return "bg-amber-500";
- return "bg-primary";
+ if (percent >= 90) {
+ return "bg-destructive";
+ }
+ if (percent >= 70) {
+ return "bg-amber-500";
+ }
+ return "bg-control-accent";
}
function usageTextClass(percent: number): string {
- if (percent >= 90) return "text-destructive";
- if (percent >= 70) return "text-amber-600 dark:text-amber-400";
+ if (percent >= 90) {
+ return "text-destructive";
+ }
+ if (percent >= 70) {
+ return "text-amber-600 dark:text-amber-400";
+ }
return "text-primary";
}
@@ -39,9 +47,18 @@ export function FloatingMonitor() {
const { isOpen, setIsOpen } = useMonitorOverlayStore();
const systemInfo = useSystemInfo({ enabled: isOpen, pollMs: 5000 });
- const constraintsRef = useRef(null);
+ const [constraintsElement, setConstraintsElement] =
+ useState(null);
+ const constraintsRef = useMemo(
+ () => ({ current: constraintsElement }),
+ [constraintsElement],
+ );
+ const dragControls = useDragControls();
- if (!isOpen) return null;
+ function startDrag(event: PointerEvent) {
+ event.preventDefault();
+ dragControls.start(event);
+ }
const ramTotal = systemInfo.memory?.total_gb ?? 0;
const ramAvailable = systemInfo.memory?.available_gb ?? 0;
@@ -64,99 +81,109 @@ export function FloatingMonitor() {
const hasGpu = (systemInfo.gpu?.available ?? false) && devices.length > 0;
return (
-
-
-
-
-
-
- {t("settings.resources.liveMonitor.title")}
-
-
-
-
-
-
-
-
setIsOpen(false)}
- title={t("common.close")}
- aria-label={t("common.close")}
- >
-
-
-
-
-
-
+ {isOpen && (
+
-
-
- {t("settings.resources.liveMonitor.ram")}
-
- {Math.round(ramPercent)}%
-
-
-
- {formatGiB(ramUsed)} / {formatGiB(ramTotal)}
-
-
-
-
- {hasGpu && (
-
-
-
- {t("settings.resources.liveMonitor.vram")}{" "}
- {devices.length > 1
- ? `(${devices.length} GPUs)`
- : `(${devices[0].name ?? "GPU"})`}
+
+
+
+
+
+ {t("settings.resources.liveMonitor.title")}
-
+
+
- {Math.round(vramPercent)}%
-
+
+
+
+
setIsOpen(false)}
+ title={t("common.close")}
+ aria-label={t("common.close")}
+ >
+
+
-
- {formatGiB(vramUsed)} / {formatGiB(vramTotal)}
-
-
- )}
-
-
-
+
+
+
+
+ {t("settings.resources.liveMonitor.ram")}
+
+ {Math.round(ramPercent)}%
+
+
+
+ {formatGiB(ramUsed)} / {formatGiB(ramTotal)}
+
+
+
+
+ {hasGpu && (
+
+
+
+ {t("settings.resources.liveMonitor.vram")}{" "}
+ {devices.length > 1
+ ? `(${devices.length} GPUs)`
+ : `(${devices[0].name ?? "GPU"})`}
+
+
+ {Math.round(vramPercent)}%
+
+
+
+ {formatGiB(vramUsed)} / {formatGiB(vramTotal)}
+
+
+
+ )}
+
+
+
+ )}
+
);
}
diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx
index 840383de90..3db15ffe30 100644
--- a/studio/frontend/src/components/llama-update-banner.tsx
+++ b/studio/frontend/src/components/llama-update-banner.tsx
@@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
+import { resyncInferenceStatusAfterServerModelChange } from "@/features/chat";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref";
import { toast } from "@/lib/toast";
@@ -81,9 +82,14 @@ export function LlamaUpdateBanner({
positioned = true,
}: LlamaUpdateBannerProps): ReactElement | null {
const showBannerPref = useShowLlamaUpdateBanner();
+ // Not gated on showBannerPref: this hook instance is the app-wide listener
+ // for a cross-tab reload_required resync (the settings-sheet's own instance
+ // only runs during an MTP-fallback rebuild), so muting the banner must not
+ // also silence that resync -- it only suppresses the UI below.
const { status, visible, applying, apply, dismiss, snooze } =
useLlamaUpdateCheck({
- enabled: enabled && showBannerPref,
+ enabled,
+ onReloadRequired: resyncInferenceStatusAfterServerModelChange,
});
async function handleUpdate() {
@@ -102,7 +108,10 @@ export function LlamaUpdateBanner({
}
const show =
- visible && status != null && (status.update_available || applying);
+ showBannerPref &&
+ visible &&
+ status != null &&
+ (status.update_available || applying);
const sizeBytes = status?.update_size_bytes ?? null;
const sizeLabel =
sizeBytes && sizeBytes > 0
diff --git a/studio/frontend/src/components/section-card.tsx b/studio/frontend/src/components/section-card.tsx
index cd8eb3d503..e894749494 100644
--- a/studio/frontend/src/components/section-card.tsx
+++ b/studio/frontend/src/components/section-card.tsx
@@ -17,10 +17,10 @@ interface SectionCardProps {
}
const accentStyles = {
+ // Brand accent variant; follows the active palette via --control-accent.
emerald: {
- border: "ring-emerald-500/20",
- iconBox:
- "ring-emerald-200 bg-emerald-50 text-emerald-600 dark:ring-emerald-800 dark:bg-emerald-950 dark:text-emerald-400",
+ border: "ring-control-accent/20",
+ iconBox: "ring-control-accent/25 bg-control-accent/10 text-control-accent",
},
indigo: {
border: "ring-indigo-500/20",
@@ -61,7 +61,7 @@ export function SectionCard({
)}
>
{featured && (
-
+
)}
{/* Header */}
@@ -77,7 +77,7 @@ export function SectionCard({
{title}
{badge && (
-
+
{badge}
)}
diff --git a/studio/frontend/src/components/tauri/window-titlebar.tsx b/studio/frontend/src/components/tauri/window-titlebar.tsx
index 66efe8d74a..d86af54f79 100644
--- a/studio/frontend/src/components/tauri/window-titlebar.tsx
+++ b/studio/frontend/src/components/tauri/window-titlebar.tsx
@@ -93,7 +93,7 @@ function WindowControlButton({
title={label}
onClick={onClick}
className={cn(
- "relative z-[80] inline-flex size-8 items-center justify-center rounded-[10px] text-muted-foreground/90 transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ "relative z-[80] inline-flex size-8 items-center justify-center rounded-[10px] text-muted-foreground/90 transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
>
@@ -287,7 +287,7 @@ export function WindowTitlebar({
event.stopPropagation();
togglePinned();
}}
- className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] text-nav-icon-idle transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] text-nav-icon-idle transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{children}
{
duration?: number
@@ -15,6 +15,7 @@ interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"butt
export function useAnimatedThemeToggle(duration = 400) {
const [isDark, setIsDark] = useState(false)
const anchorRef = useRef(null)
+ const inFlightRef = useRef(false)
useEffect(() => {
const updateTheme = () => {
@@ -30,45 +31,63 @@ export function useAnimatedThemeToggle(duration = 400) {
}, [])
const toggleTheme = useCallback(async () => {
- const anchor = anchorRef.current
+ // One toggle per animation. Clicks during a slow view transition would
+ // otherwise queue up and land as invisible back-and-forth flips, so the
+ // theme looks stuck until an odd number of clicks gets through.
+ if (inFlightRef.current) return
+
const applyTheme = () => {
flushSync(() => {
- const newTheme = !isDark
- setIsDark(newTheme)
- setTheme(newTheme ? "dark" : "light")
+ // Read the live class instead of React state, which can lag the DOM
+ // while a transition is being captured.
+ const nextDark = !document.documentElement.classList.contains("dark")
+ setIsDark(nextDark)
+ setTheme(nextDark ? "dark" : "light")
})
}
- if (!document.startViewTransition) {
+ // Skip the view transition (its clip-path runs via the Web Animations API,
+ // which CSS force-reduced-motion cannot reach) when reduced motion is set.
+ if (!document.startViewTransition || prefersReducedMotion()) {
applyTheme()
return
}
- await document.startViewTransition(applyTheme).ready
+ inFlightRef.current = true
+ try {
+ const transition = document.startViewTransition(applyTheme)
+ await transition.ready
- if (anchor) {
- const { top, left, width, height } = anchor.getBoundingClientRect()
- const x = left + width / 2
- const y = top + height / 2
- const maxRadius = Math.hypot(
- Math.max(left, window.innerWidth - left),
- Math.max(top, window.innerHeight - top)
- )
- document.documentElement.animate(
- {
- clipPath: [
- `circle(0px at ${x}px ${y}px)`,
- `circle(${maxRadius}px at ${x}px ${y}px)`,
- ],
- },
- {
- duration,
- easing: "ease-in-out",
- pseudoElement: "::view-transition-new(root)",
- }
- )
+ const anchor = anchorRef.current
+ if (anchor) {
+ const { top, left, width, height } = anchor.getBoundingClientRect()
+ const x = left + width / 2
+ const y = top + height / 2
+ const maxRadius = Math.hypot(
+ Math.max(left, window.innerWidth - left),
+ Math.max(top, window.innerHeight - top)
+ )
+ document.documentElement.animate(
+ {
+ clipPath: [
+ `circle(0px at ${x}px ${y}px)`,
+ `circle(${maxRadius}px at ${x}px ${y}px)`,
+ ],
+ },
+ {
+ duration,
+ easing: "ease-in-out",
+ pseudoElement: "::view-transition-new(root)",
+ }
+ )
+ }
+ await transition.finished
+ } catch {
+ // A skipped transition still applied the theme.
+ } finally {
+ inFlightRef.current = false
}
- }, [isDark, duration])
+ }, [duration])
return { isDark, toggleTheme, anchorRef }
}
@@ -78,70 +97,13 @@ export const AnimatedThemeToggler = ({
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
- const [isDark, setIsDark] = useState(false)
- const buttonRef = useRef(null)
-
- useEffect(() => {
- const updateTheme = () => {
- setIsDark(document.documentElement.classList.contains("dark"))
- }
-
- updateTheme()
-
- const observer = new MutationObserver(updateTheme)
- observer.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ["class"],
- })
-
- return () => observer.disconnect()
- }, [])
-
- const toggleTheme = useCallback(async () => {
- if (!buttonRef.current) return
-
- const apply = () => {
- flushSync(() => {
- const newTheme = !isDark
- setIsDark(newTheme)
- setTheme(newTheme ? "dark" : "light")
- })
- }
-
- if (!document.startViewTransition) {
- apply()
- return
- }
-
- await document.startViewTransition(apply).ready
-
- const { top, left, width, height } =
- buttonRef.current.getBoundingClientRect()
- const x = left + width / 2
- const y = top + height / 2
- const maxRadius = Math.hypot(
- Math.max(left, window.innerWidth - left),
- Math.max(top, window.innerHeight - top)
- )
-
- document.documentElement.animate(
- {
- clipPath: [
- `circle(0px at ${x}px ${y}px)`,
- `circle(${maxRadius}px at ${x}px ${y}px)`,
- ],
- },
- {
- duration,
- easing: "ease-in-out",
- pseudoElement: "::view-transition-new(root)",
- }
- )
- }, [isDark, duration])
+ const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle(duration)
return (
{
+ anchorRef.current = node
+ }}
onClick={toggleTheme}
className={cn(className)}
{...props}
diff --git a/studio/frontend/src/components/ui/badge.tsx b/studio/frontend/src/components/ui/badge.tsx
index 0f2f334986..a476a16b89 100644
--- a/studio/frontend/src/components/ui/badge.tsx
+++ b/studio/frontend/src/components/ui/badge.tsx
@@ -10,7 +10,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
export const badgeVariants = cva(
- "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
+ "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {
diff --git a/studio/frontend/src/components/ui/button.tsx b/studio/frontend/src/components/ui/button.tsx
index 51fc31697e..e3663d724b 100644
--- a/studio/frontend/src/components/ui/button.tsx
+++ b/studio/frontend/src/components/ui/button.tsx
@@ -10,14 +10,14 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
export const buttonVariants = cva(
- "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-full border border-transparent text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
+ "focus-visible:border-ring dark:focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-full border border-transparent text-sm font-medium aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
outline:
- "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
+ "border-border bg-background hover:bg-accent/50 dark:border-transparent dark:bg-white/[0.06] dark:hover:bg-white/10 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
diff --git a/studio/frontend/src/components/ui/calendar.tsx b/studio/frontend/src/components/ui/calendar.tsx
index be52f9c354..6ace64bed6 100644
--- a/studio/frontend/src/components/ui/calendar.tsx
+++ b/studio/frontend/src/components/ui/calendar.tsx
@@ -225,7 +225,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
- "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70",
+ "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-1 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
diff --git a/studio/frontend/src/components/ui/checkbox.tsx b/studio/frontend/src/components/ui/checkbox.tsx
index edd8965348..c770b5c3e1 100644
--- a/studio/frontend/src/components/ui/checkbox.tsx
+++ b/studio/frontend/src/components/ui/checkbox.tsx
@@ -16,7 +16,7 @@ function Checkbox({
{
+ onWheel?.(event);
+ // Dialog scroll locks cancel native wheel scrolling on this
+ // body-portaled popup, so scroll the list by hand while one is
+ // active.
+ if (!document.body.hasAttribute("data-scroll-locked")) return;
+ const list = event.currentTarget.querySelector(
+ '[data-slot="combobox-list"]',
+ );
+ if (!list) return;
+ const step =
+ event.deltaMode === 1
+ ? event.deltaY * 24
+ : event.deltaMode === 2
+ ? event.deltaY * list.clientHeight
+ : event.deltaY;
+ list.scrollTop += step;
+ }}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:bg-input/30 max-h-72 min-w-36 overflow-hidden rounded-xl corner-squircle duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none group/combobox-content relative pointer-events-auto max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
className,
@@ -290,7 +309,7 @@ function ComboboxChips({
) {
return (
-
-
+
+
@@ -72,7 +72,7 @@ export function CopyableErrorChip({
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy error message"}
className={cn(
- "inline-flex items-center gap-1 rounded-md border border-border/60 px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ "inline-flex items-center gap-1 rounded-md border border-border/60 px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
copied && "border-emerald-500/40 text-emerald-600 dark:text-emerald-500",
)}
>
diff --git a/studio/frontend/src/components/ui/field.tsx b/studio/frontend/src/components/ui/field.tsx
index f1e6b23999..2a24d1ace7 100644
--- a/studio/frontend/src/components/ui/field.tsx
+++ b/studio/frontend/src/components/ui/field.tsx
@@ -107,7 +107,7 @@ function FieldLabel({
[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
+ "has-data-checked:bg-primary/5 has-data-checked:border-ring-strong dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
diff --git a/studio/frontend/src/components/ui/info-hint.tsx b/studio/frontend/src/components/ui/info-hint.tsx
index 4433478647..db663c792e 100644
--- a/studio/frontend/src/components/ui/info-hint.tsx
+++ b/studio/frontend/src/components/ui/info-hint.tsx
@@ -20,7 +20,7 @@ export function InfoHint({ children }: { children: ReactNode }) {
) {
data-slot="input-group"
role="group"
className={cn(
- "border-input bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-9 rounded-4xl border transition-colors has-data-[align=block-end]:rounded-2xl has-data-[align=block-start]:rounded-2xl has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[textarea]:rounded-xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
+ "border-border bg-background dark:border-transparent dark:bg-white/[0.06] has-[[data-slot=input-group-control]:focus-visible]:border-ring dark:has-[[data-slot=input-group-control]:focus-visible]:border-transparent dark:has-[[data-slot=input-group-control]:focus-visible]:bg-white/[0.12] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-9 rounded-full border transition-colors has-data-[align=block-end]:rounded-2xl has-data-[align=block-start]:rounded-2xl has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[textarea]:rounded-xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className,
)}
{...props}
diff --git a/studio/frontend/src/components/ui/input.tsx b/studio/frontend/src/components/ui/input.tsx
index 4dfdf484a7..f98790eb01 100644
--- a/studio/frontend/src/components/ui/input.tsx
+++ b/studio/frontend/src/components/ui/input.tsx
@@ -5,18 +5,142 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+const BASE_CLASSES =
+ // White fill with a subtle border,
+ // fully-rounded pill for single-row controls.
+ "bg-background border-border dark:border-transparent dark:bg-white/[0.06] focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-full border px-3.5 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
+
+function stepNumberInput(input: HTMLInputElement, direction: 1 | -1): void {
+ if (input.disabled || input.readOnly) {
+ return;
+ }
+ const step = input.step === "" ? 1 : Number(input.step) || 1;
+ const min = input.min === "" ? null : Number(input.min);
+ const max = input.max === "" ? null : Number(input.max);
+ // An empty field steps from its placeholder (the effective default).
+ const current =
+ input.value === "" ? Number(input.placeholder) : Number(input.value);
+ let next: number;
+ if (Number.isFinite(current)) {
+ // Snap to the step grid (anchored at min, like the native spinner) rather
+ // than adding step to an off-grid typed value, which would leave a
+ // step-invalid result. Mirrors HTMLInputElement.stepUp/stepDown.
+ const base = min ?? 0;
+ const pos = (current - base) / step;
+ const rounded = Math.round(pos);
+ const onGrid = Math.abs(pos - rounded) < 1e-9;
+ const nextPos = onGrid
+ ? rounded + direction
+ : direction === 1
+ ? Math.ceil(pos)
+ : Math.floor(pos);
+ next = base + nextPos * step;
+ } else {
+ // Stepping an empty, non-numeric field starts from the minimum, matching
+ // the native spinner.
+ next = min ?? direction * step;
+ }
+ if (min !== null) {
+ next = Math.max(min, next);
+ }
+ if (max !== null) {
+ next = Math.min(max, next);
+ }
+ // Trim float noise from fractional steps.
+ const decimals = (String(step).split(".")[1] ?? "").length;
+ const value = String(Number(next.toFixed(decimals)));
+ // Write through the native setter and emit "input" so the React onChange
+ // of controlled callers fires.
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ setter?.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+function StepperArrow({ direction }: { direction: 1 | -1 }) {
return (
+
+
+
+ );
+}
+
+function StepperButton({ direction }: { direction: 1 | -1 }) {
+ return (
+ {
+ // Keep focus on the field itself.
+ event.preventDefault();
+ }}
+ onClick={(event) => {
+ const input = event.currentTarget
+ .closest('[data-slot="number-input"]')
+ ?.querySelector("input");
+ if (input) {
+ input.focus({ preventScroll: true });
+ stepNumberInput(input, direction);
+ }
+ }}
+ className="flex h-1/2 w-full cursor-default items-center justify-center text-foreground/55 hover:bg-black/10 active:bg-black/15 dark:text-white/70 dark:hover:bg-white/10 dark:active:bg-white/15"
+ >
+
+
+ );
+}
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ const field = (
);
+ if (type !== "number") {
+ return field;
+ }
+ // Number fields swap the native spinner (hidden globally in index.css) for
+ // a shared grey stepper.
+ return (
+
+ /^(?:min-w|max-w|w)-/.test(c) ||
+ c === "nodrag" ||
+ c === "nopan" ||
+ c === "nowheel",
+ ),
+ )}
+ >
+ {field}
+
+
+
+
+
+ );
}
export { Input };
diff --git a/studio/frontend/src/components/ui/navigation-menu.tsx b/studio/frontend/src/components/ui/navigation-menu.tsx
index 3014c3f66c..f8a6dfc503 100644
--- a/studio/frontend/src/components/ui/navigation-menu.tsx
+++ b/studio/frontend/src/components/ui/navigation-menu.tsx
@@ -8,7 +8,7 @@ import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
-import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function NavigationMenu({
@@ -69,7 +69,7 @@ export function NavigationMenuItem({
}
export const navigationMenuTriggerStyle = cva(
- "bg-background hover:bg-muted focus:bg-muted data-open:hover:bg-muted data-open:focus:bg-muted data-open:bg-muted/50 focus-visible:ring-ring/50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-2xl px-4.5 py-2.5 text-sm font-medium transition-all focus-visible:ring-[3px] focus-visible:outline-1 disabled:opacity-50 group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center disabled:pointer-events-none outline-none",
+ "bg-background hover:bg-muted focus:bg-muted data-open:hover:bg-muted data-open:focus:bg-muted data-open:bg-muted/50 focus-visible:ring-ring data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-2xl px-4.5 py-2.5 text-sm font-medium transition-all focus-visible:ring-1 focus-visible:outline-1 disabled:opacity-50 group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center disabled:pointer-events-none outline-none",
);
export function NavigationMenuTrigger({
@@ -87,7 +87,7 @@ export function NavigationMenuTrigger({
>
{children}{" "}
) {
+}: React.ComponentProps & {
+ container?: HTMLElement | null;
+}) {
+ // Inside a modal dialog the body scroll lock swallows wheel events on
+ // body-portaled content; portal into the dialog instead (like Select).
+ const dialogContainer = useDialogPortalContainer();
return (
-
+
diff --git a/studio/frontend/src/components/ui/radio-group.tsx b/studio/frontend/src/components/ui/radio-group.tsx
index 0f9d4b2407..62225adc6e 100644
--- a/studio/frontend/src/components/ui/radio-group.tsx
+++ b/studio/frontend/src/components/ui/radio-group.tsx
@@ -29,7 +29,7 @@ function RadioGroupItem({
div]:rotate-90",
+ "group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
diff --git a/studio/frontend/src/components/ui/scroll-area.tsx b/studio/frontend/src/components/ui/scroll-area.tsx
index e9087ef376..1e60dcb10e 100644
--- a/studio/frontend/src/components/ui/scroll-area.tsx
+++ b/studio/frontend/src/components/ui/scroll-area.tsx
@@ -21,7 +21,7 @@ function ScrollArea({
>
{children}
diff --git a/studio/frontend/src/components/ui/select.tsx b/studio/frontend/src/components/ui/select.tsx
index 07f39fa935..925cc45b36 100644
--- a/studio/frontend/src/components/ui/select.tsx
+++ b/studio/frontend/src/components/ui/select.tsx
@@ -8,14 +8,12 @@ import type * as React from "react";
import { createContext, useContext, useState } from "react";
import { Tick02Icon } from "@/lib/tick-icon";
-import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import {
+ ChevronDownStandardIcon,
+ ChevronUpStandardIcon,
+} from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { useDialogPortalContainer } from "@/components/ui/dialog";
-import {
- ArrowDown01Icon,
- ArrowUp01Icon,
- UnfoldMoreIcon,
-} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
const SelectOpenContext = createContext(false);
@@ -70,7 +68,7 @@ function SelectTrigger({
...props
}: React.ComponentProps & {
size?: "sm" | "default";
- icon?: typeof UnfoldMoreIcon;
+ icon?: typeof ChevronDownStandardIcon;
iconClassName?: string;
animateRadius?: boolean;
}) {
@@ -91,7 +89,7 @@ function SelectTrigger({
: undefined
}
className={cn(
- "border-input data-[placeholder]:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
+ "border-border data-[placeholder]:text-muted-foreground bg-background hover:bg-accent/50 dark:border-transparent dark:bg-white/[0.06] dark:hover:bg-white/10 focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-full border px-3.5 py-2 text-sm transition-colors aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
className,
)}
{...props}
@@ -222,7 +220,7 @@ function SelectScrollUpButton({
)}
{...props}
>
-
+
);
}
@@ -240,7 +238,7 @@ function SelectScrollDownButton({
)}
{...props}
>
-
+
);
}
diff --git a/studio/frontend/src/components/ui/sidebar.tsx b/studio/frontend/src/components/ui/sidebar.tsx
index 3a8694908f..4eb37d4e89 100644
--- a/studio/frontend/src/components/ui/sidebar.tsx
+++ b/studio/frontend/src/components/ui/sidebar.tsx
@@ -471,7 +471,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
- "text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
+ "text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-1 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
className
)}
{...props}
@@ -491,7 +491,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
- "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-2 [&>svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-1 [&>svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
className
)}
{...props}
@@ -637,7 +637,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
- "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 aspect-square w-5 rounded-md p-0 peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 focus-visible:ring-2 [&>svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 aspect-square w-5 rounded-md p-0 peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 focus-visible:ring-1 [&>svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
showOnHover &&
"peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-open:opacity-100 md:opacity-0",
className
@@ -747,7 +747,7 @@ function SidebarMenuSubButton({
data-size={size}
data-active={isActive}
className={cn(
- "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-1 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
className
)}
{...props}
diff --git a/studio/frontend/src/components/ui/slider.tsx b/studio/frontend/src/components/ui/slider.tsx
index 705182e5ae..b921b25942 100644
--- a/studio/frontend/src/components/ui/slider.tsx
+++ b/studio/frontend/src/components/ui/slider.tsx
@@ -103,7 +103,7 @@ function Slider({
))}
diff --git a/studio/frontend/src/components/ui/sonner.tsx b/studio/frontend/src/components/ui/sonner.tsx
index 33c2b77262..aec1235b81 100644
--- a/studio/frontend/src/components/ui/sonner.tsx
+++ b/studio/frontend/src/components/ui/sonner.tsx
@@ -9,7 +9,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
-import { useTheme } from "next-themes";
+import { useTheme } from "@/features/settings/stores/theme-store";
import { Toaster as Sonner, type ToasterProps } from "sonner";
// Make toast text selectable. Sonner's onPointerDown calls setPointerCapture(),
@@ -33,9 +33,9 @@ const handleToastPointerDownCapture = (
};
const Toaster = ({ ...props }: ToasterProps) => {
- // Use resolvedTheme so sonner's data-sonner-theme always matches the class
- // next-themes puts on ; sonner-side "system" resolution can drift.
- const { resolvedTheme } = useTheme();
+ // Use the resolved mode so sonner's data-sonner-theme always matches the
+ // class the theme store puts on .
+ const { resolved } = useTheme();
return (
// display:contents adds no box; only carries the selection-fix handler.
@@ -45,7 +45,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
onPointerDownCapture={handleToastPointerDownCapture}
>
{
// Pin the close button inside the toast's top-right corner.
// Sonner defaults to the left/outside edge, so keep the horizontal
// override here and the top offset in index.css.
- "--toast-close-button-start": "unset",
+ "--toast-close-button-start": "auto",
"--toast-close-button-end": "8px",
"--toast-close-button-transform": "none",
} as React.CSSProperties
diff --git a/studio/frontend/src/components/ui/switch.tsx b/studio/frontend/src/components/ui/switch.tsx
index 910005c0d6..db63936f37 100644
--- a/studio/frontend/src/components/ui/switch.tsx
+++ b/studio/frontend/src/components/ui/switch.tsx
@@ -18,14 +18,14 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
- "data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
+ "data-checked:bg-control-accent data-unchecked:bg-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
);
diff --git a/studio/frontend/src/components/ui/tabs.tsx b/studio/frontend/src/components/ui/tabs.tsx
index 1e5381ab18..27f0257d86 100644
--- a/studio/frontend/src/components/ui/tabs.tsx
+++ b/studio/frontend/src/components/ui/tabs.tsx
@@ -106,11 +106,13 @@ export function TabsTrigger({
data-slot="tabs-trigger"
value={value}
className={cn(
- "gap-1.5 rounded-xl corner-squircle border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-colors group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ "gap-1.5 rounded-xl corner-squircle border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring dark:focus-visible:border-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-colors group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
// Line variant is a roomier pill (no underline); padding overrides px-2 py-1.
"group-data-[variant=line]/tabs-list:px-3.5 group-data-[variant=line]/tabs-list:py-2.5",
"data-active:text-foreground dark:data-active:text-foreground",
+ // The sliding pill marks the active tab; no focus ring on top of it.
+ "data-active:focus-visible:ring-0 data-active:focus-visible:border-transparent dark:data-active:focus-visible:border-transparent data-active:focus-visible:outline-none",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5",
className,
)}
@@ -122,7 +124,7 @@ export function TabsTrigger({
className={cn(
"absolute inset-0",
indicatorClassName ??
- "rounded-xl bg-background dark:bg-input/30 dark:border dark:border-input group-data-[variant=line]/tabs-list:bg-[#ececec] dark:group-data-[variant=line]/tabs-list:bg-[#2d2f33] dark:group-data-[variant=line]/tabs-list:border-0",
+ "rounded-xl bg-background dark:bg-input/30 group-data-[variant=line]/tabs-list:bg-[#ececec] dark:group-data-[variant=line]/tabs-list:bg-[#2d2f33] dark:group-data-[variant=line]/tabs-list:border-0",
)}
transition={{
type: "spring",
diff --git a/studio/frontend/src/components/ui/textarea.tsx b/studio/frontend/src/components/ui/textarea.tsx
index 7ffa58f7b9..697ee9843b 100644
--- a/studio/frontend/src/components/ui/textarea.tsx
+++ b/studio/frontend/src/components/ui/textarea.tsx
@@ -18,7 +18,9 @@ function Textarea({
);
}
function BypassPermissionsToggle() {
- const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
- const setBypassPermissions = useChatRuntimeStore(
- (s) => s.setBypassPermissions,
- );
- const [dialogOpen, setDialogOpen] = useState(false);
+ const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
return (
-
-
-
-
- Bypass permissions
-
-
- Dangerous. Runs every tool call with no confirmation and disables
- the python/terminal sandbox. Environment secrets are stripped, but
- code can still read files and credentials on your machine.
-
-
-
{
- if (next) setDialogOpen(true);
- else setBypassPermissions(false);
- }}
- />
+
+
+
+ Bypass permissions
+
+
+ How Unsloth approves tool calls before they run. Full access is
+ dangerous: it disables confirmations and the code sandbox.
+
- {bypassPermissions ? (
+ {/* Full width, styled like the panel selects/preset input. */}
+
+ {permissionMode === "full" ? (
Tool calls run with no confirmation and no sandbox.
) : null}
-
-
-
- Enable Bypass permissions?
-
- Bypass permissions is dangerous since the AI model might delete,
- corrupt your machine, and or cause real world damage to you or the
- world - only accept if you are certain
-
-
-
- Cancel
- {
- setBypassPermissions(true);
- setDialogOpen(false);
- }}
- >
- I understand
-
-
-
-
);
}
diff --git a/studio/frontend/src/features/chat/components/context-usage-bar.tsx b/studio/frontend/src/features/chat/components/context-usage-bar.tsx
index 16dee2a624..80f502e222 100644
--- a/studio/frontend/src/features/chat/components/context-usage-bar.tsx
+++ b/studio/frontend/src/features/chat/components/context-usage-bar.tsx
@@ -23,7 +23,7 @@ function getSeverityColor(percent: number): {
} {
if (percent > 85) return { bar: "bg-red-500", text: "text-red-500" };
if (percent > 65) return { bar: "bg-amber-500", text: "text-amber-500" };
- return { bar: "bg-emerald-500", text: "text-emerald-500" };
+ return { bar: "bg-control-accent", text: "text-control-accent" };
}
export const ContextUsageBar: FC<{
diff --git a/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx b/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx
index b28bf83737..cb0234579b 100644
--- a/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx
+++ b/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx
@@ -60,18 +60,6 @@ const TTL_MAX = 20; // OpenAI hard cap on expires_after.minutes
// minute without hammering /v1/containers.
const REFRESH_POLL_MS = 30_000;
-function ageLabel(epochSeconds: number | null | undefined): string {
- if (!epochSeconds) return "";
- const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds);
- if (ageSec < 60) return `${ageSec}s ago`;
- const ageMin = Math.floor(ageSec / 60);
- if (ageMin < 60) return `${ageMin}m ago`;
- const ageHr = Math.floor(ageMin / 60);
- if (ageHr < 48) return `${ageHr}h ago`;
- const ageDay = Math.floor(ageHr / 24);
- return `${ageDay}d ago`;
-}
-
function shortContainerId(id: string): string {
// Mid-truncate keeps the "cntr_" prefix readable and still surfaces the
// tail digits users sometimes copy off OpenAI's dashboard.
@@ -463,7 +451,7 @@ export function OpenAICodeExecSection({
max={TTL_MAX}
value={ttlValue}
onChange={(e) => onTtlChange(e.target.value)}
- className="h-8 w-14 px-2 text-center text-sm tabular-nums"
+ className="h-8 w-[72px] pl-3 text-sm tabular-nums"
/>
@@ -509,7 +497,7 @@ export function OpenAICodeExecSection({
key={c.id}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-xs transition-colors ${
isActive
- ? "border-primary/30 bg-primary/5"
+ ? "border-ring-strong bg-primary/5"
: "border-border/60 hover:bg-muted/40"
} ${canActivate ? "cursor-pointer" : ""} ${
running ? "" : "opacity-60"
diff --git a/studio/frontend/src/features/chat/components/project-switcher.tsx b/studio/frontend/src/features/chat/components/project-switcher.tsx
index e17a1d139e..13360ccf7e 100644
--- a/studio/frontend/src/features/chat/components/project-switcher.tsx
+++ b/studio/frontend/src/features/chat/components/project-switcher.tsx
@@ -49,7 +49,7 @@ export function ProjectSwitcher({
? "Loading project"
: "Pick a project"
}
- className="-mx-1 flex h-[34px] shrink-0 items-center gap-2 rounded-full pl-3 pr-2.5 transition-colors hover:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-[#2d2e32]"
+ className="-mx-1 flex h-[34px] shrink-0 items-center gap-2 rounded-full pl-3 pr-2.5 transition-colors hover:bg-[#ececec] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:hover:bg-accent"
>
{
+ const signal = options?.signal;
+ const includeLoras = options?.includeLoras ?? true;
+ const { setModels, setLoras, setCheckpoint, setModelsError } =
+ useChatRuntimeStore.getState();
+ setModelsError(null);
+ try {
+ const [listRes, statusRes, lorasRes] = await Promise.all([
+ listModels(),
+ getInferenceStatus(),
+ includeLoras ? listLoras() : Promise.resolve(null),
+ ]);
+
+ // Cancellation can land while the requests above are in flight. Bail
+ // before writing backend state back -- cancelLoading already cleared it.
+ if (signal?.aborted) return;
+
+ setModels(listRes.models.map(toChatModelSummary));
+ if (lorasRes) {
+ setLoras(lorasRes.loras.map(toLoraSummary));
+ }
+
+ const selectedCheckpoint = useChatRuntimeStore.getState().params.checkpoint;
+ const isExternalSelectionActive = isExternalModelId(selectedCheckpoint);
+ if (statusRes.active_model && !isExternalSelectionActive) {
+ const checkpointId = resolveInferenceCheckpointId(statusRes);
+ if (checkpointId) {
+ setCheckpoint(checkpointId, statusRes.gguf_variant);
+ applyActiveModelStatusToStore(statusRes, {
+ previousCheckpoint: selectedCheckpoint,
+ });
+ // setModels(listRes...) above used catalog data, which omits audio
+ // capability. Re-apply live status so attach gates survive a refresh.
+ syncModelCapabilities(checkpointId, statusRes);
+ }
+ } else if (!statusRes.active_model && !isExternalSelectionActive) {
+ useChatRuntimeStore.setState({
+ modelRequiresTrustRemoteCode: false,
+ loadedIsMultimodal: false,
+ loadedIsDiffusion: false,
+ });
+ }
+ } catch (error) {
+ if (signal?.aborted) return;
+ const message =
+ error instanceof Error ? error.message : "Failed to load models";
+ setModelsError(message);
+ toast.error("Failed to refresh models", {
+ description: message,
+ });
+ }
+}
+
+/**
+ * Reconcile the UI after the SERVER unloaded the active model out from under it
+ * (e.g. a llama.cpp update unloads the running model to swap the binary): the
+ * model selector drops to "select model" instead of pointing at a model that now
+ * 400s on send. Imperative so the global llama-update banner (which has no
+ * chat-runtime handle) can call it.
+ *
+ * Only a LOCAL selection points at the unloaded model. An external-provider
+ * selection has no llama.cpp mirror and still works, so clearing it (which also
+ * wipes its persisted id) would drop a valid, unrelated model; skip the clear so
+ * the refresh below leaves it intact.
+ */
+export async function resyncInferenceStatusAfterServerModelChange(): Promise {
+ if (!isExternalModelId(useChatRuntimeStore.getState().params.checkpoint)) {
+ useChatRuntimeStore.getState().clearCheckpoint();
+ }
+ await syncInferenceStatusToStore();
+}
+
export function useChatModelRuntime() {
const params = useChatRuntimeStore((state) => state.params);
const models = useChatRuntimeStore((state) => state.models);
const loras = useChatRuntimeStore((state) => state.loras);
- const setModels = useChatRuntimeStore((state) => state.setModels);
- const setLoras = useChatRuntimeStore((state) => state.setLoras);
const setParams = useChatRuntimeStore((state) => state.setParams);
const setModelsError = useChatRuntimeStore((state) => state.setModelsError);
const setLastModelLoadError = useChatRuntimeStore(
(state) => state.setLastModelLoadError,
);
- const setCheckpoint = useChatRuntimeStore((state) => state.setCheckpoint);
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
const [loadingModel, setLoadingModel] = useState<{
@@ -312,59 +401,11 @@ export function useChatModelRuntime() {
[],
);
- const refresh = useCallback(async (options?: {
- signal?: AbortSignal;
- includeLoras?: boolean;
- }) => {
- const signal = options?.signal;
- const includeLoras = options?.includeLoras ?? true;
- setModelsError(null);
- try {
- const [listRes, statusRes, lorasRes] = await Promise.all([
- listModels(),
- getInferenceStatus(),
- includeLoras ? listLoras() : Promise.resolve(null),
- ]);
-
- // Cancellation can land while the requests above are in flight. Bail
- // before writing backend state back -- cancelLoading already cleared it.
- if (signal?.aborted) return;
-
- setModels(listRes.models.map(toChatModelSummary));
- if (lorasRes) {
- setLoras(lorasRes.loras.map(toLoraSummary));
- }
-
- const selectedCheckpoint = useChatRuntimeStore.getState().params.checkpoint;
- const isExternalSelectionActive = isExternalModelId(selectedCheckpoint);
- if (statusRes.active_model && !isExternalSelectionActive) {
- const checkpointId = resolveInferenceCheckpointId(statusRes);
- if (checkpointId) {
- setCheckpoint(checkpointId, statusRes.gguf_variant);
- applyActiveModelStatusToStore(statusRes, {
- previousCheckpoint: selectedCheckpoint,
- });
- // setModels(listRes...) above used catalog data, which omits audio
- // capability. Re-apply live status so attach gates survive a refresh.
- syncModelCapabilities(checkpointId, statusRes);
- }
- } else if (!statusRes.active_model && !isExternalSelectionActive) {
- useChatRuntimeStore.setState({
- modelRequiresTrustRemoteCode: false,
- loadedIsMultimodal: false,
- loadedIsDiffusion: false,
- });
- }
- } catch (error) {
- if (signal?.aborted) return;
- const message =
- error instanceof Error ? error.message : "Failed to load models";
- setModelsError(message);
- toast.error("Failed to refresh models", {
- description: message,
- });
- }
- }, [setCheckpoint, setLoras, setModels, setModelsError, setParams]);
+ const refresh = useCallback(
+ (options?: { signal?: AbortSignal; includeLoras?: boolean }) =>
+ syncInferenceStatusToStore(options),
+ [],
+ );
const cancelLoading = useCallback(() => {
const model = loadingModelRef.current;
@@ -593,6 +634,30 @@ export function useChatModelRuntime() {
is_lora: isLora,
gguf_variant: ggufVariant ?? null,
});
+ // Upgrade consent runs before the security dialogs; Accept installs and the load continues.
+ if (validation.requires_transformers_upgrade) {
+ const upgraded = await confirmTransformersUpgradeIfNeeded({
+ modelName: modelId,
+ upgrade: validation.transformers_upgrade,
+ // No installable release: custom-code models may fall back to the trust_remote_code gate below.
+ trustRemoteCodeFallback: validation.requires_trust_remote_code,
+ });
+ // The install unloads the previous model before the swap (even when
+ // the swap then fails), so any exit after this point must roll back.
+ // False for the custom-code fallback, which resolves without installing.
+ if (
+ useTransformersUpgradeDialogStore
+ .getState()
+ .consumeServerUnloadedChat()
+ && currentCheckpoint
+ ) {
+ previousWasUnloaded = true;
+ }
+ if (!upgraded) {
+ throw new Error(getTransformersUpgradeRequiredMessage(displayName));
+ }
+ }
+ if (abortCtrl.signal.aborted) throw new Error("Cancelled");
// Open the consent dialog when the model needs custom-code consent or has a
// flagged unsafe file. Fires even when trustRemoteCode is preset on, since the
// worker requires a matching fingerprint that only the dialog produces.
@@ -733,7 +798,8 @@ export function useChatModelRuntime() {
: (["low", "medium", "high"] as const);
const existingReasoningEffort = useChatRuntimeStore.getState().reasoningEffort;
const clampedReasoningEffort =
- reasoningStyle === "enable_thinking_effort"
+ reasoningStyle === "enable_thinking_effort" ||
+ reasoningStyle === "reasoning_effort"
? clampReasoningEffortToLevels(
existingReasoningEffort,
reasoningEffortLevels,
@@ -818,6 +884,23 @@ export function useChatModelRuntime() {
}
}
await refresh({ signal: abortCtrl.signal });
+ if (
+ !isLora &&
+ !(loadResponse.is_lora ?? false) &&
+ !nativePathToken &&
+ !isLocalModelPath(modelId) &&
+ !isExternalModelId(modelId)
+ ) {
+ if (loadResponse.is_gguf || isGguf || ggufVariant) {
+ recordLastLocalModelLoad({
+ id: modelId,
+ kind: "gguf",
+ ggufVariant: ggufVariant ?? null,
+ });
+ } else {
+ recordLastLocalModelLoad({ id: modelId, kind: "model" });
+ }
+ }
// A successful load owns the shared (pick-unscoped) settings fields,
// so any surviving stage is stale: the just-loaded pick itself, or a
// pick queued for a different model mid-load whose knobs this load
diff --git a/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts b/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts
index 841f2c2a2f..9badd43bc3 100644
--- a/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts
+++ b/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts
@@ -28,13 +28,43 @@ const SEARCH_REBUILD_DEBOUNCE_MS = 300;
// Keys whose values are base64 image/audio payloads, not searchable text.
const BINARY_KEY = /b64|base64|^(images?|audio|video)$/i;
+// Drop a trailing __MCP_IMAGES__ envelope only when it is the valid JSON image
+// array appended by the backend, so legit tool text that merely mentions the
+// marker stays searchable. (base64 runs below are scrubbed regardless.)
+function stripMcpImageSuffix(value: string): string {
+ const marker = "\n__MCP_IMAGES__:";
+ const idx = value.lastIndexOf(marker);
+ if (idx === -1) return value;
+ try {
+ const images: unknown = JSON.parse(value.slice(idx + marker.length));
+ if (
+ Array.isArray(images) &&
+ images.length > 0 &&
+ images.every(
+ (img) =>
+ typeof img === "object" &&
+ img !== null &&
+ typeof (img as Record).data === "string" &&
+ typeof (img as Record).mimeType === "string",
+ )
+ ) {
+ return value.slice(0, idx);
+ }
+ } catch {
+ // Not a valid envelope; leave the text intact.
+ }
+ return value;
+}
+
// Readable text from tool args/results, dropping base64 image/audio blobs so
// they never bloat the index (object fields by key, plus data URLs / long
// base64 runs and the "__IMAGES__" suffix inside strings).
function searchableText(value: unknown, depth = 0): string {
if (typeof value === "string") {
- const cut = value.indexOf("\n__IMAGES__:");
- return (cut === -1 ? value : value.slice(0, cut))
+ let text = stripMcpImageSuffix(value);
+ const cut = text.indexOf("\n__IMAGES__:");
+ if (cut !== -1) text = text.slice(0, cut);
+ return text
.replace(/data:[^;,\s]+;base64,[A-Za-z0-9+/=]+/g, " ")
.replace(/[A-Za-z0-9+/]{120,}={0,2}/g, " ");
}
diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts
index 7cd9611c71..a8b5fc23ad 100644
--- a/studio/frontend/src/features/chat/index.ts
+++ b/studio/frontend/src/features/chat/index.ts
@@ -17,6 +17,12 @@ export {
type Preset,
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
+export {
+ preferFullToolOutput,
+ toolOutputKey,
+ useToolPaneScope,
+} from "./tool-output-scope";
+export { PermissionModeDropdown } from "./permission-mode-select";
export { useChatSearchStore } from "./stores/chat-search-store";
export { usePinnedChatsStore } from "./stores/pinned-chats-store";
export { useChatPreferencesStore } from "./stores/chat-preferences-store";
@@ -25,7 +31,10 @@ export {
usePlusMenuPrefsStore,
type PlusMenuItemId,
} from "./stores/plus-menu-prefs-store";
-export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
+export {
+ useChatModelRuntime,
+ resyncInferenceStatusAfterServerModelChange,
+} from "./hooks/use-chat-model-runtime";
export {
customProviderDisplayName,
isExternalModelId,
@@ -36,6 +45,7 @@ export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export type { ProjectRecord } from "./types";
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
+export { listStoredChatThreads } from "./utils/chat-history-storage";
export { ArtifactCard } from "./artifacts/artifact-card";
export {
useChatArtifactsStore,
diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts
index 9386650fee..a4b5f848e2 100644
--- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts
+++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts
@@ -3,16 +3,19 @@
import { getInferenceStatus } from "../api/chat-api";
import { mergeBackendRecommendedInference } from "../presets/preset-policy";
+import { clampReasoningEffortToLevels } from "../provider-capabilities";
import {
CHAT_REASONING_ENABLED_KEY,
- loadOptionalBool,
type ReasoningEffort,
type ReasoningStyle,
+ loadOptionalBool,
resolveToolsEnabledOnLoad,
useChatRuntimeStore,
} from "../stores/chat-runtime-store";
-import { isMultimodalResponse, type InferenceStatusResponse } from "../types/api";
-import { clampReasoningEffortToLevels } from "../provider-capabilities";
+import {
+ type InferenceStatusResponse,
+ isMultimodalResponse,
+} from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
type LocalReasoningEffort = Extract;
@@ -31,7 +34,10 @@ export function normalizeSpeculativeType(
return "ngram";
}
if (s === "mtp+ngram") return "mtp+ngram";
- const parts = s.split(",").map((p) => p.trim()).filter(Boolean);
+ const parts = s
+ .split(",")
+ .map((p) => p.trim())
+ .filter(Boolean);
const hasMtp = parts.some((p) => p === "mtp" || p === "draft-mtp");
const hasNgram = parts.some(
(p) => p === "ngram" || p === "ngram-mod" || p === "ngram-simple",
@@ -165,7 +171,8 @@ export function applyActiveModelStatusToStore(
const currentSpecType = normalizeSpeculativeType(status.speculative_type);
const prevState = useChatRuntimeStore.getState();
const clampedReasoningEffort =
- reasoningStyle === "enable_thinking_effort"
+ reasoningStyle === "enable_thinking_effort" ||
+ reasoningStyle === "reasoning_effort"
? clampReasoningEffortToLevels(
prevState.reasoningEffort,
reasoningEffortLevels,
@@ -197,6 +204,12 @@ export function applyActiveModelStatusToStore(
ggufContextLength: currentGgufContextLength,
ggufMaxContextLength,
ggufNativeContextLength,
+ // A non-GGUF status must also drop a stale native-path token: without this the
+ // isGguf OR (activeGgufVariant || activeNativePathToken || ggufContextLength)
+ // stays true after switching from a native GGUF to a transformers model, so a
+ // Codex-only detection would auto-select for a model its preflight rejects. A real
+ // GGUF load reports is_gguf: true, so its token is preserved (the load path owns it).
+ ...(status.is_gguf ? {} : { activeNativePathToken: null }),
modelRequiresTrustRemoteCode: status.requires_trust_remote_code ?? false,
defaultChatTemplate: nextDefaultChatTemplate,
loadedIsMultimodal: isMultimodalResponse(status),
@@ -245,7 +258,7 @@ export function applyActiveModelStatusToStore(
const mid = checkpointId.toLowerCase();
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
- if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
+ if (sizeMatch && Number.parseFloat(sizeMatch[1]) < 9) {
reasoningDefault = false;
}
}
@@ -281,8 +294,7 @@ export async function tryAdoptServerActiveModel(): Promise {
}
// Re-check after the await: keep a checkpoint the user picked meanwhile.
- const previousCheckpoint =
- useChatRuntimeStore.getState().params.checkpoint;
+ const previousCheckpoint = useChatRuntimeStore.getState().params.checkpoint;
if (previousCheckpoint) {
return true;
}
diff --git a/studio/frontend/src/features/chat/permission-mode-select.tsx b/studio/frontend/src/features/chat/permission-mode-select.tsx
new file mode 100644
index 0000000000..4277c1bfcf
--- /dev/null
+++ b/studio/frontend/src/features/chat/permission-mode-select.tsx
@@ -0,0 +1,338 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import {
+ ChevronDown,
+ CircleAlert,
+ CircleOff,
+ Hand,
+ ShieldCheck,
+ XIcon,
+} from "lucide-react";
+import { useState } from "react";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import { Tick02Icon } from "@/lib/tick-icon";
+import { cn } from "@/lib/utils";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ type PermissionMode,
+ useChatRuntimeStore,
+} from "./stores/chat-runtime-store";
+
+/**
+ * Permission levels for the Bypass permissions dropdowns (General settings,
+ * chat settings sheet, composer "+" menu). Off sits last as the toggle that
+ * turns the feature off entirely.
+ */
+export const PERMISSION_MODE_OPTIONS: readonly {
+ value: PermissionMode;
+ label: string;
+ description: string;
+ icon: typeof Hand;
+}[] = [
+ {
+ value: "ask",
+ label: "Ask for approval",
+ description: "Always ask before tool calls edit files or use the internet",
+ icon: Hand,
+ },
+ {
+ value: "auto",
+ label: "Approve for me",
+ description: "Only ask for actions detected as potentially unsafe",
+ icon: ShieldCheck,
+ },
+ {
+ value: "full",
+ label: "Full access",
+ description:
+ "Unrestricted: no approval prompts and the code sandbox is disabled",
+ icon: CircleAlert,
+ },
+ {
+ value: "off",
+ label: "Off",
+ description: "Turn off bypass permissions",
+ icon: CircleOff,
+ },
+] as const;
+
+export function permissionModeOption(mode: PermissionMode) {
+ return (
+ PERMISSION_MODE_OPTIONS.find((option) => option.value === mode) ??
+ PERMISSION_MODE_OPTIONS[0]
+ );
+}
+
+/** The option rows shared by every permission dropdown/submenu. Non-full
+ * levels apply directly; picking Full access must go through the caller's
+ * danger confirmation, so it's a separate callback. */
+export function PermissionModeMenuItems({
+ onRequestFullAccess,
+}: {
+ onRequestFullAccess: () => void;
+}) {
+ const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
+ const setPermissionMode = useChatRuntimeStore((s) => s.setPermissionMode);
+
+ return (
+ <>
+ {PERMISSION_MODE_OPTIONS.map((option) => (
+ {
+ // Reselecting the active level toggles the feature off.
+ if (option.value === permissionMode) {
+ setPermissionMode("off");
+ } else if (option.value === "full") {
+ onRequestFullAccess();
+ } else {
+ setPermissionMode(option.value);
+ }
+ }}
+ className={cn(
+ "items-start gap-2 py-2",
+ permissionMode === option.value && "font-medium",
+ option.value === "full" &&
+ permissionMode === "full" &&
+ "text-bypass",
+ )}
+ >
+
+
+ {option.label}
+
+ {option.description}
+
+
+ {permissionMode === option.value ? (
+
+ ) : null}
+
+ ))}
+ >
+ );
+}
+
+/** Danger confirmation shown before Full access turns on. Self-contained so
+ * the dropdown works outside the chat page (e.g. the Settings dialog). */
+export function FullAccessConfirmDialog({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const setPermissionMode = useChatRuntimeStore((s) => s.setPermissionMode);
+
+ return (
+
+
+
+ Enable Full access?
+
+ Full access (Bypass permissions) is dangerous since the AI model
+ might delete, corrupt your machine, and or cause real world damage
+ to you or the world - only accept if you are certain
+
+
+
+ Cancel
+ {
+ setPermissionMode("full");
+ onOpenChange(false);
+ }}
+ >
+ I understand
+
+
+
+
+ );
+}
+
+/**
+ * Select-style dropdown (like the MCP composer menu) for picking the
+ * permission level. Used in General settings and the chat settings sheet.
+ */
+export function PermissionModeDropdown({
+ side = "bottom",
+ align = "end",
+ triggerClassName,
+}: {
+ side?: "top" | "bottom";
+ align?: "start" | "end";
+ triggerClassName?: string;
+} = {}) {
+ const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const active = permissionModeOption(permissionMode);
+ const ActiveIcon = active.icon;
+
+ return (
+ <>
+
+
+
+
+
+ {active.label}
+
+
+
+
+
+
+ How should tool calls be approved?
+
+
+ setTimeout(() => setConfirmOpen(true), 0)
+ }
+ />
+
+
+
+ >
+ );
+}
+
+/**
+ * Composer pill (mirrors the MCP pill) showing the current permission level
+ * in the chat box; clicking opens the level dropdown. Danger-styled while
+ * Full access is on. The Full access pick routes through the store-driven
+ * BypassPermissionsConfirmDialog mounted at the chat-page root, so the
+ * warning survives this menu unmounting.
+ */
+export function PermissionModeComposerPill({
+ side = "bottom",
+}: {
+ side?: "top" | "bottom";
+} = {}) {
+ const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
+ const setBypassConfirmOpen = useChatRuntimeStore(
+ (s) => s.setBypassConfirmOpen,
+ );
+ const setPermissionMode = useChatRuntimeStore((s) => s.setPermissionMode);
+ const active = permissionModeOption(permissionMode);
+ const ActiveIcon = active.icon;
+ const fullAccess = permissionMode === "full";
+
+ // Off means the feature is off: no pill (re-enable via the "+" menu or
+ // settings, like the pre-levels bypass badge).
+ if (permissionMode === "off") return null;
+
+ return (
+
+
+
+ {/* The icon doubles as an off switch (mirrors the MCP pill): hover
+ swaps it to an X; clicking it turns bypass permissions Off (no
+ prompts, sandbox on) without opening the menu. In compact
+ icon-only mode the glyph is the whole button, so clicks fall
+ through and open the menu instead. */}
+ {
+ if (e.currentTarget.closest('[data-pill-compact="true"]')) {
+ return;
+ }
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ if (e.currentTarget.closest('[data-pill-compact="true"]')) {
+ return;
+ }
+ e.stopPropagation();
+ setPermissionMode("off");
+ }}
+ className="composer-pill-glyph cursor-pointer"
+ >
+
+
+
+ {active.label}
+
+
+
+
+
+ How should tool calls be approved?
+
+
+ setTimeout(() => setBypassConfirmOpen(true), 0)
+ }
+ />
+
+
+ );
+}
diff --git a/studio/frontend/src/features/chat/projects-page.tsx b/studio/frontend/src/features/chat/projects-page.tsx
index b9a5658a96..5e0b37895b 100644
--- a/studio/frontend/src/features/chat/projects-page.tsx
+++ b/studio/frontend/src/features/chat/projects-page.tsx
@@ -429,7 +429,7 @@ export function ProjectsPage() {
openProject(project.id);
}
}}
- className="group/project-card relative flex min-h-[172px] cursor-pointer flex-col rounded-[26px] bg-card p-6 text-left shadow-[0_2px_12px_-4px_rgba(0,0,0,0.10)] transition-colors duration-150 hover:bg-[#f2f2f2] dark:shadow-none dark:hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ className="group/project-card relative flex min-h-[172px] cursor-pointer flex-col rounded-[26px] bg-card p-6 text-left shadow-[0_2px_12px_-4px_rgba(0,0,0,0.10)] transition-colors duration-150 hover:bg-[#f2f2f2] dark:shadow-none dark:hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
diff --git a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx
index ee3a49526f..33fc6286c0 100644
--- a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx
+++ b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx
@@ -54,6 +54,7 @@ import {
syncStoredChatMessages,
} from "../utils/chat-history-storage";
import { notifyChatHistoryUpdated } from "../api/chat-api";
+import { isMcpImageToolResult } from "../api/chat-adapter";
import { usePlusMenuPrefsStore } from "../stores/plus-menu-prefs-store";
import type { ThreadRecord, MessageRecord } from "../types";
@@ -142,14 +143,6 @@ function exportAllListsCsv(entries: PromptListEntry[]): void {
downloadBlob(`list_name,order,prompt_text\n${rows}`, "prompt-lists.csv", "text/csv");
}
-function exportCollectionJsonl(prompts: PromptEntry[], lists: PromptListEntry[]): void {
- const lines = [
- ...prompts.map((e) => JSON.stringify({ type: "prompt", name: e.name, text: e.text })),
- ...lists.map((e) => JSON.stringify({ type: "prompt_list", name: e.name, items: e.items })),
- ].join("\n");
- downloadBlob(lines, "prompt-collection.jsonl", "application/x-ndjson");
-}
-
function contentBlocksToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return JSON.stringify(content);
@@ -170,11 +163,14 @@ function contentBlocksToText(content: unknown): string {
parts.push("[thinking]\n" + thinkText + "\n[/thinking]");
}
} else if (p.type === "tool-call") {
+ // Keep base64 image payloads out of every export format: use the
+ // model-visible text for MCP image results (matches chat replay).
+ const result = isMcpImageToolResult(p.result) ? p.result.text : p.result;
parts.push(
JSON.stringify({
tool_call: p.toolName,
args: p.args,
- result: p.result,
+ result,
}),
);
} else if (p.type === "image") {
@@ -299,7 +295,15 @@ function messageToOpenAI(msg: { role: unknown; content: unknown; attachments?: u
const argsStr = p.args != null ? JSON.stringify(p.args) : (typeof p.argsText === "string" ? p.argsText : "{}");
toolCalls.push({ id, type: "function", function: { name, arguments: argsStr } });
if (p.result !== undefined && p.result !== null) {
- const resultStr = typeof p.result === "string" ? p.result : JSON.stringify(p.result);
+ // Keep base64 image payloads out of exports: MCP image results carry
+ // their model-visible text alongside the data, so serialize the text
+ // (matching chat replay) instead of the full object.
+ const resultStr =
+ typeof p.result === "string"
+ ? p.result
+ : isMcpImageToolResult(p.result)
+ ? p.result.text
+ : JSON.stringify(p.result);
toolResults.push({ role: "tool", tool_call_id: id, name, content: resultStr });
}
}
@@ -1117,7 +1121,7 @@ function ExportModal({
className={cn(
"flex w-full cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 transition-all",
scope === "single"
- ? "border-primary/40 bg-primary/5 ring-1 ring-primary/20"
+ ? "border-ring-strong bg-primary/5"
: "border-border/60 hover:border-border hover:bg-muted/30",
)}
>
@@ -1140,7 +1144,7 @@ function ExportModal({
className={cn(
"flex w-full cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 transition-all",
scope === "training"
- ? "border-primary/40 bg-primary/5 ring-1 ring-primary/20"
+ ? "border-ring-strong bg-primary/5"
: "border-border/60 hover:border-border hover:bg-muted/30",
)}
>
@@ -1262,14 +1266,14 @@ function PromptCard({
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Prompt name..."
- className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow"
+ className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow"
/>
diff --git a/studio/frontend/src/features/onboarding/components/steps/model-type-step.tsx b/studio/frontend/src/features/onboarding/components/steps/model-type-step.tsx
index 2a286a8c80..4f9f1ffea9 100644
--- a/studio/frontend/src/features/onboarding/components/steps/model-type-step.tsx
+++ b/studio/frontend/src/features/onboarding/components/steps/model-type-step.tsx
@@ -97,10 +97,10 @@ export function ModelTypeStep(): ReactElement {
"relative shadow-primary/30 transition-all duration-150 ease-out",
isDisabled && "opacity-50 bg-muted/50",
!isDisabled &&
- "hover:ring-primary/40 hover:-translate-y-0.5 hover:shadow-sm",
+ "hover:ring-ring hover:-translate-y-0.5 hover:shadow-sm",
isSelected &&
!isDisabled &&
- "ring-2 ring-primary -translate-y-0.5 shadow-sm",
+ "ring-1 ring-ring-strong -translate-y-0.5 shadow-sm",
)}
>
{isDisabled && (
@@ -187,8 +187,8 @@ export function ModelTypeStep(): ReactElement {
size="sm"
className={cn(
"relative shadow-primary/30 transition-all duration-150 ease-out",
- "hover:ring-primary/40 hover:-translate-y-0.5 hover:shadow-sm",
- chatOnlySelected && "ring-2 ring-primary -translate-y-0.5 shadow-sm",
+ "hover:ring-ring hover:-translate-y-0.5 hover:shadow-sm",
+ chatOnlySelected && "ring-1 ring-ring-strong -translate-y-0.5 shadow-sm",
)}
>
diff --git a/studio/frontend/src/features/onboarding/components/steps/summary-step.tsx b/studio/frontend/src/features/onboarding/components/steps/summary-step.tsx
index eb50d398b6..996966c34b 100644
--- a/studio/frontend/src/features/onboarding/components/steps/summary-step.tsx
+++ b/studio/frontend/src/features/onboarding/components/steps/summary-step.tsx
@@ -118,8 +118,8 @@ export function SummaryStep() {
-
-
+
+
GPU
@@ -144,8 +144,8 @@ export function SummaryStep() {
-
-
+
+
Model
diff --git a/studio/frontend/src/features/onboarding/components/wizard-layout.tsx b/studio/frontend/src/features/onboarding/components/wizard-layout.tsx
index 22274c5d7d..348f0fd051 100644
--- a/studio/frontend/src/features/onboarding/components/wizard-layout.tsx
+++ b/studio/frontend/src/features/onboarding/components/wizard-layout.tsx
@@ -9,6 +9,7 @@ import { Suspense, lazy, useEffect, useRef, useState } from "react";
import type { ConfettiRef } from "@/components/ui/confetti";
import { STEPS } from "@/config/training";
import { isOnboardingDone, markOnboardingDone } from "@/features/auth";
+import { prefersReducedMotion } from "@/features/settings";
import { useTrainingConfigStore } from "@/features/training";
import { SplashScreen } from "./splash-screen";
import { WizardContent } from "./wizard-content";
@@ -52,20 +53,23 @@ export function WizardLayout() {
useEffect(() => {
if (isFinalStep && !hasFiredRef.current) {
hasFiredRef.current = true;
- confettiRef.current?.fire({
- particleCount: 80,
- angle: 60,
- spread: 55,
- origin: { x: 0, y: 0.6 },
- colors: ["#34b482", "#26ccff", "#a25afd", "#88ff5a"],
- });
- confettiRef.current?.fire({
- particleCount: 80,
- angle: 120,
- spread: 55,
- origin: { x: 1, y: 0.6 },
- colors: ["#34b482", "#26ccff", "#a25afd", "#88ff5a"],
- });
+ // Honor Appearance > Reduce motion; skip the celebration when reduced.
+ if (!prefersReducedMotion()) {
+ confettiRef.current?.fire({
+ particleCount: 80,
+ angle: 60,
+ spread: 55,
+ origin: { x: 0, y: 0.6 },
+ colors: ["#34b482", "#26ccff", "#a25afd", "#88ff5a"],
+ });
+ confettiRef.current?.fire({
+ particleCount: 80,
+ angle: 120,
+ spread: 55,
+ origin: { x: 1, y: 0.6 },
+ colors: ["#34b482", "#26ccff", "#a25afd", "#88ff5a"],
+ });
+ }
}
if (!isFinalStep) {
hasFiredRef.current = false;
diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx
index 7c81411a3c..5fc6d090db 100644
--- a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx
+++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx
@@ -5,11 +5,12 @@ import { publicAssetUrl } from "@/components/mascot-img";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
import { getAuthToken } from "@/features/auth";
import { cn } from "@/lib/utils";
import { useT } from "@/i18n";
import { toastError, toastSuccess } from "@/shared/toast";
-import { Camera01Icon } from "@hugeicons/core-free-icons";
+import { Edit03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { SLOTH_AVATARS } from "../sloth-avatars";
@@ -63,6 +64,10 @@ export function ProfilePersonalizationPanel() {
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
const avatarShape = useUserProfileStore((s) => s.avatarShape);
const setAvatarShape = useUserProfileStore((s) => s.setAvatarShape);
+ const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
+ const setShowGreetingSloth = useUserProfileStore(
+ (s) => s.setShowGreetingSloth,
+ );
const [imageError, setImageError] = useState
(null);
const [draftName, setDraftName] = useState(displayName);
@@ -128,7 +133,7 @@ export function ProfilePersonalizationPanel() {
}
};
- const applyAvatar = (value: string) => {
+ const applyAvatar = (value: string | null) => {
setAvatarDataUrl(value);
const persisted = readPersistedProfile();
if (persisted && persisted.avatarDataUrl === value) {
@@ -154,9 +159,29 @@ export function ProfilePersonalizationPanel() {
}
};
- const pickSloth = (path: string) => {
+ // The avatar is shown all over the app (sidebar, chat messages, greeting),
+ // so writing it to the store can trigger a wide re-render. Mark the picked
+ // value locally first so its ring moves this frame, then commit the store
+ // write on the next frame. Boxed because null is a valid pick (no picture).
+ const [pendingAvatar, setPendingAvatar] = useState<{
+ value: string | null;
+ } | null>(null);
+ const shownAvatar = pendingAvatar ? pendingAvatar.value : avatarDataUrl;
+
+ useEffect(() => {
+ if (pendingAvatar && avatarDataUrl === pendingAvatar.value) {
+ setPendingAvatar(null);
+ }
+ }, [avatarDataUrl, pendingAvatar]);
+
+ const pickAvatarValue = (value: string | null) => {
setImageError(null);
- applyAvatar(publicAssetUrl(path));
+ setPendingAvatar({ value });
+ requestAnimationFrame(() => applyAvatar(value));
+ };
+
+ const pickSloth = (path: string) => {
+ pickAvatarValue(publicAssetUrl(path));
};
return (
@@ -164,7 +189,7 @@ export function ProfilePersonalizationPanel() {
@@ -181,14 +206,17 @@ export function ProfilePersonalizationPanel() {
fileInputRef.current?.click()}
- className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
+ className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("settings.profile.changePicture")}
>
-
+
-
+
{t("settings.profile.displayName")}
@@ -215,7 +243,10 @@ export function ProfilePersonalizationPanel() {
-
+
{t("settings.profile.nickname")}
@@ -242,7 +273,10 @@ export function ProfilePersonalizationPanel() {
-
+
{t("settings.profile.avatarShape")}
@@ -268,6 +302,28 @@ export function ProfilePersonalizationPanel() {
+
+
+
+ {t("settings.profile.greetingSloth")}
+
+
+ {t("settings.profile.greetingSlothDescription")}
+
+
+
+
+
{t("settings.profile.chooseSloth")}
@@ -275,7 +331,7 @@ export function ProfilePersonalizationPanel() {
{SLOTH_AVATARS.map((path) => {
const url = publicAssetUrl(path);
- const selected = avatarDataUrl === url;
+ const selected = shownAvatar === url;
const label =
path.split("/").pop()?.replace(/\.png$/i, "").replace(/^large\s+/i, "").trim() ??
"sloth";
@@ -288,14 +344,32 @@ export function ProfilePersonalizationPanel() {
aria-label={label}
title={label}
className={cn(
- "relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border transition hover:ring-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
- selected && "ring-2 ring-primary",
+ // No transition here: animating the ring makes the old
+ // icon's selection border linger when switching sloths.
+ "relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
+ // Selection keeps the 1px weight, only darker.
+ selected && "ring-ring-strong hover:ring-ring-strong",
)}
>
);
})}
+
pickAvatarValue(null)}
+ aria-pressed={shownAvatar === null}
+ aria-label={t("settings.profile.noPicture")}
+ title={t("settings.profile.noPicture")}
+ className={cn(
+ "relative flex aspect-square items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
+ shownAvatar === null && "ring-ring-strong hover:ring-ring-strong",
+ )}
+ >
+
+ {t("settings.profile.noneLabel")}
+
+
diff --git a/studio/frontend/src/features/profile/components/user-avatar.tsx b/studio/frontend/src/features/profile/components/user-avatar.tsx
index e2c7dec856..2db103c1d4 100644
--- a/studio/frontend/src/features/profile/components/user-avatar.tsx
+++ b/studio/frontend/src/features/profile/components/user-avatar.tsx
@@ -47,7 +47,7 @@ export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvata
[0];
@@ -57,7 +71,8 @@ function sameProfile(a: ProfileSnapshot, b: ProfileSnapshot): boolean {
a.displayName === b.displayName &&
a.nickname === b.nickname &&
a.avatarDataUrl === b.avatarDataUrl &&
- a.avatarShape === b.avatarShape
+ a.avatarShape === b.avatarShape &&
+ a.showGreetingSloth === b.showGreetingSloth
);
}
@@ -104,18 +119,21 @@ function profileSnapshot(): ProfileSnapshot {
nickname: s.nickname,
avatarDataUrl: s.avatarDataUrl,
avatarShape: s.avatarShape,
+ showGreetingSloth: s.showGreetingSloth,
};
}
function payload(
profile: ProfileSnapshot,
theme: Theme,
- language: Locale | null,
+ palette: Palette,
+ customization: AppearanceCustomization,
+ language: LocalePreference | null,
): PersonalizationWrite {
return {
- version: 1,
+ version: PERSONALIZATION_VERSION,
profile: normalizeProfile(profile),
- appearance: { theme, language },
+ appearance: { theme, palette, language, customization },
};
}
@@ -123,18 +141,36 @@ function serialized(data: PersonalizationWrite): string {
return JSON.stringify(data);
}
+// Version 1 clients wrote language on every save, so a legacy "en" usually
+// means the user never picked a language. Map it to auto; explicit picks of
+// other locales (the old default was English) are kept. Version 2 payloads
+// are trusted verbatim, so a deliberate English pick stays pinned.
+export function remoteLanguagePreference(
+ version: unknown,
+ language: unknown,
+): unknown {
+ const isLegacy = typeof version !== "number" || version < 2;
+ if (isLegacy && language === "en") return DEFAULT_LOCALE_PREFERENCE;
+ return language;
+}
+
function hasLocalSettings(
profile: ProfileSnapshot,
theme: Theme,
- language: Locale,
+ palette: Palette,
+ customization: AppearanceCustomization,
+ language: LocalePreference,
): boolean {
return Boolean(
profile.displayName ||
profile.nickname ||
profile.avatarDataUrl ||
profile.avatarShape !== "circle" ||
+ !profile.showGreetingSloth ||
theme !== "system" ||
- language !== DEFAULT_LOCALE,
+ palette !== "standard" ||
+ !isDefaultCustomization(customization) ||
+ language !== DEFAULT_LOCALE_PREFERENCE,
);
}
@@ -143,11 +179,16 @@ export function usePersonalizationSync(enabled: boolean): void {
const nickname = useUserProfileStore((s) => s.nickname);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const avatarShape = useUserProfileStore((s) => s.avatarShape);
+ const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
const { theme } = useTheme();
- const language = useLocale();
+ const { palette } = usePalette();
+ const customization = useAppearanceCustomStore((s) => s.customization);
+ const language = useLocalePreference();
const [hydratedGeneration, setHydratedGeneration] = useState(0);
const authGenerationRef = useRef(0);
const latestThemeRef = useRef(theme);
+ const latestPaletteRef = useRef(palette);
+ const latestCustomizationRef = useRef(customization);
const latestLanguageRef = useRef(language);
const lastSavedRef = useRef("");
const saveInFlightRef = useRef(false);
@@ -166,6 +207,14 @@ export function usePersonalizationSync(enabled: boolean): void {
latestThemeRef.current = theme;
}, [theme]);
+ useEffect(() => {
+ latestPaletteRef.current = palette;
+ }, [palette]);
+
+ useEffect(() => {
+ latestCustomizationRef.current = customization;
+ }, [customization]);
+
useEffect(() => {
latestLanguageRef.current = language;
}, [language]);
@@ -184,21 +233,71 @@ export function usePersonalizationSync(enabled: boolean): void {
const remote = await loadPersonalization();
if (cancelled) return;
if (remote.saved) {
+ // Legacy records predating a field come back server-defaulted. Keep
+ // the local value and re-push it (lastSaved below records the remote
+ // default so the push detects the diff) rather than treating the
+ // default as an explicit remote choice. A record that actually stored
+ // the field reports Saved=true and still wins.
+ const localGreeting = useUserProfileStore.getState().showGreetingSloth;
+ const remoteGreeting = remote.profile.showGreetingSloth !== false;
+ const keepLocalGreeting =
+ remote.greetingSlothSaved === false && localGreeting === false;
const nextProfile: ProfileSnapshot = {
displayName: remote.profile.displayName ?? "",
nickname: remote.profile.nickname ?? "",
avatarDataUrl: remote.profile.avatarDataUrl ?? null,
- avatarShape: remote.profile.avatarShape === "rounded" ? "rounded" : "circle",
+ avatarShape:
+ remote.profile.avatarShape === "rounded" ? "rounded" : "circle",
+ showGreetingSloth: keepLocalGreeting ? localGreeting : remoteGreeting,
};
const nextTheme = remote.appearance.theme;
- const nextLanguage = isSupportedLocale(remote.appearance.language)
- ? remote.appearance.language
+ const localPalette = latestPaletteRef.current;
+ const remotePalette = isPalette(remote.appearance.palette)
+ ? remote.appearance.palette
+ : localPalette;
+ const keepLocalPalette =
+ remote.paletteSaved === false && localPalette !== "standard";
+ const nextPalette = keepLocalPalette ? localPalette : remotePalette;
+ const remoteCustomization = sanitizeCustomization(
+ remote.appearance.customization,
+ );
+ const localCustomization = latestCustomizationRef.current;
+ const keepLocalCustomization =
+ remote.customizationSaved === false &&
+ !isDefaultCustomization(localCustomization);
+ const nextCustomization = keepLocalCustomization
+ ? localCustomization
+ : remoteCustomization;
+ const remoteLanguage = remoteLanguagePreference(
+ remote.version,
+ remote.appearance.language,
+ );
+ const nextLanguage = isLocalePreference(remoteLanguage)
+ ? remoteLanguage
: latestLanguageRef.current;
useUserProfileStore.setState(nextProfile);
if (nextTheme !== latestThemeRef.current) setTheme(nextTheme);
- if (nextLanguage !== latestLanguageRef.current) setLocale(nextLanguage);
+ if (nextPalette !== latestPaletteRef.current) setPalette(nextPalette);
+ if (
+ !keepLocalCustomization &&
+ JSON.stringify(nextCustomization) !==
+ JSON.stringify(latestCustomizationRef.current)
+ ) {
+ useAppearanceCustomStore.getState().replaceAll(nextCustomization);
+ }
+ if (nextLanguage !== latestLanguageRef.current)
+ setLocale(nextLanguage);
+ // lastSaved records what the server actually has (server-side defaults
+ // for legacy fields) so the debounced push re-uploads preserved local
+ // values.
lastSavedRef.current = serialized(
- payload(nextProfile, nextTheme, nextLanguage),
+ payload(
+ { ...nextProfile, showGreetingSloth: remoteGreeting },
+ nextTheme,
+ remotePalette,
+ remoteCustomization,
+ nextLanguage,
+ ),
);
} else {
const rawProfile = profileSnapshot();
@@ -207,10 +306,26 @@ export function usePersonalizationSync(enabled: boolean): void {
useUserProfileStore.setState(nextProfile);
}
const nextTheme = latestThemeRef.current;
- const nextLanguage = getLocale();
- const nextPayload = payload(nextProfile, nextTheme, nextLanguage);
+ const nextPalette = latestPaletteRef.current;
+ const nextCustomization = latestCustomizationRef.current;
+ const nextLanguage = getLocalePreference();
+ const nextPayload = payload(
+ nextProfile,
+ nextTheme,
+ nextPalette,
+ nextCustomization,
+ nextLanguage,
+ );
const nextSerialized = serialized(nextPayload);
- if (hasLocalSettings(nextProfile, nextTheme, nextLanguage)) {
+ if (
+ hasLocalSettings(
+ nextProfile,
+ nextTheme,
+ nextPalette,
+ nextCustomization,
+ nextLanguage,
+ )
+ ) {
try {
await savePersonalization(nextPayload);
lastSavedRef.current = nextSerialized;
@@ -238,8 +353,10 @@ export function usePersonalizationSync(enabled: boolean): void {
useEffect(() => {
if (!enabled || hydratedGeneration !== authGenerationRef.current) return;
const current = payload(
- { displayName, nickname, avatarDataUrl, avatarShape },
+ { displayName, nickname, avatarDataUrl, avatarShape, showGreetingSloth },
theme,
+ palette,
+ customization,
language,
);
const currentSerialized = serialized(current);
@@ -260,7 +377,10 @@ export function usePersonalizationSync(enabled: boolean): void {
nickname,
avatarDataUrl,
avatarShape,
+ showGreetingSloth,
theme,
+ palette,
+ customization,
language,
drainSaveQueue,
]);
diff --git a/studio/frontend/src/features/profile/stores/user-profile-store.ts b/studio/frontend/src/features/profile/stores/user-profile-store.ts
index 0055a58f5d..a882a936a8 100644
--- a/studio/frontend/src/features/profile/stores/user-profile-store.ts
+++ b/studio/frontend/src/features/profile/stores/user-profile-store.ts
@@ -12,10 +12,12 @@ export interface UserProfileState {
nickname: string;
avatarDataUrl: string | null;
avatarShape: AvatarShape;
+ showGreetingSloth: boolean;
setDisplayName: (displayName: string) => void;
setNickname: (nickname: string) => void;
setAvatarDataUrl: (avatarDataUrl: string | null) => void;
setAvatarShape: (avatarShape: AvatarShape) => void;
+ setShowGreetingSloth: (showGreetingSloth: boolean) => void;
}
export const useUserProfileStore = create()(
@@ -25,10 +27,12 @@ export const useUserProfileStore = create()(
nickname: "",
avatarDataUrl: null,
avatarShape: "circle",
+ showGreetingSloth: true,
setDisplayName: (displayName) => set({ displayName }),
setNickname: (nickname) => set({ nickname }),
setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }),
setAvatarShape: (avatarShape) => set({ avatarShape }),
+ setShowGreetingSloth: (showGreetingSloth) => set({ showGreetingSloth }),
}),
{ name: "unsloth_user_profile" },
),
diff --git a/studio/frontend/src/features/profile/utils/avatar-initials.ts b/studio/frontend/src/features/profile/utils/avatar-initials.ts
index 2683b52a37..c80d2eafd9 100644
--- a/studio/frontend/src/features/profile/utils/avatar-initials.ts
+++ b/studio/frontend/src/features/profile/utils/avatar-initials.ts
@@ -8,18 +8,16 @@ export function initialsFromName(name: string): string {
}
/**
- * Default Unsloth-brand background for the avatar fallback (readable white text).
+ * Accent background for the avatar fallback with a readable foreground.
*
- * Uses the shared `--primary` design token instead of a one-off hardcoded
- * shade, so the avatar always matches the app's general brand green (send
- * button, primary buttons, etc.). It previously hardcoded a slightly different
- * `#14b789`, which looked inconsistent next to primary-colored UI such as the
- * artifact preview/code panel.
- *
- * The literal fallback (the current `--primary` value) only applies if this
- * reusable avatar is ever rendered outside the theme root, where `--primary`
- * is undefined -- it keeps the avatar branded instead of transparent.
+ * Uses `--control-accent`, the token behind toggles and badges, so the
+ * avatar follows the palette accent (green in standard, blue in classic)
+ * and any custom accent the user picks in Appearance. The literals only
+ * apply outside the theme root, keeping the avatar branded there.
*/
-export function avatarBgStyle(): { backgroundColor: string } {
- return { backgroundColor: "var(--primary, #17b88b)" };
+export function avatarBgStyle(): { backgroundColor: string; color: string } {
+ return {
+ backgroundColor: "var(--control-accent, #17b88b)",
+ color: "var(--control-accent-foreground, #ffffff)",
+ };
}
diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts
index 4fe24b1f6d..f4e8167cb3 100644
--- a/studio/frontend/src/features/recipe-studio/api/index.ts
+++ b/studio/frontend/src/features/recipe-studio/api/index.ts
@@ -494,3 +494,13 @@ export async function removeUnstructuredFile(
throw new Error("Failed to remove file");
}
}
+
+export async function removeUnstructuredBlock(blockId: string): Promise {
+ const res = await authFetch(
+ `${DATA_DESIGNER_API_BASE}/seed/unstructured-block/${encodeURIComponent(blockId)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok && res.status !== 404) {
+ throw new Error("Failed to remove uploaded files");
+ }
+}
diff --git a/studio/frontend/src/features/recipe-studio/components/chip-input.tsx b/studio/frontend/src/features/recipe-studio/components/chip-input.tsx
index 55db6f280c..3e093aa82a 100644
--- a/studio/frontend/src/features/recipe-studio/components/chip-input.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/chip-input.tsx
@@ -84,7 +84,7 @@ export function ChipInput({
return (
{values.map((value, index) => (
@@ -179,16 +181,28 @@ export function ExecutionProgressIsland({
{!minimized && (
<>
-
+
Done: {formatMetricValue(execution.progress?.done)}
-
+
Total: {formatMetricValue(execution.progress?.total)}
-
+
Rate: {formatMetricValue(execution.progress?.rate)}
-
+
ETA: {formatEta(execution.progress?.eta_sec)}
@@ -208,10 +222,7 @@ export function ExecutionProgressIsland({
icon={currentColumnIcon}
className="size-3.5 shrink-0"
/>
-
+
Column: {execution.current_column ?? "--"}
@@ -221,7 +232,8 @@ export function ExecutionProgressIsland({
className="mt-1 truncate px-3 text-[11px] text-muted-foreground"
title={`Batch: ${execution.batch?.idx ?? "--"}/${execution.batch?.total ?? "--"}`}
>
- Batch: {execution.batch?.idx ?? "--"}/{execution.batch?.total ?? "--"}
+ Batch: {execution.batch?.idx ?? "--"}/
+ {execution.batch?.total ?? "--"}
)}
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/models/local-recipe-model-selector.tsx b/studio/frontend/src/features/recipe-studio/dialogs/models/local-recipe-model-selector.tsx
index c333fd84f8..c857b88a91 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/models/local-recipe-model-selector.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/models/local-recipe-model-selector.tsx
@@ -577,7 +577,8 @@ export function LocalRecipeModelSelector({
);
return (
-
+ // modal keeps the list wheel-scrollable inside dialog scroll locks
+
@@ -105,7 +105,7 @@ export function ModelProviderDialog({
type="button"
className={`rounded-xl border px-4 py-3 text-left transition-colors ${
!isLocal
- ? "border-primary/40 bg-primary/5"
+ ? "border-ring-strong bg-primary/5"
: "border-border/60 bg-muted/10 hover:border-border"
}`}
onClick={() => onUpdate({ is_local: false })}
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/preview-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/preview-dialog.tsx
index 7bcf47a974..3181d9fe67 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/preview-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/preview-dialog.tsx
@@ -17,10 +17,10 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import {
AlertCircleIcon,
- ArrowDown01Icon,
CheckmarkCircle02Icon,
CookBookIcon,
TestTube01Icon,
@@ -413,7 +413,7 @@ function RunDialogBody({
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
- onSettingsChange({ maxConversationCorrectionSteps: value }),
+ onSettingsChange({
+ maxConversationCorrectionSteps: value,
+ }),
setCorrectionsDraft,
)
}
@@ -624,7 +626,8 @@ function RunDialogBody({
Keep running through failures
- Useful for longer runs when you want as many rows as possible.
+ Useful for longer runs when you want as many rows as
+ possible.
state.queueUploadCleanup,
+ );
+
+ // config.id collides across recipes (ids reset to n1 on import); use a
+ // stable per-block uid instead. Generate one synchronously so the first
+ // rendered drop zone cannot upload under a legacy node id.
+ const uploadUid = config.unstructured_upload_uid?.trim() ?? "";
+ const unstructuredFileCount = config.unstructured_file_ids?.length ?? 0;
+ const generatedUploadUidRef = useRef(null);
+ if (
+ mode === "unstructured" &&
+ !uploadUid &&
+ unstructuredFileCount === 0 &&
+ generatedUploadUidRef.current === null
+ ) {
+ generatedUploadUidRef.current = makeUnstructuredUploadUid();
+ }
+ const uploadBlockId = resolveUnstructuredUploadBlockId({
+ configId: config.id,
+ uploadUid,
+ generatedUploadUid: generatedUploadUidRef.current,
+ unstructuredFileCount,
+ });
+
+ useEffect(() => {
+ if (mode !== "unstructured") return;
+ if (uploadUid) return;
+ if (unstructuredFileCount > 0) return;
+ const nextUid =
+ generatedUploadUidRef.current ?? makeUnstructuredUploadUid();
+ generatedUploadUidRef.current = nextUid;
+ onUpdate({ unstructured_upload_uid: nextUid });
+ }, [mode, uploadUid, unstructuredFileCount, onUpdate]);
+
const prevModeRef = useRef(mode);
useEffect(() => {
const prevMode = prevModeRef.current;
@@ -720,6 +760,11 @@ export function SeedDialog({
subset: config.hf_subset?.trim() || undefined,
preview_size: 10,
});
+ // Queue the block's upload directory for deletion after the next
+ // save; only uid-namespaced directories qualify (single owner).
+ if (uploadUid && unstructuredFileCount > 0) {
+ queueUploadCleanup(uploadUid);
+ }
onUpdate({
hf_path: response.resolved_path,
seed_columns: response.columns,
@@ -730,6 +775,7 @@ export function SeedDialog({
hf_split: response.split ?? "",
hf_subset: response.subset ?? "",
local_file_name: "",
+ unstructured_upload_uid: "",
unstructured_file_ids: [],
unstructured_file_names: [],
unstructured_file_sizes: [],
@@ -754,6 +800,11 @@ export function SeedDialog({
content_base64: payload,
preview_size: 10,
});
+ // Queue the block's upload directory for deletion after the next
+ // save; only uid-namespaced directories qualify (single owner).
+ if (uploadUid && unstructuredFileCount > 0) {
+ queueUploadCleanup(uploadUid);
+ }
onUpdate({
hf_path: response.resolved_path,
seed_columns: response.columns,
@@ -765,6 +816,7 @@ export function SeedDialog({
hf_subset: "",
hf_split: "",
local_file_name: localFile.name,
+ unstructured_upload_uid: "",
unstructured_file_ids: [],
unstructured_file_names: [],
unstructured_file_sizes: [],
@@ -789,7 +841,7 @@ export function SeedDialog({
const { chunkSize, chunkOverlap } = resolveChunking(config);
const response = await inspectSeedUpload({
- block_id: config.id,
+ block_id: uploadBlockId,
file_ids: fileIds,
file_names: fileNames,
preview_size: 10,
@@ -827,7 +879,18 @@ export function SeedDialog({
setIsInspecting(false);
}
},
- [config, getCurrentLoadKey, localFile, mode, onUpdate, unstructuredFiles],
+ [
+ config,
+ getCurrentLoadKey,
+ localFile,
+ mode,
+ onUpdate,
+ queueUploadCleanup,
+ unstructuredFiles,
+ unstructuredFileCount,
+ uploadBlockId,
+ uploadUid,
+ ],
);
useEffect(() => {
@@ -997,7 +1060,7 @@ export function SeedDialog({
{mode === "unstructured" && (
(null);
const filesRef = useRef(files);
+ const blockIdRef = useRef(blockId);
+ const mountedRef = useRef(true);
const [isDragOver, setIsDragOver] = useState(false);
useEffect(() => {
filesRef.current = files;
- }, [files]);
+ blockIdRef.current = blockId;
+ }, [files, blockId]);
+ useEffect(() => () => {
+ mountedRef.current = false;
+ }, []);
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
@@ -134,15 +140,32 @@ export function UnstructuredDropZone({
if (entry.status === "uploading" && entry.abortController) {
entry.abortController.abort();
}
- if (
+ const needsServerRemove =
entry.id &&
entry.status === "ok" &&
- !deletedIdsRef.current.has(entry.id)
- ) {
- deletedIdsRef.current.add(entry.id);
- void removeUnstructuredFile(blockId, entry.id).catch(() => {});
- }
+ !deletedIdsRef.current.has(entry.id);
onFilesChange((prev) => prev.filter((_, i) => i !== index));
+ if (!needsServerRemove) return;
+ deletedIdsRef.current.add(entry.id);
+ removeUnstructuredFile(blockId, entry.id).catch(() => {
+ // Skip if the drop zone unmounted or its block changed: the id no
+ // longer belongs here and restoring would leak it into another block.
+ if (!mountedRef.current || blockIdRef.current !== blockId) return;
+ // Still exists server-side (counts toward quota); restore it at its
+ // original position.
+ deletedIdsRef.current.delete(entry.id);
+ onFilesChange((prev) => {
+ const next = [...prev];
+ next.splice(Math.min(index, next.length), 0, {
+ id: entry.id,
+ name: entry.name,
+ size: entry.size,
+ status: "ok",
+ error: "Remove failed — try again",
+ });
+ return next;
+ });
+ });
},
[blockId, onFilesChange],
);
@@ -188,7 +211,7 @@ export function UnstructuredDropZone({
state.configs);
const vars = getAvailableVariableEntries(configs, configId);
- const variableNames = useMemo(() => new Set(vars.map((entry) => entry.name)), [vars]);
+ const variableNames = useMemo(
+ () => new Set(vars.map((entry) => entry.name)),
+ [vars],
+ );
const hasUserRoot = variableNames.has("user");
const userFieldEntries = useMemo(
() =>
@@ -71,19 +74,22 @@ export function AvailableVariables({
onClick={() => setShowUserFields((prev) => !prev)}
className="cursor-pointer"
aria-expanded={showUserFields}
- aria-label={showUserFields ? "Hide user fields" : "Show user fields"}
+ aria-label={
+ showUserFields ? "Hide user fields" : "Show user fields"
+ }
>
{`{{ ${v.name} }}`}
);
})}
- {hasUserRoot && showUserFields &&
+ {hasUserRoot &&
+ showUserFields &&
userFieldEntries.map((entry) => (
).map((envKey) => [envKey, ""]),
+ Object.keys(output.env as Record).map((envKey) => [
+ envKey,
+ "",
+ ]),
);
}
return output;
@@ -82,10 +87,7 @@ function inferHfRepoIdFromPath(pathValue: unknown): string {
if (typeof pathValue !== "string") {
return "";
}
- const parts = pathValue
- .trim()
- .split("/")
- .filter(Boolean);
+ const parts = pathValue.trim().split("/").filter(Boolean);
if (parts.length >= 3 && parts[0] === "datasets") {
return `${parts[1]}/${parts[2]}`;
}
@@ -126,8 +128,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
typeof ui?.seed_source_type === "string" ? ui.seed_source_type : null;
const sourceType =
typeof source?.seed_type === "string" ? source.seed_type : null;
- const shouldResetHfState =
- sourceType === "hf" || uiSourceType === "hf";
+ const shouldResetHfState = sourceType === "hf" || uiSourceType === "hf";
const shouldResetLocalState =
sourceType === "local" ||
sourceType === "unstructured" ||
@@ -144,6 +145,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
ui.seed_drop_columns = [];
ui.seed_preview_rows = [];
ui.local_file_name = "";
+ ui.unstructured_upload_uid = "";
ui.unstructured_file_ids = [];
ui.unstructured_file_names = [];
ui.unstructured_file_sizes = [];
@@ -165,6 +167,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
ui.seed_drop_columns = [];
ui.seed_preview_rows = [];
ui.local_file_name = "";
+ ui.unstructured_upload_uid = "";
ui.unstructured_file_ids = [];
ui.unstructured_file_names = [];
ui.unstructured_file_sizes = [];
@@ -174,6 +177,43 @@ function sanitizeSeedForShare(payload: unknown): unknown {
return root;
}
+// Delete queued upload directories once a save stops referencing them, so a
+// reload before autosave can never leave the saved recipe pointing at
+// already-deleted files. Skips any uid the just-saved payload still uses.
+function drainQueuedUploadCleanups(
+ savedPayload: RecipePayloadResult["payload"],
+): void {
+ const pending = useRecipeStudioStore.getState().pendingUploadCleanups;
+ if (pending.length === 0) {
+ return;
+ }
+ const ui =
+ savedPayload && typeof savedPayload === "object"
+ ? (savedPayload as { ui?: Record }).ui
+ : undefined;
+ const savedUid =
+ ui && typeof ui.unstructured_upload_uid === "string"
+ ? ui.unstructured_upload_uid
+ : "";
+ const ready = pending.filter((uid) => uid !== savedUid);
+ if (ready.length === 0) {
+ return;
+ }
+ for (const uid of ready) {
+ void removeUnstructuredBlock(uid)
+ .then(() => {
+ useRecipeStudioStore.setState((state) => ({
+ pendingUploadCleanups: state.pendingUploadCleanups.filter(
+ (pendingUid) => pendingUid !== uid,
+ ),
+ }));
+ })
+ .catch((error) => {
+ console.warn("Failed to clean up uploaded documents:", error);
+ });
+ }
+}
+
export function useRecipePersistence({
recipeId,
initialRecipeName,
@@ -202,8 +242,10 @@ export function useRecipePersistence({
() => buildSignature(normalizedWorkflowName, currentPayload),
[currentPayload, normalizedWorkflowName],
);
- const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
- const saveTone: SaveTone = !isDirty && Boolean(lastSavedAt) ? "success" : "error";
+ const isDirty =
+ savedSignature.length > 0 && currentSignature !== savedSignature;
+ const saveTone: SaveTone =
+ !isDirty && Boolean(lastSavedAt) ? "success" : "error";
const savedAtLabel = formatSavedLabel(lastSavedAt);
useEffect(() => {
@@ -214,7 +256,9 @@ export function useRecipePersistence({
setLastSavedAt(initialSavedAt);
setCopied(false);
- const parsed = importRecipePayload(JSON.stringify(initialPayload));
+ const parsed = importRecipePayload(JSON.stringify(initialPayload), {
+ preserveUnstructuredUploads: true,
+ });
if (parsed.snapshot) {
loadRecipe(parsed.snapshot);
} else {
@@ -252,6 +296,7 @@ export function useRecipePersistence({
});
setLastSavedAt(result.updatedAt);
setSavedSignature(buildSignature(nextName, currentPayload));
+ drainQueuedUploadCleanups(currentPayload);
} catch (error) {
console.error("Save recipe failed:", error);
toastError("Save failed", "Could not save recipe.");
@@ -270,11 +315,28 @@ export function useRecipePersistence({
return () => window.clearTimeout(timeoutId);
}, [isDirty, persistRecipe, saveLoading]);
+ // Drain queued cleanups even when autosave is skipped: a net-zero edit (add
+ // then remove an unstructured seed before the 800ms debounce) keeps isDirty
+ // false, so the autosave effect never drains and the queued uid leaks its
+ // upload dir. Not-dirty means currentPayload equals the saved recipe, and
+ // drain skips the uid it still references, so only dirs no saved recipe
+ // points at are deleted (keeps the save-first invariant).
+ useEffect(() => {
+ if (!initialRecipeReady || isDirty || saveLoading) {
+ return;
+ }
+ drainQueuedUploadCleanups(currentPayload);
+ }, [currentPayload, initialRecipeReady, isDirty, saveLoading]);
+
const copyRecipe = useCallback(async (): Promise => {
setCopied(false);
try {
- const safePayload = sanitizeSeedForShare(stripApiKeys(payloadResult.payload));
- const ok = await copyTextToClipboard(JSON.stringify(safePayload, null, 2));
+ const safePayload = sanitizeSeedForShare(
+ stripApiKeys(payloadResult.payload),
+ );
+ const ok = await copyTextToClipboard(
+ JSON.stringify(safePayload, null, 2),
+ );
if (!ok) {
throw new Error("Clipboard not available.");
}
diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
index 8659cad4fb..a1ff72ee9b 100644
--- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
@@ -36,6 +36,7 @@ import {
} from "../utils/handles";
import type { RecipeSnapshot } from "../utils/import";
import { getLayoutedElements } from "../utils/layout";
+import { makeUnstructuredUploadUid } from "../utils/config-factories";
import {
centerModelInfraNodes,
optimizeModelInfraEdgeHandles,
@@ -76,6 +77,12 @@ type RecipeStudioState = {
nextId: number;
nextY: number;
fitViewTick: number;
+ // Upload-uid directories whose owning block dropped them; server-side
+ // deletion is deferred until a save no longer references them, so a
+ // reload before autosave cannot leave a saved recipe pointing at
+ // deleted files.
+ pendingUploadCleanups: string[];
+ queueUploadCleanup: (uid: string) => void;
setSheetOpen: (open: boolean) => void;
setSheetView: (view: SheetView) => void;
setProcessors: (processors: RecipeProcessorConfig[]) => void;
@@ -137,6 +144,7 @@ const INITIAL_STATE = {
nextId: 3,
nextY: 280,
fitViewTick: 0,
+ pendingUploadCleanups: [],
} satisfies Pick<
RecipeStudioState,
| "nodes"
@@ -154,6 +162,7 @@ const INITIAL_STATE = {
| "nextId"
| "nextY"
| "fitViewTick"
+ | "pendingUploadCleanups"
>;
function buildAddedNodeState(
@@ -269,6 +278,20 @@ function isModelSemanticEdge(
);
}
+// Upload uid of a seed block whose server-side directory becomes orphaned
+// when the block drops it. Only uid directories qualify (single owner);
+// legacy node-id directories can be shared by other recipes.
+function seedUploadCleanupUid(config: NodeConfig | undefined): string | null {
+ if (!config || config.kind !== "seed") {
+ return null;
+ }
+ const uid = config.unstructured_upload_uid?.trim();
+ if (!uid || !config.unstructured_file_ids?.length) {
+ return null;
+ }
+ return uid;
+}
+
export const useRecipeStudioStore = create((set, get) => ({
...INITIAL_STATE,
setSheetOpen: (open) => set({ sheetOpen: open }),
@@ -278,6 +301,12 @@ export const useRecipeStudioStore = create((set, get) => ({
setDialogOpen: (open) => set({ dialogOpen: open }),
setExecutionLocked: (locked) => set({ executionLocked: locked }),
resetRecipe: () => set(INITIAL_STATE),
+ queueUploadCleanup: (uid) =>
+ set((state) =>
+ state.pendingUploadCleanups.includes(uid)
+ ? state
+ : { pendingUploadCleanups: [...state.pendingUploadCleanups, uid] },
+ ),
selectConfig: (id) => set({ activeConfigId: id, dialogOpen: false }),
openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }),
setLayoutDirection: (direction) =>
@@ -383,7 +412,18 @@ export const useRecipeStudioStore = create((set, get) => ({
}
return buildAddedNodeState(state, "sampler", type, position, openDialog);
}),
- addSeedNode: (type, position, openDialog = true) =>
+ addSeedNode: (type, position, openDialog = true) => {
+ const current = get();
+ if (!current.executionLocked) {
+ // The reset below clears the block's upload uid and file list; queue
+ // its server-side directory for deletion after the next save.
+ const uid = seedUploadCleanupUid(
+ Object.values(current.configs).find((config) => config.kind === "seed"),
+ );
+ if (uid) {
+ current.queueUploadCleanup(uid);
+ }
+ }
set((state) => {
if (state.executionLocked) {
return state;
@@ -413,6 +453,8 @@ export const useRecipeStudioStore = create((set, get) => ({
hf_token: "",
hf_endpoint: "https://huggingface.co",
local_file_name: "",
+ unstructured_upload_uid:
+ nextSourceType === "unstructured" ? makeUnstructuredUploadUid() : "",
unstructured_file_ids: [],
unstructured_file_names: [],
unstructured_file_sizes: [],
@@ -446,7 +488,8 @@ export const useRecipeStudioStore = create((set, get) => ({
activeConfigId: existing.id,
dialogOpen: openDialog,
};
- }),
+ });
+ },
addLlmNode: (type, position, openDialog = true) =>
set((state) => {
if (state.executionLocked) {
@@ -699,6 +742,9 @@ export const useRecipeStudioStore = create((set, get) => ({
dialogOpen: false,
sheetView: "root",
fitViewTick: state.fitViewTick + 1,
+ // Queued cleanups belong to the previous recipe; draining them after
+ // a save of this one could delete files its saved payload still uses.
+ pendingUploadCleanups: [],
})),
setAuxNodePosition: (id, position) =>
set((state) => {
@@ -786,6 +832,17 @@ export const useRecipeStudioStore = create((set, get) => ({
set(applyUpdate);
},
onNodesChange: (changes) => {
+ const current = get();
+ if (!current.executionLocked) {
+ for (const change of changes) {
+ if (change.type === "remove") {
+ const uid = seedUploadCleanupUid(current.configs[change.id]);
+ if (uid) {
+ current.queueUploadCleanup(uid);
+ }
+ }
+ }
+ }
const applyNodesChange = (state: RecipeStudioState) => {
if (state.executionLocked) {
return state;
diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts
index b8ed13f70b..9231c5f7a3 100644
--- a/studio/frontend/src/features/recipe-studio/types/index.ts
+++ b/studio/frontend/src/features/recipe-studio/types/index.ts
@@ -340,6 +340,8 @@ export type SeedConfig = {
hf_token?: string;
hf_endpoint?: string;
local_file_name?: string;
+ // ui-only: stable per-block id for uploads, since node ids collide across imports
+ unstructured_upload_uid?: string;
unstructured_file_ids?: string[];
unstructured_file_names?: string[];
unstructured_file_sizes?: number[];
diff --git a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
index 76fc2c38ee..d47bac8858 100644
--- a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
@@ -20,6 +20,46 @@ import type {
} from "../types";
import { nextName } from "./naming";
+export function makeUnstructuredUploadUid(): string {
+ if (typeof globalThis.crypto?.randomUUID === "function") {
+ return globalThis.crypto.randomUUID().replace(/-/g, "").toLowerCase();
+ }
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
+ const bytes = new Uint8Array(16);
+ globalThis.crypto.getRandomValues(bytes);
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
+ "",
+ );
+ }
+ let uid = "";
+ while (uid.length < 32) {
+ uid += Math.floor(Math.random() * 0x100000000)
+ .toString(16)
+ .padStart(8, "0");
+ }
+ return uid.slice(0, 32);
+}
+
+export function resolveUnstructuredUploadBlockId({
+ configId,
+ uploadUid,
+ generatedUploadUid,
+ unstructuredFileCount,
+}: {
+ configId: string;
+ uploadUid: string;
+ generatedUploadUid: string | null;
+ unstructuredFileCount: number;
+}): string {
+ if (uploadUid) {
+ return uploadUid;
+ }
+ if (generatedUploadUid) {
+ return generatedUploadUid;
+ }
+ return unstructuredFileCount > 0 ? configId : "";
+}
+
export function makeSamplerConfig(
id: string,
samplerType: SamplerType,
@@ -368,6 +408,9 @@ export function makeSeedConfig(
hf_token: "",
hf_endpoint: "https://huggingface.co",
local_file_name: "",
+ ...(seedSourceType === "unstructured"
+ ? { unstructured_upload_uid: makeUnstructuredUploadUid() }
+ : {}),
unstructured_file_ids: [],
unstructured_file_names: [],
unstructured_file_sizes: [],
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
index ac881d0373..54df2ffd50 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
@@ -16,11 +16,7 @@ import type {
} from "../../types";
import { buildEdges } from "./edges";
import { isRecord, parseJson, readString } from "./helpers";
-import {
- parseColumn,
- parseModelConfig,
- parseModelProvider,
-} from "./parsers";
+import { parseColumn, parseModelConfig, parseModelProvider } from "./parsers";
import { parseSeedConfig } from "./parsers/seed-config-parser";
import { buildNodes, parseUi } from "./ui";
import type { ImportResult } from "./types";
@@ -43,6 +39,7 @@ type UiInput = {
seed_drop_columns?: unknown;
seed_preview_rows?: unknown;
local_file_name?: unknown;
+ unstructured_upload_uid?: unknown;
unstructured_file_ids?: unknown;
unstructured_file_names?: unknown;
unstructured_file_sizes?: unknown;
@@ -51,6 +48,10 @@ type UiInput = {
advanced_open_by_node?: unknown;
};
+type ImportRecipePayloadOptions = {
+ preserveUnstructuredUploads?: boolean;
+};
+
type UiMarkdownNoteNode = {
name: string;
markdown: string;
@@ -90,7 +91,7 @@ function parseProcessors(input: unknown): RecipeProcessorConfig[] {
? templateRaw
: isRecord(templateRaw)
? JSON.stringify(templateRaw, null, 2)
- : "{\n \"text\": \"{{ column_name }}\"\n}";
+ : '{\n "text": "{{ column_name }}"\n}';
processors.push({
id: `p${index + 1}`,
// biome-ignore lint/style/useNamingConvention: api schema
@@ -135,9 +136,7 @@ function parseSeedDropColumns(input: unknown): string[] {
return Array.from(values);
}
-function parseMcpProviders(
- input: unknown,
-): Map {
+function parseMcpProviders(input: unknown): Map {
const providers = new Map();
if (!Array.isArray(input)) {
return providers;
@@ -156,13 +155,12 @@ function parseMcpProviders(
const args = Array.isArray(item.args)
? item.args.map((value) => String(value))
: [];
- const envPairs =
- isRecord(item.env)
- ? Object.entries(item.env).map(([key, value]) => ({
- key: String(key),
- value: String(value),
- }))
- : [];
+ const envPairs = isRecord(item.env)
+ ? Object.entries(item.env).map(([key, value]) => ({
+ key: String(key),
+ value: String(value),
+ }))
+ : [];
providers.set(name, {
id: `mcp-${index + 1}`,
name,
@@ -209,7 +207,8 @@ function parseToolConfigs(input: unknown): Map {
allow_tools: allowTools,
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns:
- item.max_tool_call_turns === null || item.max_tool_call_turns === undefined
+ item.max_tool_call_turns === null ||
+ item.max_tool_call_turns === undefined
? "5"
: String(item.max_tool_call_turns),
// biome-ignore lint/style/useNamingConvention: api schema
@@ -257,7 +256,9 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
return noteNodes;
}
-function parseUiToolProfileNodes(input: unknown): Map> {
+function parseUiToolProfileNodes(
+ input: unknown,
+): Map> {
const toolProfiles = new Map>();
if (!Array.isArray(input)) {
return toolProfiles;
@@ -312,9 +313,15 @@ function parseAdvancedOpenByNode(input: unknown): Record {
return out;
}
-type AdvancedOpenConfig = LlmConfig | SamplerConfig | SeedConfig | ValidatorConfig;
+type AdvancedOpenConfig =
+ | LlmConfig
+ | SamplerConfig
+ | SeedConfig
+ | ValidatorConfig;
-function isAdvancedOpenConfig(config: NodeConfig): config is AdvancedOpenConfig {
+function isAdvancedOpenConfig(
+ config: NodeConfig,
+): config is AdvancedOpenConfig {
return (
config.kind === "llm" ||
config.kind === "sampler" ||
@@ -350,7 +357,8 @@ function buildToolProfileConfig(
.map((providerName) => mcpProvidersByName.get(providerName))
.flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : [])),
// biome-ignore lint/style/useNamingConvention: ui schema
- fetched_tools_by_provider: fetchedToolsByProfileName.get(canonical.tool_alias) ?? {},
+ fetched_tools_by_provider:
+ fetchedToolsByProfileName.get(canonical.tool_alias) ?? {},
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: [...(canonical.allow_tools ?? [])],
// biome-ignore lint/style/useNamingConvention: api schema
@@ -360,7 +368,10 @@ function buildToolProfileConfig(
};
}
-export function importRecipePayload(input: string): ImportResult {
+export function importRecipePayload(
+ input: string,
+ options: ImportRecipePayloadOptions = {},
+): ImportResult {
const parsed = parseJson(input);
if (!parsed.data || !isRecord(parsed.data)) {
return {
@@ -369,9 +380,9 @@ export function importRecipePayload(input: string): ImportResult {
};
}
- const recipe = (isRecord(parsed.data.recipe)
- ? parsed.data.recipe
- : parsed.data) as RecipeInput;
+ const recipe = (
+ isRecord(parsed.data.recipe) ? parsed.data.recipe : parsed.data
+ ) as RecipeInput;
const ui = isRecord(parsed.data.ui) ? (parsed.data.ui as UiInput) : null;
if (!Array.isArray(recipe.columns)) {
@@ -410,21 +421,36 @@ export function importRecipePayload(input: string): ImportResult {
.map((row) => ({ ...row }))
: undefined;
const uiLocalFileName = readString(ui?.local_file_name) ?? undefined;
- // Preserve file IDs/names from saved recipes (cleared at share time by sanitizeSeedForShare)
- const uiUnstructuredFileIds: string[] = Array.isArray(ui?.unstructured_file_ids)
- ? (ui.unstructured_file_ids as string[]).filter((v): v is string => typeof v === "string")
- : [];
- const uiUnstructuredFileNames: string[] = Array.isArray(ui?.unstructured_file_names)
- ? (ui.unstructured_file_names as string[]).filter((v): v is string => typeof v === "string")
- : [];
- const uiUnstructuredFileSizes: number[] = Array.isArray(ui?.unstructured_file_sizes)
- ? (ui.unstructured_file_sizes as number[]).filter((v): v is number => typeof v === "number")
- : [];
+ const preserveUnstructuredUploads =
+ options.preserveUnstructuredUploads === true;
+ const uiUnstructuredUploadUid = preserveUnstructuredUploads
+ ? (readString(ui?.unstructured_upload_uid) ?? undefined)
+ : undefined;
+ const uiUnstructuredFileIds: string[] =
+ preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_ids)
+ ? (ui.unstructured_file_ids as string[]).filter(
+ (v): v is string => typeof v === "string",
+ )
+ : [];
+ const uiUnstructuredFileNames: string[] =
+ preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_names)
+ ? (ui.unstructured_file_names as string[]).filter(
+ (v): v is string => typeof v === "string",
+ )
+ : [];
+ const uiUnstructuredFileSizes: number[] =
+ preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_sizes)
+ ? (ui.unstructured_file_sizes as number[]).filter(
+ (v): v is number => typeof v === "number",
+ )
+ : [];
const uiUnstructuredChunkSize = readStringNumber(ui?.unstructured_chunk_size);
const uiUnstructuredChunkOverlap = readStringNumber(
ui?.unstructured_chunk_overlap,
);
- const uiAdvancedOpenByNode = parseAdvancedOpenByNode(ui?.advanced_open_by_node);
+ const uiAdvancedOpenByNode = parseAdvancedOpenByNode(
+ ui?.advanced_open_by_node,
+ );
const uiMarkdownNotes = parseUiMarkdownNoteNodes(ui?.nodes);
const uiToolProfilesByName = parseUiToolProfileNodes(ui?.nodes);
@@ -459,11 +485,13 @@ export function importRecipePayload(input: string): ImportResult {
: payloadSeedDropColumns,
seed_preview_rows: uiSeedPreviewRows,
local_file_name: uiLocalFileName,
+ unstructuredUploadUid: uiUnstructuredUploadUid,
unstructuredFileIds: uiUnstructuredFileIds,
unstructuredFileNames: uiUnstructuredFileNames,
unstructuredFileSizes: uiUnstructuredFileSizes,
unstructured_chunk_size: uiUnstructuredChunkSize,
unstructured_chunk_overlap: uiUnstructuredChunkOverlap,
+ preserveUnstructuredUploads,
});
if (seedConfig) {
applyAdvancedOpen(seedConfig, uiAdvancedOpenByNode);
@@ -567,12 +595,7 @@ export function importRecipePayload(input: string): ImportResult {
const { layouts, auxNodes, edges: uiEdges, layoutDirection } = parseUi(ui);
const resolvedLayoutDirection = layoutDirection ?? "LR";
const nodes = buildNodes(configs, layouts);
- const edges = buildEdges(
- configs,
- nameToId,
- uiEdges,
- resolvedLayoutDirection,
- );
+ const edges = buildEdges(configs, nameToId, uiEdges, resolvedLayoutDirection);
const auxNodePositions = Object.fromEntries(
auxNodes.flatMap((item) => {
const llmId = nameToId.get(item.llm);
@@ -583,10 +606,7 @@ export function importRecipePayload(input: string): ImportResult {
}),
);
- const maxY = nodes.reduce(
- (acc, node) => Math.max(acc, node.position.y),
- 0,
- );
+ const maxY = nodes.reduce((acc, node) => Math.max(acc, node.position.y), 0);
return {
errors: [],
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts
index 21eadb3195..939205fe6d 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts
@@ -197,17 +197,26 @@ export function parseSeedConfig(
seed_drop_columns?: string[];
seed_preview_rows?: Record[];
local_file_name?: string;
+ unstructuredUploadUid?: string;
unstructuredFileIds?: string[];
unstructuredFileNames?: string[];
unstructuredFileSizes?: number[];
unstructured_chunk_size?: string;
unstructured_chunk_overlap?: string;
+ preserveUnstructuredUploads?: boolean;
},
): SeedConfig | null {
if (!seedConfigRaw) {
return null;
}
- const parsed = parseSeedSettings(seedConfigRaw);
+ const parsed = { ...parseSeedSettings(seedConfigRaw) };
+ if (
+ parsed.seed_source_type === "unstructured" &&
+ options?.preserveUnstructuredUploads !== true
+ ) {
+ parsed.hf_path = "";
+ parsed.resolved_paths = [];
+ }
let sourceType: SeedSourceType = "hf";
if (parsed.seed_source_type === "hf") {
sourceType = "hf";
@@ -230,6 +239,9 @@ export function parseSeedConfig(
...(options?.local_file_name !== undefined
? { local_file_name: options.local_file_name }
: {}),
+ ...(options?.unstructuredUploadUid
+ ? { unstructured_upload_uid: options.unstructuredUploadUid }
+ : {}),
...(options?.unstructuredFileIds !== undefined
? { unstructured_file_ids: options.unstructuredFileIds }
: {}),
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
index 34c3c34274..9b2ad5b2b5 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
@@ -440,6 +440,9 @@ export function buildRecipePayload(
unstructured_file_names: firstSeed.unstructured_file_names,
unstructured_file_sizes: firstSeed.unstructured_file_sizes,
}),
+ ...(firstSeed?.unstructured_upload_uid?.trim() && {
+ unstructured_upload_uid: firstSeed.unstructured_upload_uid,
+ }),
...(firstSeed &&
firstSeed.unstructured_chunk_size !== undefined && {
unstructured_chunk_size: firstSeed.unstructured_chunk_size,
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
index 902ea796b4..763e68c1fb 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
@@ -71,6 +71,8 @@ export type RecipePayload = {
seed_preview_rows?: Record[];
local_file_name?: string;
// biome-ignore lint/style/useNamingConvention: api schema
+ unstructured_upload_uid?: string;
+ // biome-ignore lint/style/useNamingConvention: api schema
unstructured_file_ids?: string[];
// biome-ignore lint/style/useNamingConvention: api schema
unstructured_file_names?: string[];
diff --git a/studio/frontend/src/features/settings/api/coding-agents.ts b/studio/frontend/src/features/settings/api/coding-agents.ts
new file mode 100644
index 0000000000..ae371b2d3a
--- /dev/null
+++ b/studio/frontend/src/features/settings/api/coding-agents.ts
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { authFetch } from "@/features/auth";
+import { readFastApiError } from "@/lib/format-fastapi-error";
+
+export type CodingAgentsInfo = {
+ // Every agent `unsloth start` supports, in the CLI's declared order.
+ agents: string[];
+ // Subset of `agents` whose CLI binary was found on PATH by the backend.
+ detected: string[];
+};
+
+type ApiCodingAgentsInfo = {
+ agents: string[];
+ detected: string[];
+};
+
+// Which CLIs are on PATH is environment state, not a persisted setting -- it
+// can change any time the user installs something new, so this only
+// de-duplicates concurrent in-flight calls (e.g. React strict-mode's double
+// mount) rather than caching the result across the module's lifetime. Every
+// fresh call (each time a settings panel mounts) re-checks PATH for real.
+let inFlightInfo: Promise | null = null;
+
+function fromApi(info: ApiCodingAgentsInfo): CodingAgentsInfo {
+ return { agents: info.agents, detected: info.detected };
+}
+
+async function fetchCodingAgents(): Promise {
+ const res = await authFetch("/api/settings/coding-agents");
+ if (!res.ok) {
+ throw new Error(
+ await readFastApiError(res, "Failed to load installed coding agents"),
+ );
+ }
+ return fromApi(await res.json());
+}
+
+export async function loadCodingAgents(): Promise {
+ inFlightInfo ??= fetchCodingAgents().finally(() => {
+ inFlightInfo = null;
+ });
+ return inFlightInfo;
+}
diff --git a/studio/frontend/src/features/settings/api/personalization.ts b/studio/frontend/src/features/settings/api/personalization.ts
index 829e8249bb..7355b6fd0b 100644
--- a/studio/frontend/src/features/settings/api/personalization.ts
+++ b/studio/frontend/src/features/settings/api/personalization.ts
@@ -3,17 +3,21 @@
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
+import type { AppearanceCustomization } from "../stores/appearance-custom-store";
export type PersonalizationProfile = {
displayName: string;
nickname: string;
avatarDataUrl: string | null;
avatarShape: "circle" | "rounded";
+ showGreetingSloth: boolean;
};
export type PersonalizationAppearance = {
theme: "light" | "dark" | "system";
+ palette: "standard" | "classic" | "minimal";
language: string | null;
+ customization: AppearanceCustomization;
};
export type Personalization = {
@@ -22,18 +26,28 @@ export type Personalization = {
appearance: PersonalizationAppearance;
// Distinguishes server hydrate from first local migration.
saved: boolean;
+ // False when the stored record predates these fields (legacy migration): the
+ // client then keeps local values instead of the server-filled defaults.
+ customizationSaved: boolean;
+ paletteSaved: boolean;
+ greetingSlothSaved: boolean;
};
export async function loadPersonalization(): Promise {
const res = await authFetch("/api/settings/personalization");
if (!res.ok) {
- throw new Error(await readFastApiError(res, "Failed to load personalization"));
+ throw new Error(
+ await readFastApiError(res, "Failed to load personalization"),
+ );
}
return (await res.json()) as Personalization;
}
export async function savePersonalization(
- data: Omit,
+ data: Omit<
+ Personalization,
+ "saved" | "customizationSaved" | "paletteSaved" | "greetingSlothSaved"
+ >,
): Promise {
const res = await authFetch("/api/settings/personalization", {
method: "PUT",
@@ -41,6 +55,8 @@ export async function savePersonalization(
body: JSON.stringify(data),
});
if (!res.ok) {
- throw new Error(await readFastApiError(res, "Failed to save personalization"));
+ throw new Error(
+ await readFastApiError(res, "Failed to save personalization"),
+ );
}
}
diff --git a/studio/frontend/src/features/settings/components/agent-command.ts b/studio/frontend/src/features/settings/components/agent-command.ts
index 9e87922970..38b2d73c3b 100644
--- a/studio/frontend/src/features/settings/components/agent-command.ts
+++ b/studio/frontend/src/features/settings/components/agent-command.ts
@@ -12,7 +12,7 @@ const DEFAULT_AGENT = "claude";
// URL.hostname brackets IPv6 literals (`new URL("http://[::1]:8888").hostname` is
// "[::1]"), so strip the brackets before matching the bare "::1" loopback rules below.
-function normalizeHost(host: string): string {
+export function normalizeHost(host: string): string {
const lower = host.toLowerCase();
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
}
@@ -26,7 +26,7 @@ function isDefaultLocalHost(host: string): boolean {
}
// Match the CLI auto-mint rule (is_loopback_url): localhost, ::1, and all of 127.0.0.0/8.
-function isLoopbackHost(host: string): boolean {
+export function isLoopbackHost(host: string): boolean {
if (host === "localhost" || host === "::1") return true;
const octets = host.split(".");
return (
diff --git a/studio/frontend/src/features/settings/components/api-monitor-console.tsx b/studio/frontend/src/features/settings/components/api-monitor-console.tsx
index 36d09115ae..ab67d4e006 100644
--- a/studio/frontend/src/features/settings/components/api-monitor-console.tsx
+++ b/studio/frontend/src/features/settings/components/api-monitor-console.tsx
@@ -80,7 +80,7 @@ function UsageBar({ value }: { value?: number | null }): ReactElement | null {
return (
diff --git a/studio/frontend/src/features/settings/components/appearance-custom-controls.tsx b/studio/frontend/src/features/settings/components/appearance-custom-controls.tsx
new file mode 100644
index 0000000000..bd5459f88f
--- /dev/null
+++ b/studio/frontend/src/features/settings/components/appearance-custom-controls.tsx
@@ -0,0 +1,973 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Slider } from "@/components/ui/slider";
+import { Switch } from "@/components/ui/switch";
+import { type TranslationKey, useT } from "@/i18n";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import { toast } from "@/lib/toast";
+import { cn } from "@/lib/utils";
+import {
+ Cancel01Icon,
+ Folder01Icon,
+ Upload01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { motion, useReducedMotionConfig } from "motion/react";
+import {
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
+import {
+ CODE_FONT_SIZE_RANGE,
+ type CustomModeColors,
+ DEFAULT_CUSTOMIZATION,
+ MAX_IMPORTED_FONTS,
+ MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH,
+ type ReduceMotionSetting,
+ UI_FONT_SIZE_RANGE,
+ isDefaultCustomization,
+ useAppearanceCustomStore,
+} from "../stores/appearance-custom-store";
+import {
+ type Palette,
+ type ResolvedTheme,
+ usePalette,
+ useTheme,
+} from "../stores/theme-store";
+import { ColorPickerSwatch } from "./color-picker";
+
+/* ------------------------------- Colors -------------------------------- */
+
+// Seed values shown in the pickers while no override is set. Mirrors the
+// palette token values in index.css (foregrounds converted from oklch).
+type DefaultModeColors = { [K in keyof CustomModeColors]: string };
+const PALETTE_DEFAULT_COLORS: Record<
+ Palette,
+ Record
+> = {
+ standard: {
+ light: { accent: "#17b88b", background: "#fefefd", foreground: "#262626" },
+ dark: { accent: "#17b88b", background: "#181818", foreground: "#ececec" },
+ },
+ classic: {
+ light: { accent: "#339cff", background: "#ffffff", foreground: "#1a1c1f" },
+ dark: { accent: "#4dabff", background: "#181818", foreground: "#ececec" },
+ },
+ minimal: {
+ light: { accent: "#171717", background: "#ffffff", foreground: "#171717" },
+ dark: { accent: "#ededed", background: "#181818", foreground: "#ededed" },
+ },
+};
+
+/**
+ * Color override control for the CURRENTLY ACTIVE resolved mode. Only the
+ * active mode is editable; the other mode's overrides stay stored and take
+ * effect when the color scheme flips.
+ */
+export function ActiveColorControl({
+ colorKey,
+ label,
+}: {
+ colorKey: keyof CustomModeColors;
+ label: string;
+}) {
+ const t = useT();
+ const { resolved } = useTheme();
+ const { palette } = usePalette();
+ const override = useAppearanceCustomStore(
+ (s) => s.customization.colors[resolved][colorKey],
+ );
+ const setColor = useAppearanceCustomStore((s) => s.setColor);
+ const fallback = PALETTE_DEFAULT_COLORS[palette][resolved][colorKey];
+ const value = override ?? fallback;
+ return (
+
+ {override !== null && (
+ setColor(resolved, colorKey, null)}
+ >
+ {t("settings.appearance.custom.reset")}
+
+ )}
+ setColor(resolved, colorKey, hex)}
+ label={label}
+ />
+
+ );
+}
+
+/* ------------------------------ Typography ------------------------------ */
+
+/** Font each slot resolves to when no override is set (see index.css). */
+const DEFAULT_FONT_NAMES = {
+ ui: "Inter Variable",
+ heading: "Hellix",
+ chat: "Inter Variable",
+ code: "JetBrains Mono",
+} as const;
+
+/** Fonts Unsloth Studio already ships (bundled @font-face / fontsource). */
+const BUNDLED_FONTS = [
+ "Inter Variable",
+ "Hellix",
+ "Space Grotesk Variable",
+ "Figtree Variable",
+ "JetBrains Mono",
+ "Fira Code",
+] as const;
+
+/* ----------------------------- Device fonts ------------------------------ */
+
+// Fallback candidates probed by canvas measurement when the Local Font
+// Access API (Chromium-only) is unavailable or denied.
+const CANDIDATE_DEVICE_FONTS = [
+ "American Typewriter",
+ "Andale Mono",
+ "Arial",
+ "Avenir",
+ "Avenir Next",
+ "Baskerville",
+ "Calibri",
+ "Cambria",
+ "Candara",
+ "Cantarell",
+ "Charter",
+ "Comic Sans MS",
+ "Consolas",
+ "Constantia",
+ "Corbel",
+ "Courier",
+ "Courier New",
+ "DejaVu Sans",
+ "DejaVu Sans Mono",
+ "DejaVu Serif",
+ "Didot",
+ "Fira Sans",
+ "Franklin Gothic Medium",
+ "Futura",
+ "Geneva",
+ "Georgia",
+ "Gill Sans",
+ "Helvetica",
+ "Helvetica Neue",
+ "Hoefler Text",
+ "IBM Plex Mono",
+ "IBM Plex Sans",
+ "Impact",
+ "Inconsolata",
+ "Iosevka",
+ "Lato",
+ "Liberation Mono",
+ "Liberation Sans",
+ "Liberation Serif",
+ "Lucida Grande",
+ "Menlo",
+ "Monaco",
+ "Montserrat",
+ "Noto Sans",
+ "Noto Serif",
+ "Nunito",
+ "Open Sans",
+ "Optima",
+ "Palatino",
+ "Roboto",
+ "Rockwell",
+ "Segoe UI",
+ "Seravek",
+ "Source Sans Pro",
+ "Tahoma",
+ "Times",
+ "Times New Roman",
+ "Trebuchet MS",
+ "Ubuntu",
+ "Verdana",
+];
+
+function detectFontsByMeasurement(candidates: string[]): string[] {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return [];
+ const sample = "mmmmmmmmmwwwwwwwlli";
+ const baselines = ["monospace", "sans-serif", "serif"].map((base) => {
+ ctx.font = `72px ${base}`;
+ return { base, width: ctx.measureText(sample).width };
+ });
+ return candidates.filter((family) =>
+ baselines.some(({ base, width }) => {
+ ctx.font = `72px "${family}", ${base}`;
+ return ctx.measureText(sample).width !== width;
+ }),
+ );
+}
+
+let deviceFontsCache: string[] | null = null;
+
+async function loadDeviceFonts(): Promise {
+ if (deviceFontsCache) return deviceFontsCache;
+ let families: string[] = [];
+ const query = (
+ globalThis as {
+ queryLocalFonts?: () => Promise<{ family: string }[]>;
+ }
+ ).queryLocalFonts;
+ if (typeof query === "function") {
+ try {
+ const fonts = await query();
+ families = [...new Set(fonts.map((f) => f.family))];
+ } catch {
+ // permission denied; fall back to measurement probing
+ }
+ }
+ if (families.length === 0) {
+ families = detectFontsByMeasurement(CANDIDATE_DEVICE_FONTS);
+ }
+ deviceFontsCache = families.sort((a, b) => a.localeCompare(b));
+ return deviceFontsCache;
+}
+
+function FontSelect({
+ value,
+ defaultFont,
+ onCommit,
+ ariaLabel,
+}: {
+ value: string | null;
+ defaultFont: string;
+ onCommit: (next: string | null) => void;
+ ariaLabel: string;
+}) {
+ const t = useT();
+ const defaultLabel = `${defaultFont} (${t("settings.appearance.custom.fontDefault")})`;
+ const [open, setOpen] = useState(false);
+ const [deviceFonts, setDeviceFonts] = useState(
+ deviceFontsCache,
+ );
+ const importedFonts = useAppearanceCustomStore(
+ (s) => s.customization.importedFonts,
+ );
+ const removeImportedFont = useAppearanceCustomStore(
+ (s) => s.removeImportedFont,
+ );
+
+ const handleOpenChange = (next: boolean) => {
+ setOpen(next);
+ // Kick off inside the click gesture: queryLocalFonts may need transient
+ // user activation for its permission prompt.
+ if (next && deviceFonts === null) {
+ void loadDeviceFonts().then(setDeviceFonts);
+ }
+ };
+
+ const select = (next: string | null) => {
+ onCommit(next);
+ setOpen(false);
+ };
+
+ const folderFonts = useFolderFonts();
+ const { inputs: uploadInputs, requestUpload, requestFolder, importFile } =
+ useFontImport((name) => select(name));
+
+ const knownNames = new Set([
+ ...BUNDLED_FONTS,
+ ...importedFonts.map((f) => f.name),
+ ]);
+ const deviceOnlyFonts = (deviceFonts ?? []).filter((f) => !knownNames.has(f));
+
+ const renderItem = (font: string) => (
+ select(font)}
+ data-checked={value === font}
+ className="cursor-pointer rounded-[11px]"
+ >
+ {/* Preview each entry in its own typeface. */}
+
+ {font}
+
+
+ );
+
+ return (
+
+
+
+
+ {value ?? defaultLabel}
+
+
+
+
+
+
+
+
+
+ {t("settings.appearance.custom.fontNoResults")}
+
+ select(null)}
+ data-checked={value === null}
+ className="cursor-pointer rounded-[11px]"
+ >
+
+ {defaultLabel}
+
+
+
+ {BUNDLED_FONTS.map(renderItem)}
+
+ {importedFonts.length > 0 && (
+
+ {importedFonts.map((font) => (
+ select(font.name)}
+ data-checked={value === font.name}
+ className="cursor-pointer rounded-[11px]"
+ >
+
+ {font.name}
+
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation();
+ removeImportedFont(font.name);
+ }}
+ className="ml-auto rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
+ >
+
+
+
+ ))}
+
+ )}
+ {folderFonts.some((f) => !knownNames.has(f.name)) && (
+
+ {folderFonts
+ .filter((f) => !knownNames.has(f.name))
+ .map(({ name, file }) => (
+ importFile(file)}
+ className="cursor-pointer rounded-[11px]"
+ >
+ {name}
+
+ ))}
+
+ )}
+
+ {deviceFonts === null ? (
+
+ {t("settings.appearance.custom.fontDeviceLoading")}
+
+ ) : (
+ deviceOnlyFonts.map(renderItem)
+ )}
+
+
+
+
+
+
+ {t("settings.appearance.custom.importFont.upload")}
+
+
+
+
+ {t("settings.appearance.custom.importFont.scanFolder")}
+
+
+ {uploadInputs}
+
+
+ );
+}
+
+export function UiFontRow() {
+ const t = useT();
+ const uiFont = useAppearanceCustomStore((s) => s.customization.uiFont);
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+ patch({ uiFont: next })}
+ ariaLabel={t("settings.appearance.custom.uiFont.label")}
+ />
+ );
+}
+
+export function HeadingFontRow() {
+ const t = useT();
+ const headingFont = useAppearanceCustomStore(
+ (s) => s.customization.headingFont,
+ );
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+ patch({ headingFont: next })}
+ ariaLabel={t("settings.appearance.custom.headingFont.label")}
+ />
+ );
+}
+
+export function ChatFontRow() {
+ const t = useT();
+ const chatFont = useAppearanceCustomStore((s) => s.customization.chatFont);
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+ patch({ chatFont: next })}
+ ariaLabel={t("settings.appearance.custom.chatFont.label")}
+ />
+ );
+}
+
+export function CodeFontRow() {
+ const t = useT();
+ const codeFont = useAppearanceCustomStore((s) => s.customization.codeFont);
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+ patch({ codeFont: next })}
+ ariaLabel={t("settings.appearance.custom.codeFont.label")}
+ />
+ );
+}
+
+/* ---------------------------- Imported fonts ---------------------------- */
+
+const FONT_MIME_BY_EXTENSION: Record = {
+ woff2: "font/woff2",
+ woff: "font/woff",
+ ttf: "font/ttf",
+ otf: "font/otf",
+};
+
+const MAX_FONT_FILE_BYTES = Math.floor(1.5 * 1024 * 1024);
+
+// Trailing style words stripped when matching a file name to a family the
+// user already has ("Roboto-Bold.ttf" should match an installed "Roboto").
+const FONT_STYLE_SUFFIXES = new Set([
+ "regular",
+ "bold",
+ "italic",
+ "light",
+ "medium",
+ "thin",
+ "black",
+ "semibold",
+ "extrabold",
+ "extralight",
+ "heavy",
+ "book",
+ "oblique",
+ "normal",
+ "variable",
+ "vf",
+]);
+
+function fontBaseName(fileName: string): string {
+ return fileName
+ .replace(/\.[^.]+$/, "")
+ .replace(/[;{}()<>"']/g, "")
+ .replace(/[_-]+/g, " ")
+ .trim();
+}
+
+function familyCandidates(base: string): string[] {
+ const candidates = [base];
+ let words = base.split(/\s+/);
+ while (
+ words.length > 1 &&
+ FONT_STYLE_SUFFIXES.has(words[words.length - 1].toLowerCase())
+ ) {
+ words = words.slice(0, -1);
+ candidates.push(words.join(" "));
+ }
+ return candidates;
+}
+
+/* ----------------------------- Folder fonts ----------------------------- */
+
+// Font files found by a folder scan this session, shared by every dropdown.
+// File handles cannot be persisted from a plain directory input, so the list
+// lives for the session; picking one imports it through the normal path.
+type FolderFont = { name: string; file: File };
+let folderFontsCache: FolderFont[] = [];
+const folderFontsListeners = new Set<() => void>();
+
+function setFolderFonts(next: FolderFont[]) {
+ folderFontsCache = next;
+ for (const listener of folderFontsListeners) listener();
+}
+
+function useFolderFonts(): FolderFont[] {
+ return useSyncExternalStore(
+ (onChange) => {
+ folderFontsListeners.add(onChange);
+ return () => folderFontsListeners.delete(onChange);
+ },
+ () => folderFontsCache,
+ );
+}
+
+function fontNameFromFile(fileName: string, taken: Set): string {
+ const base = fontBaseName(fileName).slice(0, 60) || "Imported font";
+ let candidate = base;
+ let suffix = 2;
+ while (taken.has(candidate)) {
+ candidate = `${base} ${suffix}`;
+ suffix += 1;
+ }
+ return candidate;
+}
+
+/**
+ * File-import plumbing for the font dropdowns: hidden inputs, validation,
+ * and persistence. Successful uploads call onImported with the font name
+ * so the dropdown can select it for its slot right away. Fonts the user
+ * already has (bundled, imported, or installed on the device) are selected
+ * directly instead of embedding a duplicate copy.
+ */
+function useFontImport(onImported: (name: string) => void) {
+ const t = useT();
+ const importedFonts = useAppearanceCustomStore(
+ (s) => s.customization.importedFonts,
+ );
+ const addImportedFont = useAppearanceCustomStore((s) => s.addImportedFont);
+ const inputRef = useRef(null);
+ const folderRef = useRef(null);
+
+ // Match the file name against fonts that already exist somewhere.
+ const findExisting = (fileName: string): string | null => {
+ for (const candidate of familyCandidates(fontBaseName(fileName))) {
+ const lower = candidate.toLowerCase();
+ const imported = importedFonts.find((f) => f.name.toLowerCase() === lower);
+ if (imported) return imported.name;
+ const bundled = BUNDLED_FONTS.find((f) => f.toLowerCase() === lower);
+ if (bundled) return bundled;
+ if (detectFontsByMeasurement([candidate]).length > 0) return candidate;
+ }
+ return null;
+ };
+
+ const importFile = (file: File) => {
+ const existing = findExisting(file.name);
+ if (existing) {
+ toast.info(t("settings.appearance.custom.importFont.alreadyAvailable"));
+ onImported(existing);
+ return;
+ }
+ if (importedFonts.length >= MAX_IMPORTED_FONTS) {
+ toast.error(t("settings.appearance.custom.importFont.errorLimit"));
+ return;
+ }
+ const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
+ const mime = FONT_MIME_BY_EXTENSION[extension];
+ if (!mime) {
+ toast.error(t("settings.appearance.custom.importFont.errorInvalidType"));
+ return;
+ }
+ if (file.size > MAX_FONT_FILE_BYTES) {
+ toast.error(t("settings.appearance.custom.importFont.errorTooLarge"));
+ return;
+ }
+ const reader = new FileReader();
+ reader.onerror = () => {
+ toast.error(t("settings.appearance.custom.importFont.errorFailed"));
+ };
+ reader.onload = () => {
+ const result = reader.result;
+ if (typeof result !== "string") {
+ toast.error(t("settings.appearance.custom.importFont.errorFailed"));
+ return;
+ }
+ // Rewrite whatever MIME the browser guessed to the extension's font
+ // type so the stored data URL passes frontend and backend validation.
+ const base64 = result.slice(result.indexOf(",") + 1);
+ const dataUrl = `data:${mime};base64,${base64}`;
+ // Keep the persisted store under the localStorage quota.
+ const existingTotal = importedFonts.reduce(
+ (sum, f) => sum + f.dataUrl.length,
+ 0,
+ );
+ if (
+ existingTotal + dataUrl.length >
+ MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH
+ ) {
+ toast.error(
+ t("settings.appearance.custom.importFont.errorStorageFull"),
+ );
+ return;
+ }
+ const taken = new Set([
+ ...BUNDLED_FONTS,
+ ...importedFonts.map((f) => f.name),
+ ]);
+ const name = fontNameFromFile(file.name, taken);
+ // Prove the file is a loadable font before persisting it.
+ const face = new FontFace(name, `url(${dataUrl})`);
+ face
+ .load()
+ .then(() => {
+ addImportedFont({ name, dataUrl });
+ onImported(name);
+ })
+ .catch(() => {
+ toast.error(t("settings.appearance.custom.importFont.errorFailed"));
+ });
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const scanFolder = (files: FileList) => {
+ const found: FolderFont[] = [];
+ const seen = new Set();
+ for (const file of Array.from(files)) {
+ const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
+ if (!FONT_MIME_BY_EXTENSION[extension]) continue;
+ const name = fontBaseName(file.name).slice(0, 60);
+ if (!name || seen.has(name.toLowerCase())) continue;
+ seen.add(name.toLowerCase());
+ found.push({ name, file });
+ if (found.length >= 200) break;
+ }
+ if (found.length === 0) {
+ toast.info(t("settings.appearance.custom.importFont.folderNoFonts"));
+ return;
+ }
+ found.sort((a, b) => a.name.localeCompare(b.name));
+ setFolderFonts(found);
+ };
+
+ const requestUpload = () => inputRef.current?.click();
+ const requestFolder = () => folderRef.current?.click();
+
+ const inputs = (
+ <>
+ {
+ const file = e.target.files?.[0];
+ if (file) importFile(file);
+ e.target.value = "";
+ }}
+ />
+ {
+ if (e.target.files) scanFolder(e.target.files);
+ e.target.value = "";
+ }}
+ />
+ >
+ );
+
+ return { inputs, requestUpload, requestFolder, importFile };
+}
+
+function FontSizeInput({
+ value,
+ range,
+ onCommit,
+ ariaLabel,
+}: {
+ value: number | null;
+ range: { min: number; max: number; default: number };
+ onCommit: (next: number | null) => void;
+ ariaLabel: string;
+}) {
+ const [draft, setDraft] = useState(value === null ? "" : String(value));
+ useEffect(() => {
+ setDraft(value === null ? "" : String(value));
+ }, [value]);
+ const commit = () => {
+ const trimmed = draft.trim();
+ if (trimmed === "") {
+ onCommit(null);
+ return;
+ }
+ const parsed = Number.parseInt(trimmed, 10);
+ if (Number.isNaN(parsed)) {
+ setDraft(value === null ? "" : String(value));
+ return;
+ }
+ onCommit(Math.min(range.max, Math.max(range.min, parsed)));
+ };
+ return (
+
+ setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ commit();
+ e.currentTarget.blur();
+ }
+ }}
+ aria-label={ariaLabel}
+ className="h-8 w-20 text-xs"
+ />
+ px
+
+ );
+}
+
+export function UiFontSizeRow() {
+ const t = useT();
+ const uiFontSize = useAppearanceCustomStore(
+ (s) => s.customization.uiFontSize,
+ );
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+ patch({ uiFontSize: next })}
+ ariaLabel={t("settings.appearance.custom.uiFontSize.label")}
+ />
+ );
+}
+
+export function CodeFontSizeRow() {
+ const t = useT();
+ const codeFontSize = useAppearanceCustomStore(
+ (s) => s.customization.codeFontSize,
+ );
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+ patch({ codeFontSize: next })}
+ ariaLabel={t("settings.appearance.custom.codeFontSize.label")}
+ />
+ );
+}
+
+export function FontSmoothingSwitch() {
+ const fontSmoothing = useAppearanceCustomStore(
+ (s) => s.customization.fontSmoothing,
+ );
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+ patch({ fontSmoothing: checked })}
+ />
+ );
+}
+
+/* ------------------------------ Interface ------------------------------- */
+
+export function ContrastSliderRow() {
+ const t = useT();
+ const contrast = useAppearanceCustomStore((s) => s.customization.contrast);
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+
+ patch({ contrast: values[0] })}
+ aria-label={t("settings.appearance.custom.contrast.label")}
+ />
+
+ {contrast}
+
+
+ );
+}
+
+export function PointerCursorsSwitch() {
+ const pointerCursors = useAppearanceCustomStore(
+ (s) => s.customization.pointerCursors,
+ );
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+ patch({ pointerCursors: checked })}
+ />
+ );
+}
+
+const REDUCE_MOTION_OPTIONS: {
+ value: ReduceMotionSetting;
+ labelKey: TranslationKey;
+}[] = [
+ {
+ value: "system",
+ labelKey: "settings.appearance.custom.reduceMotion.system",
+ },
+ { value: "on", labelKey: "settings.appearance.custom.reduceMotion.on" },
+ { value: "off", labelKey: "settings.appearance.custom.reduceMotion.off" },
+];
+
+export function ReduceMotionSegmented() {
+ const t = useT();
+ const reduceMotion = useAppearanceCustomStore(
+ (s) => s.customization.reduceMotion,
+ );
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ const reduced = useReducedMotionConfig();
+ return (
+
+ {REDUCE_MOTION_OPTIONS.map((opt) => {
+ const active = reduceMotion === opt.value;
+ return (
+ patch({ reduceMotion: opt.value })}
+ aria-pressed={active}
+ className={cn(
+ "relative flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors",
+ active
+ ? "text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {active && (
+
+ )}
+ {t(opt.labelKey)}
+
+ );
+ })}
+
+ );
+}
+
+/* ------------------------------- Reset all ------------------------------ */
+
+export function ResetCustomizationButton() {
+ const t = useT();
+ const customization = useAppearanceCustomStore((s) => s.customization);
+ const resetAll = useAppearanceCustomStore((s) => s.resetAll);
+ const pristine = isDefaultCustomization(customization);
+ return (
+
+ {t("settings.appearance.custom.resetAll")}
+
+ );
+}
+
+export { DEFAULT_CUSTOMIZATION };
diff --git a/studio/frontend/src/features/settings/components/color-picker.tsx b/studio/frontend/src/features/settings/components/color-picker.tsx
new file mode 100644
index 0000000000..d42e439349
--- /dev/null
+++ b/studio/frontend/src/features/settings/components/color-picker.tsx
@@ -0,0 +1,270 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useT } from "@/i18n";
+import { cn } from "@/lib/utils";
+import { ColorPickerIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { useEffect, useRef, useState } from "react";
+
+/* ------------------------- HSV ↔ hex conversions ------------------------- */
+
+type Hsv = { h: number; s: number; v: number };
+
+function hexToHsv(hex: string): Hsv {
+ const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
+ const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
+ const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+ const d = max - min;
+ let h = 0;
+ if (d !== 0) {
+ if (max === r) h = ((g - b) / d) % 6;
+ else if (max === g) h = (b - r) / d + 2;
+ else h = (r - g) / d + 4;
+ h *= 60;
+ if (h < 0) h += 360;
+ }
+ return { h, s: max === 0 ? 0 : d / max, v: max };
+}
+
+function hsvToHex({ h, s, v }: Hsv): string {
+ const c = v * s;
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
+ const m = v - c;
+ let rgb: [number, number, number];
+ if (h < 60) rgb = [c, x, 0];
+ else if (h < 120) rgb = [x, c, 0];
+ else if (h < 180) rgb = [0, c, x];
+ else if (h < 240) rgb = [0, x, c];
+ else if (h < 300) rgb = [x, 0, c];
+ else rgb = [c, 0, x];
+ const channel = (value: number) =>
+ Math.round((value + m) * 255)
+ .toString(16)
+ .padStart(2, "0");
+ return `#${channel(rgb[0])}${channel(rgb[1])}${channel(rgb[2])}`;
+}
+
+const HEX_PATTERN = /^#?([0-9a-fA-F]{6})$/;
+
+function isLightColor(hex: string): boolean {
+ const r = Number.parseInt(hex.slice(1, 3), 16);
+ const g = Number.parseInt(hex.slice(3, 5), 16);
+ const b = Number.parseInt(hex.slice(5, 7), 16);
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 > 0.62;
+}
+
+/* ------------------------------- Component ------------------------------- */
+
+type EyeDropperResult = { sRGBHex: string };
+type EyeDropperConstructor = new () => {
+ open: () => Promise;
+};
+
+/**
+ * In-app color picker in a Popover: saturation/value area, hue slider, hex
+ * field, and (where supported) a screen eyedropper. Replaces the native
+ * so no OS-level color panel is left dangling when the
+ * user clicks away; the popover dismisses like any other popup.
+ */
+export function ColorPickerSwatch({
+ value,
+ onChange,
+ label,
+}: {
+ value: string;
+ onChange: (hex: string) => void;
+ label: string;
+}) {
+ const t = useT();
+ const [open, setOpen] = useState(false);
+ const [hsv, setHsv] = useState(() => hexToHsv(value));
+ const [hexDraft, setHexDraft] = useState(value);
+ const areaRef = useRef(null);
+
+ // Re-seed the picker from the outside value each time it opens (the value
+ // may have changed via reset, palette switch, or remote sync).
+ useEffect(() => {
+ if (open) {
+ setHsv(hexToHsv(value));
+ setHexDraft(value);
+ }
+ }, [open, value]);
+
+ const emit = (next: Hsv) => {
+ setHsv(next);
+ const hex = hsvToHex(next);
+ setHexDraft(hex);
+ onChange(hex);
+ };
+
+ const moveFromPointer = (e: React.PointerEvent) => {
+ const area = areaRef.current;
+ if (!area) return;
+ const rect = area.getBoundingClientRect();
+ const s = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
+ const v =
+ 1 - Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height));
+ emit({ ...hsv, s, v });
+ };
+
+ const commitHex = () => {
+ const match = HEX_PATTERN.exec(hexDraft.trim());
+ if (!match) {
+ setHexDraft(hsvToHex(hsv));
+ return;
+ }
+ const hex = `#${match[1].toLowerCase()}`;
+ setHsv(hexToHsv(hex));
+ setHexDraft(hex);
+ onChange(hex);
+ };
+
+ const eyeDropperCtor = (
+ globalThis as { EyeDropper?: EyeDropperConstructor }
+ ).EyeDropper;
+
+ const pickFromScreen = async () => {
+ if (!eyeDropperCtor) return;
+ try {
+ const result = await new eyeDropperCtor().open();
+ const hex = result.sRGBHex.toLowerCase();
+ if (HEX_PATTERN.test(hex)) {
+ setHsv(hexToHsv(hex));
+ setHexDraft(hex);
+ onChange(hex);
+ }
+ } catch {
+ // user dismissed the eyedropper
+ }
+ };
+
+ const hueColor = hsvToHex({ h: hsv.h, s: 1, v: 1 });
+ const light = isLightColor(value);
+
+ return (
+
+
+
+
+ {value.toUpperCase()}
+
+
+
+
+
{
+ e.currentTarget.setPointerCapture(e.pointerId);
+ moveFromPointer(e);
+ }}
+ onPointerMove={(e) => {
+ if (e.buttons === 1) moveFromPointer(e);
+ }}
+ onKeyDown={(e) => {
+ // Arrow keys move the saturation (x) / value (y) selection so the
+ // area is operable without a pointer; Shift takes coarser steps.
+ const step = e.shiftKey ? 0.1 : 0.01;
+ let next: Hsv | null = null;
+ if (e.key === "ArrowLeft")
+ next = { ...hsv, s: Math.max(0, hsv.s - step) };
+ else if (e.key === "ArrowRight")
+ next = { ...hsv, s: Math.min(1, hsv.s + step) };
+ else if (e.key === "ArrowDown")
+ next = { ...hsv, v: Math.max(0, hsv.v - step) };
+ else if (e.key === "ArrowUp")
+ next = { ...hsv, v: Math.min(1, hsv.v + step) };
+ if (next) {
+ e.preventDefault();
+ emit(next);
+ }
+ }}
+ >
+
+
+
emit({ ...hsv, h: Number(e.target.value) })}
+ className="h-3 w-full cursor-pointer appearance-none rounded-full outline-none [&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-transparent [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
+ style={{
+ background:
+ "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
+ }}
+ />
+
+ setHexDraft(e.target.value)}
+ onBlur={commitHex}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ commitHex();
+ e.currentTarget.blur();
+ }
+ }}
+ aria-label={t("settings.appearance.custom.colorPicker.hex")}
+ spellCheck={false}
+ className="h-8 w-full min-w-0 rounded-full border border-border bg-background px-3 font-mono text-xs text-foreground uppercase outline-none focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] dark:border-transparent dark:bg-white/[0.06]"
+ />
+ {eyeDropperCtor && (
+ void pickFromScreen()}
+ aria-label={t(
+ "settings.appearance.custom.colorPicker.eyedropper",
+ )}
+ title={t("settings.appearance.custom.colorPicker.eyedropper")}
+ className="flex size-8 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground transition-colors hover:text-foreground"
+ >
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/settings/components/create-key-form.tsx b/studio/frontend/src/features/settings/components/create-key-form.tsx
index e2ad23e59f..642d9ad320 100644
--- a/studio/frontend/src/features/settings/components/create-key-form.tsx
+++ b/studio/frontend/src/features/settings/components/create-key-form.tsx
@@ -64,7 +64,7 @@ export function CreateKeyForm({
onClick={() => setExpiry(p.value)}
aria-pressed={active}
className={cn(
- "inline-flex h-8 items-center rounded-full px-3.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ "inline-flex h-8 items-center rounded-full px-3.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
diff --git a/studio/frontend/src/features/settings/components/key-reveal-card.tsx b/studio/frontend/src/features/settings/components/key-reveal-card.tsx
index aa4f67348b..34517cf3bd 100644
--- a/studio/frontend/src/features/settings/components/key-reveal-card.tsx
+++ b/studio/frontend/src/features/settings/components/key-reveal-card.tsx
@@ -43,7 +43,7 @@ export function KeyRevealCard({
onClick={handleCopy}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2.5 font-mono text-sm transition-colors hover:bg-muted/60",
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
copied && "border-emerald-500/40 bg-emerald-500/10",
)}
aria-label={
@@ -68,7 +68,7 @@ export function KeyRevealCard({
type="button"
size="sm"
onClick={onDone}
- className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
+ className="focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{t("common.done")}
diff --git a/studio/frontend/src/features/settings/components/language-select.tsx b/studio/frontend/src/features/settings/components/language-select.tsx
index 9d30e06147..1f388d6f2c 100644
--- a/studio/frontend/src/features/settings/components/language-select.tsx
+++ b/studio/frontend/src/features/settings/components/language-select.tsx
@@ -9,22 +9,23 @@ import {
SelectValue,
} from "@/components/ui/select";
import {
+ AUTO_LOCALE,
LOCALES,
- isSupportedLocale,
+ isLocalePreference,
setLocale,
useT,
- useLocale,
+ useLocalePreference,
} from "@/i18n";
export function LanguageSelect() {
const t = useT();
- const locale = useLocale();
+ const preference = useLocalePreference();
return (
{
- if (isSupportedLocale(value)) setLocale(value);
+ if (isLocalePreference(value)) setLocale(value);
}}
>
-
+
+
+ {t("settings.appearance.language.autoDetect")}
+
{Object.entries(LOCALES).map(([value, metadata]) => (
{metadata.nativeLabel}
diff --git a/studio/frontend/src/features/settings/components/model-auto-switch-section.tsx b/studio/frontend/src/features/settings/components/model-auto-switch-section.tsx
index 581c78ebd1..5bebefa84c 100644
--- a/studio/frontend/src/features/settings/components/model-auto-switch-section.tsx
+++ b/studio/frontend/src/features/settings/components/model-auto-switch-section.tsx
@@ -121,7 +121,7 @@ export function ModelAutoSwitchSection() {
>
-
+
setDraftIdleSeconds(event.target.value)}
- className="h-8 w-full pr-8"
+ className="h-8 w-24"
/>
-
+
s
diff --git a/studio/frontend/src/features/settings/components/palette-cards.tsx b/studio/frontend/src/features/settings/components/palette-cards.tsx
new file mode 100644
index 0000000000..e6b920574b
--- /dev/null
+++ b/studio/frontend/src/features/settings/components/palette-cards.tsx
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { type TranslationKey, useT } from "@/i18n";
+import {
+ type Palette,
+ type ResolvedTheme,
+ usePalette,
+ useTheme,
+} from "../stores/theme-store";
+
+type PreviewColors = {
+ bg: string;
+ sidebar: string;
+ accent: string;
+ text: string;
+ border: string;
+};
+
+// Representative swatches per palette × resolved mode. Hardcoded (rather than
+// reading the live CSS variables) so every card previews its own palette while
+// only one palette is active on . Sidebar tones are slightly exaggerated
+// so the two surfaces stay distinguishable at thumbnail size.
+const PREVIEWS: Record
> = {
+ standard: {
+ light: {
+ bg: "#fefefd",
+ sidebar: "#f1f1ef",
+ accent: "#17b88b",
+ text: "#444444",
+ border: "#e4e4e0",
+ },
+ dark: {
+ bg: "#181818",
+ sidebar: "#262626",
+ accent: "#17b88b",
+ text: "#b5b5b5",
+ border: "#303030",
+ },
+ },
+ classic: {
+ light: {
+ bg: "#ffffff",
+ sidebar: "#ededed",
+ accent: "#339cff",
+ text: "#4b4d50",
+ border: "#e6e6e6",
+ },
+ dark: {
+ bg: "#181818",
+ sidebar: "#262626",
+ accent: "#4dabff",
+ text: "#b5b5b5",
+ border: "#303030",
+ },
+ },
+ minimal: {
+ light: {
+ bg: "#ffffff",
+ sidebar: "#f2f2f2",
+ accent: "#171717",
+ text: "#555555",
+ border: "#e2e2e2",
+ },
+ dark: {
+ bg: "#181818",
+ sidebar: "#262626",
+ accent: "#ededed",
+ text: "#a8a8a8",
+ border: "#303030",
+ },
+ },
+};
+
+const OPTIONS: {
+ value: Palette;
+ labelKey: TranslationKey;
+}[] = [
+ { value: "standard", labelKey: "settings.appearance.palette.standard" },
+ { value: "classic", labelKey: "settings.appearance.palette.classic" },
+ { value: "minimal", labelKey: "settings.appearance.palette.minimal" },
+];
+
+function PalettePreview({ colors }: { colors: PreviewColors }) {
+ return (
+
+ );
+}
+
+export function PaletteCards() {
+ const t = useT();
+ const { resolved } = useTheme();
+ const { palette, setPalette } = usePalette();
+ return (
+
+ {OPTIONS.map((opt) => {
+ const active = palette === opt.value;
+ return (
+
setPalette(opt.value)}
+ aria-pressed={active}
+ data-palette-value={opt.value}
+ // The active ring is CSS-driven off html[data-palette] (see
+ // .palette-card in index.css) so it moves in the same style
+ // pass that swaps the tokens; keying it off React state leaves
+ // the ring on the old card until the app finishes re-rendering.
+ className="palette-card flex flex-col gap-2 rounded-xl border border-border p-2.5 text-left transition-colors focus-visible:border-ring focus-visible:outline-none"
+ >
+
+
+
+ {t(opt.labelKey)}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/studio/frontend/src/features/settings/components/settings-row.tsx b/studio/frontend/src/features/settings/components/settings-row.tsx
index 3c049d2f99..3cf9822811 100644
--- a/studio/frontend/src/features/settings/components/settings-row.tsx
+++ b/studio/frontend/src/features/settings/components/settings-row.tsx
@@ -24,6 +24,7 @@ export function SettingsRow({
}) {
return (
+
{title}
@@ -24,7 +24,14 @@ export function SettingsSection({
) : null}
- {children}
+ {/* No per-row dividers: rows inside a titled section are related.
+ SettingsGroupDivider separates unrelated clusters. */}
+ {children}
);
}
+
+/** Divider between unrelated clusters of rows inside one section. */
+export function SettingsGroupDivider() {
+ return
;
+}
diff --git a/studio/frontend/src/features/settings/components/sidebar-menu-customizer.tsx b/studio/frontend/src/features/settings/components/sidebar-menu-customizer.tsx
new file mode 100644
index 0000000000..e7e6576eef
--- /dev/null
+++ b/studio/frontend/src/features/settings/components/sidebar-menu-customizer.tsx
@@ -0,0 +1,148 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import {
+ CloudIcon,
+ CpuIcon,
+ CursorInfo02Icon,
+ DragDropVerticalIcon,
+ Globe02Icon,
+ HelpCircleIcon,
+ Logout05Icon,
+ Message01Icon,
+ Moon02Icon,
+ PaintBrush02Icon,
+ PowerIcon,
+ Settings02Icon,
+ UserIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Reorder, useDragControls } from "motion/react";
+import { Switch } from "@/components/ui/switch";
+import { useT } from "@/i18n";
+import type { TranslationKey } from "@/i18n";
+import type { IconSvgElement } from "@hugeicons/react";
+import type { SidebarMenuItemPref } from "../stores/appearance-custom-store";
+import { useAppearanceCustomStore } from "../stores/appearance-custom-store";
+
+const ITEM_META: Record<
+ SidebarMenuItemPref["id"],
+ { icon: IconSvgElement; labelKey: TranslationKey }
+> = {
+ api: { icon: Globe02Icon, labelKey: "shell.navigation.api" },
+ darkMode: { icon: Moon02Icon, labelKey: "settings.appearance.sidebarMenu.darkModeToggle" },
+ guidedTour: { icon: CursorInfo02Icon, labelKey: "shell.navigation.guidedTour" },
+ profile: { icon: UserIcon, labelKey: "settings.tabs.profile" },
+ appearance: { icon: PaintBrush02Icon, labelKey: "settings.tabs.appearance" },
+ resources: { icon: CpuIcon, labelKey: "settings.tabs.resources" },
+ chat: { icon: Message01Icon, labelKey: "settings.tabs.chat" },
+ connections: { icon: CloudIcon, labelKey: "settings.tabs.connections" },
+};
+
+function FixedRow({ icon, label }: { icon: IconSvgElement; label: string }) {
+ return (
+
+ {/* Spacer where the drag handle sits on movable rows. */}
+
+
+ {label}
+
+ );
+}
+
+function MovableRow({ item }: { item: SidebarMenuItemPref }) {
+ const t = useT();
+ const controls = useDragControls();
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ const sidebarMenu = useAppearanceCustomStore(
+ (s) => s.customization.sidebarMenu,
+ );
+ const meta = ITEM_META[item.id];
+ return (
+
+ {
+ e.preventDefault();
+ controls.start(e);
+ }}
+ className="flex size-4 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground active:cursor-grabbing"
+ >
+
+
+
+ {t(meta.labelKey)}
+
+ patch({
+ sidebarMenu: sidebarMenu.map((entry) =>
+ entry.id === item.id ? { ...entry, visible } : entry,
+ ),
+ })
+ }
+ />
+
+ );
+}
+
+/**
+ * Show/hide and reorder the optional sidebar profile menu items. The pinned
+ * entries (Settings on top; Help, Log out, Shutdown below) are rendered as
+ * static rows so the final menu layout is obvious.
+ */
+export function SidebarMenuCustomizer() {
+ const t = useT();
+ const sidebarMenu = useAppearanceCustomStore(
+ (s) => s.customization.sidebarMenu,
+ );
+ const patch = useAppearanceCustomStore((s) => s.patch);
+ return (
+
+
+
item.id)}
+ onReorder={(ids: SidebarMenuItemPref["id"][]) =>
+ patch({
+ sidebarMenu: ids.flatMap(
+ (id) => sidebarMenu.find((entry) => entry.id === id) ?? [],
+ ),
+ })
+ }
+ className="flex flex-col"
+ >
+ {sidebarMenu.map((item) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/settings/components/usage-examples.tsx b/studio/frontend/src/features/settings/components/usage-examples.tsx
index c43dd0f219..86125585cc 100644
--- a/studio/frontend/src/features/settings/components/usage-examples.tsx
+++ b/studio/frontend/src/features/settings/components/usage-examples.tsx
@@ -16,6 +16,7 @@ import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { useChatRuntimeStore } from "@/features/chat";
import { useT } from "@/i18n";
import type { TranslationKey } from "@/i18n";
+import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
@@ -25,14 +26,15 @@ import {
InformationCircleIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
+import { loadCodingAgents } from "../api/coding-agents";
import {
type OpenAIAutoSwitchSettings,
loadOpenAIAutoSwitchSettings,
updateOpenAIAutoSwitchSettings,
} from "../api/openai-auto-switch";
-import { buildAgentCommand } from "./agent-command";
+import { buildAgentCommand, isLoopbackHost, normalizeHost } from "./agent-command";
type ExampleType =
| "curl"
@@ -114,6 +116,30 @@ const DOC_LINKS = [
{ label: "Hermes Agent", href: "https://unsloth.ai/docs/integrations/hermes-agent" },
];
+// Falls back to this list until the backend's installed-CLI check resolves;
+// kept in sync with the `unsloth start
` subcommands and with
+// CODING_AGENTS in studio/backend/utils/coding_agents.py.
+const DEFAULT_AGENTS = [
+ "claude",
+ "codex",
+ "openclaw",
+ "opencode",
+ "hermes",
+ "pi",
+];
+// The agent selection resets to this whenever an auto-pick is no longer
+// trustworthy (leaving loopback, or the only compatible detected agent
+// stops being compatible) rather than lingering on a stale choice.
+const DEFAULT_AGENT = "claude";
+const AGENT_LABELS: Record = {
+ claude: "Claude Code",
+ codex: "Codex",
+ openclaw: "OpenClaw",
+ opencode: "OpenCode",
+ hermes: "Hermes",
+ pi: "Pi",
+};
+
const j = (s: string): string => JSON.stringify(s);
const shSingle = (s: string): string => s.replace(/'/g, "'\\''");
const psSingle = (s: string): string => s.replace(/'/g, "''");
@@ -399,6 +425,17 @@ function useLoadedModelName(): string {
}, [checkpoint, ggufVariant]);
}
+// Backend PATH detection is only safe in the desktop app, where the UI owns
+// the local backend. A browser loopback URL may be an SSH/local port forward.
+function canUseLocalAgentDetection(base: string): boolean {
+ if (!isTauri) return false;
+ try {
+ return isLoopbackHost(normalizeHost(new URL(base).hostname));
+ } catch {
+ return false;
+ }
+}
+
const SHIKI_THEMES = [unslothLightTheme, unslothDarkTheme] as [
typeof unslothLightTheme,
typeof unslothDarkTheme,
@@ -443,7 +480,18 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
const [copied, setCopied] = useState(false);
const [copiedUrl, setCopiedUrl] = useState(false);
const [copiedAgent, setCopiedAgent] = useState(false);
+ const [agent, setAgent] = useState(DEFAULT_AGENT);
+ const [availableAgents, setAvailableAgents] =
+ useState(DEFAULT_AGENTS);
+ const [detectedAgents, setDetectedAgents] = useState([]);
+ // True once the user has picked an agent themselves; guards the detection
+ // effect below from clobbering that choice if it resolves afterward.
+ const agentPickedByUserRef = useRef(false);
const [useTunnel, setUseTunnel] = useState(readUseTunnelPref);
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
+ const base =
+ useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
+ const localAgentDetection = canUseLocalAgentDetection(base);
// null while loading; the same setting the General tab exposes (shared cache).
const [autoSwitch, setAutoSwitch] = useState(
null,
@@ -454,6 +502,78 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
void fetchDeviceType({ force: true });
}, []);
+ // Fetching is the only job of this effect: populate availableAgents/
+ // detectedAgents (or clear them). Which agent gets auto-picked from that
+ // list is derived separately below, so it can react to the loaded model
+ // changing too, not just a fresh fetch.
+ useEffect(() => {
+ // Browser loopback URLs can be SSH/local forwards, so only the desktop app
+ // may use backend PATH checks to mark or auto-pick local agents.
+ if (!localAgentDetection) {
+ setDetectedAgents([]);
+ // A previously auto-picked agent was only ever verified against the
+ // Studio backend's PATH, which is meaningless now that this panel no
+ // longer targets a loopback base -- don't leave it selected, but
+ // never touch a choice the user made by hand.
+ if (!agentPickedByUserRef.current) {
+ setAgent(DEFAULT_AGENT);
+ }
+ return;
+ }
+
+ let cancelled = false;
+ void loadCodingAgents()
+ .then((info) => {
+ if (cancelled) return;
+ setAvailableAgents(info.agents);
+ setDetectedAgents(info.detected);
+ })
+ .catch(() => {
+ // Best-effort: keep the default agent list and let the user pick manually.
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [localAgentDetection]);
+
+ // Single source of truth for the auto-picked agent, re-derived whenever
+ // the detected list or the loaded model's GGUF-ness changes -- in either
+ // direction. `codex` needs a GGUF model (unsloth_cli's
+ // _require_gguf_for_codex exits otherwise), so it's only preferred once
+ // the loaded model actually qualifies; loading a GGUF model *after* a
+ // non-GGUF-gated fallback picked something else re-steers back to codex
+ // just as loading a non-GGUF model steers away from it. Never overrides a
+ // choice the user made by hand.
+ // activeGgufVariant alone only covers an HF-repo GGUF pick (a specific
+ // quant variant string) -- a direct local .gguf file (custom folder /
+ // LM Studio / drag-drop) is just as much a GGUF the codex preflight would
+ // accept, but never has a "variant" to report, and would otherwise read as
+ // non-GGUF here. activeNativePathToken covers the drag-drop/picked-file
+ // case; ggufContextLength is only ever populated when the backend's
+ // /api/inference/status last reported is_gguf: true for the active model
+ // (see applyActiveModelStatusToStore), so together these three cover every
+ // path a model can be GGUF through, matching the same is_gguf-or-equivalent
+ // check hasGgufSource applies to a staged pick.
+ const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
+ const activeNativePathToken = useChatRuntimeStore((s) => s.activeNativePathToken);
+ const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
+ useEffect(() => {
+ if (agentPickedByUserRef.current) return;
+ if (detectedAgents.length === 0) return;
+ const isGguf =
+ activeGgufVariant != null || activeNativePathToken != null || ggufContextLength != null;
+ const preferred = detectedAgents.find((a) => a !== "codex" || isGguf);
+ if (preferred) {
+ setAgent(preferred);
+ } else if (agent === "codex" && !isGguf) {
+ // codex was auto-picked while a GGUF model was active and it's the
+ // only detected agent; now that the model isn't GGUF anymore, nothing
+ // detected is actually runnable, so fall back to the default instead
+ // of leaving a codex command unsloth_cli will reject.
+ setAgent(DEFAULT_AGENT);
+ }
+ }, [agent, detectedAgents, activeGgufVariant, activeNativePathToken, ggufContextLength]);
+
useEffect(() => {
let cancelled = false;
void loadOpenAIAutoSwitchSettings()
@@ -470,9 +590,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
const model = useLoadedModelName();
const key = apiKey || KEY_PLACEHOLDER;
- const origin = typeof window !== "undefined" ? window.location.origin : "";
- const base =
- useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
const autoSwitchOn = autoSwitch?.enabled ?? false;
const snippets = useMemo(
@@ -481,8 +598,8 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
);
// Agent command must target the server the panel shows, not the :8888 default.
const agentCommand = useMemo(
- () => buildAgentCommand(base, key, os),
- [base, key, os],
+ () => buildAgentCommand(base, key, os, agent),
+ [base, key, os, agent],
);
const osAware = OS_AWARE[lang];
@@ -558,7 +675,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
setLang(tab.id)}
aria-pressed={active}
className={cn(
- "rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ "rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
@@ -661,7 +778,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
onClick={() => setOs("unix")}
aria-pressed={os === "unix"}
className={cn(
- "rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ "rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
os === "unix"
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
@@ -674,7 +791,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
onClick={() => setOs("windows")}
aria-pressed={os === "windows"}
className={cn(
- "rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ "rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
os === "windows"
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
@@ -688,7 +805,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
{t("settings.apiKeys.codingAgentsHint")}
+
+ {availableAgents.map((id) => {
+ const installed = detectedAgents.includes(id);
+ const active = agent === id;
+ return (
+ {
+ agentPickedByUserRef.current = true;
+ setAgent(id);
+ }}
+ aria-pressed={active}
+ title={
+ installed
+ ? t("settings.apiKeys.codingAgentDetected")
+ : undefined
+ }
+ className={cn(
+ "flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
+ active
+ ? "hub-tab-toggle-pill text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {AGENT_LABELS[id] ?? id}
+ {installed ? (
+
+ ) : null}
+
+ );
+ })}
+
{agentCommand}
@@ -717,7 +870,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
- {t("settings.apiKeys.codingAgentsSwap")}
+ {detectedAgents.length > 0
+ ? t("settings.apiKeys.codingAgentsDetectedHint", {
+ agents: detectedAgents
+ .map((id) => AGENT_LABELS[id] ?? id)
+ .join(", "),
+ })
+ : t("settings.apiKeys.codingAgentsSwap")}
@@ -738,7 +897,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
href={link.href}
target="_blank"
rel="noreferrer"
- className="inline-flex items-center gap-0.5 rounded font-medium text-foreground underline decoration-border underline-offset-2 transition-colors hover:decoration-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ className="inline-flex items-center gap-0.5 rounded font-medium text-foreground underline decoration-border underline-offset-2 transition-colors hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{link.label}
diff --git a/studio/frontend/src/features/settings/index.ts b/studio/frontend/src/features/settings/index.ts
index 364ca1611f..1e4d9d8844 100644
--- a/studio/frontend/src/features/settings/index.ts
+++ b/studio/frontend/src/features/settings/index.ts
@@ -6,7 +6,27 @@ export {
loadPersonalization,
savePersonalization,
} from "./api/personalization";
-export { setTheme, useTheme } from "./stores/theme-store";
+export {
+ isPalette,
+ setPalette,
+ setTheme,
+ usePalette,
+ useTheme,
+} from "./stores/theme-store";
+export {
+ DEFAULT_CUSTOMIZATION,
+ applyCustomizationToDocument,
+ isDefaultCustomization,
+ prefersReducedMotion,
+ sanitizeCustomization,
+ useAppearanceCustomStore,
+} from "./stores/appearance-custom-store";
+export type {
+ AppearanceCustomization,
+ CustomModeColors,
+ ReduceMotionSetting,
+} from "./stores/appearance-custom-store";
+export { useMonitorOverlayStore } from "./stores/monitor-overlay-store";
export type {
Personalization,
PersonalizationAppearance,
@@ -14,4 +34,4 @@ export type {
} from "./api/personalization";
export { useSettingsDialogStore } from "./stores/settings-dialog-store";
export type { SettingsTab } from "./stores/settings-dialog-store";
-export type { ResolvedTheme, Theme } from "./stores/theme-store";
+export type { Palette, ResolvedTheme, Theme } from "./stores/theme-store";
diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx
index 0d201edc0d..7625449648 100644
--- a/studio/frontend/src/features/settings/settings-dialog.tsx
+++ b/studio/frontend/src/features/settings/settings-dialog.tsx
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+import { FloatingMonitor } from "@/components/floating-monitor";
import {
Dialog,
DialogContent,
@@ -9,6 +10,7 @@ import {
} from "@/components/ui/dialog";
import { type TranslationKey, useT } from "@/i18n";
import { cn } from "@/lib/utils";
+import { MicIcon } from "@/lib/mic-icon";
import {
Cancel01Icon,
CloudIcon,
@@ -17,12 +19,21 @@ import {
HelpCircleIcon,
Message01Icon,
PaintBrush02Icon,
+ Search01Icon,
Settings02Icon,
UserIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotion } from "motion/react";
-import { useEffect, useRef } from "react";
+import {
+ type FC,
+ useDeferredValue,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { SETTINGS_SEARCH_INDEX } from "./settings-search";
import {
type SettingsTab,
useSettingsDialogStore,
@@ -35,12 +46,14 @@ import { ConnectionsTab } from "./tabs/connections-tab";
import { GeneralTab } from "./tabs/general-tab";
import { ProfileTab } from "./tabs/profile-tab";
import { ResourcesTab } from "./tabs/resources-tab";
-import { FloatingMonitor } from "@/components/floating-monitor";
+import { VoiceTab } from "./tabs/voice-tab";
interface TabDef {
id: SettingsTab;
labelKey: TranslationKey;
- icon: typeof Settings02Icon;
+ icon?: typeof Settings02Icon;
+ /** Plain component icon, for icons shared with chat (not hugeicons). */
+ iconComponent?: FC<{ className?: string }>;
badgeKey?: TranslationKey;
}
@@ -56,6 +69,7 @@ const TABS: TabDef[] = [
id: "resources",
labelKey: "settings.tabs.resources",
icon: CpuIcon,
+ badgeKey: "common.new",
},
{
id: "chat",
@@ -72,6 +86,11 @@ const TABS: TabDef[] = [
id: "connections",
labelKey: "settings.tabs.connections",
icon: CloudIcon,
+ },
+ {
+ id: "voice",
+ labelKey: "settings.tabs.voice",
+ iconComponent: MicIcon,
badgeKey: "common.new",
},
{ id: "about", labelKey: "settings.tabs.about", icon: HelpCircleIcon },
@@ -89,6 +108,8 @@ function renderTab(tab: SettingsTab) {
return
;
case "chat":
return
;
+ case "voice":
+ return
;
case "connections":
return
;
case "api-keys":
@@ -106,12 +127,88 @@ export function SettingsDialog() {
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
const opener = useSettingsDialogStore((s) => s.opener);
const reduced = useReducedMotion();
+ // Mounting a heavy tab panel (System, Connections) in the same commit as
+ // the nav highlight makes the highlight lag the click. Render the panel
+ // from a deferred value so the nav updates first.
+ const panelTab = useDeferredValue(activeTab);
+ const [query, setQuery] = useState("");
+
+ const results = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return null;
+ return TABS.map((tab) => {
+ const tabLabel = t(tab.labelKey);
+ const entries = SETTINGS_SEARCH_INDEX[tab.id]
+ .map((key) => t(key))
+ .filter((label) => label.toLowerCase().includes(q));
+ const deduped = [...new Set(entries)];
+ return {
+ tab,
+ tabLabel,
+ entries: deduped,
+ tabMatches: tabLabel.toLowerCase().includes(q),
+ };
+ }).filter((r) => r.tabMatches || r.entries.length > 0);
+ }, [query, t]);
+
+ const [pendingScroll, setPendingScroll] = useState<{
+ tab: SettingsTab;
+ entry: string;
+ } | null>(null);
+ const mainScrollRef = useRef
(null);
+
+ const openResult = (tab: SettingsTab, entry?: string) => {
+ setActiveTab(tab);
+ setQuery("");
+ setPendingScroll(entry ? { tab, entry } : null);
+ };
+
+ // Scroll to the row/section a search result points at once the tab has
+ // rendered, and flash it so the eye lands on the right place. The tab panel
+ // renders deferred, so retry across frames until the row exists instead of
+ // racing a single fixed delay (which silently missed under render lag).
+ useEffect(() => {
+ if (!pendingScroll) return;
+ // Wait until the destination tab is mounted before matching, so a same-named
+ // row in the previous tab (for example "Storage") is not scrolled to instead.
+ if (panelTab !== pendingScroll.tab) return;
+ let frame = 0;
+ let tries = 0;
+ const attempt = () => {
+ const root = mainScrollRef.current;
+ const target = root
+ ? [
+ ...root.querySelectorAll("[data-settings-label]"),
+ ].find((el) => el.dataset.settingsLabel === pendingScroll.entry)
+ : undefined;
+ if (target) {
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
+ target.classList.add("settings-search-hit");
+ window.setTimeout(
+ () => target.classList.remove("settings-search-hit"),
+ 1600,
+ );
+ setPendingScroll(null);
+ } else if (tries++ < 30) {
+ frame = window.requestAnimationFrame(attempt);
+ } else {
+ setPendingScroll(null);
+ }
+ };
+ frame = window.requestAnimationFrame(attempt);
+ return () => window.cancelAnimationFrame(frame);
+ }, [pendingScroll, panelTab]);
+
+ useEffect(() => {
+ if (!open) setQuery("");
+ }, [open]);
const tabButtonRefs = useRef>({
general: null,
profile: null,
appearance: null,
resources: null,
chat: null,
+ voice: null,
connections: null,
"api-keys": null,
about: null,
@@ -141,9 +238,9 @@ export function SettingsDialog() {
}
}}
className={cn(
- // Cap at 820px but shrink to the viewport so it doesn't clip on
- // iPad-portrait widths (640-820px) where fixed `w-[820px]` overflows.
- "settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden",
+ // Cap at 880px but shrink to the viewport so it doesn't clip on
+ // iPad-portrait widths where a fixed width overflows.
+ "settings-surface !max-w-[min(880px,calc(100vw-2rem))] h-[560px] w-[min(880px,calc(100vw-2rem))] p-0 overflow-hidden",
// Soft shadow, no outline ring. Pin --radius to the light value so
// corner rounding matches in dark mode.
"shadow-border rounded-xl ring-0 [--radius:1.1rem]",
@@ -157,11 +254,91 @@ export function SettingsDialog() {
{t("settings.dialog.description")}
-
-
+
+
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape" && query) {
+ e.stopPropagation();
+ setQuery("");
+ }
+ }}
+ placeholder={t("settings.dialog.searchPlaceholder")}
+ aria-label={t("settings.dialog.searchPlaceholder")}
+ className="h-8 w-full rounded-full border border-border bg-background pr-8 pl-8 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] dark:border-transparent dark:bg-white/[0.06]"
+ />
+ {query && (
+ setQuery("")}
+ aria-label={t("settings.dialog.closeAriaLabel")}
+ className="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground hover:text-foreground"
+ >
+
+
+ )}
+
+ {results ? (
+
+ {results.length === 0 ? (
+
+ {t("settings.dialog.searchNoResults")}
+
+ ) : (
+ results.map(({ tab, tabLabel, entries }) => (
+
+ openResult(tab.id)}
+ className="flex h-[30px] items-center gap-2.5 rounded-full pl-3 pr-2.5 text-[13.5px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
+ >
+ {tab.iconComponent ? (
+
+ ) : tab.icon ? (
+
+ ) : null}
+ {tabLabel}
+
+ {entries.map((entry) => (
+ openResult(tab.id, entry)}
+ className="flex h-[30px] items-center rounded-full pl-10 pr-2.5 text-left text-[14px] text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
+ >
+ {entry}
+
+ ))}
+
+ ))
+ )}
+
+ ) : null}
+
{t("settings.dialog.title")}
-
-
+
+
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
@@ -175,38 +352,44 @@ export function SettingsDialog() {
className={cn(
"relative flex h-[32px] items-center gap-2.5 rounded-full pl-3 pr-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
"max-sm:shrink-0",
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
+ "focus-visible:outline-none",
+ // The active pill already marks the current tab, so
+ // only unselected items get a keyboard focus ring.
active
- ? "text-black dark:text-white"
- : "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white",
+ ? "text-accent-foreground"
+ : "text-[#383835] dark:text-[#c7c7c4] hover:bg-accent hover:text-accent-foreground focus-visible:ring-1 focus-visible:ring-ring",
)}
>
{active && (
)}
-
+ {tab.iconComponent ? (
+
+ ) : tab.icon ? (
+
+ ) : null}
{t(tab.labelKey)}
{tab.badgeKey ? (
-
+
{t(tab.badgeKey)}
) : null}
@@ -220,13 +403,16 @@ export function SettingsDialog() {
-
- {renderTab(activeTab)}
+
+ {renderTab(panelTab)}
diff --git a/studio/frontend/src/features/settings/settings-search.ts b/studio/frontend/src/features/settings/settings-search.ts
new file mode 100644
index 0000000000..786ee11d0a
--- /dev/null
+++ b/studio/frontend/src/features/settings/settings-search.ts
@@ -0,0 +1,128 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import type { TranslationKey } from "@/i18n";
+import type { SettingsTab } from "./stores/settings-dialog-store";
+
+/**
+ * Searchable entries per tab: the label/title keys rendered by each tab.
+ * Tab names themselves always match, so tabs without translatable rows
+ * (profile, connections) are still reachable from search.
+ */
+export const SETTINGS_SEARCH_INDEX: Record = {
+ general: [
+ "settings.general.account",
+ "settings.general.password",
+ "settings.general.huggingFaceToken",
+ "settings.general.gettingStarted",
+ "settings.general.startOnboarding",
+ "settings.general.storage.sectionTitle",
+ "settings.general.storage.modelsFolder",
+ "settings.appearance.language.title",
+ "settings.appearance.language.label",
+ "settings.general.notifications.sectionTitle",
+ "settings.general.notifications.showLlamaUpdates",
+ "settings.general.previewSharing.sectionTitle",
+ "settings.general.previewSharing.enableLabel",
+ "settings.general.previewSharing.revokeLabel",
+ "settings.general.rag.sectionTitle",
+ "settings.general.rag.embeddingModel",
+ "settings.general.helperLlm.sectionTitle",
+ "settings.general.helperLlm.preloadOnStartup",
+ "settings.general.uploads.sectionTitle",
+ "settings.general.uploads.maxUploadSize",
+ "settings.general.resetPreferences.sectionTitle",
+ "settings.general.resetPreferences.label",
+ ],
+ profile: [
+ "settings.profile.title",
+ "settings.profile.description",
+ "settings.profile.displayName",
+ "settings.profile.nickname",
+ "settings.profile.avatarShape",
+ "settings.profile.greetingSloth",
+ ],
+ appearance: [
+ "settings.appearance.theme.label",
+ "settings.appearance.palette.label",
+ "settings.appearance.custom.colors.accent",
+ "settings.appearance.custom.colors.background",
+ "settings.appearance.custom.colors.foreground",
+ "settings.appearance.custom.uiFont.label",
+ "settings.appearance.custom.headingFont.label",
+ "settings.appearance.custom.chatFont.label",
+ "settings.appearance.custom.codeFont.label",
+ "settings.appearance.custom.contrast.label",
+ "settings.appearance.custom.pointerCursors.label",
+ "settings.appearance.custom.reduceMotion.label",
+ "settings.appearance.custom.uiFontSize.label",
+ "settings.appearance.custom.codeFontSize.label",
+ "settings.appearance.custom.fontSmoothing.label",
+ "settings.appearance.layout.compactSidebar",
+ "settings.appearance.sidebarMenu.title",
+ "settings.appearance.sidebarMenu.darkModeToggle",
+ ],
+ resources: [
+ "settings.resources.liveMonitor.title",
+ "settings.resources.liveMonitor.cpu",
+ "settings.resources.liveMonitor.ram",
+ "settings.resources.liveMonitor.vram",
+ "settings.resources.liveMonitor.disk",
+ "settings.resources.gpu.title",
+ "settings.resources.storage.title",
+ "settings.resources.storage.modelsFolder",
+ "settings.resources.storage.systemDisk",
+ "settings.resources.environment.title",
+ "settings.resources.environment.backend",
+ "settings.resources.environment.python",
+ "settings.resources.environment.torch",
+ "settings.resources.environment.transformers",
+ "settings.resources.liveUpdates",
+ ],
+ chat: [
+ "settings.general.chatDefaults",
+ "settings.general.autoTitleNewChats",
+ "settings.chat.artifacts.title",
+ "settings.chat.artifacts.collapseHtmlBlocks",
+ "settings.chat.artifacts.allowNetworkAccess",
+ "settings.chat.data",
+ "settings.chat.exportConversations",
+ "settings.chat.importChats",
+ "settings.chat.clearAllChats",
+ "settings.chat.exportHistory",
+ "settings.chat.modelDisclaimer",
+ ],
+ "api-keys": [
+ "settings.apiKeys.title",
+ "settings.apiKeys.description",
+ "settings.apiKeys.accessTokens",
+ ],
+ connections: [],
+ voice: [
+ "settings.voice.dictation.sectionTitle",
+ "settings.voice.dictation.microphoneLabel",
+ "settings.voice.dictation.languageLabel",
+ "settings.voice.dictation.testLabel",
+ "settings.voice.dictionary.sectionTitle",
+ "settings.voice.recents.sectionTitle",
+ "settings.voice.readAloud.sectionTitle",
+ "settings.voice.readAloud.buttonLabel",
+ "settings.voice.readAloud.engineLabel",
+ "settings.voice.readAloud.voiceLabel",
+ "settings.voice.readAloud.speedLabel",
+ "settings.voice.readAloud.pitchLabel",
+ "settings.voice.readAloud.volumeLabel",
+ "settings.voice.readAloud.previewLabel",
+ ],
+ about: [
+ "settings.about.updates",
+ "settings.about.releaseNotes",
+ "settings.about.documentation",
+ "settings.about.help",
+ "settings.about.feedback",
+ "settings.about.hardware",
+ "settings.about.license.sectionTitle",
+ "settings.about.dangerZone",
+ "settings.about.shutDownStudio",
+ ],
+};
diff --git a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts
new file mode 100644
index 0000000000..b8c8d96f5a
--- /dev/null
+++ b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts
@@ -0,0 +1,562 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { create } from "zustand";
+import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
+import type { ResolvedTheme } from "./theme-store";
+
+// Best-effort persistence: localStorage can be blocked (private browsing) and
+// zustand's persist write path is unguarded, so a throw would break a store
+// action. Swallow storage errors and keep the in-memory state.
+const guardedLocalStorage: StateStorage = {
+ getItem: (name) => {
+ try {
+ return window.localStorage.getItem(name);
+ } catch {
+ return null;
+ }
+ },
+ setItem: (name, value) => {
+ try {
+ window.localStorage.setItem(name, value);
+ } catch {
+ // ignore: the customization stays in memory for this session
+ }
+ },
+ removeItem: (name) => {
+ try {
+ window.localStorage.removeItem(name);
+ } catch {
+ // ignore
+ }
+ },
+};
+
+export type ReduceMotionSetting = "system" | "on" | "off";
+
+export type CustomModeColors = {
+ accent: string | null;
+ background: string | null;
+ foreground: string | null;
+};
+
+export type ImportedFont = {
+ /** Font-family name the FontFace is registered under. */
+ name: string;
+ /** data: URL of the uploaded font file. */
+ dataUrl: string;
+};
+
+/**
+ * Optional entries of the sidebar profile menu. Settings, Help, Log out, and
+ * Shutdown are pinned and never appear here. The settings-tab shortcuts ship
+ * hidden; General and About are covered by the pinned Settings and Help.
+ */
+export const SIDEBAR_MENU_ITEM_IDS = [
+ "api",
+ "darkMode",
+ "guidedTour",
+ "profile",
+ "appearance",
+ "resources",
+ "chat",
+ "connections",
+] as const;
+
+export type SidebarMenuItemId = (typeof SIDEBAR_MENU_ITEM_IDS)[number];
+
+export type SidebarMenuItemPref = {
+ id: SidebarMenuItemId;
+ visible: boolean;
+};
+
+export const SIDEBAR_MENU_DEFAULT_VISIBLE: Record =
+ {
+ api: true,
+ darkMode: true,
+ guidedTour: true,
+ profile: false,
+ appearance: false,
+ resources: false,
+ chat: false,
+ connections: false,
+ };
+
+export const MAX_IMPORTED_FONTS = 3;
+/** Imported-font family name cap; must match the backend name max_length (100). */
+export const MAX_IMPORTED_FONT_NAME_LENGTH = 100;
+/** ~1.5 MB file → ~2 MB base64; must stay in sync with the backend cap. */
+export const MAX_IMPORTED_FONT_DATA_URL_LENGTH = 2_200_000;
+/**
+ * Aggregate cap across all imported fonts. localStorage quotas are commonly
+ * ~5M UTF-16 units per origin; staying under that keeps the persisted store
+ * writable even with other keys present.
+ */
+export const MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH = 4_400_000;
+
+export type AppearanceCustomization = {
+ colors: { light: CustomModeColors; dark: CustomModeColors };
+ uiFont: string | null;
+ headingFont: string | null;
+ chatFont: string | null;
+ codeFont: string | null;
+ importedFonts: ImportedFont[];
+ /** Root font size in px (rem base). null = browser default (16). */
+ uiFontSize: number | null;
+ /** Code/pre font size in px. null = inherit each element's own size. */
+ codeFontSize: number | null;
+ /** 0–100; 50 is neutral (no adjustment). */
+ contrast: number;
+ pointerCursors: boolean;
+ reduceMotion: ReduceMotionSetting;
+ /** true = the app default (antialiased). */
+ fontSmoothing: boolean;
+ /** Order and visibility of the optional sidebar profile menu items. */
+ sidebarMenu: SidebarMenuItemPref[];
+};
+
+const EMPTY_MODE_COLORS: CustomModeColors = {
+ accent: null,
+ background: null,
+ foreground: null,
+};
+
+export const DEFAULT_CUSTOMIZATION: AppearanceCustomization = {
+ colors: { light: { ...EMPTY_MODE_COLORS }, dark: { ...EMPTY_MODE_COLORS } },
+ uiFont: null,
+ headingFont: null,
+ chatFont: null,
+ codeFont: null,
+ importedFonts: [],
+ uiFontSize: null,
+ codeFontSize: null,
+ contrast: 50,
+ pointerCursors: false,
+ reduceMotion: "system",
+ fontSmoothing: true,
+ sidebarMenu: SIDEBAR_MENU_ITEM_IDS.map((id) => ({
+ id,
+ visible: SIDEBAR_MENU_DEFAULT_VISIBLE[id],
+ })),
+};
+
+export const UI_FONT_SIZE_RANGE = { min: 12, max: 20, default: 16 } as const;
+export const CODE_FONT_SIZE_RANGE = { min: 10, max: 20, default: 13 } as const;
+
+const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
+
+export function isHexColor(value: unknown): value is string {
+ return typeof value === "string" && HEX_COLOR_PATTERN.test(value);
+}
+
+function sanitizeColor(value: unknown): string | null {
+ return isHexColor(value) ? value.toLowerCase() : null;
+}
+
+function sanitizeFont(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ // Strip the same characters the backend rejects (_FONT_NAME_FORBIDDEN plus
+ // control chars) so a locally chosen name never fails the personalization PUT
+ // and stalls sync; also stops smuggling CSS through the inline setProperty.
+ const cleaned = value
+ .replace(/[;{}()<>"'\\/,`]/g, "")
+ .replace(/\p{Cc}/gu, "")
+ .trim()
+ .slice(0, 200);
+ return cleaned.length > 0 ? cleaned : null;
+}
+
+function sanitizeSize(
+ value: unknown,
+ range: { min: number; max: number },
+): number | null {
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
+ const rounded = Math.round(value);
+ if (rounded < range.min || rounded > range.max) {
+ return Math.min(range.max, Math.max(range.min, rounded));
+ }
+ return rounded;
+}
+
+function sanitizeModeColors(value: unknown): CustomModeColors {
+ const source = (value ?? {}) as Partial;
+ return {
+ accent: sanitizeColor(source.accent),
+ background: sanitizeColor(source.background),
+ foreground: sanitizeColor(source.foreground),
+ };
+}
+
+const FONT_DATA_URL_PATTERN =
+ /^data:(?:font\/(?:woff2?|ttf|otf|sfnt)|application\/(?:octet-stream|x-font-\w+|font-\w+));base64,[A-Za-z0-9+/=]+$/;
+
+function sanitizeImportedFonts(value: unknown): ImportedFont[] {
+ if (!Array.isArray(value)) return [];
+ const fonts: ImportedFont[] = [];
+ const seen = new Set();
+ let total = 0;
+ for (const entry of value) {
+ if (fonts.length >= MAX_IMPORTED_FONTS) break;
+ const source = (entry ?? {}) as Partial;
+ // Cap to the backend name length so an over-long name can't fail the PUT.
+ const rawName = sanitizeFont(source.name);
+ const name = rawName ? rawName.slice(0, MAX_IMPORTED_FONT_NAME_LENGTH) : null;
+ if (!name || seen.has(name)) continue;
+ const dataUrl = source.dataUrl;
+ if (
+ typeof dataUrl !== "string" ||
+ dataUrl.length > MAX_IMPORTED_FONT_DATA_URL_LENGTH ||
+ total + dataUrl.length > MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH ||
+ !FONT_DATA_URL_PATTERN.test(dataUrl)
+ ) {
+ continue;
+ }
+ seen.add(name);
+ total += dataUrl.length;
+ fonts.push({ name, dataUrl });
+ }
+ return fonts;
+}
+
+function isSidebarMenuItemId(value: unknown): value is SidebarMenuItemId {
+ return SIDEBAR_MENU_ITEM_IDS.includes(value as SidebarMenuItemId);
+}
+
+function sanitizeSidebarMenu(value: unknown): SidebarMenuItemPref[] {
+ const items: SidebarMenuItemPref[] = [];
+ const seen = new Set();
+ for (const entry of Array.isArray(value) ? value : []) {
+ const source = (entry ?? {}) as Partial;
+ if (!isSidebarMenuItemId(source.id) || seen.has(source.id)) continue;
+ seen.add(source.id);
+ items.push({ id: source.id, visible: source.visible !== false });
+ }
+ // Ids added after the payload was written land at the end with their
+ // default visibility.
+ for (const id of SIDEBAR_MENU_ITEM_IDS) {
+ if (!seen.has(id))
+ items.push({ id, visible: SIDEBAR_MENU_DEFAULT_VISIBLE[id] });
+ }
+ return items;
+}
+
+/**
+ * Coerce arbitrary (persisted/remote) data into a valid customization object.
+ * Anything malformed falls back to the default for that field, so a bad
+ * payload can never wedge the UI.
+ */
+export function sanitizeCustomization(value: unknown): AppearanceCustomization {
+ const source = (value ?? {}) as Partial & {
+ colors?: { light?: unknown; dark?: unknown };
+ };
+ const contrast =
+ typeof source.contrast === "number" && Number.isFinite(source.contrast)
+ ? Math.min(100, Math.max(0, Math.round(source.contrast)))
+ : DEFAULT_CUSTOMIZATION.contrast;
+ return {
+ colors: {
+ light: sanitizeModeColors(source.colors?.light),
+ dark: sanitizeModeColors(source.colors?.dark),
+ },
+ uiFont: sanitizeFont(source.uiFont),
+ headingFont: sanitizeFont(source.headingFont),
+ chatFont: sanitizeFont(source.chatFont),
+ codeFont: sanitizeFont(source.codeFont),
+ importedFonts: sanitizeImportedFonts(source.importedFonts),
+ uiFontSize: sanitizeSize(source.uiFontSize, UI_FONT_SIZE_RANGE),
+ codeFontSize: sanitizeSize(source.codeFontSize, CODE_FONT_SIZE_RANGE),
+ contrast,
+ pointerCursors: source.pointerCursors === true,
+ reduceMotion:
+ source.reduceMotion === "on" || source.reduceMotion === "off"
+ ? source.reduceMotion
+ : "system",
+ fontSmoothing: source.fontSmoothing !== false,
+ sidebarMenu: sanitizeSidebarMenu(source.sidebarMenu),
+ };
+}
+
+export function isDefaultCustomization(c: AppearanceCustomization): boolean {
+ return JSON.stringify(c) === JSON.stringify(DEFAULT_CUSTOMIZATION);
+}
+
+interface AppearanceCustomState {
+ customization: AppearanceCustomization;
+ setColor: (
+ mode: ResolvedTheme,
+ key: keyof CustomModeColors,
+ value: string | null,
+ ) => void;
+ patch: (partial: Partial) => void;
+ addImportedFont: (font: ImportedFont) => void;
+ removeImportedFont: (name: string) => void;
+ replaceAll: (next: AppearanceCustomization) => void;
+ resetAll: () => void;
+}
+
+export const useAppearanceCustomStore = create()(
+ persist(
+ (set) => ({
+ customization: DEFAULT_CUSTOMIZATION,
+ setColor: (mode, key, value) =>
+ set((state) => ({
+ customization: {
+ ...state.customization,
+ colors: {
+ ...state.customization.colors,
+ [mode]: {
+ ...state.customization.colors[mode],
+ [key]: sanitizeColor(value),
+ },
+ },
+ },
+ })),
+ patch: (partial) =>
+ set((state) => ({
+ customization: sanitizeCustomization({
+ ...state.customization,
+ ...partial,
+ }),
+ })),
+ addImportedFont: (font) =>
+ set((state) => ({
+ customization: sanitizeCustomization({
+ ...state.customization,
+ importedFonts: [
+ ...state.customization.importedFonts.filter(
+ (f) => f.name !== font.name,
+ ),
+ font,
+ ],
+ }),
+ })),
+ removeImportedFont: (name) =>
+ set((state) => {
+ const c = state.customization;
+ return {
+ customization: sanitizeCustomization({
+ ...c,
+ importedFonts: c.importedFonts.filter((f) => f.name !== name),
+ // Fall back to the default font wherever the removed one was in use.
+ uiFont: c.uiFont === name ? null : c.uiFont,
+ headingFont: c.headingFont === name ? null : c.headingFont,
+ chatFont: c.chatFont === name ? null : c.chatFont,
+ codeFont: c.codeFont === name ? null : c.codeFont,
+ }),
+ };
+ }),
+ replaceAll: (next) => set({ customization: sanitizeCustomization(next) }),
+ resetAll: () => set({ customization: DEFAULT_CUSTOMIZATION }),
+ }),
+ {
+ name: "unsloth_appearance_customization",
+ version: 2,
+ storage: createJSONStorage(() => guardedLocalStorage),
+ migrate: (persisted) => {
+ const state = (persisted ?? {}) as Partial;
+ return {
+ customization: sanitizeCustomization(state.customization),
+ } as AppearanceCustomState;
+ },
+ // Sanitize on EVERY rehydrate, not just version bumps: a same-version
+ // payload written by an older bundle (e.g. before importedFonts existed)
+ // would otherwise reach the app with missing fields and crash .map calls.
+ merge: (persisted, current) => ({
+ ...current,
+ customization: sanitizeCustomization(
+ (persisted as Partial | undefined)
+ ?.customization,
+ ),
+ }),
+ },
+ ),
+);
+
+/* ------------------------------ DOM applier ------------------------------ */
+
+const DEFAULT_SANS_STACK =
+ '"Inter Variable", ui-sans-serif, sans-serif, system-ui';
+const DEFAULT_HEADING_STACK =
+ '"Hellix", "Space Grotesk Variable", var(--font-sans)';
+const DEFAULT_MONO_STACK = "JetBrains Mono, monospace";
+
+/** WCAG-ish relative luminance from a #rrggbb hex. */
+function hexLuminance(hex: string): number {
+ const channel = (i: number) => {
+ const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
+}
+
+function readableForeground(hex: string): string {
+ return hexLuminance(hex) > 0.45 ? "#111417" : "#ffffff";
+}
+
+/**
+ * FontFaces registered for imported fonts, keyed by family name. The dataUrl is
+ * tracked too so a re-import under the same name (new bytes) replaces the face.
+ */
+const registeredFontFaces = new Map();
+
+function syncImportedFonts(fonts: ImportedFont[]): void {
+ if (typeof document === "undefined" || !("fonts" in document)) return;
+ // Never trust the shape at this boundary: a stale
+ // persisted payload without importedFonts must not crash the applier.
+ const wanted = new Map(
+ (Array.isArray(fonts) ? fonts : []).map((f) => [f.name, f.dataUrl]),
+ );
+ // Drop faces whose name is gone OR whose bytes changed: document.fonts is a
+ // set of FontFace objects, not keyed by family, so a stale face must be
+ // deleted before the new bytes are added.
+ for (const [name, entry] of registeredFontFaces) {
+ if (wanted.get(name) !== entry.dataUrl) {
+ document.fonts.delete(entry.face);
+ registeredFontFaces.delete(name);
+ }
+ }
+ for (const [name, dataUrl] of wanted) {
+ if (registeredFontFaces.has(name)) continue;
+ try {
+ const face = new FontFace(name, `url(${dataUrl})`);
+ registeredFontFaces.set(name, { face, dataUrl });
+ document.fonts.add(face);
+ face.load().catch(() => {
+ document.fonts.delete(face);
+ // Only drop the entry if it still points at THIS face; a same-name
+ // re-import may have replaced it while this load was pending.
+ if (registeredFontFaces.get(name)?.face === face) {
+ registeredFontFaces.delete(name);
+ }
+ });
+ } catch {
+ registeredFontFaces.delete(name);
+ }
+ }
+}
+
+/**
+ * The custom "Accent" recolors the accent family (toggles, badges, chart-1).
+ * Focus/selection rings and button colors (--primary) are deliberately left
+ * alone: highlight borders stay neutral and Classic's buttons stay neutral.
+ */
+const ACCENT_VARS = ["--control-accent", "--chart-1"] as const;
+const ACCENT_FG_VARS = ["--control-accent-foreground"] as const;
+
+/**
+ * Push the customization onto as inline CSS variables, attributes, and
+ * classes. Everything is keyed off explicit hooks (inline vars beat every
+ * palette block; the classes/attributes gate rules in index.css), so the
+ * default customization leaves the document byte-identical to stock.
+ */
+export function applyCustomizationToDocument(
+ c: AppearanceCustomization,
+ resolved: ResolvedTheme,
+): void {
+ if (typeof document === "undefined") return;
+ const el = document.documentElement;
+ const style = el.style;
+
+ const setVar = (name: string, value: string | null) => {
+ if (value === null) style.removeProperty(name);
+ else style.setProperty(name, value);
+ };
+
+ const colors = c.colors[resolved];
+
+ for (const name of ACCENT_VARS) setVar(name, colors.accent);
+ for (const name of ACCENT_FG_VARS) {
+ setVar(name, colors.accent ? readableForeground(colors.accent) : null);
+ }
+ setVar("--background", colors.background);
+ setVar("--foreground", colors.foreground);
+
+ syncImportedFonts(c.importedFonts);
+
+ // Family names are single identifiers picked from the dropdown (sanitizeFont
+ // strips quote characters), so quoting here is always safe.
+ setVar(
+ "--font-sans",
+ c.uiFont ? `"${c.uiFont}", ${DEFAULT_SANS_STACK}` : null,
+ );
+ setVar(
+ "--font-heading",
+ c.headingFont ? `"${c.headingFont}", ${DEFAULT_HEADING_STACK}` : null,
+ );
+ // Only set while a heading font is chosen, so elements pinned to their
+ // own default (the chat greeting) can still follow the user's pick.
+ setVar(
+ "--custom-heading-font",
+ c.headingFont ? `"${c.headingFont}", ${DEFAULT_HEADING_STACK}` : null,
+ );
+ setVar(
+ "--font-mono",
+ c.codeFont ? `"${c.codeFont}", ${DEFAULT_MONO_STACK}` : null,
+ );
+ // Chat code fences/inline code default to Fira Code (index.css), not
+ // --font-mono; route a dedicated token so the Code font reaches them too.
+ setVar(
+ "--custom-code-font",
+ c.codeFont ? `"${c.codeFont}", "Fira Code", ui-monospace, monospace` : null,
+ );
+
+ if (c.chatFont) {
+ el.setAttribute("data-chat-font", "");
+ setVar("--custom-chat-font", `"${c.chatFont}", ${DEFAULT_SANS_STACK}`);
+ } else {
+ el.removeAttribute("data-chat-font");
+ setVar("--custom-chat-font", null);
+ }
+
+ if (c.uiFontSize !== null && c.uiFontSize !== UI_FONT_SIZE_RANGE.default) {
+ style.fontSize = `${c.uiFontSize}px`;
+ } else {
+ style.removeProperty("font-size");
+ }
+
+ if (c.codeFontSize !== null) {
+ el.setAttribute("data-code-font-size", "");
+ setVar("--custom-code-font-size", `${c.codeFontSize}px`);
+ } else {
+ el.removeAttribute("data-code-font-size");
+ setVar("--custom-code-font-size", null);
+ }
+
+ if (c.contrast !== 50) {
+ // Map |contrast - 50| ∈ (0, 50] onto a 0–40% color-mix toward the
+ // foreground (higher contrast) or background (lower contrast).
+ const mix = Math.round(Math.abs(c.contrast - 50) * 0.8);
+ el.setAttribute("data-contrast-adjust", "");
+ setVar("--contrast-mix", `${mix}%`);
+ setVar(
+ "--contrast-target",
+ c.contrast > 50 ? "var(--foreground)" : "var(--background)",
+ );
+ } else {
+ el.removeAttribute("data-contrast-adjust");
+ setVar("--contrast-mix", null);
+ setVar("--contrast-target", null);
+ }
+
+ el.classList.toggle("pointer-cursors", c.pointerCursors);
+ el.classList.toggle("force-reduced-motion", c.reduceMotion === "on");
+ // "off" opts out of the OS reduced-motion preference for CSS animations;
+ // the media rules in index.css skip html.force-motion.
+ el.classList.toggle("force-motion", c.reduceMotion === "off");
+ el.classList.toggle("no-font-smoothing", !c.fontSmoothing);
+}
+
+/**
+ * Resolved reduce-motion decision: the in-app setting wins ("on"/"off"),
+ * otherwise fall back to the OS preference. For imperative motion
+ * (canvas-confetti, view transitions) that CSS/MotionConfig cannot reach.
+ */
+export function prefersReducedMotion(): boolean {
+ const setting = useAppearanceCustomStore.getState().customization.reduceMotion;
+ if (setting === "on") return true;
+ if (setting === "off") return false;
+ return (
+ typeof window !== "undefined" &&
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
+ );
+}
diff --git a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts
index 234e92b3d0..b9e9c75b14 100644
--- a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts
+++ b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts
@@ -9,6 +9,7 @@ export type SettingsTab =
| "appearance"
| "resources"
| "chat"
+ | "voice"
| "connections"
| "api-keys"
| "about";
@@ -63,6 +64,7 @@ function loadInitialTab(): SettingsTab {
"appearance",
"resources",
"chat",
+ "voice",
"connections",
"api-keys",
"about",
diff --git a/studio/frontend/src/features/settings/stores/theme-store.ts b/studio/frontend/src/features/settings/stores/theme-store.ts
index 6a1275fb6f..603467969f 100644
--- a/studio/frontend/src/features/settings/stores/theme-store.ts
+++ b/studio/frontend/src/features/settings/stores/theme-store.ts
@@ -5,8 +5,30 @@ import { useSyncExternalStore } from "react";
export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
+export type Palette = "standard" | "classic" | "minimal";
const STORAGE_KEY = "theme";
+const PALETTE_STORAGE_KEY = "palette";
+
+export const PALETTES: readonly Palette[] = ["standard", "classic", "minimal"];
+
+export function isPalette(value: unknown): value is Palette {
+ return value === "standard" || value === "classic" || value === "minimal";
+}
+
+// Persist a re-derived literal from a fixed allow-list rather than the argument,
+// so a value arriving via the authenticated personalization sync is not tracked
+// as sensitive data flowing into storage (these are plain UI preferences).
+const STORED_THEME: Record = {
+ light: "light",
+ dark: "dark",
+ system: "system",
+};
+const STORED_PALETTE: Record = {
+ standard: "standard",
+ classic: "classic",
+ minimal: "minimal",
+};
function readStoredTheme(): Theme {
if (typeof window === "undefined") return "system";
@@ -16,10 +38,29 @@ function readStoredTheme(): Theme {
} catch {
return "system";
}
- if (stored === "light" || stored === "dark" || stored === "system") return stored;
+ if (stored === "light" || stored === "dark" || stored === "system")
+ return stored;
return "system";
}
+function readStoredPalette(): Palette {
+ if (typeof window === "undefined") return "standard";
+ let stored: string | null = null;
+ try {
+ stored = window.localStorage.getItem(PALETTE_STORAGE_KEY);
+ } catch {
+ return "standard";
+ }
+ return isPalette(stored) ? stored : "standard";
+}
+
+// In-memory source of truth so a selected value survives even when
+// localStorage is blocked (private browsing). Without it the snapshots would
+// re-read empty storage and revert React state to the default while the DOM
+// already changed.
+let currentTheme: Theme = readStoredTheme();
+let currentPalette: Palette = readStoredPalette();
+
function systemPrefersDark(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
@@ -32,11 +73,23 @@ function resolveTheme(theme: Theme): ResolvedTheme {
function applyToDocument(resolved: ResolvedTheme) {
if (typeof document === "undefined") return;
- // Keep "dark"/"light" mutually exclusive: next-themes (via Sonner) adds
- // "light" on first mount, so without this toggle we'd get "light dark".
- const cl = document.documentElement.classList;
- cl.toggle("dark", resolved === "dark");
- cl.toggle("light", resolved === "light");
+ const el = document.documentElement;
+ el.classList.toggle("dark", resolved === "dark");
+ el.classList.toggle("light", resolved === "light");
+ // Native controls (scrollbars, spinners, pickers) follow the app mode.
+ el.style.colorScheme = resolved;
+}
+
+function applyPaletteToDocument(palette: Palette) {
+ if (typeof document === "undefined") return;
+ const el = document.documentElement;
+ // Standard is the base :root/.dark palette; no attribute keeps the DOM
+ // (and CSS selectors) simple for the default look.
+ if (palette === "standard") {
+ el.removeAttribute("data-palette");
+ } else {
+ el.setAttribute("data-palette", palette);
+ }
}
const listeners = new Set<() => void>();
@@ -46,36 +99,58 @@ function subscribe(cb: () => void) {
return () => listeners.delete(cb);
}
const mq = window.matchMedia("(prefers-color-scheme: dark)");
- const syncTheme = () => {
- applyToDocument(resolveTheme(readStoredTheme()));
+ // OS scheme flip: only the resolved value changes; keep the in-memory choice
+ // (re-reading storage here would clobber it when storage is blocked).
+ const onSchemeChange = () => {
+ applyToDocument(resolveTheme(currentTheme));
cb();
};
- // Apply on mount so this store is the single source of truth for the DOM
- // class. Without this, initial paint depends solely on next-themes, which on
- // a fresh origin (e.g. a new Cloudflare --secure link with empty
- // localStorage) falls back to its own default and shows light while the
- // control still reads "system".
- applyToDocument(resolveTheme(readStoredTheme()));
+ // Another tab wrote storage (only fires when storage is available): adopt it.
const onStorage = (e: StorageEvent) => {
- if (e.key === STORAGE_KEY || e.key === null) syncTheme();
+ if (
+ e.key === STORAGE_KEY ||
+ e.key === PALETTE_STORAGE_KEY ||
+ e.key === null
+ ) {
+ currentTheme = readStoredTheme();
+ currentPalette = readStoredPalette();
+ applyToDocument(resolveTheme(currentTheme));
+ applyPaletteToDocument(currentPalette);
+ cb();
+ }
};
- mq.addEventListener("change", syncTheme);
+ // Apply on mount so this store is the single source of truth for the DOM
+ // class after the index.html bootstrap script painted the first frame.
+ applyToDocument(resolveTheme(currentTheme));
+ applyPaletteToDocument(currentPalette);
+ mq.addEventListener("change", onSchemeChange);
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(cb);
- mq.removeEventListener("change", syncTheme);
+ mq.removeEventListener("change", onSchemeChange);
window.removeEventListener("storage", onStorage);
};
}
function getSnapshot(): Theme {
- return readStoredTheme();
+ return currentTheme;
}
function getServerSnapshot(): Theme {
return "system";
}
+// Snapshot the RESOLVED mode too: under "system" the theme string never
+// changes when the OS scheme flips, so consumers keyed on `resolved`
+// (customization applier, mode-scoped settings) would not re-render.
+function getResolvedSnapshot(): ResolvedTheme {
+ return resolveTheme(currentTheme);
+}
+
+function getResolvedServerSnapshot(): ResolvedTheme {
+ return "light";
+}
+
/**
* Single source of truth for setting the theme. All writers (Settings dialog
* control, sidebar dropdown toggler) route through this so the DOM class,
@@ -83,10 +158,10 @@ function getServerSnapshot(): Theme {
*/
export function setTheme(next: Theme): void {
if (typeof window === "undefined") return;
- // Persist "system" explicitly so next-themes doesn't clobber the choice on
- // reload.
+ currentTheme = next;
+ // Persist "system" explicitly so a reload keeps following the OS.
try {
- window.localStorage.setItem(STORAGE_KEY, next);
+ window.localStorage.setItem(STORAGE_KEY, STORED_THEME[next]);
} catch {
// ignore storage failures
}
@@ -100,6 +175,47 @@ export function useTheme(): {
setTheme: (next: Theme) => void;
} {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
- const resolved = resolveTheme(theme);
+ const resolved = useSyncExternalStore(
+ subscribe,
+ getResolvedSnapshot,
+ getResolvedServerSnapshot,
+ );
return { theme, resolved, setTheme };
}
+
+function getPaletteSnapshot(): Palette {
+ return currentPalette;
+}
+
+function getPaletteServerSnapshot(): Palette {
+ return "standard";
+}
+
+/**
+ * Single source of truth for setting the color palette; mirrors setTheme so
+ * the data-palette attribute, localStorage, and React subscribers stay in
+ * sync.
+ */
+export function setPalette(next: Palette): void {
+ if (typeof window === "undefined") return;
+ currentPalette = next;
+ try {
+ window.localStorage.setItem(PALETTE_STORAGE_KEY, STORED_PALETTE[next]);
+ } catch {
+ // ignore storage failures
+ }
+ applyPaletteToDocument(next);
+ listeners.forEach((cb) => cb());
+}
+
+export function usePalette(): {
+ palette: Palette;
+ setPalette: (next: Palette) => void;
+} {
+ const palette = useSyncExternalStore(
+ subscribe,
+ getPaletteSnapshot,
+ getPaletteServerSnapshot,
+ );
+ return { palette, setPalette };
+}
diff --git a/studio/frontend/src/features/settings/stores/voice-settings-store.ts b/studio/frontend/src/features/settings/stores/voice-settings-store.ts
new file mode 100644
index 0000000000..9e38f4c6ce
--- /dev/null
+++ b/studio/frontend/src/features/settings/stores/voice-settings-store.ts
@@ -0,0 +1,245 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+// Voice preferences in localStorage. Adapters read them at call time so
+// changes apply without reloading the chat runtime.
+
+export interface RecentDictation {
+ text: string;
+ at: number;
+}
+
+const MAX_RECENT_DICTATIONS = 20;
+// Cap stored transcript length so a few long dictations cannot bloat the
+// persisted blob and trip a synchronous localStorage quota error on save.
+const MAX_RECENT_DICTATION_LENGTH = 2000;
+const MAX_DICTIONARY_ENTRIES = 100;
+const MAX_DICTIONARY_ENTRY_LENGTH = 120;
+
+export interface VoiceSettingsState {
+ /** Input device for dictation. "default" = system default microphone. */
+ micDeviceId: string;
+ setMicDeviceId: (value: string) => void;
+
+ /** BCP 47 tag for speech recognition, or "auto" for the browser locale. */
+ dictationLanguage: string;
+ setDictationLanguage: (value: string) => void;
+
+ /** Exact spellings applied to matching transcript words and phrases. */
+ dictionary: string[];
+ addDictionaryEntry: (value: string) => void;
+ updateDictionaryEntry: (index: number, value: string) => void;
+ /** Trim the entry; drop it when it was left empty. Call on input blur. */
+ commitDictionaryEntry: (index: number) => void;
+ removeDictionaryEntry: (index: number) => void;
+
+ /** Final transcripts, newest first, so text can be recovered. */
+ recentDictations: RecentDictation[];
+ addRecentDictation: (text: string) => void;
+ clearRecentDictations: () => void;
+
+ /** Show the read-aloud button on assistant responses. */
+ ttsEnabled: boolean;
+ setTtsEnabled: (value: boolean) => void;
+
+ /** "system": speechSynthesis voices. "studio": the loaded TTS audio model. */
+ ttsEngine: "system" | "studio";
+ setTtsEngine: (value: "system" | "studio") => void;
+
+ /** speechSynthesis voiceURI, or "default" for the system voice. */
+ ttsVoiceURI: string;
+ setTtsVoiceURI: (value: string) => void;
+
+ ttsRate: number;
+ setTtsRate: (value: number) => void;
+ ttsPitch: number;
+ setTtsPitch: (value: number) => void;
+ ttsVolume: number;
+ setTtsVolume: (value: number) => void;
+}
+
+export const useVoiceSettingsStore = create()(
+ persist(
+ (set) => ({
+ micDeviceId: "default",
+ setMicDeviceId: (micDeviceId) => set({ micDeviceId }),
+
+ dictationLanguage: "auto",
+ setDictationLanguage: (dictationLanguage) => set({ dictationLanguage }),
+
+ dictionary: [],
+ addDictionaryEntry: (value) =>
+ set((state) => {
+ const trimmed = value.trim().slice(0, MAX_DICTIONARY_ENTRY_LENGTH);
+ if (!trimmed) return state;
+ if (state.dictionary.length >= MAX_DICTIONARY_ENTRIES) return state;
+ if (
+ state.dictionary.some(
+ (entry) => entry.toLowerCase() === trimmed.toLowerCase(),
+ )
+ ) {
+ return state;
+ }
+ return { dictionary: [...state.dictionary, trimmed] };
+ }),
+ // Keep the raw value so the input edits freely; commitDictionaryEntry finalizes on blur.
+ updateDictionaryEntry: (index, value) =>
+ set((state) => {
+ const dictionary = [...state.dictionary];
+ if (index < 0 || index >= dictionary.length) return state;
+ dictionary[index] = value.slice(0, MAX_DICTIONARY_ENTRY_LENGTH);
+ return { dictionary };
+ }),
+ commitDictionaryEntry: (index) =>
+ set((state) => {
+ const dictionary = [...state.dictionary];
+ if (index < 0 || index >= dictionary.length) return state;
+ const trimmed = dictionary[index]?.trim() ?? "";
+ if (trimmed) {
+ dictionary[index] = trimmed;
+ } else {
+ dictionary.splice(index, 1);
+ }
+ return { dictionary };
+ }),
+ removeDictionaryEntry: (index) =>
+ set((state) => ({
+ dictionary: state.dictionary.filter((_, i) => i !== index),
+ })),
+
+ recentDictations: [],
+ addRecentDictation: (text) =>
+ set((state) => {
+ const trimmed = text.trim().slice(0, MAX_RECENT_DICTATION_LENGTH);
+ if (!trimmed) return state;
+ return {
+ recentDictations: [
+ { text: trimmed, at: Date.now() },
+ ...state.recentDictations,
+ ].slice(0, MAX_RECENT_DICTATIONS),
+ };
+ }),
+ clearRecentDictations: () => set({ recentDictations: [] }),
+
+ ttsEnabled: true,
+ setTtsEnabled: (ttsEnabled) => set({ ttsEnabled }),
+
+ ttsEngine: "system",
+ setTtsEngine: (ttsEngine) => set({ ttsEngine }),
+
+ ttsVoiceURI: "default",
+ setTtsVoiceURI: (ttsVoiceURI) => set({ ttsVoiceURI }),
+
+ ttsRate: 1,
+ setTtsRate: (ttsRate) => set({ ttsRate }),
+ ttsPitch: 1,
+ setTtsPitch: (ttsPitch) => set({ ttsPitch }),
+ ttsVolume: 1,
+ setTtsVolume: (ttsVolume) => set({ ttsVolume }),
+ }),
+ {
+ name: "unsloth_voice_settings",
+ merge: (persisted, current) => {
+ const saved = persisted as Partial | undefined;
+ return {
+ ...current,
+ micDeviceId: asString(saved?.micDeviceId, "default"),
+ dictationLanguage: asString(saved?.dictationLanguage, "auto"),
+ dictionary: Array.isArray(saved?.dictionary)
+ ? saved.dictionary
+ .filter((v): v is string => typeof v === "string" && !!v.trim())
+ .map((v) => v.trim().slice(0, MAX_DICTIONARY_ENTRY_LENGTH))
+ .slice(0, MAX_DICTIONARY_ENTRIES)
+ : [],
+ recentDictations: Array.isArray(saved?.recentDictations)
+ ? saved.recentDictations
+ .filter(
+ (v): v is RecentDictation =>
+ typeof v?.text === "string" && typeof v?.at === "number",
+ )
+ .slice(0, MAX_RECENT_DICTATIONS)
+ .map((v) => ({
+ text: v.text.slice(0, MAX_RECENT_DICTATION_LENGTH),
+ at: v.at,
+ }))
+ : [],
+ ttsEnabled:
+ typeof saved?.ttsEnabled === "boolean" ? saved.ttsEnabled : true,
+ ttsEngine: saved?.ttsEngine === "studio" ? "studio" : "system",
+ ttsVoiceURI: asString(saved?.ttsVoiceURI, "default"),
+ ttsRate: clampNumber(saved?.ttsRate, 0.5, 2, 1),
+ ttsPitch: clampNumber(saved?.ttsPitch, 0, 2, 1),
+ ttsVolume: clampNumber(saved?.ttsVolume, 0, 1, 1),
+ };
+ },
+ },
+ ),
+);
+
+function asString(value: unknown, fallback: string): string {
+ return typeof value === "string" && value ? value : fallback;
+}
+
+function clampNumber(
+ value: unknown,
+ min: number,
+ max: number,
+ fallback: number,
+): number {
+ if (typeof value !== "number" || Number.isNaN(value)) return fallback;
+ return Math.min(max, Math.max(min, value));
+}
+
+/** Resolve the "auto" language setting to a concrete BCP 47 tag. */
+export function resolveDictationLanguage(setting?: string): string {
+ const value = setting ?? useVoiceSettingsStore.getState().dictationLanguage;
+ if (value && value !== "auto") return value;
+ return typeof navigator !== "undefined" && navigator.language
+ ? navigator.language
+ : "en-US";
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/**
+ * Rewrite dictionary phrases in a transcript to their exact stored form,
+ * matching case-insensitively on word boundaries ("jane doe" -> "Jane Doe").
+ */
+export function applyDictationDictionary(
+ transcript: string,
+ dictionary?: string[],
+): string {
+ const entries = dictionary ?? useVoiceSettingsStore.getState().dictionary;
+ if (!transcript || entries.length === 0) return transcript;
+ let result = transcript;
+ for (const entry of entries) {
+ const trimmed = entry.trim();
+ if (!trimmed) continue;
+ // Whitespace-tolerant pattern so "jane doe" still matches.
+ const pattern = trimmed.split(/\s+/).map(escapeRegExp).join("\\s+");
+ try {
+ // Capture the leading boundary instead of using a lookbehind, which
+ // engines that support dictation but not lookbehind (Safari < 16.4)
+ // cannot compile; the catch below would otherwise skip every entry.
+ const regex = new RegExp(
+ `(^|[^\\p{L}\\p{N}])(${pattern})(?![\\p{L}\\p{N}])`,
+ "giu",
+ );
+ // Re-emit the boundary; callback form avoids $-pattern expansion.
+ result = result.replace(regex, (_match, prefix) => `${prefix}${trimmed}`);
+ } catch {
+ // Skip entries that produce an invalid pattern.
+ }
+ }
+ return result;
+}
+
+/** Record a finished dictation so it can be recovered from settings. */
+export function recordRecentDictation(text: string): void {
+ useVoiceSettingsStore.getState().addRecentDictation(text);
+}
diff --git a/studio/frontend/src/features/settings/tabs/appearance-tab.tsx b/studio/frontend/src/features/settings/tabs/appearance-tab.tsx
index af4f9959c8..1ee8c0d349 100644
--- a/studio/frontend/src/features/settings/tabs/appearance-tab.tsx
+++ b/studio/frontend/src/features/settings/tabs/appearance-tab.tsx
@@ -4,13 +4,33 @@
import { Switch } from "@/components/ui/switch";
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
import { useT } from "@/i18n";
-import { LanguageSelect } from "../components/language-select";
+import {
+ ActiveColorControl,
+ ChatFontRow,
+ CodeFontRow,
+ CodeFontSizeRow,
+ ContrastSliderRow,
+ FontSmoothingSwitch,
+ HeadingFontRow,
+ PointerCursorsSwitch,
+ ReduceMotionSegmented,
+ ResetCustomizationButton,
+ UiFontRow,
+ UiFontSizeRow,
+} from "../components/appearance-custom-controls";
+import { PaletteCards } from "../components/palette-cards";
import { SettingsRow } from "../components/settings-row";
-import { SettingsSection } from "../components/settings-section";
+import { SidebarMenuCustomizer } from "../components/sidebar-menu-customizer";
+import {
+ SettingsGroupDivider,
+ SettingsSection,
+} from "../components/settings-section";
import { ThemeSegmented } from "../components/theme-segmented";
+import { useTheme } from "../stores/theme-store";
export function AppearanceTab() {
const t = useT();
+ const { resolved } = useTheme();
const { pinned, setPinned } = useSidebarPin();
return (
@@ -30,25 +50,119 @@ export function AppearanceTab() {
>
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/studio/frontend/src/features/settings/tabs/chat-tab.tsx b/studio/frontend/src/features/settings/tabs/chat-tab.tsx
index 9519898b21..8ef68500cb 100644
--- a/studio/frontend/src/features/settings/tabs/chat-tab.tsx
+++ b/studio/frontend/src/features/settings/tabs/chat-tab.tsx
@@ -53,7 +53,10 @@ import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ArchivedChatsDialog } from "../components/archived-chats-dialog";
import { SettingsRow } from "../components/settings-row";
-import { SettingsSection } from "../components/settings-section";
+import {
+ SettingsGroupDivider,
+ SettingsSection,
+} from "../components/settings-section";
import { useSettingsDialogStore } from "../stores/settings-dialog-store";
// Adjustable "+" menu items shown in settings, in display order. Icons mirror
@@ -170,6 +173,8 @@ export function ChatTab() {
}, [archivedChatsRequested, consumeArchivedChatsRequest]);
const [exporting, setExporting] = useState(false);
const [clearing, setClearing] = useState(false);
+ const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
+ const setAutoTitle = useChatRuntimeStore((state) => state.setAutoTitle);
const collapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.collapseHtmlArtifacts,
);
@@ -409,6 +414,7 @@ export function ChatTab() {
/>
))}
+
+
+
+
+
+
+
s.hfToken);
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
- const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
- const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
const chatOnly = usePlatformStore((s) => s.chatOnly);
const showLlamaUpdates = useShowLlamaUpdateBanner();
const redirectTo = `${pathname}${search}`;
@@ -573,12 +580,21 @@ export function GeneralTab() {
) : null}
-
+
-
+
+
+
+
+
+
+
@@ -664,9 +680,7 @@ export function GeneralTab() {
}
onClick={() => void saveEmbeddingModel(false)}
>
- {isSavingEmbeddingModel
- ? t("common.saving")
- : t("common.save")}
+ {isSavingEmbeddingModel ? t("common.saving") : t("common.save")}
{embeddingModelError ? (
@@ -714,7 +728,7 @@ export function GeneralTab() {
>
-
+
setDraftUploadLimit(event.target.value)}
- className="h-8 w-full pr-10"
+ className="h-8 w-24"
/>
-
+
MB
diff --git a/studio/frontend/src/features/settings/tabs/profile-tab.tsx b/studio/frontend/src/features/settings/tabs/profile-tab.tsx
index e00da414d0..c515c3f7d7 100644
--- a/studio/frontend/src/features/settings/tabs/profile-tab.tsx
+++ b/studio/frontend/src/features/settings/tabs/profile-tab.tsx
@@ -10,10 +10,16 @@ export function ProfileTab() {
return (
diff --git a/studio/frontend/src/features/settings/tabs/resources-tab.tsx b/studio/frontend/src/features/settings/tabs/resources-tab.tsx
index 6c30858c63..9a359f0f38 100644
--- a/studio/frontend/src/features/settings/tabs/resources-tab.tsx
+++ b/studio/frontend/src/features/settings/tabs/resources-tab.tsx
@@ -32,7 +32,7 @@ function clampPercent(value: number | null | undefined): number {
function usageIndicatorClass(percent: number): string {
if (percent >= 90) return "bg-destructive";
if (percent >= 70) return "bg-amber-500";
- return "bg-primary";
+ return "bg-control-accent";
}
function usageTextClass(percent: number): string {
diff --git a/studio/frontend/src/features/settings/tabs/voice-tab.tsx b/studio/frontend/src/features/settings/tabs/voice-tab.tsx
new file mode 100644
index 0000000000..4b86105926
--- /dev/null
+++ b/studio/frontend/src/features/settings/tabs/voice-tab.tsx
@@ -0,0 +1,882 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Slider } from "@/components/ui/slider";
+import { Switch } from "@/components/ui/switch";
+import {
+ StudioSpeechSynthesisAdapter,
+ createConfiguredUtterance,
+ curateSystemVoices,
+ generateStudioTtsAudio,
+} from "@/features/chat/adapters/studio-speech-synthesis-adapter";
+import {
+ StudioWebSpeechDictationAdapter,
+ describeSpeechError,
+ isMissingDeviceError,
+} from "@/features/chat/adapters/studio-web-speech-dictation-adapter";
+import { useT } from "@/i18n";
+import { toast } from "@/lib/toast";
+import { MicIcon } from "@/lib/mic-icon";
+import { copyToClipboard } from "@/lib/copy-to-clipboard";
+import {
+ Copy01Icon,
+ Delete02Icon,
+ PlusSignIcon,
+ VolumeHighIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { SquareIcon } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { SettingsRow } from "../components/settings-row";
+import { SettingsSection } from "../components/settings-section";
+import {
+ applyDictationDictionary,
+ recordRecentDictation,
+ resolveDictationLanguage,
+ useVoiceSettingsStore,
+} from "../stores/voice-settings-store";
+
+// Languages offered for browser speech recognition.
+const DICTATION_LANGUAGES: { value: string; label: string }[] = [
+ { value: "auto", label: "" }, // label rendered via i18n
+ { value: "en-US", label: "English (US)" },
+ { value: "en-GB", label: "English (UK)" },
+ { value: "zh-CN", label: "中文 (简体)" },
+ { value: "ja-JP", label: "日本語" },
+ { value: "ko-KR", label: "한국어" },
+ { value: "es-ES", label: "Español" },
+ { value: "fr-FR", label: "Français" },
+ { value: "de-DE", label: "Deutsch" },
+ { value: "it-IT", label: "Italiano" },
+ { value: "pt-BR", label: "Português (Brasil)" },
+ { value: "ru-RU", label: "Русский" },
+ { value: "hi-IN", label: "हिन्दी" },
+ { value: "ar-SA", label: "العربية" },
+];
+
+const TTS_PREVIEW_TEXT =
+ "Hello from Unsloth Studio! This is a preview of the selected voice.";
+
+function useAudioInputDevices() {
+ const t = useT();
+ const [devices, setDevices] = useState
([]);
+ const [hasLabels, setHasLabels] = useState(false);
+
+ const refresh = useCallback(async () => {
+ if (!navigator.mediaDevices?.enumerateDevices) return;
+ try {
+ const all = await navigator.mediaDevices.enumerateDevices();
+ const inputs = all.filter((d) => d.kind === "audioinput");
+ setDevices(inputs);
+ setHasLabels(inputs.some((d) => d.label));
+ } catch {
+ // Enumeration can fail in insecure contexts; leave the list empty.
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ const media = navigator.mediaDevices;
+ if (!media?.addEventListener) return;
+ media.addEventListener("devicechange", refresh);
+ return () => media.removeEventListener("devicechange", refresh);
+ }, [refresh]);
+
+ // Labels are hidden until mic permission; open a short stream to get them.
+ const requestAccess = useCallback(async () => {
+ // Insecure contexts (plain http on a LAN address) have no mediaDevices.
+ if (!navigator.mediaDevices?.getUserMedia) {
+ toast.error(t("settings.voice.dictation.micAccessUnsupported"));
+ return;
+ }
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+ stream.getTracks().forEach((track) => track.stop());
+ await refresh();
+ } catch {
+ toast.error(t("settings.voice.dictation.micAccessBlocked"));
+ }
+ }, [refresh, t]);
+
+ return { devices, hasLabels, requestAccess };
+}
+
+function useSystemVoices() {
+ const [voices, setVoices] = useState([]);
+
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.speechSynthesis) return;
+ const synth = window.speechSynthesis;
+ const load = () => setVoices(synth.getVoices());
+ load();
+ synth.addEventListener?.("voiceschanged", load);
+ return () => synth.removeEventListener?.("voiceschanged", load);
+ }, []);
+
+ return voices;
+}
+
+/** Inline mic test: runs speech recognition and shows the live transcript. */
+function DictationTest() {
+ const t = useT();
+ const [testing, setTesting] = useState(false);
+ const [transcript, setTranscript] = useState("");
+ const [interim, setInterim] = useState("");
+ const recognitionRef = useRef(null);
+ const streamRef = useRef(null);
+ // Guards the getUserMedia await so a mic opened after unmount is released.
+ const disposedRef = useRef(false);
+ // Mirrors the transcript state so onend can record it without stale closures.
+ const transcriptRef = useRef("");
+
+ // Single cleanup path: the browser can end recognition on its own (silence
+ // timeout, service disconnect), so onend must release the mic and save the
+ // transcript, not just the Stop button.
+ const finalize = useCallback(() => {
+ streamRef.current?.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ recognitionRef.current = null;
+ if (transcriptRef.current) {
+ recordRecentDictation(transcriptRef.current);
+ transcriptRef.current = "";
+ }
+ setTesting(false);
+ setInterim("");
+ }, []);
+
+ const stop = useCallback(() => {
+ const recognition = recognitionRef.current;
+ if (recognition) {
+ // onend fires next and runs finalize()
+ recognition.stop();
+ } else {
+ finalize();
+ }
+ }, [finalize]);
+
+ useEffect(() => {
+ disposedRef.current = false;
+ return () => {
+ disposedRef.current = true;
+ recognitionRef.current?.abort();
+ streamRef.current?.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ };
+ }, []);
+
+ // Set before the getUserMedia await so a double click or a slow
+ // permission prompt cannot start a second recognizer over the first.
+ const startingRef = useRef(false);
+
+ const start = useCallback(async () => {
+ const SpeechRecognitionAPI =
+ window.SpeechRecognition ?? window.webkitSpeechRecognition;
+ if (!SpeechRecognitionAPI) return;
+ if (startingRef.current || recognitionRef.current) return;
+ startingRef.current = true;
+ setTranscript("");
+ setInterim("");
+ transcriptRef.current = "";
+
+ const { micDeviceId } = useVoiceSettingsStore.getState();
+ let audioTrack: MediaStreamTrack | undefined;
+ try {
+ let stream: MediaStream;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio:
+ micDeviceId && micDeviceId !== "default"
+ ? { deviceId: { exact: micDeviceId } }
+ : true,
+ });
+ } catch (error) {
+ // Saved mic may be unplugged; fall back to the default device.
+ if (micDeviceId !== "default" && isMissingDeviceError(error)) {
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ } else {
+ throw error;
+ }
+ }
+ if (disposedRef.current) {
+ stream.getTracks().forEach((track) => track.stop());
+ startingRef.current = false;
+ return;
+ }
+ streamRef.current = stream;
+ audioTrack = stream.getAudioTracks()[0];
+ } catch {
+ startingRef.current = false;
+ toast.error(t("settings.voice.dictation.micOpenFailed"));
+ return;
+ }
+
+ const recognition = new SpeechRecognitionAPI();
+ recognition.lang = resolveDictationLanguage();
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
+ let interimText = "";
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ const result = event.results[i];
+ const text = result?.[0]?.transcript ?? "";
+ if (result?.isFinal) {
+ const corrected = applyDictationDictionary(text.trim());
+ setTranscript((prev) => {
+ const next = prev ? `${prev} ${corrected}` : corrected;
+ transcriptRef.current = next;
+ return next;
+ });
+ } else {
+ interimText += text;
+ }
+ }
+ setInterim(interimText);
+ };
+ recognition.onerror = (event) => {
+ // onend follows and runs finalize(); surface non-abort failures here.
+ const errorEvent = event as SpeechRecognitionErrorEvent;
+ if (errorEvent.error !== "aborted") {
+ toast.error(describeSpeechError(errorEvent.error, errorEvent.message));
+ }
+ };
+ recognition.onend = () => finalize();
+ try {
+ if (audioTrack) {
+ try {
+ recognition.start(audioTrack);
+ } catch {
+ // Engine has no start(track) overload: it will capture from the
+ // default device, so release the selected-device stream.
+ streamRef.current?.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ recognition.start();
+ }
+ } else {
+ recognition.start();
+ }
+ } catch {
+ startingRef.current = false;
+ finalize();
+ return;
+ }
+ recognitionRef.current = recognition;
+ startingRef.current = false;
+ setTesting(true);
+ }, [finalize, t]);
+
+ const finishedTest = !testing && transcript;
+
+ return (
+
+
+ {
+ if (testing) {
+ stop();
+ } else {
+ void start();
+ }
+ }}
+ >
+ {testing ? (
+ <>
+
+ {t("settings.voice.dictation.stopTest")}
+ >
+ ) : (
+ <>
+
+ {t("settings.voice.dictation.startTest")}
+ >
+ )}
+
+
+ {(testing || transcript) && (
+
+ {transcript || interim ? (
+ <>
+
{transcript}
+ {interim ? (
+
{interim}
+ ) : null}
+ >
+ ) : (
+
+ {testing ? t("settings.voice.dictation.listening") : ""}
+
+ )}
+ {finishedTest ? (
+
+ {t("settings.voice.dictation.testSaved")}
+
+ ) : null}
+
+ )}
+
+ );
+}
+
+export function VoiceTab() {
+ const t = useT();
+ const micDeviceId = useVoiceSettingsStore((s) => s.micDeviceId);
+ const setMicDeviceId = useVoiceSettingsStore((s) => s.setMicDeviceId);
+ const dictationLanguage = useVoiceSettingsStore((s) => s.dictationLanguage);
+ const setDictationLanguage = useVoiceSettingsStore(
+ (s) => s.setDictationLanguage,
+ );
+ const dictionary = useVoiceSettingsStore((s) => s.dictionary);
+ const addDictionaryEntry = useVoiceSettingsStore((s) => s.addDictionaryEntry);
+ const updateDictionaryEntry = useVoiceSettingsStore(
+ (s) => s.updateDictionaryEntry,
+ );
+ const commitDictionaryEntry = useVoiceSettingsStore(
+ (s) => s.commitDictionaryEntry,
+ );
+ const removeDictionaryEntry = useVoiceSettingsStore(
+ (s) => s.removeDictionaryEntry,
+ );
+ const recentDictations = useVoiceSettingsStore((s) => s.recentDictations);
+ const clearRecentDictations = useVoiceSettingsStore(
+ (s) => s.clearRecentDictations,
+ );
+ const ttsEnabled = useVoiceSettingsStore((s) => s.ttsEnabled);
+ const setTtsEnabled = useVoiceSettingsStore((s) => s.setTtsEnabled);
+ const ttsEngine = useVoiceSettingsStore((s) => s.ttsEngine);
+ const setTtsEngine = useVoiceSettingsStore((s) => s.setTtsEngine);
+ const ttsVoiceURI = useVoiceSettingsStore((s) => s.ttsVoiceURI);
+ const setTtsVoiceURI = useVoiceSettingsStore((s) => s.setTtsVoiceURI);
+ const ttsRate = useVoiceSettingsStore((s) => s.ttsRate);
+ const setTtsRate = useVoiceSettingsStore((s) => s.setTtsRate);
+ const ttsPitch = useVoiceSettingsStore((s) => s.ttsPitch);
+ const setTtsPitch = useVoiceSettingsStore((s) => s.setTtsPitch);
+ const ttsVolume = useVoiceSettingsStore((s) => s.ttsVolume);
+ const setTtsVolume = useVoiceSettingsStore((s) => s.setTtsVolume);
+
+ const { devices, hasLabels, requestAccess } = useAudioInputDevices();
+ const rawVoices = useSystemVoices();
+ const voices = useMemo(
+ () => curateSystemVoices(rawVoices, ttsVoiceURI),
+ // dictationLanguage feeds the curation language filter.
+ [rawVoices, ttsVoiceURI, dictationLanguage],
+ );
+ const [newEntry, setNewEntry] = useState("");
+ const [previewing, setPreviewing] = useState(false);
+
+ const dictationSupported = StudioWebSpeechDictationAdapter.isSupported();
+ const ttsSupported = StudioSpeechSynthesisAdapter.isSupported();
+ const systemTtsSupported =
+ StudioSpeechSynthesisAdapter.systemVoicesSupported();
+ const effectiveTtsEngine = systemTtsSupported ? ttsEngine : "studio";
+
+ // Keep an item for an unplugged saved mic so the value stays visible.
+ const knownMic = devices.some((d) => d.deviceId === micDeviceId);
+
+ const handleAddEntry = () => {
+ const trimmed = newEntry.trim();
+ if (!trimmed) return;
+ addDictionaryEntry(trimmed);
+ setNewEntry("");
+ };
+
+ const previewAudioRef = useRef(null);
+ const previewAbortRef = useRef(null);
+ // Mirrors `previewing` so unmount cleanup can tell whether this tab owns
+ // the current speechSynthesis utterance; read-aloud shares the global
+ // synthesizer and must not be cancelled by merely closing settings.
+ const previewingRef = useRef(false);
+ // Only a system-voice preview owns the shared speechSynthesis channel; a
+ // studio (Audio) preview must not cancel an unrelated chat read-aloud.
+ const ownsSystemPreviewRef = useRef(false);
+ const markPreviewing = useCallback((value: boolean) => {
+ previewingRef.current = value;
+ setPreviewing(value);
+ }, []);
+
+ const releasePreviewAudio = useCallback(() => {
+ if (previewAudioRef.current) {
+ previewAudioRef.current.pause();
+ previewAudioRef.current.src = "";
+ previewAudioRef.current = null;
+ }
+ }, []);
+
+ const stopPreview = useCallback(() => {
+ if (!previewingRef.current) return;
+ if (ownsSystemPreviewRef.current) {
+ window.speechSynthesis?.cancel();
+ ownsSystemPreviewRef.current = false;
+ }
+ previewAbortRef.current?.abort();
+ previewAbortRef.current = null;
+ releasePreviewAudio();
+ markPreviewing(false);
+ }, [markPreviewing, releasePreviewAudio]);
+
+ const previewTts = async () => {
+ if (!ttsSupported) return;
+ // Ref, not state: a double-click before rerender still reads previewing
+ // as false and would start a second request that orphans the first.
+ if (previewingRef.current) {
+ stopPreview();
+ return;
+ }
+ if (effectiveTtsEngine === "studio") {
+ const controller = new AbortController();
+ previewAbortRef.current = controller;
+ ownsSystemPreviewRef.current = false;
+ markPreviewing(true);
+ try {
+ const url = await generateStudioTtsAudio(
+ TTS_PREVIEW_TEXT,
+ controller.signal,
+ );
+ if (controller.signal.aborted) return;
+ const audio = new Audio(url);
+ audio.playbackRate = ttsRate;
+ audio.volume = ttsVolume;
+ // Some browsers reset playbackRate to 1 once the source loads; reapply
+ // it on loadedmetadata so the speed setting reliably takes effect.
+ audio.addEventListener("loadedmetadata", () => {
+ audio.playbackRate = ttsRate;
+ });
+ audio.addEventListener("ended", () => {
+ releasePreviewAudio();
+ markPreviewing(false);
+ });
+ audio.addEventListener("error", () => {
+ releasePreviewAudio();
+ markPreviewing(false);
+ // Surface playback failures like the catch below, instead of just
+ // resetting the button with no explanation.
+ toast.error("TTS preview failed");
+ });
+ previewAudioRef.current = audio;
+ await audio.play();
+ } catch (error) {
+ if (!controller.signal.aborted) {
+ toast.error(
+ error instanceof Error ? error.message : "TTS preview failed",
+ );
+ }
+ releasePreviewAudio();
+ markPreviewing(false);
+ }
+ return;
+ }
+ if (!StudioSpeechSynthesisAdapter.systemVoicesSupported()) {
+ toast.error(t("settings.voice.readAloud.notSupported"));
+ return;
+ }
+ const utterance = createConfiguredUtterance(TTS_PREVIEW_TEXT);
+ utterance.addEventListener("end", () => {
+ ownsSystemPreviewRef.current = false;
+ markPreviewing(false);
+ });
+ utterance.addEventListener("error", () => {
+ ownsSystemPreviewRef.current = false;
+ markPreviewing(false);
+ });
+ ownsSystemPreviewRef.current = true;
+ window.speechSynthesis.cancel();
+ window.speechSynthesis.speak(utterance);
+ markPreviewing(true);
+ };
+
+ // Stop any preview playback when the tab unmounts.
+ useEffect(() => stopPreview, [stopPreview]);
+
+ return (
+
+
+
+
+
+ {hasLabels ? (
+
+
+
+
+
+
+ {t("settings.voice.dictation.systemDefault")}
+
+ {devices
+ .filter((d) => d.deviceId && d.deviceId !== "default")
+ .map((d, i) => (
+
+ {d.label || `Microphone ${i + 1}`}
+
+ ))}
+ {!knownMic && micDeviceId !== "default" ? (
+
+ {t("settings.voice.dictation.savedMicDisconnected")}
+
+ ) : null}
+
+
+ ) : (
+
+
+ {t("settings.voice.dictation.allowMicrophone")}
+
+ )}
+
+
+
+
+
+
+
+
+ {DICTATION_LANGUAGES.map(({ value, label }) => (
+
+ {value === "auto"
+ ? t("settings.voice.dictation.languageAuto")
+ : label}
+
+ ))}
+
+
+
+
+ {dictationSupported ? (
+
+ ) : (
+
+ )}
+
+
+
+ {dictionary.map((entry, index) => (
+
+ updateDictionaryEntry(index, e.target.value)}
+ // Skip the empty-row commit-splice when focus moves to this row's
+ // Remove button (keyboard Tab), so its index stays valid and its
+ // activation deletes this row instead of the next one.
+ onBlur={(e) => {
+ if (
+ (e.relatedTarget as HTMLElement | null)?.dataset.dictRemove ===
+ String(index)
+ ) {
+ return;
+ }
+ commitDictionaryEntry(index);
+ }}
+ className="h-8 flex-1 text-sm"
+ aria-label={`Dictionary entry ${index + 1}`}
+ />
+ e.preventDefault()}
+ onClick={() => removeDictionaryEntry(index)}
+ aria-label={`Remove dictionary entry ${index + 1}`}
+ >
+
+
+
+ ))}
+
+ setNewEntry(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddEntry();
+ }
+ }}
+ placeholder="Jane Doe"
+ className="h-8 flex-1 text-sm"
+ aria-label="New dictionary entry"
+ />
+
+
+ {t("settings.voice.dictionary.addEntry")}
+
+
+
+
+
+ {recentDictations.length === 0 ? (
+
+ {t("settings.voice.recents.empty")}
+
+ ) : (
+ <>
+ {recentDictations.map((item) => (
+
+
+
+ {item.text}
+
+
+ {new Date(item.at).toLocaleString()}
+
+
+
{
+ // Helper falls back to execCommand where navigator.clipboard
+ // is unavailable (Safari, insecure http LAN contexts).
+ if (await copyToClipboard(item.text)) {
+ toast.success(t("settings.voice.recents.copied"));
+ } else {
+ toast.error(t("settings.voice.recents.copyFailed"));
+ }
+ }}
+ >
+
+
+
+ ))}
+
+
+
+ {t("settings.voice.recents.clear")}
+
+
+ >
+ )}
+
+
+
+ {ttsSupported ? (
+ <>
+
+
+
+
+
+
+ setTtsEngine(value === "studio" ? "studio" : "system")
+ }
+ >
+
+
+
+
+ {systemTtsSupported ? (
+
+ {t("settings.voice.readAloud.engineSystem")}
+
+ ) : null}
+
+ {t("settings.voice.readAloud.engineStudio")}
+
+
+
+
+
+ {effectiveTtsEngine === "studio" ? (
+
+ ) : (
+
+
+
+
+
+
+
+ {t("settings.voice.dictation.systemDefault")}
+
+ {voices.map((voice) => (
+
+ {voice.name} ({voice.lang})
+
+ ))}
+
+
+
+ )}
+
+
+ v !== undefined && setTtsRate(v)}
+ className="w-48"
+ aria-label="Speaking rate"
+ />
+
+
+ {effectiveTtsEngine === "system" && (
+
+ v !== undefined && setTtsPitch(v)}
+ className="w-48"
+ aria-label="Voice pitch"
+ />
+
+ )}
+
+
+ v !== undefined && setTtsVolume(v)}
+ className="w-48"
+ aria-label="Playback volume"
+ />
+
+
+
+ void previewTts()}
+ >
+ {previewing ? (
+ <>
+
+ {t("settings.voice.readAloud.stopAction")}
+ >
+ ) : (
+ <>
+
+ {t("settings.voice.readAloud.previewAction")}
+ >
+ )}
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx
index 285644c5d4..d00d192bb2 100644
--- a/studio/frontend/src/features/studio/sections/dataset-section.tsx
+++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx
@@ -28,29 +28,38 @@ import {
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { useDebouncedValue, useHfTokenValidation } from "@/hooks";
+import { usePlatformStore } from "@/config/env";
import { useHubDatasetSearch } from "@/features/hub/hooks/use-hub-dataset-search";
import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scroll";
+import {
+ formatUploadSize,
+ getCachedUploadLimitBytes,
+ getCachedUploadLimitLabel,
+ loadUploadLimitSettings,
+ subscribeUploadLimitSettings,
+} from "@/features/settings/api/upload-limit";
import {
HfDatasetSubsetSplitSelectors,
+ type LocalDatasetInfo,
listLocalDatasets,
uploadTrainingDataset,
useDatasetPreviewDialogStore,
useTrainingConfigStore,
- type LocalDatasetInfo,
} from "@/features/training";
// Imported directly from the store module rather than the "@/features/training"
// barrel to avoid an import cycle (the barrel re-exports this section's siblings).
import { hasSeparateStreamingEvalSplit } from "@/features/training/stores/training-config-store";
-import { useNavigate } from "@tanstack/react-router";
+import { useDebouncedValue, useHfTokenValidation } from "@/hooks";
+import { translate, useT } from "@/i18n";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import { toast } from "@/lib/toast";
+import { cn } from "@/lib/utils";
import {
- ArrowDown01Icon,
Cancel01Icon,
CloudUploadIcon,
Database02Icon,
@@ -60,6 +69,7 @@ import {
ViewIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
+import { useNavigate } from "@tanstack/react-router";
import {
type ChangeEvent,
type DragEvent,
@@ -69,19 +79,9 @@ import {
useRef,
useState,
} from "react";
-import { toast } from "@/lib/toast";
-import {
- formatUploadSize,
- getCachedUploadLimitBytes,
- getCachedUploadLimitLabel,
- loadUploadLimitSettings,
- subscribeUploadLimitSettings,
-} from "@/features/settings/api/upload-limit";
import { useShallow } from "zustand/react/shallow";
import { DocumentUploadRedirectDialog } from "./document-upload-redirect-dialog";
-import { translate, useT } from "@/i18n";
import { S3ConfigForm } from "./s3-config-form";
-import { usePlatformStore } from "@/config/env";
const TRAINING_UPLOAD_EXTENSIONS = [
".csv",
@@ -242,7 +242,13 @@ export function DatasetSection() {
);
if (trainOnCompletions)
streamingBlockers.push('Turn off "Assistant completions only".');
- if (!hasSeparateStreamingEvalSplit({ evalSteps, datasetSplit, datasetEvalSplit }))
+ if (
+ !hasSeparateStreamingEvalSplit({
+ evalSteps,
+ datasetSplit,
+ datasetEvalSplit,
+ })
+ )
streamingBlockers.push(
"Pick a separate eval split — evaluation is on but no distinct eval split is set.",
);
@@ -255,9 +261,13 @@ export function DatasetSection() {
"Embedding models don't support streaming (training needs the full dataset).",
);
if (isDatasetImage)
- streamingBlockers.push("This dataset looks like images, which can't stream.");
+ streamingBlockers.push(
+ "This dataset looks like images, which can't stream.",
+ );
if (isDatasetAudio)
- streamingBlockers.push("This dataset looks like audio, which can't stream.");
+ streamingBlockers.push(
+ "This dataset looks like audio, which can't stream.",
+ );
if (platformDeviceType === "mac")
streamingBlockers.push(
"Streaming isn't supported on Apple Silicon (MLX) yet.",
@@ -491,12 +501,16 @@ export function DatasetSection() {
const comboboxAnchorRef = useRef(null);
const fileInputRef = useRef(null);
const evalFileInputRef = useRef(null);
- const { scrollRef, sentinelRef } = useHubInfiniteScroll(fetchMore, scannedCount, {
- enabled: pickerTab === "huggingface",
- isFetching: isLoading || isLoadingMore,
- resultCount: hfResults.length,
- resetKey: debouncedQuery,
- });
+ const { scrollRef, sentinelRef } = useHubInfiniteScroll(
+ fetchMore,
+ scannedCount,
+ {
+ enabled: pickerTab === "huggingface",
+ isFetching: isLoading || isLoadingMore,
+ resultCount: hfResults.length,
+ resetKey: debouncedQuery,
+ },
+ );
const [isUploading, setIsUploading] = useState(false);
const [isDatasetDragOver, setIsDatasetDragOver] = useState(false);
@@ -519,9 +533,11 @@ export function DatasetSection() {
setUploadLimitLabel(settings.maxUploadSizeLabel);
};
const unsubscribe = subscribeUploadLimitSettings(applyLimit);
- void loadUploadLimitSettings().then((settings) => {
- if (!cancelled) applyLimit(settings);
- }).catch(() => {});
+ void loadUploadLimitSettings()
+ .then((settings) => {
+ if (!cancelled) applyLimit(settings);
+ })
+ .catch(() => {});
return () => {
cancelled = true;
unsubscribe();
@@ -568,7 +584,10 @@ export function DatasetSection() {
toast.success(successMessage, { description: uploaded.filename });
} catch (error) {
toast.error(t("studio.dataset.uploadFailed"), {
- description: error instanceof Error ? error.message : t("studio.dataset.unknownError"),
+ description:
+ error instanceof Error
+ ? error.message
+ : t("studio.dataset.unknownError"),
});
} finally {
setIsUploading(false);
@@ -592,7 +611,11 @@ export function DatasetSection() {
return;
}
- await handleFileUpload(file, selectLocalDataset, t("studio.dataset.datasetUploaded"));
+ await handleFileUpload(
+ file,
+ selectLocalDataset,
+ t("studio.dataset.datasetUploaded"),
+ );
};
const handleDatasetFileChange = async (
@@ -640,7 +663,11 @@ export function DatasetSection() {
event.target.value = "";
if (!file) return;
- await handleFileUpload(file, setUploadedEvalFile, t("studio.dataset.evalDatasetUploaded"));
+ await handleFileUpload(
+ file,
+ setUploadedEvalFile,
+ t("studio.dataset.evalDatasetUploaded"),
+ );
};
const handleOpenLearningRecipes = useCallback(() => {
@@ -672,9 +699,9 @@ export function DatasetSection() {
}[] = [
{ value: "huggingface", label: "Hugging Face" },
{ value: "upload", label: t("studio.dataset.localTab") },
- ...(!isMultimodalModel
- ? [{ value: "s3" as const, label: "Amazon S3" }]
- : []),
+ ...(isMultimodalModel
+ ? []
+ : [{ value: "s3" as const, label: "Amazon S3" }]),
];
const activeIndex = Math.max(
0,
@@ -728,355 +755,375 @@ export function DatasetSection() {
{datasetSource === "s3" && }
{datasetSource !== "s3" && (
-
-
- {t("studio.dataset.chooseDataset")}
-
- {datasetSource === "upload" ? t("studio.dataset.localTab") : "Hugging Face"}
+
+
+ {t("studio.dataset.chooseDataset")}
+
+ {datasetSource === "upload"
+ ? t("studio.dataset.localTab")
+ : "Hugging Face"}
+
+
+
+
+
+
+
+
+ {t("studio.dataset.chooseDatasetTooltip")}{" "}
+
+ {t("studio.params.readMore")}
+
+
+
-
-
- {
+ if (event.key !== "Enter") return;
+ if (!(event.target instanceof HTMLInputElement)) return;
+ event.preventDefault();
+ if (pickerTab === "huggingface") {
+ if (hfResults.length > 0) {
+ handleDatasetSelect(hfResults[0].id);
+ } else {
+ const text = event.target.value.trim();
+ if (text) handleDatasetSelect(text);
+ }
+ return;
+ }
+
+ if (localResultIds.length > 0) {
+ const selectedId = localResultIds[0];
+ const path = localPathById.get(selectedId);
+ if (path) {
+ handleLocalDatasetSelect(path);
+ }
+ }
+ }}
+ >
+ {
+ setSearchQuery("");
+ if (
+ open &&
+ (pickerTab === "local" || activeSourceTab === "local")
+ ) {
+ void refreshLocalDatasets();
+ }
+ if (!open) {
+ setPickerTab(
+ pendingSourceTabRef.current ?? activeSourceTab,
+ );
+ pendingSourceTabRef.current = null;
+ }
+ }}
+ onValueChange={(value) => {
+ if (!value) {
+ clearSelectionForTab(pickerTab);
+ return;
+ }
+ if (pickerTab === "huggingface") {
+ handleDatasetSelect(value);
+ return;
+ }
+ const path = localPathById.get(value);
+ if (path) {
+ handleLocalDatasetSelect(path);
+ }
+ }}
+ onInputValueChange={(value, eventDetails) =>
+ handleInputChange(value, eventDetails)
+ }
+ itemToStringValue={(id) =>
+ pickerTab === "local" ? (localLabelById.get(id) ?? id) : id
+ }
+ autoHighlight={true}
+ >
+
-
-
-
-
- {t("studio.dataset.chooseDatasetTooltip")}{" "}
+
+
+
+
+
+
+
{
+ setPickerTab(value as "huggingface" | "local");
+ setSearchQuery("");
+ }}
+ className="w-full"
+ >
+
+
+ Hugging Face
+
+
+ {t("studio.dataset.localTab")}
+
+
+
+
+ {isLoading ? (
+
+ {" "}
+ {t("studio.dataset.searching")}
+
+ ) : (
+
+ {t("studio.dataset.noDatasetsFound")}
+
+ )}
+
+
+ {(id: string) => {
+ return (
+
+
+
+
+ {id}
+
+
+
+ {id}
+
+
+
+ );
+ }}
+
+
+ {isLoadingMore && (
+
+
+
+ )}
+
+
+
+
+ {localLoading ? (
+
+ {" "}
+ {t("studio.dataset.loadingLocalDatasets")}
+
+ ) : (
+ <>
+ {localError ? (
+
+ {localError}
+
+ ) : (
+
+
+
+ {localDatasets.length === 0
+ ? t("studio.dataset.noLocalDatasetsYet")
+ : t(
+ "studio.dataset.noLocalDatasetsMatchSearch",
+ )}
+
+ {localDatasets.length === 0 ? (
+
+
+ {t("studio.dataset.openDataRecipes")}
+
+
+ ) : null}
+
+
+ )}
+
+
+ {(id: string) => {
+ const label = localLabelById.get(id) ?? id;
+ return (
+
+
+
+
+ {label}
+
+
+
+ {label}
+
+
+
+ );
+ }}
+
+
+ >
+ )}
+
+
+
+
+
+
+ {(tokenValidationError ?? hfSearchError) && (
+
+ {tokenValidationError ?? hfSearchError}
+ {" — "}
- {t("studio.params.readMore")}
+ {t("studio.dataset.getOrUpdateToken")}
-
-
-
- {
- if (event.key !== "Enter") return;
- if (!(event.target instanceof HTMLInputElement)) return;
- event.preventDefault();
- if (pickerTab === "huggingface") {
- if (hfResults.length > 0) {
- handleDatasetSelect(hfResults[0].id);
- } else {
- const text = event.target.value.trim();
- if (text) handleDatasetSelect(text);
- }
- return;
- }
-
- if (localResultIds.length > 0) {
- const selectedId = localResultIds[0];
- const path = localPathById.get(selectedId);
- if (path) {
- handleLocalDatasetSelect(path);
- }
- }
- }}
- >
-
{
- setSearchQuery("");
- if (
- open &&
- (pickerTab === "local" || activeSourceTab === "local")
- ) {
- void refreshLocalDatasets();
- }
- if (!open) {
- setPickerTab(
- pendingSourceTabRef.current ?? activeSourceTab,
- );
- pendingSourceTabRef.current = null;
- }
- }}
- onValueChange={(value) => {
- if (!value) {
- clearSelectionForTab(pickerTab);
- return;
- }
- if (pickerTab === "huggingface") {
- handleDatasetSelect(value);
- return;
- }
- const path = localPathById.get(value);
- if (path) {
- handleLocalDatasetSelect(path);
- }
- }}
- onInputValueChange={(value, eventDetails) =>
- handleInputChange(value, eventDetails)
- }
- itemToStringValue={(id) =>
- pickerTab === "local" ? (localLabelById.get(id) ?? id) : id
- }
- autoHighlight={true}
- >
-
-
-
-
-
-
-
-
{
- setPickerTab(value as "huggingface" | "local");
- setSearchQuery("");
- }}
- className="w-full"
- >
-
- Hugging Face
- {t("studio.dataset.localTab")}
-
-
-
- {isLoading ? (
-
- {t("studio.dataset.searching")}
-
- ) : (
- {t("studio.dataset.noDatasetsFound")}
- )}
-
-
- {(id: string) => {
- return (
-
-
-
-
- {id}
-
-
-
- {id}
-
-
-
- );
- }}
-
-
- {isLoadingMore && (
-
-
-
- )}
-
-
-
-
- {localLoading ? (
-
- {t("studio.dataset.loadingLocalDatasets")}
-
- ) : (
- <>
- {localError ? (
-
- {localError}
-
- ) : (
-
-
-
- {localDatasets.length === 0
- ? t("studio.dataset.noLocalDatasetsYet")
- : t("studio.dataset.noLocalDatasetsMatchSearch")}
-
- {localDatasets.length === 0 ? (
-
- {t("studio.dataset.openDataRecipes")}
-
- ) : null}
-
-
- )}
-
-
- {(id: string) => {
- const label = localLabelById.get(id) ?? id;
- return (
-
-
-
-
- {label}
-
-
-
- {label}
-
-
-
- );
- }}
-
-
- >
- )}
-
-
-
-
-
+
+ )}
+ {isCheckingToken && (
+
+ {t("studio.dataset.checkingToken")}
+
+ )}
+ {pickerTab !== activeSourceTab && (
+
+ {t("studio.dataset.browsingSource", {
+ browsing:
+ pickerTab === "local"
+ ? t("studio.dataset.localDatasets")
+ : "Hugging Face",
+ current:
+ datasetSource === "upload"
+ ? t("studio.dataset.localTab")
+ : "Hugging Face",
+ })}
+
+ )}
- {(tokenValidationError ?? hfSearchError) && (
-
- {tokenValidationError ?? hfSearchError}
- {" — "}
-
- {t("studio.dataset.getOrUpdateToken")}
-
-
- )}
- {isCheckingToken && (
-
- {t("studio.dataset.checkingToken")}
-
- )}
- {pickerTab !== activeSourceTab && (
-
- {t("studio.dataset.browsingSource", {
- browsing:
- pickerTab === "local"
- ? t("studio.dataset.localDatasets")
- : "Hugging Face",
- current:
- datasetSource === "upload"
- ? t("studio.dataset.localTab")
- : "Hugging Face",
- })}
-
- )}
-
)}
{datasetSource !== "s3" &&
(isHfDatasetSelected ? (
-
- ) : !selectedDatasetName ? (
-
- ) : datasetSource === "upload" && selectedLocalDataset ? (
-
-
-
-
- {t("studio.dataset.localDatasetMetadata")}
-
-
- {t("studio.dataset.dataRecipeOutput")}
-
-
-
+
+ ) : selectedDatasetName ? (
+ datasetSource === "upload" && selectedLocalDataset ? (
+
+
+
+
+ {t("studio.dataset.localDatasetMetadata")}
+
+
+ {t("studio.dataset.dataRecipeOutput")}
+
+
+
-
-
-
-
0
- ? String(selectedLocalColumns.length)
- : "--"
- }
- />
-
-
+
+
+
+ 0
+ ? String(selectedLocalColumns.length)
+ : "--"
+ }
+ />
+
+
+
+
-
-
- ) : null)}
+ ) : null
+ ) : (
+
+ ))}
{datasetSource === "upload" && uploadedFile && (
@@ -1135,7 +1182,7 @@ export function DatasetSection() {
{t("studio.dataset.advanced")}
@@ -1180,11 +1227,15 @@ export function DatasetSection() {
- {t("studio.dataset.auto")}
+
+ {t("studio.dataset.auto")}
+
Alpaca
ChatML
ShareGPT
- {t("studio.dataset.rawText")}
+
+ {t("studio.dataset.rawText")}
+
@@ -1382,8 +1433,8 @@ export function DatasetSection() {
{t("studio.dataset.dropFileOrClick")}
- {TRAINING_DATASET_UPLOAD_LABEL} · up to{" "}
- {uploadLimitLabel}; {DOCUMENT_REDIRECT_LABEL}
+ {TRAINING_DATASET_UPLOAD_LABEL} · up to {uploadLimitLabel}
+ ; {DOCUMENT_REDIRECT_LABEL}
@@ -1400,7 +1451,10 @@ export function DatasetSection() {
{isUploading ? (
) : (
-
+
)}
{isUploading
? t("studio.dataset.uploading")
diff --git a/studio/frontend/src/features/studio/sections/params-section.tsx b/studio/frontend/src/features/studio/sections/params-section.tsx
index 2609558145..8d6e18b83f 100644
--- a/studio/frontend/src/features/studio/sections/params-section.tsx
+++ b/studio/frontend/src/features/studio/sections/params-section.tsx
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-import { usePlatformStore } from "@/config/env";
import { SectionCard } from "@/components/section-card";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -9,7 +8,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
-import { Input } from "@/components/ui/input";
import {
Combobox,
ComboboxContent,
@@ -18,6 +16,7 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
+import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -32,6 +31,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { usePlatformStore } from "@/config/env";
import {
CONTEXT_LENGTHS,
CPT_TARGET_MODULES,
@@ -39,18 +39,27 @@ import {
OPTIMIZER_OPTIONS,
TARGET_MODULES,
} from "@/config/training";
-import { useMaxStepsEpochsToggle, useTrainingConfigStore } from "@/features/training";
+import {
+ useMaxStepsEpochsToggle,
+ useTrainingConfigStore,
+} from "@/features/training";
import { isRawTextDatasetFormat } from "@/features/training/lib/training-methods";
+import { useT } from "@/i18n";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { isAdapterMethod } from "@/types/training";
import type { GradientCheckpointing } from "@/types/training";
import {
- ArrowDown01Icon,
InformationCircleIcon,
Settings04Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react";
-import { useT } from "@/i18n";
+import {
+ type ReactElement,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
type StudioT = ReturnType
;
@@ -122,7 +131,7 @@ function SliderRow({
min={min}
max={max}
step={step}
- className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
+ className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-ring [&::-webkit-inner-spin-button]:appearance-none"
/>
@@ -224,9 +233,11 @@ export function ParamsSection(): ReactElement {
title={t("studio.params.title")}
description={t("studio.params.description")}
accent="orange"
- className={`${needsExpandedHeight
- ? "min-h-studio-config-column"
- : "h-studio-config-column"} duration-150`}
+ className={`${
+ needsExpandedHeight
+ ? "min-h-studio-config-column"
+ : "h-studio-config-column"
+ } duration-150`}
>
@@ -387,9 +402,13 @@ export function ParamsSection(): ReactElement {
setCtxInput(String(store.contextLength));
}}
onKeyDown={(e) => {
- if (e.key !== "Enter") { return; }
+ if (e.key !== "Enter") {
+ return;
+ }
const n = trySetContextLength(ctxInput);
- if (n === null) { return; }
+ if (n === null) {
+ return;
+ }
if (!ctxItems.includes(ctxInput.trim())) {
e.stopPropagation();
e.preventDefault();
@@ -398,7 +417,9 @@ export function ParamsSection(): ReactElement {
}}
/>
- {t("studio.params.customContextLength")}
+
+ {t("studio.params.customContextLength")}
+
{(id: string) => (
@@ -506,194 +527,204 @@ export function ParamsSection(): ReactElement {
{t("studio.params.loraSettings")}
-
- {t("studio.params.rankTooltip")}{" "}
-
- {t("studio.params.readMore")}
-
- >
- }
- value={store.loraRank}
- onChange={store.setLoraRank}
- min={4}
- max={128}
- step={4}
- />
-
- {t("studio.params.alphaTooltip")}{" "}
-
- {t("studio.params.readMore")}
-
- >
- }
- value={store.loraAlpha}
- onChange={store.setLoraAlpha}
- min={4}
- max={256}
- step={4}
- />
-
- {t("studio.params.dropoutTooltip")}{" "}
-
- {t("studio.params.readMore")}
-
- >
- }
- value={store.loraDropout}
- onChange={store.setLoraDropout}
- min={0}
- max={0.5}
- step={0.01}
- format={(v) => v.toFixed(2)}
- />
+
+ {t("studio.params.rankTooltip")}{" "}
+
+ {t("studio.params.readMore")}
+
+ >
+ }
+ value={store.loraRank}
+ onChange={store.setLoraRank}
+ min={4}
+ max={128}
+ step={4}
+ />
+
+ {t("studio.params.alphaTooltip")}{" "}
+
+ {t("studio.params.readMore")}
+
+ >
+ }
+ value={store.loraAlpha}
+ onChange={store.setLoraAlpha}
+ min={4}
+ max={256}
+ step={4}
+ />
+
+ {t("studio.params.dropoutTooltip")}{" "}
+
+ {t("studio.params.readMore")}
+
+ >
+ }
+ value={store.loraDropout}
+ onChange={store.setLoraDropout}
+ min={0}
+ max={0.5}
+ step={0.01}
+ format={(v) => v.toFixed(2)}
+ />
- {/* Vision checkboxes */}
- {showVisionLora && (
-
+ {/* Vision checkboxes */}
+ {showVisionLora && (
+
+ {(
+ [
+ [
+ "finetuneVisionLayers",
+ t("studio.params.visionLayers"),
+ store.finetuneVisionLayers,
+ store.setFinetuneVisionLayers,
+ ],
+ [
+ "finetuneLanguageLayers",
+ t("studio.params.languageLayers"),
+ store.finetuneLanguageLayers,
+ store.setFinetuneLanguageLayers,
+ ],
+ [
+ "finetuneAttentionModules",
+ t("studio.params.attentionModules"),
+ store.finetuneAttentionModules,
+ store.setFinetuneAttentionModules,
+ ],
+ [
+ "finetuneMLPModules",
+ t("studio.params.mlpModules"),
+ store.finetuneMLPModules,
+ store.setFinetuneMLPModules,
+ ],
+ ] as const
+ ).map(([key, label, value, setter]) => (
+
+
+ (setter as (v: boolean) => void)(!!v)
+ }
+ />
+